PDF thumbnails fail for every PDF with non-embedded standard fonts (missing standardFontDataUrl + Path2D/DOMMatrix globals) #76

Open
opened 2026-07-31 10:13:00 +00:00 by laoc · 3 comments
Owner

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-thumbnail very 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 what dev does today.

Symptom

renderPdfThumbnail throws:

Error: Value is none of these types `String`, `Path`,
    at CanvasGraphics.paintChar (pdfjs-dist/legacy/build/pdf.mjs:13432:15)
    at CanvasGraphics.showText   (pdfjs-dist/legacy/build/pdf.mjs:13595:16)

preceded by two warnings:

Warning: UnknownErrorException: Ensure that the `standardFontDataUrl` API parameter is provided.
Warning: getPathGenerator - ignoring character: "Error: Requesting object that isn't resolved yet Helvetica_path_H."

Page counting is unaffected — readPdfPageCount only 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 sets font.disableFontFace and 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:

  1. standardFontDataUrl is never passed. grep -rn "standardFontDataUrl" src/ returns nothing; neither getDocument call in src/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, so getPathGenerator yields an unresolved glyph.
  2. Path2D and DOMMatrix are not on globalThis. paintChar builds a Path2D and calls ctx.fill(path). Node 24 has neither global (typeof globalThis.Path2D === 'undefined'), and @napi-rs/canvas's ctx.fill() rejects any path that is not its own Path2D class — which is exactly the String/Path napi type error above.

Two form details that cost me a wrong first fix:

  • standardFontDataUrl must be a plain filesystem path with a trailing slash. A file:// URL fails (Unable to load font data at: file:///…/LiberationSans-Regular.ttf — the file exists); no trailing slash concatenates into …/standard_fontsLiberationSans-Regular.ttf.
  • The assets ship with the dependency — pdfjs-dist@4.10.38/standard_fonts/ contains LiberationSans-Regular.ttf and 15 more. No new dependency, no vendored binary.

Measurement

Two minimal fixtures, same page count, same code path:

fixture before after
vector marks only, no font renders renders
one line of non-embedded Helvetica throws renders

Four real PDFs off wss://amb-relay.edufeed.org (an independent sample from TestOER's five). dark counts non-white pixels, so this is "glyphs actually drew", not merely "did not throw":

BEFORE (dev today)
RENDER FAIL  Value is none of these types `String`, `Path`   alle-M-Seiten-Sek1-Troemper-Abel.pdf
RENDER FAIL  Value is none of these types `String`, `Path`   FD-2-25-Sek1-Tim-Praesi.pdf
RENDER FAIL  Value is none of these types `String`, `Path`   Fd-01-23-KaloudisSchepers-M-gesamt.pdf
RENDER FAIL  Value is none of these types `String`, `Path`   2020-Friedensdekade-Challenge-Material.pdf

AFTER (both fixes)
RENDER OK  pages=28  dark= 33805/90000    alle-M-Seiten-Sek1-Troemper-Abel.pdf
RENDER OK  pages=36  dark= 13501/90000    FD-2-25-Sek1-Tim-Praesi.pdf
RENDER OK  pages= 9  dark= 60796/226400   Fd-01-23-KaloudisSchepers-M-gesamt.pdf
RENDER OK  pages= 3  dark= 31066/226400   2020-Friedensdekade-Challenge-Material.pdf

TestOER's five, same before-state, via the real endpoint: thumbnail=502 for all five while /api/pdf-info returned 11, 22, 3, 9 and 1 pages respectively.

The vector-only fixture is the control: it renders in both states, so @napi-rs/canvas and sharp are 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. Both getDocument calls need the parameter. Needs a regression test using a text-bearing fixture — the current tests pass because their fixture has no text.

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-thumbnail` very 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 what `dev` does today. ## Symptom `renderPdfThumbnail` throws: ``` Error: Value is none of these types `String`, `Path`, at CanvasGraphics.paintChar (pdfjs-dist/legacy/build/pdf.mjs:13432:15) at CanvasGraphics.showText (pdfjs-dist/legacy/build/pdf.mjs:13595:16) ``` preceded by two warnings: ``` Warning: UnknownErrorException: Ensure that the `standardFontDataUrl` API parameter is provided. Warning: getPathGenerator - ignoring character: "Error: Requesting object that isn't resolved yet Helvetica_path_H." ``` Page *counting* is unaffected — `readPdfPageCount` only 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 sets `font.disableFontFace` and 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**: 1. **`standardFontDataUrl` is never passed.** `grep -rn "standardFontDataUrl" src/` returns nothing; neither `getDocument` call in `src/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, so `getPathGenerator` yields an unresolved glyph. 2. **`Path2D` and `DOMMatrix` are not on `globalThis`.** `paintChar` builds a `Path2D` and calls `ctx.fill(path)`. Node 24 has neither global (`typeof globalThis.Path2D === 'undefined'`), and `@napi-rs/canvas`'s `ctx.fill()` rejects any path that is not its own `Path2D` class — which is exactly the `String`/`Path` napi type error above. Two form details that cost me a wrong first fix: - `standardFontDataUrl` must be a **plain filesystem path with a trailing slash**. A `file://` URL fails (`Unable to load font data at: file:///…/LiberationSans-Regular.ttf` — the file exists); no trailing slash concatenates into `…/standard_fontsLiberationSans-Regular.ttf`. - The assets ship with the dependency — `pdfjs-dist@4.10.38/standard_fonts/` contains `LiberationSans-Regular.ttf` and 15 more. No new dependency, no vendored binary. ## Measurement Two minimal fixtures, same page count, same code path: | fixture | before | after | |---|---|---| | vector marks only, no font | renders | renders | | one line of non-embedded Helvetica | **throws** | renders | Four real PDFs off `wss://amb-relay.edufeed.org` (an independent sample from TestOER's five). `dark` counts non-white pixels, so this is "glyphs actually drew", not merely "did not throw": ``` BEFORE (dev today) RENDER FAIL Value is none of these types `String`, `Path` alle-M-Seiten-Sek1-Troemper-Abel.pdf RENDER FAIL Value is none of these types `String`, `Path` FD-2-25-Sek1-Tim-Praesi.pdf RENDER FAIL Value is none of these types `String`, `Path` Fd-01-23-KaloudisSchepers-M-gesamt.pdf RENDER FAIL Value is none of these types `String`, `Path` 2020-Friedensdekade-Challenge-Material.pdf AFTER (both fixes) RENDER OK pages=28 dark= 33805/90000 alle-M-Seiten-Sek1-Troemper-Abel.pdf RENDER OK pages=36 dark= 13501/90000 FD-2-25-Sek1-Tim-Praesi.pdf RENDER OK pages= 9 dark= 60796/226400 Fd-01-23-KaloudisSchepers-M-gesamt.pdf RENDER OK pages= 3 dark= 31066/226400 2020-Friedensdekade-Challenge-Material.pdf ``` TestOER's five, same before-state, via the real endpoint: `thumbnail=502` for all five while `/api/pdf-info` returned 11, 22, 3, 9 and 1 pages respectively. **The vector-only fixture is the control**: it renders in both states, so `@napi-rs/canvas` and `sharp` are 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. Both `getDocument` calls need the parameter. Needs a regression test using a text-bearing fixture — the current tests pass because their fixture has no text.
Author
Owner

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:30142 on wss://amb-relay.edufeed.org (749 events over three time windows). Rendered each with the exact call pdfThumbnail.js makes, at dev 65a16f1b (git rev-parse HEAD printed before and after in the same shell), counting non-white pixels so a blank page cannot pass as a render.

outcome at 65a16f1b (today) n
RENDER FAIL 32
RENDER OK 3
not a parseable PDF at all 4
fetch failure 0

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

disableFontFace defaults to isNodeJS (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 the Path2D branch on that flag alone:

if (font.disableFontFace || isAddToPathSet || patternFill || patternStroke) {
  path = font.getPathGenerator(this.commonObjs, character);
}
if (font.disableFontFace || patternFill || patternStroke) {
  ...
  ctx.fill(path);          // Node 24: no global Path2D; @napi-rs/canvas rejects anything else

Font embedding is irrelevant. Measured, not just read: the failing PDFs report missingFile=false with 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 + DOMMatrix from @napi-rs/canvas as globals, same 39 URLs, same commit:

without globals with globals
RENDER OK 3 32
RENDER FAIL 32 2

standardFontDataUrl is not what unblocks production. It is still correct — 8 of the sample report missingFile=true and need it — but the Path2D half alone moves 3 → 32.

Two residual failures the globals do not fix, both non-text paths:

  1. Value is none of these types `Image`, `ImageData`, `CanvasElement`, `SVGCanvas` — page-1 image handoff. (friedenseiche-frauenberg.de/…/Niemann - Hass und Nächstenliebe.pdf)
  2. A soft-mask transparency group — genericComposeSMask (pdf.mjs:13078) via composeSMaskcomposeendGroup. (ekir.de/www/downloads/Themenpaket_Fluechtlinge.pdf)

Both are the same shape as the Path2D one: pdf.js's canvas backend expects browser globals/objects that Node lacks and @napi-rs/canvas will 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

  • 4 of 39 URLs are not parseable PDFs (Invalid PDF structure). readPdfPageCount throws 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.
  • Every failing cover means the #57 page count costs a full second fetch and parse, because the sidecar is only written by a successful thumbnail render. At 91% that is the normal case, not the edge case.

Retracted before it reached anyone

My probe crashed the Node process on an unhandled AbortException after catching the image error, which reads exactly like "one PDF kills the server." It is my harness's fault, not the app's — I called page.getOperatorList() and left that task dangling. Running the app's own renderPdfThumbnail on the same file at 65a16f1b: CAUGHT then SURVIVED — 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.org 46 and rpi-ekkw-ekhn.de 26 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.

## 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:30142` on `wss://amb-relay.edufeed.org` (749 events over three time windows). Rendered each with the exact call `pdfThumbnail.js` makes, at `dev` `65a16f1b` (`git rev-parse HEAD` printed before and after in the same shell), counting non-white pixels so a blank page cannot pass as a render. | outcome at `65a16f1b` (today) | n | |---|---| | **RENDER FAIL** | **32** | | RENDER OK | 3 | | not a parseable PDF at all | 4 | | fetch failure | 0 | **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 `disableFontFace` defaults to `isNodeJS` (`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 the `Path2D` branch on that flag alone: ```js if (font.disableFontFace || isAddToPathSet || patternFill || patternStroke) { path = font.getPathGenerator(this.commonObjs, character); } if (font.disableFontFace || patternFill || patternStroke) { ... ctx.fill(path); // Node 24: no global Path2D; @napi-rs/canvas rejects anything else ``` Font *embedding is irrelevant*. Measured, not just read: the failing PDFs report `missingFile=false` with 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` + `DOMMatrix` from `@napi-rs/canvas` as globals, same 39 URLs, same commit: | | without globals | with globals | |---|---|---| | RENDER OK | 3 | **32** | | RENDER FAIL | 32 | **2** | **`standardFontDataUrl` is not what unblocks production.** It is still correct — 8 of the sample report `missingFile=true` and need it — but the `Path2D` half alone moves 3 → 32. **Two residual failures the globals do not fix**, both non-text paths: 1. ``Value is none of these types `Image`, `ImageData`, `CanvasElement`, `SVGCanvas` `` — page-1 image handoff. (`friedenseiche-frauenberg.de/…/Niemann - Hass und Nächstenliebe.pdf`) 2. A soft-mask transparency group — `genericComposeSMask` (`pdf.mjs:13078`) via `composeSMask` → `compose` → `endGroup`. (`ekir.de/www/downloads/Themenpaket_Fluechtlinge.pdf`) Both are the same shape as the `Path2D` one: pdf.js's canvas backend expects browser globals/objects that Node lacks and `@napi-rs/canvas` will 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 - **4 of 39 URLs are not parseable PDFs** (`Invalid PDF structure`). `readPdfPageCount` throws 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. - Every failing cover means the #57 page count costs a **full second fetch and parse**, because the sidecar is only written by a successful thumbnail render. At 91% that is the normal case, not the edge case. ### Retracted before it reached anyone My probe crashed the Node process on an unhandled `AbortException` after catching the image error, which reads exactly like "one PDF kills the server." **It is my harness's fault, not the app's** — I called `page.getOperatorList()` and left that task dangling. Running the app's own `renderPdfThumbnail` on the same file at `65a16f1b`: `CAUGHT` then `SURVIVED — 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.org` 46 and `rpi-ekkw-ekhn.de` 26 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.
Author
Owner

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:216 sets thumbFailed from the <img onerror> handler, and :134 then drops through to TypoCover. A 502 from /api/pdf-thumbnail therefore 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.js returns 502 at :64, before the writePdfCache calls at :67 and :69. So a failed render writes no pages.json either, /api/pdf-info misses the sidecar, and fetchPdfBytes pulls 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, not error, so thumbFailed never 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.

fixture                          dark(<200)   any-non-white
truly empty page                          0               0   <- blank
pale-yellow page + light grey box         0          160000   <- PAINTED, my metric says blank
mid-grey block                        57600           57600   <- ink

So the guard must be "any pixel differs from the white background we filled", not a darkness threshold. renderPdfThumbnail fills #ffffff before 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 standardFontDataUrl and the Path2D/DOMMatrix globals, two non-text failures remain, both the same shape — pdf.js's canvas backend reaching for browser globals Node lacks:

  1. Value is none of these types `Image`, `ImageData`, `CanvasElement`, `SVGCanvas` — page-1 image handoff.
  2. A soft-mask transparency group via 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.

## 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:216` sets `thumbFailed` from the `<img onerror>` handler, and `:134` then drops through to `TypoCover`. A 502 from `/api/pdf-thumbnail` therefore 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.js` returns 502 at `:64`, **before** the `writePdfCache` calls at `:67` and `:69`. So a failed render writes no `pages.json` either, `/api/pdf-info` misses the sidecar, and `fetchPdfBytes` pulls 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`, not `error`, so `thumbFailed` never 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. ``` fixture dark(<200) any-non-white truly empty page 0 0 <- blank pale-yellow page + light grey box 0 160000 <- PAINTED, my metric says blank mid-grey block 57600 57600 <- ink ``` So the guard must be **"any pixel differs from the white background we filled"**, not a darkness threshold. `renderPdfThumbnail` fills `#ffffff` before 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 `standardFontDataUrl` and the `Path2D`/`DOMMatrix` globals, two non-text failures remain, both the same shape — pdf.js's canvas backend reaching for browser globals Node lacks: 1. ``Value is none of these types `Image`, `ImageData`, `CanvasElement`, `SVGCanvas` `` — page-1 image handoff. 2. A soft-mask transparency group via `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.
Author
Owner

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<200 metric. Built it (full-page 1 1 0.902, plus a 0.902 grey box: every pixel differs from white, none is dark) and measured, at dev 65a16f1b:

fixture px raw nonWhite after WebP q80
blank 266800 0 0 agrees
pale, low contrast 266800 266800 266800 agrees
vector 266800 39048 42938 agrees (encoder adds)

No PAINTED -> BLANK on 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. renderPdfThumbnail has ctx in hand at pdfThumbnail.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 TypoCover carries 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

A render is acceptable iff at least one pixel of the raw canvas differs from the #ffffff fill applied at pdfThumbnail.js:39-41 before page.render. Otherwise return the same failure the throw path returns, so ResourceCover's onerror fires and the card lands on TypoCover.

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<200 metric 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 (+ generator i76-makepale.mjs), .scratch/i57-fixture-blank.pdf, checker .scratch/i76-encode-check.mjs.

## 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<200` metric. Built it (full-page `1 1 0.902`, plus a `0.902` grey box: every pixel differs from white, none is dark) and measured, at `dev` `65a16f1b`: | fixture | px | raw `nonWhite` | after WebP q80 | | |---|---|---|---|---| | blank | 266800 | **0** | **0** | agrees | | **pale, low contrast** | 266800 | **266800** | **266800** | agrees | | vector | 266800 | 39048 | 42938 | agrees (encoder adds) | No `PAINTED -> BLANK` on 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.** `renderPdfThumbnail` has `ctx` in hand at `pdfThumbnail.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 `TypoCover` carries 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 > A render is acceptable iff **at least one pixel of the raw canvas differs from the `#ffffff` fill** applied at `pdfThumbnail.js:39-41` before `page.render`. Otherwise return the same failure the throw path returns, so `ResourceCover`'s `onerror` fires and the card lands on `TypoCover`. 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<200` metric 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` (+ generator `i76-makepale.mjs`), `.scratch/i57-fixture-blank.pdf`, checker `.scratch/i76-encode-check.mjs`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app#76
No description provided.