Skip to content

TextLayer: stale #ensureCtxFont cache after #getAscent canvas resize yields ~3x --scale-x on first render when fontSize×scale === 30 #21578

Description

@harryberg1n

Attempted repro

Render a page with a standalone TextLayer (no viewer) where a text item's fontSize × viewport.scale lands exactly on DEFAULT_FONT_SIZE (30) on the first paint after the layer's ascent cache is cold — e.g. a 24pt run at scale 1.25 (24 × 1.25 = 30). The span for that item gets a --scale-x roughly 3× too large, so it is painted ~3× wider than the underlying glyphs. Any later re-render (e.g. a zoom) is correct.

Cause (verified in 5.7.284 source, src/display/text_layer.js)

TextLayer shares one hidden measuring canvas per lang and caches the last font it set on that context in #canvasCtxFonts (via #ensureCtxFont, to skip redundant ctx.font = assignments):

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

On an ascent-cache miss, #getAscent resizes that shared canvas, then resizes it back to 0:

static #getAscent(fontFamily, style, lang) {
  ...
  const ctx = this.#getCtx(lang);
  ctx.canvas.width = ctx.canvas.height = DEFAULT_FONT_SIZE;   // 30
  this.#ensureCtxFont(ctx, DEFAULT_FONT_SIZE, fontFamily);    // ctx.font = "30px F"; cache := {30, F}
  const metrics = ctx.measureText("");
  ...
  ctx.canvas.width = ctx.canvas.height = 0;                   // <-- resets ctx state, incl. font -> "10px sans-serif"
  ...
}

Resizing a canvas resets all of its 2D context state, including font (back to the spec default 10px sans-serif). But #canvasCtxFonts is not invalidated by that resize — it still records { size: 30, family: fontFamily }, while the context actually holds 10px sans-serif.

The next #layout for a span in the same family whose fontSize × scale === 30 then cache-hits in #ensureCtxFont and skips the ctx.font assignment:

#layout(params) {
  ...
  TextLayer.#ensureCtxFont(ctx, fontSize * this.#scale, fontFamily);  // 30 === 30, family matches -> no-op
  const { width } = ctx.measureText(div.textContent);                 // measured at 10px, not 30px
  if (width > 0) style.setProperty("--scale-x", canvasWidth * this.#scale / width);  // ~3x too large
}

measureText runs at 10px instead of 30px, so width is ~3× too small and --scale-x comes out ~3× too large. Because #getAscent statically caches the ratio per family, the resize (and thus the stale cache) only happens on the first layout for that family — which is why only the first paint is wrong and every later render is fine.

Suggested fix

Invalidate the #canvasCtxFonts entry for the context whenever the measuring canvas is resized in #getAscent (e.g. reset the cached { size, family } to { 0, "" } after the final ctx.canvas.width = ctx.canvas.height = 0), so the next #ensureCtxFont always re-issues ctx.font.

Environment

  • pdfjs-dist 5.7.284
  • Standalone TextLayer usage (no PDFViewer), observed in Chromium/Edge.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions