Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure that textLayers can be rendered in parallel, without interfering with each other #18731

Merged
merged 1 commit into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 27 additions & 24 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 All @@ -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")) {
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 @@ -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 <html> element get a lang attribute that depends on
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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.
Expand All @@ -510,7 +515,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 +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);
Expand Down
160 changes: 160 additions & 0 deletions test/unit/text_layer_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});