Merge pull request #13908 from brendandahl/xfa-find

[api-minor] XFA - Support text search in XFA documents.
This commit is contained in:
Brendan Dahl 2021-08-23 08:53:02 -07:00 committed by GitHub
commit bf5a45ce6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 531 additions and 238 deletions

View File

@ -62,6 +62,7 @@ import { MessageHandler } from "../shared/message_handler.js";
import { Metadata } from "./metadata.js"; import { Metadata } from "./metadata.js";
import { OptionalContentConfig } from "./optional_content_config.js"; import { OptionalContentConfig } from "./optional_content_config.js";
import { PDFDataTransportStream } from "./transport_stream.js"; import { PDFDataTransportStream } from "./transport_stream.js";
import { XfaText } from "./xfa_text.js";
const DEFAULT_RANGE_CHUNK_SIZE = 65536; // 2^16 = 65536 const DEFAULT_RANGE_CHUNK_SIZE = 65536; // 2^16 = 65536
const RENDERING_CANCELLED_TIMEOUT = 100; // ms const RENDERING_CANCELLED_TIMEOUT = 100; // ms
@ -1531,6 +1532,13 @@ class PDFPageProxy {
* {@link TextContent} object that represents the page's text content. * {@link TextContent} object that represents the page's text content.
*/ */
getTextContent(params = {}) { getTextContent(params = {}) {
if (this._transport._htmlForXfa) {
// TODO: We need to revisit this once the XFA foreground patch lands and
// only do this for non-foreground XFA.
return this.getXfa().then(xfa => {
return XfaText.textContent(xfa);
});
}
const readableStream = this.streamTextContent(params); const readableStream = this.streamTextContent(params);
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {

View File

@ -13,6 +13,8 @@
* limitations under the License. * limitations under the License.
*/ */
import { XfaText } from "./xfa_text.js";
class XfaLayer { class XfaLayer {
static setupStorage(html, id, element, storage, intent) { static setupStorage(html, id, element, storage, intent) {
const storedData = storage.getValue(id, { value: null }); const storedData = storage.getValue(id, { value: null });
@ -127,6 +129,9 @@ class XfaLayer {
// Set defaults. // Set defaults.
rootDiv.setAttribute("class", "xfaLayer xfaFont"); rootDiv.setAttribute("class", "xfaLayer xfaFont");
// Text nodes used for the text highlighter.
const textDivs = [];
while (stack.length > 0) { while (stack.length > 0) {
const [parent, i, html] = stack[stack.length - 1]; const [parent, i, html] = stack[stack.length - 1];
if (i + 1 === parent.children.length) { if (i + 1 === parent.children.length) {
@ -141,7 +146,9 @@ class XfaLayer {
const { name } = child; const { name } = child;
if (name === "#text") { if (name === "#text") {
html.appendChild(document.createTextNode(child.value)); const node = document.createTextNode(child.value);
textDivs.push(node);
html.appendChild(node);
continue; continue;
} }
@ -160,7 +167,11 @@ class XfaLayer {
if (child.children && child.children.length > 0) { if (child.children && child.children.length > 0) {
stack.push([child, -1, childHtml]); stack.push([child, -1, childHtml]);
} else if (child.value) { } else if (child.value) {
childHtml.appendChild(document.createTextNode(child.value)); const node = document.createTextNode(child.value);
if (XfaText.shouldBuildText(name)) {
textDivs.push(node);
}
childHtml.appendChild(node);
} }
} }
@ -185,6 +196,10 @@ class XfaLayer {
)) { )) {
el.setAttribute("readOnly", true); el.setAttribute("readOnly", true);
} }
return {
textDivs,
};
} }
/** /**

79
src/display/xfa_text.js Normal file
View File

@ -0,0 +1,79 @@
/* Copyright 2021 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.
*/
class XfaText {
/**
* Walk an XFA tree and create an array of text nodes that is compatible
* with a regular PDFs TextContent. Currently, only TextItem.str is supported,
* all other fields and styles haven't been implemented.
*
* @param {Object} xfa - An XFA fake DOM object.
*
* @returns {TextContent}
*/
static textContent(xfa) {
const items = [];
const output = {
items,
styles: Object.create(null),
};
function walk(node) {
if (!node) {
return;
}
let str = null;
const name = node.name;
if (name === "#text") {
str = node.value;
} else if (!XfaText.shouldBuildText(name)) {
return;
} else if (node?.attributes?.textContent) {
str = node.attributes.textContent;
} else if (node.value) {
str = node.value;
}
if (str !== null) {
items.push({
str,
});
}
if (!node.children) {
return;
}
for (const child of node.children) {
walk(child);
}
}
walk(xfa);
return output;
}
/**
* @param {string} name - DOM node name. (lower case)
*
* @returns {boolean} true if the DOM node should have a corresponding text
* node.
*/
static shouldBuildText(name) {
return !(
name === "textarea" ||
name === "input" ||
name === "option" ||
name === "select"
);
}
}
export { XfaText };

View File

@ -72,4 +72,37 @@ describe("find bar", () => {
); );
}); });
}); });
describe("highlight all", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("xfa_imm5257e.pdf#zoom=100", ".xfaLayer");
});
afterAll(async () => {
await closePages(pages);
});
it("must search xfa correctly", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.click("#viewFind");
await page.waitForSelector("#viewFind", { hidden: false });
await page.type("#findInput", "city");
await page.waitForSelector("#findInput[data-status='']");
await page.waitForSelector(".xfaLayer .highlight");
const resultElement = await page.waitForSelector("#findResultsCount");
const resultText = await resultElement.evaluate(el => el.textContent);
expect(resultText).toEqual("1 of 7 matches");
const selectedElement = await page.waitForSelector(
".highlight.selected"
);
const selectedText = await selectedElement.evaluate(
el => el.textContent
);
expect(selectedText).toEqual("City");
})
);
});
});
}); });

View File

@ -42,6 +42,7 @@ import { NullL10n } from "./l10n_utils.js";
import { PDFPageView } from "./pdf_page_view.js"; import { PDFPageView } from "./pdf_page_view.js";
import { SimpleLinkService } from "./pdf_link_service.js"; import { SimpleLinkService } from "./pdf_link_service.js";
import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js"; import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js";
import { TextHighlighter } from "./text_highlighter.js";
import { TextLayerBuilder } from "./text_layer_builder.js"; import { TextLayerBuilder } from "./text_layer_builder.js";
import { XfaLayerBuilder } from "./xfa_layer_builder.js"; import { XfaLayerBuilder } from "./xfa_layer_builder.js";
@ -525,7 +526,9 @@ class BaseViewer {
const scale = this.currentScale; const scale = this.currentScale;
const viewport = firstPdfPage.getViewport({ scale: scale * CSS_UNITS }); const viewport = firstPdfPage.getViewport({ scale: scale * CSS_UNITS });
const textLayerFactory = const textLayerFactory =
this.textLayerMode !== TextLayerMode.DISABLE ? this : null; this.textLayerMode !== TextLayerMode.DISABLE && !isPureXfa
? this
: null;
const xfaLayerFactory = isPureXfa ? this : null; const xfaLayerFactory = isPureXfa ? this : null;
for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) {
@ -541,6 +544,7 @@ class BaseViewer {
textLayerMode: this.textLayerMode, textLayerMode: this.textLayerMode,
annotationLayerFactory: this, annotationLayerFactory: this,
xfaLayerFactory, xfaLayerFactory,
textHighlighterFactory: this,
structTreeLayerFactory: this, structTreeLayerFactory: this,
imageResourcesPath: this.imageResourcesPath, imageResourcesPath: this.imageResourcesPath,
renderInteractiveForms: this.renderInteractiveForms, renderInteractiveForms: this.renderInteractiveForms,
@ -1242,6 +1246,7 @@ class BaseViewer {
* @param {PageViewport} viewport * @param {PageViewport} viewport
* @param {boolean} enhanceTextSelection * @param {boolean} enhanceTextSelection
* @param {EventBus} eventBus * @param {EventBus} eventBus
* @param {TextHighlighter} highlighter
* @returns {TextLayerBuilder} * @returns {TextLayerBuilder}
*/ */
createTextLayerBuilder( createTextLayerBuilder(
@ -1249,17 +1254,31 @@ class BaseViewer {
pageIndex, pageIndex,
viewport, viewport,
enhanceTextSelection = false, enhanceTextSelection = false,
eventBus eventBus,
highlighter
) { ) {
return new TextLayerBuilder({ return new TextLayerBuilder({
textLayerDiv, textLayerDiv,
eventBus, eventBus,
pageIndex, pageIndex,
viewport, viewport,
findController: this.isInPresentationMode ? null : this.findController,
enhanceTextSelection: this.isInPresentationMode enhanceTextSelection: this.isInPresentationMode
? false ? false
: enhanceTextSelection, : enhanceTextSelection,
highlighter,
});
}
/**
* @param {number} pageIndex
* @param {EventBus} eventBus
* @returns {TextHighlighter}
*/
createTextHighlighter(pageIndex, eventBus) {
return new TextHighlighter({
eventBus,
pageIndex,
findController: this.isInPresentationMode ? null : this.findController,
}); });
} }

View File

@ -162,6 +162,7 @@ class IPDFTextLayerFactory {
* @param {PageViewport} viewport * @param {PageViewport} viewport
* @param {boolean} enhanceTextSelection * @param {boolean} enhanceTextSelection
* @param {EventBus} eventBus * @param {EventBus} eventBus
* @param {TextHighlighter} highlighter
* @returns {TextLayerBuilder} * @returns {TextLayerBuilder}
*/ */
createTextLayerBuilder( createTextLayerBuilder(
@ -169,7 +170,8 @@ class IPDFTextLayerFactory {
pageIndex, pageIndex,
viewport, viewport,
enhanceTextSelection = false, enhanceTextSelection = false,
eventBus eventBus,
highlighter
) {} ) {}
} }

View File

@ -101,6 +101,11 @@ class PDFPageView {
this.textLayerFactory = options.textLayerFactory; this.textLayerFactory = options.textLayerFactory;
this.annotationLayerFactory = options.annotationLayerFactory; this.annotationLayerFactory = options.annotationLayerFactory;
this.xfaLayerFactory = options.xfaLayerFactory; this.xfaLayerFactory = options.xfaLayerFactory;
this.textHighlighter =
options.textHighlighterFactory?.createTextHighlighter(
this.id - 1,
this.eventBus
);
this.structTreeLayerFactory = options.structTreeLayerFactory; this.structTreeLayerFactory = options.structTreeLayerFactory;
this.renderer = options.renderer || RendererType.CANVAS; this.renderer = options.renderer || RendererType.CANVAS;
this.l10n = options.l10n || NullL10n; this.l10n = options.l10n || NullL10n;
@ -175,7 +180,10 @@ class PDFPageView {
async _renderXfaLayer() { async _renderXfaLayer() {
let error = null; let error = null;
try { try {
await this.xfaLayer.render(this.viewport, "display"); const result = await this.xfaLayer.render(this.viewport, "display");
if (this.textHighlighter) {
this._buildXfaTextContentItems(result.textDivs);
}
} catch (ex) { } catch (ex) {
error = ex; error = ex;
} finally { } finally {
@ -187,6 +195,16 @@ class PDFPageView {
} }
} }
async _buildXfaTextContentItems(textDivs) {
const text = await this.pdfPage.getTextContent();
const items = [];
for (const item of text.items) {
items.push(item.str);
}
this.textHighlighter.setTextMapping(textDivs, items);
this.textHighlighter.enable();
}
/** /**
* @private * @private
*/ */
@ -382,6 +400,7 @@ class PDFPageView {
if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) { if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) {
this.xfaLayer.cancel(); this.xfaLayer.cancel();
this.xfaLayer = null; this.xfaLayer = null;
this.textHighlighter?.disable();
} }
if (this._onTextLayerRendered) { if (this._onTextLayerRendered) {
this.eventBus._off("textlayerrendered", this._onTextLayerRendered); this.eventBus._off("textlayerrendered", this._onTextLayerRendered);
@ -533,7 +552,8 @@ class PDFPageView {
this.id - 1, this.id - 1,
this.viewport, this.viewport,
this.textLayerMode === TextLayerMode.ENABLE_ENHANCE, this.textLayerMode === TextLayerMode.ENABLE_ENHANCE,
this.eventBus this.eventBus,
this.textHighlighter
); );
} }
this.textLayer = textLayer; this.textLayer = textLayer;

293
web/text_highlighter.js Normal file
View File

@ -0,0 +1,293 @@
/* Copyright 2021 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.
*/
/**
* @typedef {Object} TextHighlighter
* @property {PDFFindController} findController
* @property {EventBus} eventBus - The application event bus.
* @property {number} pageIndex - The page index.
*/
/**
* TextHighlighter handles highlighting matches from the FindController in
* either the text layer or XFA layer depending on the type of document.
*/
class TextHighlighter {
constructor({ findController, eventBus, pageIndex }) {
this.findController = findController;
this.matches = [];
this.eventBus = eventBus;
this.pageIdx = pageIndex;
this._onUpdateTextLayerMatches = null;
this.textDivs = null;
this.textContentItemsStr = null;
this.enabled = false;
}
/**
* Store two arrays that will map DOM nodes to text they should contain.
* The arrays should be of equal length and the array element at each index
* should correspond to the other. e.g.
* `items[0] = "<span>Item 0</span>" and texts[0] = "Item 0";
*
* @param {Array<Node>} divs
* @param {Array<string>} texts
*/
setTextMapping(divs, texts) {
this.textDivs = divs;
this.textContentItemsStr = texts;
}
/**
* Start listening for events to update the highlighter and check if there are
* any current matches that need be highlighted.
*/
enable() {
if (!this.textDivs || !this.textContentItemsStr) {
throw new Error("Text divs and strings have not been set.");
}
if (this.enabled) {
throw new Error("TextHighlighter is already enabled.");
}
this.enabled = true;
if (!this._onUpdateTextLayerMatches) {
this._onUpdateTextLayerMatches = evt => {
if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) {
this._updateMatches();
}
};
this.eventBus._on(
"updatetextlayermatches",
this._onUpdateTextLayerMatches
);
}
this._updateMatches();
}
disable() {
if (!this.enabled) {
return;
}
this.enabled = false;
if (this._onUpdateTextLayerMatches) {
this.eventBus._off(
"updatetextlayermatches",
this._onUpdateTextLayerMatches
);
this._onUpdateTextLayerMatches = null;
}
}
_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 } = this;
const { 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) {
let div = textDivs[divIdx];
if (div.nodeType === 3) {
const span = document.createElement("span");
div.parentNode.insertBefore(span, div);
span.appendChild(div);
textDivs[divIdx] = span;
div = span;
}
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() {
if (!this.enabled) {
return;
}
const { findController, matches, pageIdx } = this;
const { 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);
}
}
export { TextHighlighter };

View File

@ -23,7 +23,8 @@ const EXPAND_DIVS_TIMEOUT = 300; // ms
* @property {EventBus} eventBus - The application event bus. * @property {EventBus} eventBus - The application event bus.
* @property {number} pageIndex - The page index. * @property {number} pageIndex - The page index.
* @property {PageViewport} viewport - The viewport of the text layer. * @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 * @property {boolean} enhanceTextSelection - Option to turn on improved
* text selection. * text selection.
*/ */
@ -31,8 +32,7 @@ const EXPAND_DIVS_TIMEOUT = 300; // ms
/** /**
* The text layer builder provides text selection functionality for the PDF. * 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 * 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 * contain text that matches the PDF text they are overlaying.
* also provides a way to highlight text that is being searched for.
*/ */
class TextLayerBuilder { class TextLayerBuilder {
constructor({ constructor({
@ -40,7 +40,7 @@ class TextLayerBuilder {
eventBus, eventBus,
pageIndex, pageIndex,
viewport, viewport,
findController = null, highlighter = null,
enhanceTextSelection = false, enhanceTextSelection = false,
}) { }) {
this.textLayerDiv = textLayerDiv; this.textLayerDiv = textLayerDiv;
@ -54,11 +54,10 @@ class TextLayerBuilder {
this.matches = []; this.matches = [];
this.viewport = viewport; this.viewport = viewport;
this.textDivs = []; this.textDivs = [];
this.findController = findController;
this.textLayerRenderTask = null; this.textLayerRenderTask = null;
this.highlighter = highlighter;
this.enhanceTextSelection = enhanceTextSelection; this.enhanceTextSelection = enhanceTextSelection;
this._onUpdateTextLayerMatches = null;
this._bindMouse(); this._bindMouse();
} }
@ -94,6 +93,9 @@ class TextLayerBuilder {
this.cancel(); this.cancel();
this.textDivs = []; this.textDivs = [];
if (this.highlighter) {
this.highlighter.setTextMapping(this.textDivs, this.textContentItemsStr);
}
const textLayerFrag = document.createDocumentFragment(); const textLayerFrag = document.createDocumentFragment();
this.textLayerRenderTask = renderTextLayer({ this.textLayerRenderTask = renderTextLayer({
textContent: this.textContent, textContent: this.textContent,
@ -109,24 +111,12 @@ class TextLayerBuilder {
() => { () => {
this.textLayerDiv.appendChild(textLayerFrag); this.textLayerDiv.appendChild(textLayerFrag);
this._finishRendering(); this._finishRendering();
this._updateMatches(); this.highlighter?.enable();
}, },
function (reason) { function (reason) {
// Cancelled or failed to render text layer; skipping errors. // 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.cancel();
this.textLayerRenderTask = null; this.textLayerRenderTask = null;
} }
if (this._onUpdateTextLayerMatches) { this.highlighter?.disable();
this.eventBus._off(
"updatetextlayermatches",
this._onUpdateTextLayerMatches
);
this._onUpdateTextLayerMatches = null;
}
} }
setTextContentStream(readableStream) { setTextContentStream(readableStream) {
@ -156,198 +140,6 @@ class TextLayerBuilder {
this.textContent = textContent; 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 * Improves text selection by adding an additional div where the mouse was
* clicked. This reduces flickering of the content if the mouse is slowly * clicked. This reduces flickering of the content if the mouse is slowly
@ -435,6 +227,7 @@ class DefaultTextLayerFactory {
* @param {PageViewport} viewport * @param {PageViewport} viewport
* @param {boolean} enhanceTextSelection * @param {boolean} enhanceTextSelection
* @param {EventBus} eventBus * @param {EventBus} eventBus
* @param {TextHighlighter} highlighter
* @returns {TextLayerBuilder} * @returns {TextLayerBuilder}
*/ */
createTextLayerBuilder( createTextLayerBuilder(
@ -442,7 +235,8 @@ class DefaultTextLayerFactory {
pageIndex, pageIndex,
viewport, viewport,
enhanceTextSelection = false, enhanceTextSelection = false,
eventBus eventBus,
highlighter
) { ) {
return new TextLayerBuilder({ return new TextLayerBuilder({
textLayerDiv, textLayerDiv,

View File

@ -17,6 +17,37 @@
--unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>"); --unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");
} }
.xfaLayer .highlight {
margin: -1px;
padding: 1px;
background-color: rgba(239, 203, 237, 1);
border-radius: 4px;
}
.xfaLayer .highlight.appended {
position: initial;
}
.xfaLayer .highlight.begin {
border-radius: 4px 0 0 4px;
}
.xfaLayer .highlight.end {
border-radius: 0 4px 4px 0;
}
.xfaLayer .highlight.middle {
border-radius: 0;
}
.xfaLayer .highlight.selected {
background-color: rgba(203, 223, 203, 1);
}
.xfaLayer ::selection {
background: rgba(0, 0, 255, 1);
}
.xfaPage { .xfaPage {
overflow: hidden; overflow: hidden;
position: relative; position: relative;

View File

@ -39,8 +39,9 @@ class XfaLayerBuilder {
/** /**
* @param {PageViewport} viewport * @param {PageViewport} viewport
* @param {string} intent (default value is 'display') * @param {string} intent (default value is 'display')
* @returns {Promise<void>} A promise that is resolved when rendering of the * @returns {Promise<Object | void>} A promise that is resolved when rendering
* annotations is complete. * of the XFA layer is complete. The first rendering will return an object
* with a `textDivs` property that can be used with the TextHighlighter.
*/ */
render(viewport, intent = "display") { render(viewport, intent = "display") {
if (intent === "print") { if (intent === "print") {
@ -67,7 +68,7 @@ class XfaLayerBuilder {
.getXfa() .getXfa()
.then(xfa => { .then(xfa => {
if (this._cancelled) { if (this._cancelled) {
return; return Promise.resolve();
} }
const parameters = { const parameters = {
viewport: viewport.clone({ dontFlip: true }), viewport: viewport.clone({ dontFlip: true }),
@ -79,15 +80,13 @@ class XfaLayerBuilder {
}; };
if (this.div) { if (this.div) {
XfaLayer.update(parameters); return XfaLayer.update(parameters);
} else {
// Create an xfa layer div and render the form
this.div = document.createElement("div");
this.pageDiv.appendChild(this.div);
parameters.div = this.div;
XfaLayer.render(parameters);
} }
// Create an xfa layer div and render the form
this.div = document.createElement("div");
this.pageDiv.appendChild(this.div);
parameters.div = this.div;
return XfaLayer.render(parameters);
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);