Skip to content

Commit

Permalink
Ensure that textLayers can be rendered in parallel, without interferi…
Browse files Browse the repository at this point in the history
…ng with each other

Note that the textContent is returned in "chunks" from the API, through the use of `ReadableStream`s, and on the main-thread we're (normally) using just one temporary canvas in order to measure the size of the textLayer `span`s; see the [`#layout`](https://github.com/mozilla/pdf.js/blob/5b4c2fe1a845169ac2b4f8f6335337c434077637/src/display/text_layer.js#L396-L428) method.

*Order of events, for parallel textLayer rendering:*
 1. Call [`render`](https://github.com/mozilla/pdf.js/blob/5b4c2fe1a845169ac2b4f8f6335337c434077637/src/display/text_layer.js#L155-L177) of the textLayer for page A.
 2. Immediately call `render` of the textLayer for page B.
 3. The first text-chunk for pageA arrives, and it's parsed/layout which means updating the cached [fontSize/fontFamily](https://github.com/mozilla/pdf.js/blob/5b4c2fe1a845169ac2b4f8f6335337c434077637/src/display/text_layer.js#L409-L413) for the textLayer of page A.
 4. The first text-chunk for pageB arrives, which means updating the cached fontSize/fontFamily *for the textLayer of page B* since this data is unique to each `TextLayer`-instance.
 5. The second text-chunk for pageA arrives, and we don't update the canvas-font since the cached fontSize/fontFamily still apply from step 3 above.

Where this potentially breaks down is between the last steps, since we're using just one temporary canvas for all measurements but have *individual* fontSize/fontFamily caches for each textLayer.
Hence it's possible that the canvas-font has actually changed, despite the cached values suggesting otherwise, and to address this we instead cache the fontSize/fontFamily globally through a new (static) helper method.

*Note:* Includes a basic unit-test, using dummy text-content, which fails on `master` and passes with this patch.
  • Loading branch information
Snuffleupagus committed Sep 10, 2024
1 parent 5b4c2fe commit 7a4cc9a
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 15 deletions.
29 changes: 14 additions & 15 deletions src/display/text_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ class TextLayer {

static #canvasContexts = new Map();

static #canvasCtxFonts = new WeakMap();

static #minFontSize = null;

static #pendingTextLayers = new Set();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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);

Expand Down Expand Up @@ -469,6 +462,15 @@ class TextLayer {
return canvasContext;
}

static #ensureCtxFont(ctx, size, family) {
const cached = this.#canvasCtxFonts.get(ctx);
if (cached && size === cached.size && family === cached.family) {
return; // The font is already set.
}
ctx.font = `${size}px ${family}`;
this.#canvasCtxFonts.set(ctx, { size, family });
}

/**
* Compute the minimum font size enforced by the browser.
*/
Expand Down Expand Up @@ -497,9 +499,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.
Expand All @@ -510,7 +511,6 @@ class TextLayer {
this.#ascentCache.set(fontFamily, ratio);

ctx.canvas.width = ctx.canvas.height = 0;
ctx.font = savedFont;
return ratio;
}

Expand Down Expand Up @@ -550,7 +550,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);
Expand Down
125 changes: 125 additions & 0 deletions test/unit/text_layer_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,129 @@ 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 loadingTask = getDocument(buildGetDocumentParams("basicapi.pdf"));
const pdfDocument = await loadingTask.promise;
const [page1, page2] = await Promise.all([
pdfDocument.getPage(1),
pdfDocument.getPage(2),
]);

const container1 = document.createElement("div"),
container2 = document.createElement("div");
const waitCapability1 = Promise.withResolvers();

// 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";

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"]);

const transform1 = Array.from(
container1.childNodes,
span => span.style.transform
),
transform2 = Array.from(
container2.childNodes,
span => span.style.transform
);
expect(transform1).toEqual(["scaleX(1.20822)", "scaleX(0.947568)"]);
expect(transform2).toEqual(["scaleX(2.66371)", "scaleX(1.95972)"]);

await loadingTask.destroy();
});
});

0 comments on commit 7a4cc9a

Please sign in to comment.