diff --git a/src/display/api.js b/src/display/api.js index 1fa6367e3..9bf74de1e 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -62,6 +62,7 @@ import { MessageHandler } from "../shared/message_handler.js"; import { Metadata } from "./metadata.js"; import { OptionalContentConfig } from "./optional_content_config.js"; import { PDFDataTransportStream } from "./transport_stream.js"; +import { XfaText } from "./xfa_text.js"; const DEFAULT_RANGE_CHUNK_SIZE = 65536; // 2^16 = 65536 const RENDERING_CANCELLED_TIMEOUT = 100; // ms @@ -1561,6 +1562,13 @@ class PDFPageProxy { * {@link TextContent} object that represents the page's text content. */ 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); return new Promise(function (resolve, reject) { diff --git a/src/display/xfa_layer.js b/src/display/xfa_layer.js index 0292e4edb..5159a9fde 100644 --- a/src/display/xfa_layer.js +++ b/src/display/xfa_layer.js @@ -13,6 +13,8 @@ * limitations under the License. */ +import { XfaText } from "./xfa_text.js"; + class XfaLayer { static setupStorage(html, id, element, storage, intent) { const storedData = storage.getValue(id, { value: null }); @@ -127,6 +129,9 @@ class XfaLayer { // Set defaults. rootDiv.setAttribute("class", "xfaLayer xfaFont"); + // Text nodes used for the text highlighter. + const textDivs = []; + while (stack.length > 0) { const [parent, i, html] = stack[stack.length - 1]; if (i + 1 === parent.children.length) { @@ -141,7 +146,9 @@ class XfaLayer { const { name } = child; if (name === "#text") { - html.appendChild(document.createTextNode(child.value)); + const node = document.createTextNode(child.value); + textDivs.push(node); + html.appendChild(node); continue; } @@ -160,7 +167,11 @@ class XfaLayer { if (child.children && child.children.length > 0) { stack.push([child, -1, childHtml]); } 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); } + + return { + textDivs, + }; } /** diff --git a/src/display/xfa_text.js b/src/display/xfa_text.js new file mode 100644 index 000000000..9bdeeee5a --- /dev/null +++ b/src/display/xfa_text.js @@ -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 }; diff --git a/test/integration/find_spec.js b/test/integration/find_spec.js index 4dad8d8af..c5c0e29fb 100644 --- a/test/integration/find_spec.js +++ b/test/integration/find_spec.js @@ -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"); + }) + ); + }); + }); }); diff --git a/web/base_viewer.js b/web/base_viewer.js index 04e184dc2..299f8baef 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -42,6 +42,7 @@ import { NullL10n } from "./l10n_utils.js"; import { PDFPageView } from "./pdf_page_view.js"; import { SimpleLinkService } from "./pdf_link_service.js"; import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js"; +import { TextHighlighter } from "./text_highlighter.js"; import { TextLayerBuilder } from "./text_layer_builder.js"; import { XfaLayerBuilder } from "./xfa_layer_builder.js"; @@ -525,7 +526,9 @@ class BaseViewer { const scale = this.currentScale; const viewport = firstPdfPage.getViewport({ scale: scale * CSS_UNITS }); const textLayerFactory = - this.textLayerMode !== TextLayerMode.DISABLE ? this : null; + this.textLayerMode !== TextLayerMode.DISABLE && !isPureXfa + ? this + : null; const xfaLayerFactory = isPureXfa ? this : null; for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { @@ -541,6 +544,7 @@ class BaseViewer { textLayerMode: this.textLayerMode, annotationLayerFactory: this, xfaLayerFactory, + textHighlighterFactory: this, structTreeLayerFactory: this, imageResourcesPath: this.imageResourcesPath, renderInteractiveForms: this.renderInteractiveForms, @@ -1242,6 +1246,7 @@ class BaseViewer { * @param {PageViewport} viewport * @param {boolean} enhanceTextSelection * @param {EventBus} eventBus + * @param {TextHighlighter} highlighter * @returns {TextLayerBuilder} */ createTextLayerBuilder( @@ -1249,17 +1254,31 @@ class BaseViewer { pageIndex, viewport, enhanceTextSelection = false, - eventBus + eventBus, + highlighter ) { return new TextLayerBuilder({ textLayerDiv, eventBus, pageIndex, viewport, - findController: this.isInPresentationMode ? null : this.findController, enhanceTextSelection: this.isInPresentationMode ? false : enhanceTextSelection, + highlighter, + }); + } + + /** + * @param {number} pageIndex + * @param {EventBus} eventBus + * @returns {TextHighlighter} + */ + createTextHighlighter(pageIndex, eventBus) { + return new TextHighlighter({ + eventBus, + pageIndex, + findController: this.isInPresentationMode ? null : this.findController, }); } diff --git a/web/interfaces.js b/web/interfaces.js index c8ec18327..512d4c42a 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -162,6 +162,7 @@ class IPDFTextLayerFactory { * @param {PageViewport} viewport * @param {boolean} enhanceTextSelection * @param {EventBus} eventBus + * @param {TextHighlighter} highlighter * @returns {TextLayerBuilder} */ createTextLayerBuilder( @@ -169,7 +170,8 @@ class IPDFTextLayerFactory { pageIndex, viewport, enhanceTextSelection = false, - eventBus + eventBus, + highlighter ) {} } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 9efa40543..3f1769aa3 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -101,6 +101,11 @@ class PDFPageView { this.textLayerFactory = options.textLayerFactory; this.annotationLayerFactory = options.annotationLayerFactory; this.xfaLayerFactory = options.xfaLayerFactory; + this.textHighlighter = + options.textHighlighterFactory?.createTextHighlighter( + this.id - 1, + this.eventBus + ); this.structTreeLayerFactory = options.structTreeLayerFactory; this.renderer = options.renderer || RendererType.CANVAS; this.l10n = options.l10n || NullL10n; @@ -175,7 +180,10 @@ class PDFPageView { async _renderXfaLayer() { let error = null; 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) { error = ex; } 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 */ @@ -382,6 +400,7 @@ class PDFPageView { if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) { this.xfaLayer.cancel(); this.xfaLayer = null; + this.textHighlighter?.disable(); } if (this._onTextLayerRendered) { this.eventBus._off("textlayerrendered", this._onTextLayerRendered); @@ -533,7 +552,8 @@ class PDFPageView { this.id - 1, this.viewport, this.textLayerMode === TextLayerMode.ENABLE_ENHANCE, - this.eventBus + this.eventBus, + this.textHighlighter ); } this.textLayer = textLayer; diff --git a/web/text_highlighter.js b/web/text_highlighter.js new file mode 100644 index 000000000..a2b7d69a5 --- /dev/null +++ b/web/text_highlighter.js @@ -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] = "Item 0" and texts[0] = "Item 0"; + * + * @param {Array} divs + * @param {Array} 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 }; diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index 635574fae..2c7022c75 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -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, diff --git a/web/xfa_layer_builder.css b/web/xfa_layer_builder.css index 192d09ec4..b2ee798f7 100644 --- a/web/xfa_layer_builder.css +++ b/web/xfa_layer_builder.css @@ -17,6 +17,37 @@ --unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,"); } +.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 { overflow: hidden; position: relative; diff --git a/web/xfa_layer_builder.js b/web/xfa_layer_builder.js index 17f891c19..0edfe905c 100644 --- a/web/xfa_layer_builder.js +++ b/web/xfa_layer_builder.js @@ -39,8 +39,9 @@ class XfaLayerBuilder { /** * @param {PageViewport} viewport * @param {string} intent (default value is 'display') - * @returns {Promise} A promise that is resolved when rendering of the - * annotations is complete. + * @returns {Promise} A promise that is resolved when rendering + * 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") { if (intent === "print") { @@ -67,7 +68,7 @@ class XfaLayerBuilder { .getXfa() .then(xfa => { if (this._cancelled) { - return; + return Promise.resolve(); } const parameters = { viewport: viewport.clone({ dontFlip: true }), @@ -79,15 +80,13 @@ class XfaLayerBuilder { }; if (this.div) { - 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); + return XfaLayer.update(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 => { console.error(error);