From 15b5808eee3669671e2b6b6b34d60b0ab9c95312 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Thu, 16 May 2024 11:28:06 +0200 Subject: [PATCH] [api-minor] Re-factor the basic textLayer-functionality This is very old code, and predates e.g. the introduction of JavaScript classes, which creates unnecessarily unwieldy code in the viewer. By introducing a new `TextLayer` class in the API, similar to how e.g. the `AnnotationLayer` looks, we're able to keep most parameters on the class-instance itself. This removes the need to manually track them in the viewer, and simplifies the call-sites. This also removes the `numTextDivs` parameter from the "textlayerrendered" event, since that's only added to support default-viewer functionality that no longer exists. Finally we try, as far as possible, to polyfill the old `renderTextLayer` and `updateTextLayer` functions since they are exposed in the library API. For *simple* invocations of `renderTextLayer` the behaviour should thus be the same, with only a warning printed in the console. --- src/display/api.js | 6 +- src/display/text_layer.js | 594 +++++++++++++++++++---------------- src/pdf.js | 9 +- test/driver.js | 24 +- test/unit/pdf_spec.js | 2 + test/unit/text_layer_spec.js | 25 +- web/pdf_page_view.js | 1 - web/pdfjs.js | 2 + web/text_layer_builder.js | 65 +--- 9 files changed, 367 insertions(+), 361 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index b7415600c777c..a099e53a91362 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -62,7 +62,6 @@ import { NodeStandardFontDataFactory, } from "display-node_utils"; import { CanvasGraphics } from "./canvas.js"; -import { cleanupTextLayer } from "./text_layer.js"; import { GlobalWorkerOptions } from "./worker_options.js"; import { MessageHandler } from "../shared/message_handler.js"; import { Metadata } from "./metadata.js"; @@ -71,6 +70,7 @@ import { PDFDataTransportStream } from "./transport_stream.js"; import { PDFFetchStream } from "display-fetch_stream"; import { PDFNetworkStream } from "display-network"; import { PDFNodeStream } from "display-node_stream"; +import { TextLayer } from "./text_layer.js"; import { XfaText } from "./xfa_text.js"; const DEFAULT_RANGE_CHUNK_SIZE = 65536; // 2^16 = 65536 @@ -2511,7 +2511,7 @@ class WorkerTransport { this.fontLoader.clear(); this.#methodPromises.clear(); this.filterFactory.destroy(); - cleanupTextLayer(); + TextLayer.cleanup(); this._networkStream?.cancelAllRequests( new AbortException("Worker was terminated.") @@ -3085,7 +3085,7 @@ class WorkerTransport { } this.#methodPromises.clear(); this.filterFactory.destroy(/* keepHCM = */ true); - cleanupTextLayer(); + TextLayer.cleanup(); } cachedPageNumber(ref) { diff --git a/src/display/text_layer.js b/src/display/text_layer.js index 3bcb2fb7aa478..dd39aa856e32a 100644 --- a/src/display/text_layer.js +++ b/src/display/text_layer.js @@ -17,12 +17,10 @@ /** @typedef {import("./api").TextContent} TextContent */ import { AbortException, Util, warn } from "../shared/util.js"; -import { setLayerDimensions } from "./display_utils.js"; +import { deprecated, setLayerDimensions } from "./display_utils.js"; /** - * Text layer render parameters. - * - * @typedef {Object} TextLayerRenderParameters + * @typedef {Object} TextLayerParameters * @property {ReadableStream | TextContent} textContentSource - Text content to * render, i.e. the value returned by the page's `streamTextContent` or * `getTextContent` method. @@ -30,186 +28,67 @@ import { setLayerDimensions } from "./display_utils.js"; * runs. * @property {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. */ /** - * Text layer update parameters. - * * @typedef {Object} TextLayerUpdateParameters - * @property {HTMLElement} container - The DOM node that will contain the text - * runs. * @property {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} [mustRotate] true if the text layer must be rotated. - * @property {boolean} [mustRescale] true if the text layer contents must be - * rescaled. + * @property {function} [onBefore] - Callback invoked before the textLayer is + * updated in the DOM. */ const MAX_TEXT_DIVS_TO_RENDER = 100000; const DEFAULT_FONT_SIZE = 30; const DEFAULT_FONT_ASCENT = 0.8; -const ascentCache = new Map(); -let _canvasContext = null; -const pendingTextLayers = new Set(); - -function getCtx(lang = null) { - if (!_canvasContext) { - // We don't use an OffscreenCanvas here because we use serif/sans serif - // fonts with it and they depends on the locale. - // In Firefox, the element get a lang attribute that depends on what - // Fluent returns for the locale and the OffscreenCanvas uses the OS locale. - // Those two locales can be different and consequently the used fonts will - // be different (see bug 1869001). - // Ideally, we should use in the text layer the fonts we've in the pdf (or - // their replacements when they aren't embedded) and then we can use an - // OffscreenCanvas. - const canvas = document.createElement("canvas"); - canvas.className = "hiddenCanvasElement"; - document.body.append(canvas); - _canvasContext = canvas.getContext("2d", { alpha: false }); - } - return _canvasContext; -} +class TextLayer { + #capability = Promise.withResolvers(); -function cleanupTextLayer() { - if (pendingTextLayers.size > 0) { - return; - } - _canvasContext?.canvas.remove(); - _canvasContext = null; -} + #container = null; -function getAscent(fontFamily, lang) { - const cachedAscent = ascentCache.get(fontFamily); - if (cachedAscent) { - return cachedAscent; - } + #disableProcessItems = false; - const ctx = getCtx(lang); + #fontInspectorEnabled = !!globalThis.FontInspector?.enabled; - const savedFont = ctx.font; - ctx.canvas.width = ctx.canvas.height = DEFAULT_FONT_SIZE; - ctx.font = `${DEFAULT_FONT_SIZE}px ${fontFamily}`; - const metrics = ctx.measureText(""); + #lang = null; - // Both properties aren't available by default in Firefox. - let ascent = metrics.fontBoundingBoxAscent; - let descent = Math.abs(metrics.fontBoundingBoxDescent); - if (ascent) { - const ratio = ascent / (ascent + descent); - ascentCache.set(fontFamily, ratio); + #layoutTextParams = null; - ctx.canvas.width = ctx.canvas.height = 0; - ctx.font = savedFont; - return ratio; - } + #pageHeight = 0; - // Try basic heuristic to guess ascent/descent. - // Draw a g with baseline at 0,0 and then get the line - // number where a pixel has non-null red component (starting - // from bottom). - ctx.strokeStyle = "red"; - ctx.clearRect(0, 0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE); - ctx.strokeText("g", 0, 0); - let pixels = ctx.getImageData( - 0, - 0, - DEFAULT_FONT_SIZE, - DEFAULT_FONT_SIZE - ).data; - descent = 0; - for (let i = pixels.length - 1 - 3; i >= 0; i -= 4) { - if (pixels[i] > 0) { - descent = Math.ceil(i / 4 / DEFAULT_FONT_SIZE); - break; - } - } + #pageWidth = 0; - // Draw an A with baseline at 0,DEFAULT_FONT_SIZE and then get the line - // number where a pixel has non-null red component (starting - // from top). - ctx.clearRect(0, 0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE); - ctx.strokeText("A", 0, DEFAULT_FONT_SIZE); - pixels = ctx.getImageData(0, 0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE).data; - ascent = 0; - for (let i = 0, ii = pixels.length; i < ii; i += 4) { - if (pixels[i] > 0) { - ascent = DEFAULT_FONT_SIZE - Math.floor(i / 4 / DEFAULT_FONT_SIZE); - break; - } - } + #reader = null; - ctx.canvas.width = ctx.canvas.height = 0; - ctx.font = savedFont; + #rootContainer = null; - if (ascent) { - const ratio = ascent / (ascent + descent); - ascentCache.set(fontFamily, ratio); - return ratio; - } + #rotation = 0; - ascentCache.set(fontFamily, DEFAULT_FONT_ASCENT); - return DEFAULT_FONT_ASCENT; -} + #scale = 0; -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; - } + #styleCache = Object.create(null); - // Only measure the width for multi-char text divs, see `appendText`. - const { width } = ctx.measureText(div.textContent); + #textContentItemsStr = []; - 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; - } -} + #textContentSource = null; -class TextLayerRenderTask { - #disableProcessItems = false; + #textDivs = []; - #reader = null; + #textDivProperties = new WeakMap(); - #textContentSource = null; + #transform = null; - constructor({ - textContentSource, - container, - viewport, - textDivs, - textDivProperties, - textContentItemsStr, - }) { + static #ascentCache = new Map(); + + static #canvasCtx = null; + + static #pendingTextLayers = new Set(); + + /** + * @param {TextLayerParameters} options + */ + constructor({ textContentSource, container, viewport }) { if (textContentSource instanceof ReadableStream) { this.#textContentSource = textContentSource; } else if ( @@ -225,56 +104,112 @@ class TextLayerRenderTask { } else { throw new Error('No "textContentSource" parameter specified.'); } - this._container = this._rootContainer = container; - this._textDivs = textDivs || []; - this._textContentItemsStr = textContentItemsStr || []; - this._fontInspectorEnabled = !!globalThis.FontInspector?.enabled; - - this._textDivProperties = textDivProperties || new WeakMap(); - this._canceled = false; - this._capability = Promise.withResolvers(); - this._layoutTextParams = { + this.#container = this.#rootContainer = container; + + this.#scale = viewport.scale * (globalThis.devicePixelRatio || 1); + this.#rotation = viewport.rotation; + this.#layoutTextParams = { prevFontSize: null, prevFontFamily: null, div: null, - scale: viewport.scale * (globalThis.devicePixelRatio || 1), properties: null, ctx: null, }; - this._styleCache = Object.create(null); const { pageWidth, pageHeight, pageX, pageY } = viewport.rawDims; - this._transform = [1, 0, 0, -1, -pageX, pageY + pageHeight]; - this._pageWidth = pageWidth; - this._pageHeight = pageHeight; + this.#transform = [1, 0, 0, -1, -pageX, pageY + pageHeight]; + this.#pageWidth = pageWidth; + this.#pageHeight = pageHeight; setLayerDimensions(container, viewport); - pendingTextLayers.add(this); + TextLayer.#pendingTextLayers.add(this); // Always clean-up the temporary canvas once rendering is no longer pending. - this._capability.promise - .finally(() => { - pendingTextLayers.delete(this); - this._layoutTextParams = null; - this._styleCache = null; - }) + this.#capability.promise .catch(() => { // Avoid "Uncaught promise" messages in the console. + }) + .then(() => { + TextLayer.#pendingTextLayers.delete(this); + this.#layoutTextParams = null; + this.#styleCache = null; }); + + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + // For testing purposes. + Object.defineProperty(this, "pageWidth", { + get() { + return this.#pageWidth; + }, + }); + Object.defineProperty(this, "pageHeight", { + get() { + return this.#pageHeight; + }, + }); + } + } + + /** + * Render the textLayer. + * @returns {Promise} + */ + render() { + const pump = () => { + this.#reader.read().then(({ value, done }) => { + if (done) { + this.#capability.resolve(); + return; + } + this.#lang ??= value.lang; + Object.assign(this.#styleCache, value.styles); + this.#processItems(value.items); + pump(); + }, this.#capability.reject); + }; + this.#reader = this.#textContentSource.getReader(); + pump(); + + return this.#capability.promise; } /** - * Promise for textLayer rendering task completion. - * @type {Promise} + * Update a previously rendered textLayer, if necessary. + * @param {TextLayerUpdateParameters} options + * @returns {undefined} */ - get promise() { - return this._capability.promise; + update({ viewport, onBefore = null }) { + const scale = viewport.scale * (globalThis.devicePixelRatio || 1); + const rotation = viewport.rotation; + + if (rotation !== this.#rotation) { + onBefore?.(); + this.#rotation = rotation; + setLayerDimensions(this.#rootContainer, { rotation }); + } + + if (scale !== this.#scale) { + onBefore?.(); + this.#scale = scale; + const params = { + prevFontSize: null, + prevFontFamily: null, + div: null, + properties: null, + ctx: TextLayer.#getCtx(this.#lang), + }; + for (const div of this.#textDivs) { + params.properties = this.#textDivProperties.get(div); + params.div = div; + this.#layout(params); + } + } } /** * Cancel rendering of the textLayer. + * @returns {undefined} */ cancel() { - this._canceled = true; const abortEx = new AbortException("TextLayer task cancelled."); this.#reader?.cancel(abortEx).catch(() => { @@ -282,19 +217,35 @@ class TextLayerRenderTask { }); this.#reader = null; - this._capability.reject(abortEx); + this.#capability.reject(abortEx); } - #processItems(items, lang) { + /** + * @type {Array} HTML elements that correspond to the text items + * of the textContent input. + * This is output and will initially be set to an empty array. + */ + get textDivs() { + return this.#textDivs; + } + + /** + * @type {Array} Strings that correspond to the `str` property of + * the text items of the textContent input. + * This is output and will initially be set to an empty array + */ + get textContentItemsStr() { + return this.#textContentItemsStr; + } + + #processItems(items) { if (this.#disableProcessItems) { return; } - if (!this._layoutTextParams.ctx) { - this._textDivProperties.set(this._rootContainer, { lang }); - this._layoutTextParams.ctx = getCtx(lang); - } - const textDivs = this._textDivs, - textContentItemsStr = this._textContentItemsStr; + this.#layoutTextParams.ctx ||= TextLayer.#getCtx(this.#lang); + + const textDivs = this.#textDivs, + textContentItemsStr = this.#textContentItemsStr; for (const item of items) { // No point in rendering many divs as it would make the browser @@ -311,24 +262,24 @@ class TextLayerRenderTask { item.type === "beginMarkedContentProps" || item.type === "beginMarkedContent" ) { - const parent = this._container; - this._container = document.createElement("span"); - this._container.classList.add("markedContent"); + const parent = this.#container; + this.#container = document.createElement("span"); + this.#container.classList.add("markedContent"); if (item.id !== null) { - this._container.setAttribute("id", `${item.id}`); + this.#container.setAttribute("id", `${item.id}`); } - parent.append(this._container); + parent.append(this.#container); } else if (item.type === "endMarkedContent") { - this._container = this._container.parentNode; + this.#container = this.#container.parentNode; } continue; } textContentItemsStr.push(item.str); - this.#appendText(item, lang); + this.#appendText(item); } } - #appendText(geom, lang) { + #appendText(geom) { // Initialize all used properties to keep the caches monomorphic. const textDiv = document.createElement("span"); const textDivProperties = { @@ -338,20 +289,21 @@ class TextLayerRenderTask { hasEOL: geom.hasEOL, fontSize: 0, }; - this._textDivs.push(textDiv); + this.#textDivs.push(textDiv); - const tx = Util.transform(this._transform, geom.transform); + const tx = Util.transform(this.#transform, geom.transform); let angle = Math.atan2(tx[1], tx[0]); - const style = this._styleCache[geom.fontName]; + const style = this.#styleCache[geom.fontName]; if (style.vertical) { angle += Math.PI / 2; } const fontFamily = - (this._fontInspectorEnabled && style.fontSubstitution) || + (this.#fontInspectorEnabled && style.fontSubstitution) || style.fontFamily; const fontHeight = Math.hypot(tx[2], tx[3]); - const fontAscent = fontHeight * getAscent(fontFamily, lang); + const fontAscent = + fontHeight * TextLayer.#getAscent(fontFamily, this.#lang); let left, top; if (angle === 0) { @@ -366,9 +318,9 @@ class TextLayerRenderTask { 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. - if (this._container === this._rootContainer) { - divStyle.left = `${((100 * left) / this._pageWidth).toFixed(2)}%`; - divStyle.top = `${((100 * top) / this._pageHeight).toFixed(2)}%`; + if (this.#container === this.#rootContainer) { + divStyle.left = `${((100 * left) / this.#pageWidth).toFixed(2)}%`; + divStyle.top = `${((100 * top) / this.#pageHeight).toFixed(2)}%`; } else { // We're in a marked content span, hence we can't use percents. divStyle.left = `${scaleFactorStr}${left.toFixed(2)}px)`; @@ -388,7 +340,7 @@ class TextLayerRenderTask { // `fontName` is only used by the FontInspector, and we only use `dataset` // here to make the font name available in the debugger. - if (this._fontInspectorEnabled) { + if (this.#fontInspectorEnabled) { textDiv.dataset.fontName = style.fontSubstitutionLoadedName || geom.fontName; } @@ -416,96 +368,188 @@ class TextLayerRenderTask { if (shouldScaleText) { textDivProperties.canvasWidth = style.vertical ? geom.height : geom.width; } - this._textDivProperties.set(textDiv, textDivProperties); - this.#layoutText(textDiv, textDivProperties); - } + this.#textDivProperties.set(textDiv, textDivProperties); - #layoutText(textDiv, textDivProperties) { - this._layoutTextParams.div = textDiv; - this._layoutTextParams.properties = textDivProperties; - layout(this._layoutTextParams); + // Finally, layout and append the text to the DOM. + this.#layoutTextParams.div = textDiv; + this.#layoutTextParams.properties = textDivProperties; + this.#layout(this.#layoutTextParams); if (textDivProperties.hasText) { - this._container.append(textDiv); + this.#container.append(textDiv); } if (textDivProperties.hasEOL) { const br = document.createElement("br"); br.setAttribute("role", "presentation"); - this._container.append(br); + this.#container.append(br); + } + } + + #layout(params) { + const { div, 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 * this.#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 * this.#scale) / width})`; + } + } + if (properties.angle !== 0) { + transform = `rotate(${properties.angle}deg) ${transform}`; + } + if (transform.length > 0) { + style.transform = transform; } } /** - * @private + * Clean-up global textLayer data. + * @returns {undefined} */ - _render() { - const styleCache = this._styleCache; + static cleanup() { + if (this.#pendingTextLayers.size > 0) { + return; + } + this.#ascentCache.clear(); - const pump = () => { - this.#reader.read().then(({ value, done }) => { - if (done) { - this._capability.resolve(); - return; - } + this.#canvasCtx?.canvas.remove(); + this.#canvasCtx = null; + } - Object.assign(styleCache, value.styles); - this.#processItems(value.items, value.lang); - pump(); - }, this._capability.reject); - }; - this.#reader = this.#textContentSource.getReader(); - pump(); + static #getCtx(lang = null) { + if (!this.#canvasCtx) { + // We don't use an OffscreenCanvas here because we use serif/sans serif + // fonts with it and they depends on the locale. + // In Firefox, the element get a lang attribute that depends on + // what Fluent returns for the locale and the OffscreenCanvas uses + // the OS locale. + // Those two locales can be different and consequently the used fonts will + // be different (see bug 1869001). + // Ideally, we should use in the text layer the fonts we've in the pdf (or + // their replacements when they aren't embedded) and then we can use an + // OffscreenCanvas. + const canvas = document.createElement("canvas"); + canvas.className = "hiddenCanvasElement"; + document.body.append(canvas); + this.#canvasCtx = canvas.getContext("2d", { alpha: false }); + } + return this.#canvasCtx; } -} -/** - * @param {TextLayerRenderParameters} params - * @returns {TextLayerRenderTask} - */ -function renderTextLayer(params) { - const task = new TextLayerRenderTask(params); - task._render(); - return task; + static #getAscent(fontFamily, lang) { + const cachedAscent = this.#ascentCache.get(fontFamily); + if (cachedAscent) { + return cachedAscent; + } + const ctx = this.#getCtx(lang); + + const savedFont = ctx.font; + ctx.canvas.width = ctx.canvas.height = DEFAULT_FONT_SIZE; + ctx.font = `${DEFAULT_FONT_SIZE}px ${fontFamily}`; + const metrics = ctx.measureText(""); + + // Both properties aren't available by default in Firefox. + let ascent = metrics.fontBoundingBoxAscent; + let descent = Math.abs(metrics.fontBoundingBoxDescent); + if (ascent) { + const ratio = ascent / (ascent + descent); + this.#ascentCache.set(fontFamily, ratio); + + ctx.canvas.width = ctx.canvas.height = 0; + ctx.font = savedFont; + return ratio; + } + + // Try basic heuristic to guess ascent/descent. + // Draw a g with baseline at 0,0 and then get the line + // number where a pixel has non-null red component (starting + // from bottom). + ctx.strokeStyle = "red"; + ctx.clearRect(0, 0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE); + ctx.strokeText("g", 0, 0); + let pixels = ctx.getImageData( + 0, + 0, + DEFAULT_FONT_SIZE, + DEFAULT_FONT_SIZE + ).data; + descent = 0; + for (let i = pixels.length - 1 - 3; i >= 0; i -= 4) { + if (pixels[i] > 0) { + descent = Math.ceil(i / 4 / DEFAULT_FONT_SIZE); + break; + } + } + + // Draw an A with baseline at 0,DEFAULT_FONT_SIZE and then get the line + // number where a pixel has non-null red component (starting + // from top). + ctx.clearRect(0, 0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE); + ctx.strokeText("A", 0, DEFAULT_FONT_SIZE); + pixels = ctx.getImageData(0, 0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE).data; + ascent = 0; + for (let i = 0, ii = pixels.length; i < ii; i += 4) { + if (pixels[i] > 0) { + ascent = DEFAULT_FONT_SIZE - Math.floor(i / 4 / DEFAULT_FONT_SIZE); + break; + } + } + + ctx.canvas.width = ctx.canvas.height = 0; + ctx.font = savedFont; + + const ratio = ascent ? ascent / (ascent + descent) : DEFAULT_FONT_ASCENT; + this.#ascentCache.set(fontFamily, ratio); + return ratio; + } } -/** - * @param {TextLayerUpdateParameters} params - * @returns {undefined} - */ -function updateTextLayer({ - container, - viewport, - textDivs, - textDivProperties, - mustRotate = true, - mustRescale = true, -}) { - if (mustRotate) { - setLayerDimensions(container, { rotation: viewport.rotation }); +function renderTextLayer() { + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) { + return; } + deprecated("`renderTextLayer`, please use `TextLayer` instead."); - if (mustRescale) { - const ctx = getCtx(textDivProperties.get(container)?.lang); - 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); - } + const { textContentSource, container, viewport, ...rest } = arguments[0]; + const restKeys = Object.keys(rest); + if (restKeys.length > 0) { + warn("Ignoring `renderTextLayer` parameters: " + restKeys.join(", ")); + } + + const textLayer = new TextLayer({ + textContentSource, + container, + viewport, + }); + + const { textDivs, textContentItemsStr } = textLayer; + const promise = textLayer.render(); + + // eslint-disable-next-line consistent-return + return { + promise, + textDivs, + textContentItemsStr, + }; +} + +function updateTextLayer() { + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) { + return; } + deprecated("`updateTextLayer`, please use `TextLayer` instead."); } -export { - cleanupTextLayer, - renderTextLayer, - TextLayerRenderTask, - updateTextLayer, -}; +export { renderTextLayer, TextLayer, updateTextLayer }; diff --git a/src/pdf.js b/src/pdf.js index 63ec9acb07f97..cb24267617494 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -21,8 +21,6 @@ /** @typedef {import("./display/api").PDFPageProxy} PDFPageProxy */ /** @typedef {import("./display/api").RenderTask} RenderTask */ /** @typedef {import("./display/display_utils").PageViewport} PageViewport */ -// eslint-disable-next-line max-len -/** @typedef {import("./display/text_layer").TextLayerRenderTask} TextLayerRenderTask */ import { AbortException, @@ -65,7 +63,11 @@ import { RenderingCancelledException, setLayerDimensions, } from "./display/display_utils.js"; -import { renderTextLayer, updateTextLayer } from "./display/text_layer.js"; +import { + renderTextLayer, + TextLayer, + 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"; @@ -122,6 +124,7 @@ export { renderTextLayer, setLayerDimensions, shadow, + TextLayer, UnexpectedResponseException, updateTextLayer, Util, diff --git a/test/driver.js b/test/driver.js index d0a91ce484578..aaeb20d580dd1 100644 --- a/test/driver.js +++ b/test/driver.js @@ -22,8 +22,8 @@ const { GlobalWorkerOptions, Outliner, PixelsPerInch, - renderTextLayer, shadow, + TextLayer, XfaLayer, } = pdfjsLib; const { GenericL10n, parseQueryString, SimpleLinkService } = pdfjsViewer; @@ -297,13 +297,12 @@ class Rasterize { `:root { --scale-factor: ${viewport.scale} }`; // Rendering text layer as HTML. - const task = renderTextLayer({ + const textLayer = new TextLayer({ textContentSource: textContent, container: div, viewport, }); - - await task.promise; + await textLayer.render(); svg.append(foreignObject); @@ -327,15 +326,14 @@ class Rasterize { `:root { --scale-factor: ${viewport.scale} }`; // Rendering text layer as HTML. - const task = renderTextLayer({ + const textLayer = new TextLayer({ textContentSource: textContent, container: dummyParent, viewport, }); + await textLayer.render(); - await task.promise; - - const { _pageWidth, _pageHeight, _textDivs } = task; + const { pageWidth, pageHeight, textDivs } = textLayer; const boxes = []; let j = 0, posRegex; @@ -343,7 +341,7 @@ class Rasterize { if (type) { continue; } - const { top, left } = _textDivs[j++].style; + const { top, left } = textDivs[j++].style; let x = parseFloat(left) / 100; let y = parseFloat(top) / 100; if (isNaN(x)) { @@ -352,12 +350,12 @@ class Rasterize { // string, e.g. `calc(var(--scale-factor)*66.32px)`. let match = left.match(posRegex); if (match) { - x = parseFloat(match[1]) / _pageWidth; + x = parseFloat(match[1]) / pageWidth; } match = top.match(posRegex); if (match) { - y = parseFloat(match[1]) / _pageHeight; + y = parseFloat(match[1]) / pageHeight; } } if (width === 0 || height === 0) { @@ -366,8 +364,8 @@ class Rasterize { boxes.push({ x, y, - width: width / _pageWidth, - height: height / _pageHeight, + width: width / pageWidth, + height: height / pageHeight, }); } // We set the borderWidth to 0.001 to slighly increase the size of the diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index a4108a47eef3b..6dfc1584fc478 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -57,6 +57,7 @@ import { } from "../../src/display/display_utils.js"; import { renderTextLayer, + TextLayer, updateTextLayer, } from "../../src/display/text_layer.js"; import { AnnotationEditorLayer } from "../../src/display/editor/annotation_editor_layer.js"; @@ -108,6 +109,7 @@ const expectedAPI = Object.freeze({ renderTextLayer, setLayerDimensions, shadow, + TextLayer, UnexpectedResponseException, updateTextLayer, Util, diff --git a/test/unit/text_layer_spec.js b/test/unit/text_layer_spec.js index b6d93eab7edfb..5b0b8a1df22bf 100644 --- a/test/unit/text_layer_spec.js +++ b/test/unit/text_layer_spec.js @@ -13,13 +13,10 @@ * limitations under the License. */ -import { - renderTextLayer, - TextLayerRenderTask, -} from "../../src/display/text_layer.js"; import { buildGetDocumentParams } from "./test_utils.js"; import { getDocument } from "../../src/display/api.js"; import { isNodeJS } from "../../src/shared/util.js"; +import { TextLayer } from "../../src/display/text_layer.js"; describe("textLayer", function () { it("creates textLayer from ReadableStream", async function () { @@ -30,18 +27,14 @@ describe("textLayer", function () { const pdfDocument = await loadingTask.promise; const page = await pdfDocument.getPage(1); - const textContentItemsStr = []; - - const textLayerRenderTask = renderTextLayer({ + const textLayer = new TextLayer({ textContentSource: page.streamTextContent(), container: document.createElement("div"), viewport: page.getViewport({ scale: 1 }), - textContentItemsStr, }); - expect(textLayerRenderTask instanceof TextLayerRenderTask).toEqual(true); + await textLayer.render(); - await textLayerRenderTask.promise; - expect(textContentItemsStr).toEqual([ + expect(textLayer.textContentItemsStr).toEqual([ "Table Of Content", "", "Chapter 1", @@ -70,18 +63,14 @@ describe("textLayer", function () { const pdfDocument = await loadingTask.promise; const page = await pdfDocument.getPage(1); - const textContentItemsStr = []; - - const textLayerRenderTask = renderTextLayer({ + const textLayer = new TextLayer({ textContentSource: await page.getTextContent(), container: document.createElement("div"), viewport: page.getViewport({ scale: 1 }), - textContentItemsStr, }); - expect(textLayerRenderTask instanceof TextLayerRenderTask).toEqual(true); + await textLayer.render(); - await textLayerRenderTask.promise; - expect(textContentItemsStr).toEqual([ + expect(textLayer.textContentItemsStr).toEqual([ "Table Of Content", "", "Chapter 1", diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 93c5be78f0065..915f566f97813 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -458,7 +458,6 @@ class PDFPageView { this.eventBus.dispatch("textlayerrendered", { source: this, pageNumber: this.id, - numTextDivs: textLayer.numTextDivs, error, }); diff --git a/web/pdfjs.js b/web/pdfjs.js index 4db23cc05828a..dd85896fd4d57 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -53,6 +53,7 @@ const { renderTextLayer, setLayerDimensions, shadow, + TextLayer, UnexpectedResponseException, updateTextLayer, Util, @@ -101,6 +102,7 @@ export { renderTextLayer, setLayerDimensions, shadow, + TextLayer, UnexpectedResponseException, updateTextLayer, Util, diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index 54b1caaf4c48a..ced5c2da0556e 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -20,7 +20,7 @@ // eslint-disable-next-line max-len /** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */ -import { normalizeUnicode, renderTextLayer, updateTextLayer } from "pdfjs-lib"; +import { normalizeUnicode, TextLayer } from "pdfjs-lib"; import { removeNullCharacters } from "./ui_utils.js"; /** @@ -41,12 +41,10 @@ class TextLayerBuilder { #onAppend = null; - #rotation = 0; - - #scale = 0; - #textContentSource = null; + #textLayer = null; + static #textLayers = new Map(); static #selectionChangeAbortController = null; @@ -57,11 +55,7 @@ class TextLayerBuilder { enablePermissions = false, onAppend = null, }) { - this.textContentItemsStr = []; this.renderingDone = false; - this.textDivs = []; - this.textDivProperties = new WeakMap(); - this.textLayerRenderTask = null; this.highlighter = highlighter; this.accessibilityManager = accessibilityManager; this.#enablePermissions = enablePermissions === true; @@ -82,10 +76,6 @@ class TextLayerBuilder { this.#bindMouse(endOfContent); } - get numTextDivs() { - return this.textDivs.length; - } - /** * Renders the text layer. * @param {PageViewport} viewport @@ -95,45 +85,28 @@ class TextLayerBuilder { throw new Error('No "textContentSource" parameter specified.'); } - const scale = viewport.scale * (globalThis.devicePixelRatio || 1); - const { rotation } = viewport; - if (this.renderingDone) { - 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, - mustRescale, - mustRotate, - }); - this.#scale = scale; - this.#rotation = rotation; - } + if (this.renderingDone && this.#textLayer) { + this.#textLayer.update({ + viewport, + onBefore: this.hide.bind(this), + }); this.show(); return; } this.cancel(); - this.highlighter?.setTextMapping(this.textDivs, this.textContentItemsStr); - this.accessibilityManager?.setTextMapping(this.textDivs); - - this.textLayerRenderTask = renderTextLayer({ + this.#textLayer = new TextLayer({ textContentSource: this.#textContentSource, container: this.div, viewport, - textDivs: this.textDivs, - textDivProperties: this.textDivProperties, - textContentItemsStr: this.textContentItemsStr, }); - await this.textLayerRenderTask.promise; + const { textDivs, textContentItemsStr } = this.#textLayer; + this.highlighter?.setTextMapping(textDivs, textContentItemsStr); + this.accessibilityManager?.setTextMapping(textDivs); + + await this.#textLayer.render(); this.#finishRendering(); - this.#scale = scale; - this.#rotation = rotation; // Ensure that the textLayer is appended to the DOM *before* handling // e.g. a pending search operation. this.#onAppend?.(this.div); @@ -161,15 +134,11 @@ class TextLayerBuilder { * Cancel rendering of the text layer. */ cancel() { - if (this.textLayerRenderTask) { - this.textLayerRenderTask.cancel(); - this.textLayerRenderTask = null; - } + this.#textLayer?.cancel(); + this.#textLayer = null; + this.highlighter?.disable(); this.accessibilityManager?.disable(); - this.textContentItemsStr.length = 0; - this.textDivs.length = 0; - this.textDivProperties = new WeakMap(); TextLayerBuilder.#removeGlobalSelectionListener(this.div); }