mirror of
https://github.com/zen-browser/pdf.js.git
synced 2025-07-08 09:20:06 +02:00
XFA - Support text search in XFA documents.
Moves the logic out of TextLayerBuilder to handle highlighting matches into a new separate class `TextHighlighter` that can be used with regular PDFs and XFA PDFs. To mimic the current find functionality in XFA, two arrays from the XFA rendering are created to get the text content and map those to DOM nodes. Fixes #13878
This commit is contained in:
parent
3c8ee25e05
commit
bb47128864
11 changed files with 531 additions and 238 deletions
|
@ -23,7 +23,8 @@ const EXPAND_DIVS_TIMEOUT = 300; // ms
|
|||
* @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 {TextHighlighter} highlighter - Optional object that will handle
|
||||
* highlighting text from the find controller.
|
||||
* @property {boolean} enhanceTextSelection - Option to turn on improved
|
||||
* text selection.
|
||||
*/
|
||||
|
@ -31,8 +32,7 @@ const EXPAND_DIVS_TIMEOUT = 300; // ms
|
|||
/**
|
||||
* The text layer builder provides text selection functionality for the PDF.
|
||||
* It does this by creating overlay divs over the PDF's 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.
|
||||
* contain text that matches the PDF text they are overlaying.
|
||||
*/
|
||||
class TextLayerBuilder {
|
||||
constructor({
|
||||
|
@ -40,7 +40,7 @@ class TextLayerBuilder {
|
|||
eventBus,
|
||||
pageIndex,
|
||||
viewport,
|
||||
findController = null,
|
||||
highlighter = null,
|
||||
enhanceTextSelection = false,
|
||||
}) {
|
||||
this.textLayerDiv = textLayerDiv;
|
||||
|
@ -54,11 +54,10 @@ class TextLayerBuilder {
|
|||
this.matches = [];
|
||||
this.viewport = viewport;
|
||||
this.textDivs = [];
|
||||
this.findController = findController;
|
||||
this.textLayerRenderTask = null;
|
||||
this.highlighter = highlighter;
|
||||
this.enhanceTextSelection = enhanceTextSelection;
|
||||
|
||||
this._onUpdateTextLayerMatches = null;
|
||||
this._bindMouse();
|
||||
}
|
||||
|
||||
|
@ -94,6 +93,9 @@ class TextLayerBuilder {
|
|||
this.cancel();
|
||||
|
||||
this.textDivs = [];
|
||||
if (this.highlighter) {
|
||||
this.highlighter.setTextMapping(this.textDivs, this.textContentItemsStr);
|
||||
}
|
||||
const textLayerFrag = document.createDocumentFragment();
|
||||
this.textLayerRenderTask = renderTextLayer({
|
||||
textContent: this.textContent,
|
||||
|
@ -109,24 +111,12 @@ class TextLayerBuilder {
|
|||
() => {
|
||||
this.textLayerDiv.appendChild(textLayerFrag);
|
||||
this._finishRendering();
|
||||
this._updateMatches();
|
||||
this.highlighter?.enable();
|
||||
},
|
||||
function (reason) {
|
||||
// Cancelled or failed to render text layer; skipping errors.
|
||||
}
|
||||
);
|
||||
|
||||
if (!this._onUpdateTextLayerMatches) {
|
||||
this._onUpdateTextLayerMatches = evt => {
|
||||
if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) {
|
||||
this._updateMatches();
|
||||
}
|
||||
};
|
||||
this.eventBus._on(
|
||||
"updatetextlayermatches",
|
||||
this._onUpdateTextLayerMatches
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -137,13 +127,7 @@ class TextLayerBuilder {
|
|||
this.textLayerRenderTask.cancel();
|
||||
this.textLayerRenderTask = null;
|
||||
}
|
||||
if (this._onUpdateTextLayerMatches) {
|
||||
this.eventBus._off(
|
||||
"updatetextlayermatches",
|
||||
this._onUpdateTextLayerMatches
|
||||
);
|
||||
this._onUpdateTextLayerMatches = null;
|
||||
}
|
||||
this.highlighter?.disable();
|
||||
}
|
||||
|
||||
setTextContentStream(readableStream) {
|
||||
|
@ -156,198 +140,6 @@ class TextLayerBuilder {
|
|||
this.textContent = textContent;
|
||||
}
|
||||
|
||||
_convertMatches(matches, matchesLength) {
|
||||
// Early exit if there is nothing to convert.
|
||||
if (!matches) {
|
||||
return [];
|
||||
}
|
||||
const { textContentItemsStr } = this;
|
||||
|
||||
let i = 0,
|
||||
iIndex = 0;
|
||||
const end = textContentItemsStr.length - 1;
|
||||
const result = [];
|
||||
|
||||
for (let m = 0, mm = matches.length; m < mm; m++) {
|
||||
// Calculate the start position.
|
||||
let 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");
|
||||
}
|
||||
|
||||
const match = {
|
||||
begin: {
|
||||
divIdx: i,
|
||||
offset: matchIdx - iIndex,
|
||||
},
|
||||
};
|
||||
|
||||
// Calculate the end position.
|
||||
matchIdx += matchesLength[m];
|
||||
|
||||
// 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,
|
||||
};
|
||||
result.push(match);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_renderMatches(matches) {
|
||||
// Early exit if there is nothing to render.
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { findController, pageIdx, textContentItemsStr, textDivs } = this;
|
||||
|
||||
const isSelectedPage = pageIdx === findController.selected.pageIdx;
|
||||
const selectedMatchIdx = findController.selected.matchIdx;
|
||||
const highlightAll = findController.state.highlightAll;
|
||||
let prevEnd = null;
|
||||
const infinity = {
|
||||
divIdx: -1,
|
||||
offset: undefined,
|
||||
};
|
||||
|
||||
function beginText(begin, className) {
|
||||
const divIdx = begin.divIdx;
|
||||
textDivs[divIdx].textContent = "";
|
||||
return appendTextToDiv(divIdx, 0, begin.offset, className);
|
||||
}
|
||||
|
||||
function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
|
||||
const div = textDivs[divIdx];
|
||||
const content = textContentItemsStr[divIdx].substring(
|
||||
fromOffset,
|
||||
toOffset
|
||||
);
|
||||
const node = document.createTextNode(content);
|
||||
if (className) {
|
||||
const span = document.createElement("span");
|
||||
span.className = `${className} appended`;
|
||||
span.appendChild(node);
|
||||
div.appendChild(span);
|
||||
return className.includes("selected") ? span.offsetLeft : 0;
|
||||
}
|
||||
div.appendChild(node);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let 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 (let i = i0; i < i1; i++) {
|
||||
const match = matches[i];
|
||||
const begin = match.begin;
|
||||
const end = match.end;
|
||||
const isSelected = isSelectedPage && i === selectedMatchIdx;
|
||||
const highlightSuffix = isSelected ? " selected" : "";
|
||||
let selectedLeft = 0;
|
||||
|
||||
// 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) {
|
||||
selectedLeft = appendTextToDiv(
|
||||
begin.divIdx,
|
||||
begin.offset,
|
||||
end.offset,
|
||||
"highlight" + highlightSuffix
|
||||
);
|
||||
} else {
|
||||
selectedLeft = appendTextToDiv(
|
||||
begin.divIdx,
|
||||
begin.offset,
|
||||
infinity.offset,
|
||||
"highlight begin" + highlightSuffix
|
||||
);
|
||||
for (let n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {
|
||||
textDivs[n0].className = "highlight middle" + highlightSuffix;
|
||||
}
|
||||
beginText(end, "highlight end" + highlightSuffix);
|
||||
}
|
||||
prevEnd = end;
|
||||
|
||||
if (isSelected) {
|
||||
// Attempt to scroll the selected match into view.
|
||||
findController.scrollMatchIntoView({
|
||||
element: textDivs[begin.divIdx],
|
||||
selectedLeft,
|
||||
pageIndex: pageIdx,
|
||||
matchIndex: selectedMatchIdx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (prevEnd) {
|
||||
appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
|
||||
}
|
||||
}
|
||||
|
||||
_updateMatches() {
|
||||
// Only show matches when all rendering is done.
|
||||
if (!this.renderingDone) {
|
||||
return;
|
||||
}
|
||||
const { findController, matches, pageIdx, textContentItemsStr, textDivs } =
|
||||
this;
|
||||
let clearedUntilDivIdx = -1;
|
||||
|
||||
// Clear all current matches.
|
||||
for (let i = 0, ii = matches.length; i < ii; i++) {
|
||||
const match = matches[i];
|
||||
const begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
|
||||
for (let n = begin, end = match.end.divIdx; n <= end; n++) {
|
||||
const div = textDivs[n];
|
||||
div.textContent = textContentItemsStr[n];
|
||||
div.className = "";
|
||||
}
|
||||
clearedUntilDivIdx = match.end.divIdx + 1;
|
||||
}
|
||||
|
||||
if (!findController?.highlightMatches) {
|
||||
return;
|
||||
}
|
||||
// Convert the matches on the `findController` into the match format
|
||||
// used for the textLayer.
|
||||
const pageMatches = findController.pageMatches[pageIdx] || null;
|
||||
const pageMatchesLength = findController.pageMatchesLength[pageIdx] || null;
|
||||
|
||||
this.matches = this._convertMatches(pageMatches, pageMatchesLength);
|
||||
this._renderMatches(this.matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Improves text selection by adding an additional div where the mouse was
|
||||
* clicked. This reduces flickering of the content if the mouse is slowly
|
||||
|
@ -435,6 +227,7 @@ class DefaultTextLayerFactory {
|
|||
* @param {PageViewport} viewport
|
||||
* @param {boolean} enhanceTextSelection
|
||||
* @param {EventBus} eventBus
|
||||
* @param {TextHighlighter} highlighter
|
||||
* @returns {TextLayerBuilder}
|
||||
*/
|
||||
createTextLayerBuilder(
|
||||
|
@ -442,7 +235,8 @@ class DefaultTextLayerFactory {
|
|||
pageIndex,
|
||||
viewport,
|
||||
enhanceTextSelection = false,
|
||||
eventBus
|
||||
eventBus,
|
||||
highlighter
|
||||
) {
|
||||
return new TextLayerBuilder({
|
||||
textLayerDiv,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue