PDF thumbnails fail for every PDF with non-embedded standard fonts (missing standardFontDataUrl + Path2D/DOMMatrix globals) #76
Labels
No labels
bug
discussion
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
edufeed/edufeed-app#76
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Server-side PDF thumbnail rendering fails for every PDF that draws text with a non-embedded standard font. On the sample measured so far that is every real PDF tried — 4/4 in my sample, 5/5 in TestOER's independent sample — so
/api/pdf-thumbnailvery likely returns 502 for the whole corpus and PDF covers have never rendered in production.Found by @TestOER while browser-verifying #75 (issue #57). Pre-existing, not introduced by #75 — TestOER confirmed with an in-run revert to
e0aa0aba, and I reproduce it below with a direct call that matches whatdevdoes today.Symptom
renderPdfThumbnailthrows:preceded by two warnings:
Page counting is unaffected —
readPdfPageCountonly parses, never rasterises, and returns the right number for every file below.Root cause — two independent defects, not one
In Node there is no
document, so pdf.js setsfont.disableFontFaceand renders each glyph as a vector path (pdf.mjs:13419-13424). That path needs two things this repo never provides, and fixing either one alone still fails:standardFontDataUrlis never passed.grep -rn "standardFontDataUrl" src/returns nothing; neithergetDocumentcall insrc/lib/server/pdfThumbnail.js(lines 31 and 67) sets it. Without it pdf.js cannot load the substitute for a non-embedded base-14 font, sogetPathGeneratoryields an unresolved glyph.Path2DandDOMMatrixare not onglobalThis.paintCharbuilds aPath2Dand callsctx.fill(path). Node 24 has neither global (typeof globalThis.Path2D === 'undefined'), and@napi-rs/canvas'sctx.fill()rejects any path that is not its ownPath2Dclass — which is exactly theString/Pathnapi type error above.Two form details that cost me a wrong first fix:
standardFontDataUrlmust be a plain filesystem path with a trailing slash. Afile://URL fails (Unable to load font data at: file:///…/LiberationSans-Regular.ttf— the file exists); no trailing slash concatenates into…/standard_fontsLiberationSans-Regular.ttf.pdfjs-dist@4.10.38/standard_fonts/containsLiberationSans-Regular.ttfand 15 more. No new dependency, no vendored binary.Measurement
Two minimal fixtures, same page count, same code path:
Four real PDFs off
wss://amb-relay.edufeed.org(an independent sample from TestOER's five).darkcounts non-white pixels, so this is "glyphs actually drew", not merely "did not throw":TestOER's five, same before-state, via the real endpoint:
thumbnail=502for all five while/api/pdf-inforeturned 11, 22, 3, 9 and 1 pages respectively.The vector-only fixture is the control: it renders in both states, so
@napi-rs/canvasandsharpare working and the failure is specific to the text path.Prevalence is not measured
9 PDFs across two independent samples, all failing, is enough to call this systemic — but nobody has swept the corpus. A PDF whose fonts are all embedded should render fine today, and none of the 9 was one. Worth counting before sizing the fix's impact.
Knock-on for #57
The linked-materials badge reads its page count from a sidecar the thumbnail render writes. If the render never succeeds, the "nearly free" page count is in practice a second full fetch and parse per card. The feature is correct either way — TestOER measured the fallback working — but its cost argument only holds once this is fixed.
Suggested fix
In
src/lib/server/pdfThumbnail.js, resolve the font directory off the installed package and install the two globals before importing pdf.js. BothgetDocumentcalls need the parameter. Needs a regression test using a text-bearing fixture — the current tests pass because their fixture has no text.Prevalence measured — and it is not a per-file property, so no corpus sweep is needed to size this
Probed 39 PDF URLs across 30 distinct hosts, pulled from
kind:30142onwss://amb-relay.edufeed.org(749 events over three time windows). Rendered each with the exact callpdfThumbnail.jsmakes, atdev65a16f1b(git rev-parse HEADprinted before and after in the same shell), counting non-white pixels so a blank page cannot pass as a render.65a16f1b(today)32 of 35 parseable PDFs — 91% — produce no cover today. Of the 32 failures, 31 are
Value is none of these types `String`, `Path`and 1 is the image variant below.Why a sweep was never the right instrument
disableFontFacedefaults toisNodeJS(pdfjs-dist/legacy/build/pdf.mjs:16457) and we never set it, so it is true for every font in every document.paintChar(13419-13422) gates thePath2Dbranch on that flag alone:Font embedding is irrelevant. Measured, not just read: the failing PDFs report
missingFile=falsewith subset tags (LBGNMB+HelveticaNeueLTPro-Lt,BCDEEE+Calibri-Bold) — fully embedded subsets — and fail identically to the non-embedded ones. The three that render are pages that paint no glyph (image-only, or an invisible OCR text layer: fonts are set, nothing is drawn).So the failure is a property of the renderer, not of the file, and "what % of the corpus is affected" reduces to "what % of covers draw a glyph."
The fix is bigger than the two defects named above
Installing
Path2D+DOMMatrixfrom@napi-rs/canvasas globals, same 39 URLs, same commit:standardFontDataUrlis not what unblocks production. It is still correct — 8 of the sample reportmissingFile=trueand need it — but thePath2Dhalf alone moves 3 → 32.Two residual failures the globals do not fix, both non-text paths:
Value is none of these types `Image`, `ImageData`, `CanvasElement`, `SVGCanvas`— page-1 image handoff. (friedenseiche-frauenberg.de/…/Niemann - Hass und Nächstenliebe.pdf)genericComposeSMask(pdf.mjs:13078) viacomposeSMask→compose→endGroup. (ekir.de/www/downloads/Themenpaket_Fluechtlinge.pdf)Both are the same shape as the
Path2Done: pdf.js's canvas backend expects browser globals/objects that Node lacks and@napi-rs/canvaswill not accept. Whoever picks this up should treat it as "supply the DOM surface pdf.js needs", not "add two options".Also: 2 of the 32 successful renders come out fully blank (
dark=0). Not-throwing is not drawing; the acceptance test for this issue needs a pixel assertion.Two things this turns up for #57
Invalid PDF structure).readPdfPageCountthrows for those too, so the page-count badge has the same hole — it degrades to type + size, which is the intended fallback, but it is not a thumbnail-only problem.Retracted before it reached anyone
My probe crashed the Node process on an unhandled
AbortExceptionafter catching the image error, which reads exactly like "one PDF kills the server." It is my harness's fault, not the app's — I calledpage.getOperatorList()and left that task dangling. Running the app's ownrenderPdfThumbnailon the same file at65a16f1b:CAUGHTthenSURVIVED — process still alive after the catch. There is no crash hazard here; the endpoint's 502 is the whole failure.Sample caveat, stated: 39 URLs is a sample, hosts are skewed (
e-teaching.org46 andrpi-ekkw-ekhn.de26 of 109 distinct URLs), and the corpus was reached through one relay. The 91% is this sample's number. The mechanism above is not sample-dependent.Acceptance criterion, from TestOER's card-level measurement — plus a defect in my own metric
Three corrections to what is above, two of them to my own numbers.
1. Today's failure is graceful, so this is an improvement and not a repair
ResourceCover.svelte:216setsthumbFailedfrom the<img onerror>handler, and:134then drops through toTypoCover. A 502 from/api/pdf-thumbnailtherefore yields a designed typographic cover, not a broken image. Nobody is looking at a broken-image icon today. That should lower this issue's urgency, not raise it.2. The real cost today is two upstream fetches per card, not one
pdf-thumbnail/+server.jsreturns 502 at:64, before thewritePdfCachecalls at:67and:69. So a failed render writes nopages.jsoneither,/api/pdf-infomisses the sidecar, andfetchPdfBytespulls the same file a second time. Measured at an instrumented file host: 2 GETs for a failing PDF vs 1 for a rendering one.At a 91% render-failure rate, two full fetches and two full parses per card is the normal path.
3. A blank render is worse than today's failure — so this must fail closed
An all-white 200 response is a valid image. It fires
load, noterror, sothumbFailednever flips and the card shows an empty white box where the typo cover would otherwise be. Fixing the renderer without this guard would turn a designed fallback into a blank hole.Acceptance criterion: a render that paints nothing must return 502, so the card lands back on
TypoCover. The check belongs in the endpoint, not only in a test.The ink test — my measurement metric was wrong, do not reuse it
I measured the 91% with "count pixels darker than 200", and I was about to propose that as the criterion. It is wrong: it classifies a fully-painted pale page as blank.
So the guard must be "any pixel differs from the white background we filled", not a darkness threshold.
renderPdfThumbnailfills#ffffffbefore rendering, so non-white is exactly "something was painted". Test it on the raw canvas, before the lossy WebP encode.Consequence for my own numbers: my "2 of 32 renders come out fully blank" used the bad metric, so 2 is an upper bound, not a count — the true number of genuinely blank pages is somewhere in 0..2 and I have not re-derived it. It does not gate this fix: whatever the number, the criterion above makes those cases fail closed.
The 91% is unaffected — those 32 were exceptions thrown out of
renderPdfThumbnail, never a pixel measurement.Scope
This is not "add two options". Beyond
standardFontDataUrland thePath2D/DOMMatrixglobals, two non-text failures remain, both the same shape — pdf.js's canvas backend reaching for browser globals Node lacks:Value is none of these types `Image`, `ImageData`, `CanvasElement`, `SVGCanvas`— page-1 image handoff.genericComposeSMask(pdf.mjs:13078).The deliverable is "supply the DOM surface pdf.js needs, and fail closed when nothing is painted".
Test fixtures available: a blank one-page PDF (empty content stream) and the pale-page case above, which is the one that would break a naive darkness threshold.
Two loose ends closed, so the implementer does not have to decide either
1. "Raw canvas, before the WebP encode" is precaution, not load-bearing — measured on the case that could have flipped it
I specified the guard should count on the raw canvas. TestOER measured both sides of the encode and found WebP q80 only ever adds non-white pixels (ringing around existing ink), so the fail-open direction — painted scoring blank — cannot happen.
That was measured on a blank fixture and a high-contrast vector fixture. Neither is what stresses a lossy encoder. The case that could plausibly flatten to uniform white is low-contrast paint on a pale ground — the same fixture that broke my
dark<200metric. Built it (full-page1 1 0.902, plus a0.902grey box: every pixel differs from white, none is dark) and measured, atdev65a16f1b:nonWhiteNo
PAINTED -> BLANKon any of the three. TestOER's vector numbers reproduce exactly here (39048 raw, 42938 encoded) on an independent run.So: implement the check wherever it is cleanest.
renderPdfThumbnailhasctxin hand atpdfThumbnail.js:44-47, so raw is also the convenient place — but if reading the post-encode buffer turns out easier somewhere, it is not wrong. Keep raw when it is free; do not contort the code for it.2. There is one unavoidable false positive, and it is benign — do not engineer around it
A PDF whose first page is legitimately blank (title sheet, scan separator) is indistinguishable from a failed render under any pixel test. It will 502 and fall back to
TypoCover.That is the right trade and worth writing down as such: the cover it loses is a white rectangle, and
TypoCovercarries strictly more information than a white rectangle. It is the mirror image of the pale-page case that broke my first metric — same false positive, opposite cost — and the guard is safe in the direction that matters. (Credit to TestOER for naming it.)Restating the criterion in final form
Not "renders". Not "dark pixels" — that metric 502s every pale-background PDF, which is a whole class of scanned worksheets and tinted handouts that render perfectly well today.
Standing correction
My "2 of 32 renders come out blank" came out of the broken
dark<200metric and is an upper bound, not a count. The true number is 0..2 and I have not re-derived it. It does not gate anything — with the criterion above those cases fail closed whatever the count is — and the 91% figure is unaffected, since those 32 were thrown exceptions rather than pixel measurements.Fixtures:
.scratch/i76-fixture-pale.pdf(+ generatori76-makepale.mjs),.scratch/i57-fixture-blank.pdf, checker.scratch/i76-encode-check.mjs.