0c13d0ff46
This patch adds Streams API support in getTextContent so that we can stream data in chunks instead of fetching whole data from worker thread to main thread. This patch supports Streams API without changing the core functionality of getTextContent. Enqueue textContent directly at getTextContent in partialEvaluator. Adds desiredSize and ready property in streamSink.
427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
/* Copyright 2012 Mozilla Foundation
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { getGlobalEventBus } from './dom_events';
|
|
import { renderTextLayer } from 'pdfjs-lib';
|
|
|
|
var EXPAND_DIVS_TIMEOUT = 300; // ms
|
|
|
|
/**
|
|
* @typedef {Object} TextLayerBuilderOptions
|
|
* @property {HTMLDivElement} textLayerDiv - The text layer container.
|
|
* @property {EventBus} eventBus - The application event bus.
|
|
* @property {number} pageIndex - The page index.
|
|
* @property {PageViewport} viewport - The viewport of the text layer.
|
|
* @property {PDFFindController} findController
|
|
* @property {boolean} enhanceTextSelection - Option to turn on improved
|
|
* text selection.
|
|
*/
|
|
|
|
/**
|
|
* TextLayerBuilder provides text-selection functionality for the PDF.
|
|
* It does this by creating overlay divs over the PDF text. These divs
|
|
* contain text that matches the PDF text they are overlaying. This object
|
|
* also provides a way to highlight text that is being searched for.
|
|
* @class
|
|
*/
|
|
var TextLayerBuilder = (function TextLayerBuilderClosure() {
|
|
function TextLayerBuilder(options) {
|
|
this.textLayerDiv = options.textLayerDiv;
|
|
this.eventBus = options.eventBus || getGlobalEventBus();
|
|
this.textContent = null;
|
|
this.textContentItemsStr = [];
|
|
this.textContentStream = null;
|
|
this.renderingDone = false;
|
|
this.pageIdx = options.pageIndex;
|
|
this.pageNumber = this.pageIdx + 1;
|
|
this.matches = [];
|
|
this.viewport = options.viewport;
|
|
this.textDivs = [];
|
|
this.findController = options.findController || null;
|
|
this.textLayerRenderTask = null;
|
|
this.enhanceTextSelection = options.enhanceTextSelection;
|
|
this._bindMouse();
|
|
}
|
|
|
|
TextLayerBuilder.prototype = {
|
|
/**
|
|
* @private
|
|
*/
|
|
_finishRendering: function TextLayerBuilder_finishRendering() {
|
|
this.renderingDone = true;
|
|
|
|
if (!this.enhanceTextSelection) {
|
|
var endOfContent = document.createElement('div');
|
|
endOfContent.className = 'endOfContent';
|
|
this.textLayerDiv.appendChild(endOfContent);
|
|
}
|
|
|
|
this.eventBus.dispatch('textlayerrendered', {
|
|
source: this,
|
|
pageNumber: this.pageNumber,
|
|
numTextDivs: this.textDivs.length,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Renders the text layer.
|
|
* @param {number} timeout (optional) if specified, the rendering waits
|
|
* for specified amount of ms.
|
|
*/
|
|
render: function TextLayerBuilder_render(timeout) {
|
|
if (!(this.textContent || this.textContentStream) || this.renderingDone) {
|
|
return;
|
|
}
|
|
this.cancel();
|
|
|
|
this.textDivs = [];
|
|
var textLayerFrag = document.createDocumentFragment();
|
|
this.textLayerRenderTask = renderTextLayer({
|
|
textContent: this.textContent,
|
|
textContentStream: this.textContentStream,
|
|
container: textLayerFrag,
|
|
viewport: this.viewport,
|
|
textDivs: this.textDivs,
|
|
textContentItemsStr: this.textContentItemsStr,
|
|
timeout,
|
|
enhanceTextSelection: this.enhanceTextSelection,
|
|
});
|
|
this.textLayerRenderTask.promise.then(() => {
|
|
this.textLayerDiv.appendChild(textLayerFrag);
|
|
this._finishRendering();
|
|
this.updateMatches();
|
|
}, function (reason) {
|
|
// cancelled or failed to render text layer -- skipping errors
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Cancels rendering of the text layer.
|
|
*/
|
|
cancel: function TextLayerBuilder_cancel() {
|
|
if (this.textLayerRenderTask) {
|
|
this.textLayerRenderTask.cancel();
|
|
this.textLayerRenderTask = null;
|
|
}
|
|
},
|
|
|
|
setTextContentStream(readableStream) {
|
|
this.cancel();
|
|
this.textContentStream = readableStream;
|
|
},
|
|
|
|
setTextContent: function TextLayerBuilder_setTextContent(textContent) {
|
|
this.cancel();
|
|
this.textContent = textContent;
|
|
},
|
|
|
|
convertMatches: function TextLayerBuilder_convertMatches(matches,
|
|
matchesLength) {
|
|
var i = 0;
|
|
var iIndex = 0;
|
|
let textContentItemsStr = this.textContentItemsStr;
|
|
var end = textContentItemsStr.length - 1;
|
|
var queryLen = (this.findController === null ?
|
|
0 : this.findController.state.query.length);
|
|
var ret = [];
|
|
if (!matches) {
|
|
return ret;
|
|
}
|
|
for (var m = 0, len = matches.length; m < len; m++) {
|
|
// Calculate the start position.
|
|
var matchIdx = matches[m];
|
|
|
|
// Loop over the divIdxs.
|
|
while (i !== end && matchIdx >=
|
|
(iIndex + textContentItemsStr[i].length)) {
|
|
iIndex += textContentItemsStr[i].length;
|
|
i++;
|
|
}
|
|
|
|
if (i === textContentItemsStr.length) {
|
|
console.error('Could not find a matching mapping');
|
|
}
|
|
|
|
var match = {
|
|
begin: {
|
|
divIdx: i,
|
|
offset: matchIdx - iIndex,
|
|
},
|
|
};
|
|
|
|
// Calculate the end position.
|
|
if (matchesLength) { // multiterm search
|
|
matchIdx += matchesLength[m];
|
|
} else { // phrase search
|
|
matchIdx += queryLen;
|
|
}
|
|
|
|
// Somewhat the same array as above, but use > instead of >= to get
|
|
// the end position right.
|
|
while (i !== end && matchIdx >
|
|
(iIndex + textContentItemsStr[i].length)) {
|
|
iIndex += textContentItemsStr[i].length;
|
|
i++;
|
|
}
|
|
|
|
match.end = {
|
|
divIdx: i,
|
|
offset: matchIdx - iIndex,
|
|
};
|
|
ret.push(match);
|
|
}
|
|
|
|
return ret;
|
|
},
|
|
|
|
renderMatches: function TextLayerBuilder_renderMatches(matches) {
|
|
// Early exit if there is nothing to render.
|
|
if (matches.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let textContentItemsStr = this.textContentItemsStr;
|
|
var textDivs = this.textDivs;
|
|
var prevEnd = null;
|
|
var pageIdx = this.pageIdx;
|
|
var isSelectedPage = (this.findController === null ?
|
|
false : (pageIdx === this.findController.selected.pageIdx));
|
|
var selectedMatchIdx = (this.findController === null ?
|
|
-1 : this.findController.selected.matchIdx);
|
|
var highlightAll = (this.findController === null ?
|
|
false : this.findController.state.highlightAll);
|
|
var infinity = {
|
|
divIdx: -1,
|
|
offset: undefined,
|
|
};
|
|
|
|
function beginText(begin, className) {
|
|
var divIdx = begin.divIdx;
|
|
textDivs[divIdx].textContent = '';
|
|
appendTextToDiv(divIdx, 0, begin.offset, className);
|
|
}
|
|
|
|
function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
|
|
var div = textDivs[divIdx];
|
|
var content =
|
|
textContentItemsStr[divIdx].substring(fromOffset, toOffset);
|
|
var node = document.createTextNode(content);
|
|
if (className) {
|
|
var span = document.createElement('span');
|
|
span.className = className;
|
|
span.appendChild(node);
|
|
div.appendChild(span);
|
|
return;
|
|
}
|
|
div.appendChild(node);
|
|
}
|
|
|
|
var i0 = selectedMatchIdx, i1 = i0 + 1;
|
|
if (highlightAll) {
|
|
i0 = 0;
|
|
i1 = matches.length;
|
|
} else if (!isSelectedPage) {
|
|
// Not highlighting all and this isn't the selected page, so do nothing.
|
|
return;
|
|
}
|
|
|
|
for (var i = i0; i < i1; i++) {
|
|
var match = matches[i];
|
|
var begin = match.begin;
|
|
var end = match.end;
|
|
var isSelected = (isSelectedPage && i === selectedMatchIdx);
|
|
var highlightSuffix = (isSelected ? ' selected' : '');
|
|
|
|
if (this.findController) {
|
|
this.findController.updateMatchPosition(pageIdx, i, textDivs,
|
|
begin.divIdx);
|
|
}
|
|
|
|
// Match inside new div.
|
|
if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
|
|
// If there was a previous div, then add the text at the end.
|
|
if (prevEnd !== null) {
|
|
appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
|
|
}
|
|
// Clear the divs and set the content until the starting point.
|
|
beginText(begin);
|
|
} else {
|
|
appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);
|
|
}
|
|
|
|
if (begin.divIdx === end.divIdx) {
|
|
appendTextToDiv(begin.divIdx, begin.offset, end.offset,
|
|
'highlight' + highlightSuffix);
|
|
} else {
|
|
appendTextToDiv(begin.divIdx, begin.offset, infinity.offset,
|
|
'highlight begin' + highlightSuffix);
|
|
for (var n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {
|
|
textDivs[n0].className = 'highlight middle' + highlightSuffix;
|
|
}
|
|
beginText(end, 'highlight end' + highlightSuffix);
|
|
}
|
|
prevEnd = end;
|
|
}
|
|
|
|
if (prevEnd) {
|
|
appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
|
|
}
|
|
},
|
|
|
|
updateMatches: function TextLayerBuilder_updateMatches() {
|
|
// Only show matches when all rendering is done.
|
|
if (!this.renderingDone) {
|
|
return;
|
|
}
|
|
|
|
// Clear all matches.
|
|
var matches = this.matches;
|
|
var textDivs = this.textDivs;
|
|
let textContentItemsStr = this.textContentItemsStr;
|
|
var clearedUntilDivIdx = -1;
|
|
|
|
// Clear all current matches.
|
|
for (var i = 0, len = matches.length; i < len; i++) {
|
|
var match = matches[i];
|
|
var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
|
|
for (var n = begin, end = match.end.divIdx; n <= end; n++) {
|
|
var div = textDivs[n];
|
|
div.textContent = textContentItemsStr[n];
|
|
div.className = '';
|
|
}
|
|
clearedUntilDivIdx = match.end.divIdx + 1;
|
|
}
|
|
|
|
if (this.findController === null || !this.findController.active) {
|
|
return;
|
|
}
|
|
|
|
// Convert the matches on the page controller into the match format
|
|
// used for the textLayer.
|
|
var pageMatches, pageMatchesLength;
|
|
if (this.findController !== null) {
|
|
pageMatches = this.findController.pageMatches[this.pageIdx] || null;
|
|
pageMatchesLength = (this.findController.pageMatchesLength) ?
|
|
this.findController.pageMatchesLength[this.pageIdx] || null : null;
|
|
}
|
|
|
|
this.matches = this.convertMatches(pageMatches, pageMatchesLength);
|
|
this.renderMatches(this.matches);
|
|
},
|
|
|
|
/**
|
|
* Fixes text selection: adds additional div where mouse was clicked.
|
|
* This reduces flickering of the content if mouse slowly dragged down/up.
|
|
* @private
|
|
*/
|
|
_bindMouse: function TextLayerBuilder_bindMouse() {
|
|
var div = this.textLayerDiv;
|
|
var self = this;
|
|
var expandDivsTimer = null;
|
|
|
|
div.addEventListener('mousedown', function (e) {
|
|
if (self.enhanceTextSelection && self.textLayerRenderTask) {
|
|
self.textLayerRenderTask.expandTextDivs(true);
|
|
if ((typeof PDFJSDev === 'undefined' ||
|
|
!PDFJSDev.test('FIREFOX || MOZCENTRAL')) &&
|
|
expandDivsTimer) {
|
|
clearTimeout(expandDivsTimer);
|
|
expandDivsTimer = null;
|
|
}
|
|
return;
|
|
}
|
|
var end = div.querySelector('.endOfContent');
|
|
if (!end) {
|
|
return;
|
|
}
|
|
if (typeof PDFJSDev === 'undefined' ||
|
|
!PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
|
|
// On non-Firefox browsers, the selection will feel better if the height
|
|
// of the endOfContent div will be adjusted to start at mouse click
|
|
// location -- this will avoid flickering when selections moves up.
|
|
// However it does not work when selection started on empty space.
|
|
var adjustTop = e.target !== div;
|
|
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('GENERIC')) {
|
|
adjustTop = adjustTop && window.getComputedStyle(end).
|
|
getPropertyValue('-moz-user-select') !== 'none';
|
|
}
|
|
if (adjustTop) {
|
|
var divBounds = div.getBoundingClientRect();
|
|
var r = Math.max(0, (e.pageY - divBounds.top) / divBounds.height);
|
|
end.style.top = (r * 100).toFixed(2) + '%';
|
|
}
|
|
}
|
|
end.classList.add('active');
|
|
});
|
|
|
|
div.addEventListener('mouseup', function (e) {
|
|
if (self.enhanceTextSelection && self.textLayerRenderTask) {
|
|
if (typeof PDFJSDev === 'undefined' ||
|
|
!PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
|
|
expandDivsTimer = setTimeout(function() {
|
|
if (self.textLayerRenderTask) {
|
|
self.textLayerRenderTask.expandTextDivs(false);
|
|
}
|
|
expandDivsTimer = null;
|
|
}, EXPAND_DIVS_TIMEOUT);
|
|
} else {
|
|
self.textLayerRenderTask.expandTextDivs(false);
|
|
}
|
|
return;
|
|
}
|
|
var end = div.querySelector('.endOfContent');
|
|
if (!end) {
|
|
return;
|
|
}
|
|
if (typeof PDFJSDev === 'undefined' ||
|
|
!PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
|
|
end.style.top = '';
|
|
}
|
|
end.classList.remove('active');
|
|
});
|
|
},
|
|
};
|
|
return TextLayerBuilder;
|
|
})();
|
|
|
|
/**
|
|
* @constructor
|
|
* @implements IPDFTextLayerFactory
|
|
*/
|
|
function DefaultTextLayerFactory() {}
|
|
DefaultTextLayerFactory.prototype = {
|
|
/**
|
|
* @param {HTMLDivElement} textLayerDiv
|
|
* @param {number} pageIndex
|
|
* @param {PageViewport} viewport
|
|
* @param {boolean} enhanceTextSelection
|
|
* @returns {TextLayerBuilder}
|
|
*/
|
|
createTextLayerBuilder(textLayerDiv, pageIndex, viewport,
|
|
enhanceTextSelection = false) {
|
|
return new TextLayerBuilder({
|
|
textLayerDiv,
|
|
pageIndex,
|
|
viewport,
|
|
enhanceTextSelection,
|
|
});
|
|
},
|
|
};
|
|
|
|
export {
|
|
TextLayerBuilder,
|
|
DefaultTextLayerFactory,
|
|
};
|