diff --git a/src/display/text_layer.js b/src/display/text_layer.js index b4e84b8df..ec1ef6e65 100644 --- a/src/display/text_layer.js +++ b/src/display/text_layer.js @@ -16,6 +16,7 @@ import { AbortException, createPromiseCapability, + FeatureTest, Util, } from "../shared/util.js"; @@ -27,16 +28,40 @@ import { * render (the object is returned by the page's `getTextContent` method). * @property {ReadableStream} [textContentStream] - Text content stream to * render (the stream is returned by the page's `streamTextContent` method). - * @property {DocumentFragment | HTMLElement} container - The DOM node that - * will contain the text runs. + * @property {HTMLElement} container - The DOM node that will contain the text + * runs. * @property {import("./display_utils").PageViewport} viewport - The target * viewport to properly layout the text runs. * @property {Array} [textDivs] - HTML elements that correspond to * the text items of the textContent input. * This is output and shall initially be set to an empty array. + * @property {WeakMap} [textDivProperties] - Some properties + * weakly mapped to the HTML elements used to render the text. * @property {Array} [textContentItemsStr] - Strings that correspond to * the `str` property of the text items of the textContent input. * This is output and shall initially be set to an empty array. + * @property {boolean} [isOffscreenCanvasSupported] true if we can use + * OffscreenCanvas to measure string widths. + */ + +/** + * Text layer update parameters. + * + * @typedef {Object} TextLayerUpdateParameters + * @property {HTMLElement} container - The DOM node that will contain the text + * runs. + * @property {import("./display_utils").PageViewport} viewport - The target + * viewport to properly layout the text runs. + * @property {Array} [textDivs] - HTML elements that correspond to + * the text items of the textContent input. + * This is output and shall initially be set to an empty array. + * @property {WeakMap} [textDivProperties] - Some properties + * weakly mapped to the HTML elements used to render the text. + * @property {boolean} [isOffscreenCanvasSupported] true if we can use + * OffscreenCanvas to measure string widths. + * @property {boolean} [mustRotate] true if the text layer must be rotated. + * @property {boolean} [mustRescale] true if the text layer contents must be + * rescaled. */ const MAX_TEXT_DIVS_TO_RENDER = 100000; @@ -44,13 +69,27 @@ const DEFAULT_FONT_SIZE = 30; const DEFAULT_FONT_ASCENT = 0.8; const ascentCache = new Map(); -function getAscent(fontFamily, ctx) { +function getCtx(size, isOffscreenCanvasSupported) { + let ctx; + if (isOffscreenCanvasSupported && FeatureTest.isOffscreenCanvasSupported) { + ctx = new OffscreenCanvas(size, size).getContext("2d", { alpha: false }); + } else { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + ctx = canvas.getContext("2d", { alpha: false }); + } + + return ctx; +} + +function getAscent(fontFamily, isOffscreenCanvasSupported) { const cachedAscent = ascentCache.get(fontFamily); if (cachedAscent) { return cachedAscent; } - ctx.save(); + const ctx = getCtx(DEFAULT_FONT_SIZE, isOffscreenCanvasSupported); + ctx.font = `${DEFAULT_FONT_SIZE}px ${fontFamily}`; const metrics = ctx.measureText(""); @@ -58,9 +97,10 @@ function getAscent(fontFamily, ctx) { let ascent = metrics.fontBoundingBoxAscent; let descent = Math.abs(metrics.fontBoundingBoxDescent); if (ascent) { - ctx.restore(); const ratio = ascent / (ascent + descent); ascentCache.set(fontFamily, ratio); + + ctx.canvas.width = ctx.canvas.height = 0; return ratio; } @@ -99,7 +139,7 @@ function getAscent(fontFamily, ctx) { } } - ctx.restore(); + ctx.canvas.width = ctx.canvas.height = 0; if (ascent) { const ratio = ascent / (ascent + descent); @@ -111,7 +151,7 @@ function getAscent(fontFamily, ctx) { return DEFAULT_FONT_ASCENT; } -function appendText(task, geom, styles, ctx) { +function appendText(task, geom, styles) { // Initialize all used properties to keep the caches monomorphic. const textDiv = document.createElement("span"); const textDivProperties = { @@ -123,14 +163,15 @@ function appendText(task, geom, styles, ctx) { }; task._textDivs.push(textDiv); - const tx = Util.transform(task._viewport.transform, geom.transform); + const tx = Util.transform(task._transform, geom.transform); let angle = Math.atan2(tx[1], tx[0]); const style = styles[geom.fontName]; if (style.vertical) { angle += Math.PI / 2; } const fontHeight = Math.hypot(tx[2], tx[3]); - const fontAscent = fontHeight * getAscent(style.fontFamily, ctx); + const fontAscent = + fontHeight * getAscent(style.fontFamily, task._isOffscreenCanvasSupported); let left, top; if (angle === 0) { @@ -140,12 +181,21 @@ function appendText(task, geom, styles, ctx) { left = tx[4] + fontAscent * Math.sin(angle); top = tx[5] - fontAscent * Math.cos(angle); } + + const scaleFactorStr = "calc(var(--scale-factor)*"; + const divStyle = textDiv.style; // Setting the style properties individually, rather than all at once, // should be OK since the `textDiv` isn't appended to the document yet. - textDiv.style.left = `${left}px`; - textDiv.style.top = `${top}px`; - textDiv.style.fontSize = `${fontHeight}px`; - textDiv.style.fontFamily = style.fontFamily; + if (task._container === task._rootContainer) { + divStyle.left = `${((100 * left) / task._pageWidth).toFixed(2)}%`; + divStyle.top = `${((100 * top) / task._pageHeight).toFixed(2)}%`; + } else { + // We're in a marked content span, hence we can't use percents. + divStyle.left = `${scaleFactorStr}${left.toFixed(2)}px)`; + divStyle.top = `${scaleFactorStr}${top.toFixed(2)}px)`; + } + divStyle.fontSize = `${scaleFactorStr}${fontHeight.toFixed(2)}px)`; + divStyle.fontFamily = style.fontFamily; textDivProperties.fontSize = fontHeight; @@ -183,11 +233,7 @@ function appendText(task, geom, styles, ctx) { } } if (shouldScaleText) { - if (style.vertical) { - textDivProperties.canvasWidth = geom.height * task._viewport.scale; - } else { - textDivProperties.canvasWidth = geom.width * task._viewport.scale; - } + textDivProperties.canvasWidth = style.vertical ? geom.height : geom.width; } task._textDivProperties.set(textDiv, textDivProperties); if (task._textContentStream) { @@ -195,6 +241,35 @@ function appendText(task, geom, styles, ctx) { } } +function layout(params) { + const { div, scale, properties, ctx, prevFontSize, prevFontFamily } = params; + const { style } = div; + let transform = ""; + if (properties.canvasWidth !== 0 && properties.hasText) { + const { fontFamily } = style; + const { canvasWidth, fontSize } = properties; + + if (prevFontSize !== fontSize || prevFontFamily !== fontFamily) { + ctx.font = `${fontSize * scale}px ${fontFamily}`; + params.prevFontSize = fontSize; + params.prevFontFamily = fontFamily; + } + + // Only measure the width for multi-char text divs, see `appendText`. + const { width } = ctx.measureText(div.textContent); + + if (width > 0) { + transform = `scaleX(${(canvasWidth * scale) / width})`; + } + } + if (properties.angle !== 0) { + transform = `rotate(${properties.angle}deg) ${transform}`; + } + if (transform.length > 0) { + style.transform = transform; + } +} + function render(task) { if (task._canceled) { return; @@ -228,40 +303,41 @@ class TextLayerRenderTask { container, viewport, textDivs, + textDivProperties, textContentItemsStr, + isOffscreenCanvasSupported, }) { this._textContent = textContent; this._textContentStream = textContentStream; - this._container = container; - this._document = container.ownerDocument; - this._viewport = viewport; + this._container = this._rootContainer = container; this._textDivs = textDivs || []; this._textContentItemsStr = textContentItemsStr || []; this._fontInspectorEnabled = !!globalThis.FontInspector?.enabled; this._reader = null; - this._layoutTextLastFontSize = null; - this._layoutTextLastFontFamily = null; - this._layoutTextCtx = null; - this._textDivProperties = new WeakMap(); + this._textDivProperties = textDivProperties || new WeakMap(); this._renderingDone = false; this._canceled = false; this._capability = createPromiseCapability(); - this._devicePixelRatio = globalThis.devicePixelRatio || 1; + this._layoutTextParams = { + prevFontSize: null, + prevFontFamily: null, + div: null, + scale: viewport.scale * (globalThis.devicePixelRatio || 1), + properties: null, + ctx: getCtx(0, isOffscreenCanvasSupported), + }; + const [pageLLx, pageLLy, pageURx, pageURy] = viewport.viewBox; + this._transform = [1, 0, 0, -1, -pageLLx, pageURy]; + this._pageWidth = pageURx - pageLLx; + this._pageHeight = pageURy - pageLLy; + + setTextLayerDimensions(container, viewport); // Always clean-up the temporary canvas once rendering is no longer pending. this._capability.promise .finally(() => { - // The `textDiv` properties are no longer needed. - this._textDivProperties = null; - - if (this._layoutTextCtx) { - // Zeroing the width and height cause Firefox to release graphics - // resources immediately, which can greatly reduce memory consumption. - this._layoutTextCtx.canvas.width = 0; - this._layoutTextCtx.canvas.height = 0; - this._layoutTextCtx = null; - } + this._layoutTextParams = null; }) .catch(() => { // Avoid "Uncaught promise" messages in the console. @@ -289,7 +365,7 @@ class TextLayerRenderTask { }); this._reader = null; } - this._capability.reject(new Error("TextLayer task cancelled.")); + this._capability.reject(new AbortException("TextLayer task cancelled.")); } /** @@ -315,7 +391,7 @@ class TextLayerRenderTask { continue; } this._textContentItemsStr.push(item.str); - appendText(this, item, styleCache, this._layoutTextCtx); + appendText(this, item, styleCache); } } @@ -323,39 +399,10 @@ class TextLayerRenderTask { * @private */ _layoutText(textDiv) { - const textDivProperties = this._textDivProperties.get(textDiv); - - let transform = ""; - if (textDivProperties.canvasWidth !== 0 && textDivProperties.hasText) { - const { fontFamily } = textDiv.style; - const { fontSize } = textDivProperties; - - // Only build font string and set to context if different from last. - if ( - fontSize !== this._layoutTextLastFontSize || - fontFamily !== this._layoutTextLastFontFamily - ) { - this._layoutTextCtx.font = `${ - fontSize * this._devicePixelRatio - }px ${fontFamily}`; - this._layoutTextLastFontSize = fontSize; - this._layoutTextLastFontFamily = fontFamily; - } - // Only measure the width for multi-char text divs, see `appendText`. - const { width } = this._layoutTextCtx.measureText(textDiv.textContent); - - if (width > 0) { - transform = `scaleX(${ - (this._devicePixelRatio * textDivProperties.canvasWidth) / width - })`; - } - } - if (textDivProperties.angle !== 0) { - transform = `rotate(${textDivProperties.angle}deg) ${transform}`; - } - if (transform.length > 0) { - textDiv.style.transform = transform; - } + const textDivProperties = (this._layoutTextParams.properties = + this._textDivProperties.get(textDiv)); + this._layoutTextParams.div = textDiv; + layout(this._layoutTextParams); if (textDivProperties.hasText) { this._container.append(textDiv); @@ -375,10 +422,6 @@ class TextLayerRenderTask { let styleCache = Object.create(null); // The temporary canvas is used to measure text length in the DOM. - const canvas = this._document.createElement("canvas"); - canvas.height = canvas.width = DEFAULT_FONT_SIZE; - - this._layoutTextCtx = canvas.getContext("2d", { alpha: false }); if (this._textContent) { const textItems = this._textContent.items; @@ -426,9 +469,67 @@ function renderTextLayer(renderParameters) { viewport: renderParameters.viewport, textDivs: renderParameters.textDivs, textContentItemsStr: renderParameters.textContentItemsStr, + textDivProperties: renderParameters.textDivProperties, + isOffscreenCanvasSupported: renderParameters.isOffscreenCanvasSupported, }); task._render(); return task; } -export { renderTextLayer, TextLayerRenderTask }; +/** + * @param {TextLayerUpdateParameters} renderParameters + * @returns {TextLayerRenderTask} + */ +function updateTextLayer({ + container, + viewport, + textDivs, + textDivProperties, + isOffscreenCanvasSupported, + mustRotate = true, + mustRescale = true, +}) { + if (mustRotate) { + setTextLayerDimensions(container, { rotation: viewport.rotation }); + } + + if (mustRescale) { + const ctx = getCtx(0, isOffscreenCanvasSupported); + const scale = viewport.scale * (globalThis.devicePixelRatio || 1); + const params = { + prevFontSize: null, + prevFontFamily: null, + div: null, + scale, + properties: null, + ctx, + }; + for (const div of textDivs) { + params.properties = textDivProperties.get(div); + params.div = div; + layout(params); + } + } +} + +/** + * @param {HTMLDivElement} div + * @param {import("./display_utils").PageViewport} viewport + */ +function setTextLayerDimensions(div, viewport) { + if (!viewport.viewBox) { + div.setAttribute("data-main-rotation", viewport.rotation); + return; + } + + const [pageLLx, pageLLy, pageURx, pageURy] = viewport.viewBox; + const pageWidth = pageURx - pageLLx; + const pageHeight = pageURy - pageLLy; + const { style } = div; + + style.width = `calc(var(--scale-factor) * ${pageWidth}px)`; + style.height = `calc(var(--scale-factor) * ${pageHeight}px)`; + div.setAttribute("data-main-rotation", viewport.rotation); +} + +export { renderTextLayer, TextLayerRenderTask, updateTextLayer }; diff --git a/src/pdf.js b/src/pdf.js index 83488d327..9dd5ebf77 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -23,6 +23,7 @@ /** @typedef {import("./display/text_layer").TextLayerRenderTask} TextLayerRenderTask */ import { + AbortException, AnnotationEditorParamsType, AnnotationEditorType, AnnotationMode, @@ -60,12 +61,12 @@ import { PixelsPerInch, RenderingCancelledException, } from "./display/display_utils.js"; +import { renderTextLayer, updateTextLayer } from "./display/text_layer.js"; import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js"; import { AnnotationEditorUIManager } from "./display/editor/tools.js"; import { AnnotationLayer } from "./display/annotation_layer.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; import { isNodeJS } from "./shared/is_node.js"; -import { renderTextLayer } from "./display/text_layer.js"; import { SVGGraphics } from "./display/svg.js"; import { XfaLayer } from "./display/xfa_layer.js"; @@ -110,6 +111,7 @@ if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("PRODUCTION")) { } export { + AbortException, AnnotationEditorLayer, AnnotationEditorParamsType, AnnotationEditorType, @@ -143,6 +145,7 @@ export { SVGGraphics, UnexpectedResponseException, UNSUPPORTED_FEATURES, + updateTextLayer, Util, VerbosityLevel, version, diff --git a/test/driver.js b/test/driver.js index e25a01456..53b9b0eb6 100644 --- a/test/driver.js +++ b/test/driver.js @@ -168,7 +168,7 @@ class Rasterize { } static get textStylePromise() { - const styles = ["./text_layer_test.css"]; + const styles = [VIEWER_CSS, "./text_layer_test.css"]; return shadow(this, "textStylePromise", loadStyles(styles)); } @@ -256,8 +256,10 @@ class Rasterize { // Items are transformed to have 1px font size. svg.setAttribute("font-size", 1); - const [overrides] = await this.textStylePromise; - style.textContent = overrides; + const [common, overrides] = await this.textStylePromise; + style.textContent = + `${common}\n${overrides}\n` + + `:root { --scale-factor: ${viewport.scale} }`; // Rendering text layer as HTML. const task = renderTextLayer({ @@ -265,6 +267,7 @@ class Rasterize { container: div, viewport, }); + await task.promise; svg.append(foreignObject); diff --git a/test/text_layer_test.css b/test/text_layer_test.css index 7b283e79a..825348dfb 100644 --- a/test/text_layer_test.css +++ b/test/text_layer_test.css @@ -22,9 +22,11 @@ right: 0; bottom: 0; line-height: 1; + opacity: 1; } .textLayer span, .textLayer br { + color: black; position: absolute; white-space: pre; transform-origin: 0% 0%; diff --git a/test/unit/text_layer_spec.js b/test/unit/text_layer_spec.js index 6376c272d..37c9b8722 100644 --- a/test/unit/text_layer_spec.js +++ b/test/unit/text_layer_spec.js @@ -24,7 +24,7 @@ import { isNodeJS } from "../../src/shared/is_node.js"; describe("textLayer", function () { it("creates textLayer from ReadableStream", async function () { if (isNodeJS) { - pending("document.createDocumentFragment is not supported in Node.js."); + pending("document.createElement is not supported in Node.js."); } const loadingTask = getDocument(buildGetDocumentParams("basicapi.pdf")); const pdfDocument = await loadingTask.promise; @@ -34,7 +34,7 @@ describe("textLayer", function () { const textLayerRenderTask = renderTextLayer({ textContentStream: page.streamTextContent(), - container: document.createDocumentFragment(), + container: document.createElement("div"), viewport: page.getViewport(), textContentItemsStr, }); diff --git a/web/app.js b/web/app.js index e79ba7b5c..201d48818 100644 --- a/web/app.js +++ b/web/app.js @@ -501,6 +501,7 @@ const PDFViewerApplication = { imageResourcesPath: AppOptions.get("imageResourcesPath"), enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), useOnlyCssZoom: AppOptions.get("useOnlyCssZoom"), + isOffscreenCanvasSupported: AppOptions.get("isOffscreenCanvasSupported"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"), enablePermissions: AppOptions.get("enablePermissions"), pageColors, diff --git a/web/default_factory.js b/web/default_factory.js index 532002317..184dfe073 100644 --- a/web/default_factory.js +++ b/web/default_factory.js @@ -165,12 +165,9 @@ class DefaultStructTreeLayerFactory { class DefaultTextLayerFactory { /** * @typedef {Object} CreateTextLayerBuilderParameters - * @property {HTMLDivElement} textLayerDiv - * @property {number} pageIndex - * @property {PageViewport} viewport - * @property {EventBus} eventBus * @property {TextHighlighter} highlighter * @property {TextAccessibilityManager} [accessibilityManager] + * @property {boolean} [isOffscreenCanvasSupported] */ /** @@ -178,20 +175,14 @@ class DefaultTextLayerFactory { * @returns {TextLayerBuilder} */ createTextLayerBuilder({ - textLayerDiv, - pageIndex, - viewport, - eventBus, highlighter, accessibilityManager = null, + isOffscreenCanvasSupported = true, }) { return new TextLayerBuilder({ - textLayerDiv, - pageIndex, - viewport, - eventBus, highlighter, accessibilityManager, + isOffscreenCanvasSupported, }); } } diff --git a/web/interfaces.js b/web/interfaces.js index 2331511a1..e8b5b1397 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -21,7 +21,6 @@ /** @typedef {import("./annotation_layer_builder").AnnotationLayerBuilder} AnnotationLayerBuilder */ // eslint-disable-next-line max-len /** @typedef {import("./annotation_editor_layer_builder").AnnotationEditorLayerBuilder} AnnotationEditorLayerBuilder */ -/** @typedef {import("./event_utils").EventBus} EventBus */ // eslint-disable-next-line max-len /** @typedef {import("./struct_tree_builder").StructTreeLayerBuilder} StructTreeLayerBuilder */ /** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */ @@ -168,12 +167,9 @@ class IRenderableView { class IPDFTextLayerFactory { /** * @typedef {Object} CreateTextLayerBuilderParameters - * @property {HTMLDivElement} textLayerDiv - * @property {number} pageIndex - * @property {PageViewport} viewport - * @property {EventBus} eventBus * @property {TextHighlighter} highlighter * @property {TextAccessibilityManager} [accessibilityManager] + * @property {boolean} [isOffscreenCanvasSupported] */ /** @@ -181,12 +177,9 @@ class IPDFTextLayerFactory { * @returns {TextLayerBuilder} */ createTextLayerBuilder({ - textLayerDiv, - pageIndex, - viewport, - eventBus, highlighter, accessibilityManager, + isOffscreenCanvasSupported, }) {} } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index ccbd4533f..3f98b2ea0 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -33,6 +33,7 @@ /** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */ import { + AbortException, AnnotationMode, createPromiseCapability, PixelsPerInch, @@ -82,6 +83,8 @@ import { TextAccessibilityManager } from "./text_accessibility.js"; * for annotation icons. Include trailing slash. * @property {boolean} [useOnlyCssZoom] - Enables CSS only zooming. The default * value is `false`. + * @property {boolean} [isOffscreenCanvasSupported] - Allows to use an + * OffscreenCanvas if needed. * @property {number} [maxCanvasPixels] - The maximum supported canvas size in * total pixels, i.e. width * height. Use -1 for no limit. The default value * is 4096 * 4096 (16 mega-pixels). @@ -128,6 +131,8 @@ class PDFPageView { options.annotationMode ?? AnnotationMode.ENABLE_FORMS; this.imageResourcesPath = options.imageResourcesPath || ""; this.useOnlyCssZoom = options.useOnlyCssZoom || false; + this.isOffscreenCanvasSupported = + options.isOffscreenCanvasSupported ?? true; this.maxCanvasPixels = options.maxCanvasPixels || MAX_CANVAS_PIXELS; this.pageColors = options.pageColors || null; @@ -174,8 +179,8 @@ class PDFPageView { const div = document.createElement("div"); div.className = "page"; - div.style.width = Math.floor(this.viewport.width) + "px"; - div.style.height = Math.floor(this.viewport.height) + "px"; + div.style.width = Math.round(this.viewport.width) + "px"; + div.style.height = Math.round(this.viewport.height) + "px"; div.setAttribute("data-page-number", this.id); div.setAttribute("role", "region"); this.l10n.get("page_landmark", { page: this.id }).then(msg => { @@ -284,6 +289,37 @@ class PDFPageView { } } + async #renderTextLayer() { + const { pdfPage, textLayer, viewport } = this; + if (!textLayer) { + return; + } + + let error = null; + try { + if (!textLayer.renderingDone) { + const readableStream = pdfPage.streamTextContent({ + includeMarkedContent: true, + }); + textLayer.setTextContentStream(readableStream); + } + await textLayer.render(viewport); + } catch (ex) { + if (ex instanceof AbortException) { + return; + } + console.error(`#renderTextLayer: "${ex}".`); + error = ex; + } + + this.eventBus.dispatch("textlayerrendered", { + source: this, + pageNumber: this.id, + numTextDivs: textLayer.numTextDivs, + error, + }); + } + async _buildXfaTextContentItems(textDivs) { const text = await this.pdfPage.getTextContent(); const items = []; @@ -320,17 +356,19 @@ class PDFPageView { keepAnnotationLayer = false, keepAnnotationEditorLayer = false, keepXfaLayer = false, + keepTextLayer = false, } = {}) { this.cancelRendering({ keepAnnotationLayer, keepAnnotationEditorLayer, keepXfaLayer, + keepTextLayer, }); this.renderingState = RenderingStates.INITIAL; const div = this.div; - div.style.width = Math.floor(this.viewport.width) + "px"; - div.style.height = Math.floor(this.viewport.height) + "px"; + div.style.width = Math.round(this.viewport.width) + "px"; + div.style.height = Math.round(this.viewport.height) + "px"; const childNodes = div.childNodes, zoomLayerNode = (keepZoomLayer && this.zoomLayer) || null, @@ -338,7 +376,8 @@ class PDFPageView { (keepAnnotationLayer && this.annotationLayer?.div) || null, annotationEditorLayerNode = (keepAnnotationEditorLayer && this.annotationEditorLayer?.div) || null, - xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null; + xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null, + textLayerNode = (keepTextLayer && this.textLayer?.div) || null; for (let i = childNodes.length - 1; i >= 0; i--) { const node = childNodes[i]; switch (node) { @@ -346,6 +385,7 @@ class PDFPageView { case annotationLayerNode: case annotationEditorLayerNode: case xfaLayerNode: + case textLayerNode: continue; } node.remove(); @@ -369,6 +409,10 @@ class PDFPageView { this.xfaLayer.hide(); } + if (textLayerNode) { + this.textLayer.hide(); + } + if (!zoomLayerNode) { if (this.canvas) { this.paintedViewportMap.delete(this.canvas); @@ -450,6 +494,7 @@ class PDFPageView { redrawAnnotationLayer: true, redrawAnnotationEditorLayer: true, redrawXfaLayer: true, + redrawTextLayer: true, }); this.eventBus.dispatch("pagerendered", { @@ -484,6 +529,7 @@ class PDFPageView { redrawAnnotationLayer: true, redrawAnnotationEditorLayer: true, redrawXfaLayer: true, + redrawTextLayer: true, }); this.eventBus.dispatch("pagerendered", { @@ -508,6 +554,7 @@ class PDFPageView { keepAnnotationLayer: true, keepAnnotationEditorLayer: true, keepXfaLayer: true, + keepTextLayer: true, }); } @@ -519,6 +566,7 @@ class PDFPageView { keepAnnotationLayer = false, keepAnnotationEditorLayer = false, keepXfaLayer = false, + keepTextLayer = false, } = {}) { if (this.paintTask) { this.paintTask.cancel(); @@ -526,7 +574,7 @@ class PDFPageView { } this.resume = null; - if (this.textLayer) { + if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) { this.textLayer.cancel(); this.textLayer = null; } @@ -561,6 +609,7 @@ class PDFPageView { redrawAnnotationLayer = false, redrawAnnotationEditorLayer = false, redrawXfaLayer = false, + redrawTextLayer = false, }) { // Scale target (canvas or svg), its wrapper and page container. const width = this.viewport.width; @@ -587,49 +636,6 @@ class PDFPageView { } target.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX}, ${scaleY})`; - if (this.textLayer) { - // Rotating the text layer is more complicated since the divs inside the - // the text layer are rotated. - // TODO: This could probably be simplified by drawing the text layer in - // one orientation and then rotating overall. - const textLayerViewport = this.textLayer.viewport; - const textRelativeRotation = - this.viewport.rotation - textLayerViewport.rotation; - const textAbsRotation = Math.abs(textRelativeRotation); - let scale = width / textLayerViewport.width; - if (textAbsRotation === 90 || textAbsRotation === 270) { - scale = width / textLayerViewport.height; - } - const textLayerDiv = this.textLayer.textLayerDiv; - let transX, transY; - switch (textAbsRotation) { - case 0: - transX = transY = 0; - break; - case 90: - transX = 0; - transY = "-" + textLayerDiv.style.height; - break; - case 180: - transX = "-" + textLayerDiv.style.width; - transY = "-" + textLayerDiv.style.height; - break; - case 270: - transX = "-" + textLayerDiv.style.width; - transY = 0; - break; - default: - console.error("Bad rotation value."); - break; - } - - textLayerDiv.style.transform = - `rotate(${textAbsRotation}deg) ` + - `scale(${scale}) ` + - `translate(${transX}, ${transY})`; - textLayerDiv.style.transformOrigin = "0% 0%"; - } - if (redrawAnnotationLayer && this.annotationLayer) { this._renderAnnotationLayer(); } @@ -639,6 +645,9 @@ class PDFPageView { if (redrawXfaLayer && this.xfaLayer) { this._renderXfaLayer(); } + if (redrawTextLayer && this.textLayer) { + this.#renderTextLayer(); + } } get width() { @@ -686,40 +695,33 @@ class PDFPageView { canvasWrapper.style.height = div.style.height; canvasWrapper.classList.add("canvasWrapper"); - const lastDivBeforeTextDiv = - this.annotationLayer?.div || this.annotationEditorLayer?.div; - - if (lastDivBeforeTextDiv) { - // The annotation layer needs to stay on top. - lastDivBeforeTextDiv.before(canvasWrapper); + if (this.textLayer) { + this.textLayer.div.before(canvasWrapper); } else { - div.append(canvasWrapper); - } - - let textLayer = null; - if (this.textLayerMode !== TextLayerMode.DISABLE && this.textLayerFactory) { - this._accessibilityManager ||= new TextAccessibilityManager(); - const textLayerDiv = document.createElement("div"); - textLayerDiv.className = "textLayer"; - textLayerDiv.style.width = canvasWrapper.style.width; - textLayerDiv.style.height = canvasWrapper.style.height; + const lastDivBeforeTextDiv = + this.annotationLayer?.div || this.annotationEditorLayer?.div; if (lastDivBeforeTextDiv) { // The annotation layer needs to stay on top. - lastDivBeforeTextDiv.before(textLayerDiv); + lastDivBeforeTextDiv.before(canvasWrapper); } else { - div.append(textLayerDiv); + div.append(canvasWrapper); } + } - textLayer = this.textLayerFactory.createTextLayerBuilder({ - textLayerDiv, - pageIndex: this.id - 1, - viewport: this.viewport, - eventBus: this.eventBus, + if ( + !this.textLayer && + this.textLayerMode !== TextLayerMode.DISABLE && + this.textLayerFactory + ) { + this._accessibilityManager ||= new TextAccessibilityManager(); + + this.textLayer = this.textLayerFactory.createTextLayerBuilder({ highlighter: this.textHighlighter, accessibilityManager: this._accessibilityManager, + isOffscreenCanvasSupported: this.isOffscreenCanvasSupported, }); + canvasWrapper.after(this.textLayer.div); } - this.textLayer = textLayer; if ( this.#annotationMode !== AnnotationMode.DISABLE && @@ -809,13 +811,7 @@ class PDFPageView { const resultPromise = paintTask.promise.then( () => { return finishPaintTask(null).then(() => { - if (textLayer) { - const readableStream = pdfPage.streamTextContent({ - includeMarkedContent: true, - }); - textLayer.setTextContentStream(readableStream); - textLayer.render(); - } + this.#renderTextLayer(); if (this.annotationLayer) { this._renderAnnotationLayer().then(() => { @@ -949,10 +945,12 @@ class PDFPageView { const sfx = approximateFraction(outputScale.sx); const sfy = approximateFraction(outputScale.sy); + canvas.width = roundToDivide(viewport.width * outputScale.sx, sfx[0]); canvas.height = roundToDivide(viewport.height * outputScale.sy, sfy[0]); - canvas.style.width = roundToDivide(viewport.width, sfx[1]) + "px"; - canvas.style.height = roundToDivide(viewport.height, sfy[1]) + "px"; + const { style } = canvas; + style.width = roundToDivide(viewport.width, sfx[1]) + "px"; + style.height = roundToDivide(viewport.height, sfy[1]) + "px"; // Add the viewport so it's known what it was originally drawn with. this.paintedViewportMap.set(canvas, viewport); diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index c162aec4b..d0b299976 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -128,6 +128,8 @@ function isValidAnnotationEditorMode(mode) { * landscape pages upon printing. The default is `false`. * @property {boolean} [useOnlyCssZoom] - Enables CSS only zooming. The default * value is `false`. + * @property {boolean} [isOffscreenCanvasSupported] - Allows to use an + * OffscreenCanvas if needed. * @property {number} [maxCanvasPixels] - The maximum supported canvas size in * total pixels, i.e. width * height. Use -1 for no limit. The default value * is 4096 * 4096 (16 mega-pixels). @@ -287,6 +289,8 @@ class PDFViewer { this.renderer = options.renderer || RendererType.CANVAS; } this.useOnlyCssZoom = options.useOnlyCssZoom || false; + this.isOffscreenCanvasSupported = + options.isOffscreenCanvasSupported ?? true; this.maxCanvasPixels = options.maxCanvasPixels; this.l10n = options.l10n || NullL10n; this.#enablePermissions = options.enablePermissions || false; @@ -775,6 +779,7 @@ class PDFViewer { ? this.renderer : null, useOnlyCssZoom: this.useOnlyCssZoom, + isOffscreenCanvasSupported: this.isOffscreenCanvasSupported, maxCanvasPixels: this.maxCanvasPixels, pageColors: this.pageColors, l10n: this.l10n, @@ -1635,12 +1640,9 @@ class PDFViewer { /** * @typedef {Object} CreateTextLayerBuilderParameters - * @property {HTMLDivElement} textLayerDiv - * @property {number} pageIndex - * @property {PageViewport} viewport - * @property {EventBus} eventBus * @property {TextHighlighter} highlighter * @property {TextAccessibilityManager} [accessibilityManager] + * @property {boolean} [isOffscreenCanvasSupported] */ /** @@ -1648,20 +1650,14 @@ class PDFViewer { * @returns {TextLayerBuilder} */ createTextLayerBuilder({ - textLayerDiv, - pageIndex, - viewport, - eventBus, highlighter, accessibilityManager = null, + isOffscreenCanvasSupported = true, }) { return new TextLayerBuilder({ - textLayerDiv, - eventBus, - pageIndex, - viewport, highlighter, accessibilityManager, + isOffscreenCanvasSupported, }); } diff --git a/web/text_highlighter.js b/web/text_highlighter.js index 066e00663..c1b828c40 100644 --- a/web/text_highlighter.js +++ b/web/text_highlighter.js @@ -95,6 +95,7 @@ class TextHighlighter { ); this._onUpdateTextLayerMatches = null; } + this._updateMatches(/* reset = */ true); } _convertMatches(matches, matchesLength) { @@ -264,8 +265,8 @@ class TextHighlighter { } } - _updateMatches() { - if (!this.enabled) { + _updateMatches(reset = false) { + if (!this.enabled && !reset) { return; } const { findController, matches, pageIdx } = this; @@ -283,7 +284,7 @@ class TextHighlighter { clearedUntilDivIdx = match.end.divIdx + 1; } - if (!findController?.highlightMatches) { + if (!findController?.highlightMatches || reset) { return; } // Convert the matches on the `findController` into the match format diff --git a/web/text_layer_builder.css b/web/text_layer_builder.css index 73772e5de..b794377e4 100644 --- a/web/text_layer_builder.css +++ b/web/text_layer_builder.css @@ -25,6 +25,7 @@ line-height: 1; text-size-adjust: none; forced-color-adjust: none; + transform-origin: 0 0; } .textLayer span, diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index f79371924..73a8cbde2 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -15,22 +15,19 @@ // eslint-disable-next-line max-len /** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */ -/** @typedef {import("./event_utils").EventBus} EventBus */ /** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */ // eslint-disable-next-line max-len /** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */ -import { renderTextLayer } from "pdfjs-lib"; +import { renderTextLayer, updateTextLayer } from "pdfjs-lib"; /** * @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 {TextHighlighter} highlighter - Optional object that will handle * highlighting text from the find controller. * @property {TextAccessibilityManager} [accessibilityManager] + * @property {boolean} [isOffscreenCanvasSupported] - Allows to use an + * OffscreenCanvas if needed. */ /** @@ -39,28 +36,28 @@ import { renderTextLayer } from "pdfjs-lib"; * contain text that matches the PDF text they are overlaying. */ class TextLayerBuilder { + #scale = 0; + + #rotation = 0; + constructor({ - textLayerDiv, - eventBus, - pageIndex, - viewport, highlighter = null, accessibilityManager = null, + isOffscreenCanvasSupported = true, }) { - this.textLayerDiv = textLayerDiv; - this.eventBus = eventBus; this.textContent = null; this.textContentItemsStr = []; this.textContentStream = null; this.renderingDone = false; - this.pageNumber = pageIndex + 1; - this.viewport = viewport; this.textDivs = []; + this.textDivProperties = new WeakMap(); this.textLayerRenderTask = null; this.highlighter = highlighter; this.accessibilityManager = accessibilityManager; + this.isOffscreenCanvasSupported = isOffscreenCanvasSupported; - this.#bindMouse(); + this.div = document.createElement("div"); + this.div.className = "textLayer"; } #finishRendering() { @@ -68,48 +65,80 @@ class TextLayerBuilder { const endOfContent = document.createElement("div"); endOfContent.className = "endOfContent"; - this.textLayerDiv.append(endOfContent); + this.div.append(endOfContent); - this.eventBus.dispatch("textlayerrendered", { - source: this, - pageNumber: this.pageNumber, - numTextDivs: this.textDivs.length, - }); + this.#bindMouse(); + } + + get numTextDivs() { + return this.textDivs.length; } /** * Renders the text layer. */ - render() { - if (!(this.textContent || this.textContentStream) || this.renderingDone) { + async render(viewport) { + if (!(this.textContent || this.textContentStream)) { + throw new Error( + `Neither "textContent" nor "textContentStream" specified.` + ); + } + + const scale = viewport.scale * (globalThis.devicePixelRatio || 1); + if (this.renderingDone) { + const { rotation } = viewport; + const mustRotate = rotation !== this.#rotation; + const mustRescale = scale !== this.#scale; + if (mustRotate || mustRescale) { + this.hide(); + updateTextLayer({ + container: this.div, + viewport, + textDivs: this.textDivs, + textDivProperties: this.textDivProperties, + isOffscreenCanvasSupported: this.isOffscreenCanvasSupported, + mustRescale, + mustRotate, + }); + this.show(); + this.#scale = scale; + this.#rotation = rotation; + } return; } - this.cancel(); - this.textDivs.length = 0; + this.cancel(); this.highlighter?.setTextMapping(this.textDivs, this.textContentItemsStr); this.accessibilityManager?.setTextMapping(this.textDivs); - const textLayerFrag = document.createDocumentFragment(); this.textLayerRenderTask = renderTextLayer({ textContent: this.textContent, textContentStream: this.textContentStream, - container: textLayerFrag, - viewport: this.viewport, + container: this.div, + viewport, textDivs: this.textDivs, + textDivProperties: this.textDivProperties, textContentItemsStr: this.textContentItemsStr, + isOffscreenCanvasSupported: this.isOffscreenCanvasSupported, }); - this.textLayerRenderTask.promise.then( - () => { - this.textLayerDiv.append(textLayerFrag); - this.#finishRendering(); - this.highlighter?.enable(); - this.accessibilityManager?.enable(); - }, - function (reason) { - // Cancelled or failed to render text layer; skipping errors. - } - ); + + await this.textLayerRenderTask.promise; + this.#finishRendering(); + this.#scale = scale; + this.accessibilityManager?.enable(); + this.show(); + } + + hide() { + // We turn off the highlighter in order to avoid to scroll into view an + // element of the text layer which could be hidden. + this.highlighter?.disable(); + this.div.hidden = true; + } + + show() { + this.div.hidden = false; + this.highlighter?.enable(); } /** @@ -122,6 +151,9 @@ class TextLayerBuilder { } this.highlighter?.disable(); this.accessibilityManager?.disable(); + this.textContentItemsStr.length = 0; + this.textDivs.length = 0; + this.textDivProperties = new WeakMap(); } setTextContentStream(readableStream) { @@ -140,7 +172,7 @@ class TextLayerBuilder { * dragged up or down. */ #bindMouse() { - const div = this.textLayerDiv; + const { div } = this; div.addEventListener("mousedown", evt => { const end = div.querySelector(".endOfContent");