Skip to content

Commit

Permalink
Merge pull request #18731 from Snuffleupagus/TextLayer-ensureCtxFont
Browse files Browse the repository at this point in the history
Ensure that textLayers can be rendered in parallel, without interfering with each other
  • Loading branch information
Snuffleupagus authored Sep 11, 2024
2 parents 870394d + 5b3d3c7 commit c52e848
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 24 deletions.
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();
});
});

0 comments on commit c52e848

Please sign in to comment.