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.
Attempted repro
Render a page with a standalone
TextLayer(no viewer) where a text item'sfontSize × viewport.scalelands exactly onDEFAULT_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-xroughly 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)TextLayershares one hidden measuring canvas per lang and caches the last font it set on that context in#canvasCtxFonts(via#ensureCtxFont, to skip redundantctx.font =assignments):On an ascent-cache miss,
#getAscentresizes that shared canvas, then resizes it back to 0:Resizing a canvas resets all of its 2D context state, including
font(back to the spec default10px sans-serif). But#canvasCtxFontsis not invalidated by that resize — it still records{ size: 30, family: fontFamily }, while the context actually holds10px sans-serif.The next
#layoutfor a span in the same family whosefontSize × scale === 30then cache-hits in#ensureCtxFontand skips thectx.fontassignment:measureTextruns at 10px instead of 30px, sowidthis ~3× too small and--scale-xcomes out ~3× too large. Because#getAscentstatically 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
#canvasCtxFontsentry for the context whenever the measuring canvas is resized in#getAscent(e.g. reset the cached{ size, family }to{ 0, "" }after the finalctx.canvas.width = ctx.canvas.height = 0), so the next#ensureCtxFontalways re-issuesctx.font.Environment
TextLayerusage (noPDFViewer), observed in Chromium/Edge.