diff --git a/src/display/text_layer.js b/src/display/text_layer.js index bace7a87ea999..2f72b712ef71f 100644 --- a/src/display/text_layer.js +++ b/src/display/text_layer.js @@ -83,6 +83,8 @@ class TextLayer { static #canvasContexts = new Map(); + static #canvasCtxFonts = new WeakMap(); + static #minFontSize = null; static #pendingTextLayers = new Set(); @@ -111,8 +113,6 @@ class TextLayer { this.#scale = viewport.scale * (globalThis.devicePixelRatio || 1); this.#rotation = viewport.rotation; this.#layoutTextParams = { - prevFontSize: null, - prevFontFamily: null, div: null, properties: null, ctx: null, @@ -128,13 +128,13 @@ class TextLayer { // Always clean-up the temporary canvas once rendering is no longer pending. this.#capability.promise - .catch(() => { - // Avoid "Uncaught promise" messages in the console. - }) - .then(() => { + .finally(() => { TextLayer.#pendingTextLayers.delete(this); this.#layoutTextParams = null; this.#styleCache = null; + }) + .catch(() => { + // Avoid "Uncaught promise" messages in the console. }); if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { @@ -195,8 +195,6 @@ class TextLayer { onBefore?.(); this.#scale = scale; const params = { - prevFontSize: null, - prevFontFamily: null, div: null, properties: null, ctx: TextLayer.#getCtx(this.#lang), @@ -394,7 +392,7 @@ class TextLayer { } #layout(params) { - const { div, properties, ctx, prevFontSize, prevFontFamily } = params; + const { div, properties, ctx } = params; const { style } = div; let transform = ""; @@ -406,12 +404,7 @@ class TextLayer { 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; - } - + TextLayer.#ensureCtxFont(ctx, fontSize * this.#scale, fontFamily); // Only measure the width for multi-char text divs, see `appendText`. const { width } = ctx.measureText(div.textContent); @@ -444,8 +437,8 @@ class TextLayer { } static #getCtx(lang = null) { - let canvasContext = this.#canvasContexts.get((lang ||= "")); - if (!canvasContext) { + let ctx = this.#canvasContexts.get((lang ||= "")); + if (!ctx) { // 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 @@ -460,13 +453,26 @@ class TextLayer { canvas.className = "hiddenCanvasElement"; canvas.lang = lang; document.body.append(canvas); - canvasContext = canvas.getContext("2d", { + ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true, }); - this.#canvasContexts.set(lang, canvasContext); + this.#canvasContexts.set(lang, ctx); + + // Also, initialize state for the `#ensureCtxFont` method. + this.#canvasCtxFonts.set(ctx, { size: 0, family: "" }); + } + return ctx; + } + + static #ensureCtxFont(ctx, size, family) { + const cached = this.#canvasCtxFonts.get(ctx); + if (size === cached.size && family === cached.family) { + return; // The font is already set. } - return canvasContext; + ctx.font = `${size}px ${family}`; + cached.size = size; + cached.family = family; } /** @@ -497,9 +503,8 @@ class TextLayer { } 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}`; + this.#ensureCtxFont(ctx, DEFAULT_FONT_SIZE, fontFamily); const metrics = ctx.measureText(""); // Both properties aren't available by default in Firefox. @@ -510,7 +515,6 @@ class TextLayer { this.#ascentCache.set(fontFamily, ratio); ctx.canvas.width = ctx.canvas.height = 0; - ctx.font = savedFont; return ratio; } @@ -550,7 +554,6 @@ class TextLayer { } ctx.canvas.width = ctx.canvas.height = 0; - ctx.font = savedFont; const ratio = ascent ? ascent / (ascent + descent) : DEFAULT_FONT_ASCENT; this.#ascentCache.set(fontFamily, ratio); diff --git a/test/unit/text_layer_spec.js b/test/unit/text_layer_spec.js index 5b0b8a1df22bf..644e742458129 100644 --- a/test/unit/text_layer_spec.js +++ b/test/unit/text_layer_spec.js @@ -90,4 +90,164 @@ describe("textLayer", function () { await loadingTask.destroy(); }); + + it("creates textLayers in parallel, from ReadableStream", async function () { + if (isNodeJS) { + pending("document.createElement is not supported in Node.js."); + } + if (typeof ReadableStream.from !== "function") { + pending("ReadableStream.from is not supported."); + } + const getTransform = container => { + const transform = []; + + for (const span of container.childNodes) { + const t = span.style.transform; + expect(t).toMatch(/^scaleX\([\d.]+\)$/); + + transform.push(t); + } + return transform; + }; + + const loadingTask = getDocument(buildGetDocumentParams("basicapi.pdf")); + const pdfDocument = await loadingTask.promise; + const [page1, page2] = await Promise.all([ + pdfDocument.getPage(1), + pdfDocument.getPage(2), + ]); + + // Create text-content streams with dummy content. + const items1 = [ + { + str: "Chapter A", + dir: "ltr", + width: 100, + height: 20, + transform: [20, 0, 0, 20, 45, 744], + fontName: "g_d0_f1", + hasEOL: false, + }, + { + str: "page 1", + dir: "ltr", + width: 50, + height: 20, + transform: [20, 0, 0, 20, 45, 744], + fontName: "g_d0_f1", + hasEOL: false, + }, + ]; + const items2 = [ + { + str: "Chapter B", + dir: "ltr", + width: 120, + height: 10, + transform: [10, 0, 0, 10, 492, 16], + fontName: "g_d0_f2", + hasEOL: false, + }, + { + str: "page 2", + dir: "ltr", + width: 60, + height: 10, + transform: [10, 0, 0, 10, 492, 16], + fontName: "g_d0_f2", + hasEOL: false, + }, + ]; + + const styles = { + g_d0_f1: { + ascent: 0.75, + descent: -0.25, + fontFamily: "serif", + vertical: false, + }, + g_d0_f2: { + ascent: 0.5, + descent: -0.5, + fontFamily: "sans-serif", + vertical: false, + }, + }; + const lang = "en"; + + // Render the textLayers serially, to have something to compare against. + const serialContainer1 = document.createElement("div"), + serialContainer2 = document.createElement("div"); + + const serialTextLayer1 = new TextLayer({ + textContentSource: { items: items1, styles, lang }, + container: serialContainer1, + viewport: page1.getViewport({ scale: 1 }), + }); + await serialTextLayer1.render(); + + const serialTextLayer2 = new TextLayer({ + textContentSource: { items: items2, styles, lang }, + container: serialContainer2, + viewport: page2.getViewport({ scale: 1 }), + }); + await serialTextLayer2.render(); + + const serialTransform1 = getTransform(serialContainer1), + serialTransform2 = getTransform(serialContainer2); + + expect(serialTransform1.length).toEqual(2); + expect(serialTransform2.length).toEqual(2); + + // Reset any global textLayer-state before rendering in parallel. + TextLayer.cleanup(); + + const container1 = document.createElement("div"), + container2 = document.createElement("div"); + const waitCapability1 = Promise.withResolvers(); + + const streamGenerator1 = (async function* () { + for (const item of items1) { + yield { items: [item], styles, lang }; + await waitCapability1.promise; + } + })(); + const streamGenerator2 = (async function* () { + for (const item of items2) { + yield { items: [item], styles, lang }; + } + })(); + + const textLayer1 = new TextLayer({ + textContentSource: ReadableStream.from(streamGenerator1), + container: container1, + viewport: page1.getViewport({ scale: 1 }), + }); + const textLayer1Promise = textLayer1.render(); + + const textLayer2 = new TextLayer({ + textContentSource: ReadableStream.from(streamGenerator2), + container: container2, + viewport: page2.getViewport({ scale: 1 }), + }); + await textLayer2.render(); + + // Ensure that the first textLayer has its rendering "paused" while + // the second textLayer renders. + waitCapability1.resolve(); + await textLayer1Promise; + + // Sanity check to make sure that all text was parsed. + expect(textLayer1.textContentItemsStr).toEqual(["Chapter A", "page 1"]); + expect(textLayer2.textContentItemsStr).toEqual(["Chapter B", "page 2"]); + + // Ensure that the transforms are identical when parsing in series/parallel. + const transform1 = getTransform(container1), + transform2 = getTransform(container2); + + expect(transform1).toEqual(serialTransform1); + expect(transform2).toEqual(serialTransform2); + + await loadingTask.destroy(); + }); });