From 588d0da48a30af397cdd4d80d1518cb21f2f9339 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:10:59 +0000 Subject: [PATCH] packet gate: look inside the PDF it was already making MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-packet-render has called page.pdf() for a long time and then read only MediaBox and /Type /Page. Eight blank sheets of the right size would have passed every assertion in it. That matters because the packet exists to be PRINTED — it is a standalone file with photos and fonts inlined precisely so it prints offline — and until now every check was on-screen. A print-only CSS rule can strip content that the on-screen checks still see. The first mutation is the argument for the whole change. Adding `@media print{.card img{display:none}}` left the on-screen line reading "95 photos, 0 typographic" — every pre-existing check green — while the PDF carried 2 images instead of 85. The second, hiding sheet contents in print, fires all three new assertions at once on a packet that is still 8 correctly sized pages. Images are compared against DISTINCT srcs, not cards. A PDF de-duplicates identical images, and laos has 95 cards and 95 but only 85 distinct sources, because ten groups share a portrait — a fact about the upstream photo set, not a rendering fault. Comparing against 95 would have reported "8 photos dropped at print time" on a correct packet. I nearly did exactly that. A scare investigated and cleared rather than reported: the PDF names only DejaVuSerif, which looked like the vendored Devanagari face failing to reach print. It is not. Under print media emulation the stack still resolves to "Noto Serif Devanagari", and the same string measures 210px in it against 420px in plain serif, so the face is genuinely in use and the PDF names are Chromium subset aliases. Corroborated independently: hi is the largest packet at 3.2MB with the fewest faces at 7, which is one big vendored face, not a fallback. A ToUnicode-based check was discarded after calibration found only 30 Latin codepoints in a known-good English PDF — an incomplete parser whose zero-Devanagari result proved nothing. All 11 locales embed fonts and carry every photo. i18n re-verified through the new assertions with a partial block (2 strings, 4/12 points, 5/12 rhythms): 2.8MB, 11 fonts, 87 images, wheel 5 translated and 7 English, 12 point rows, 0 blanks. The studio's declared map-less photo-less packet still passes — the image assertion skips at 0 distinct sources, so the opt-outs compose. Wave 34/34. --- internal/check-packet-render.mjs | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index 56c739c50..0ff999784 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -205,6 +205,14 @@ const r = await page.evaluate(() => { sheets: document.querySelectorAll('.sheet').length, cards: document.querySelectorAll('.card').length, imgs: imgs.length, + // ⚠ DISTINCT sources, not the tag count. A PDF DE-DUPLICATES identical images, so the + // number of /Subtype /Image objects can never equal the number of tags when two + // people groups share a portrait. MEASURED on laos: 95 cards, 95 , but only 85 + // distinct srcs — ten groups reuse another group's photo, which is a fact about the + // upstream photo set, not a rendering fault. Comparing the PDF against 95 would have + // reported "8 photos dropped at print time" on a correct packet; comparing against 85 + // asks the question that has a right answer. + distinctImgSrc: new Set(imgs.map((i) => i.getAttribute('src'))).size, broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length, nophoto: document.querySelectorAll('.nophoto').length, rhythms: RHYTHMS.filter((w) => body.includes(w)).length, @@ -290,6 +298,16 @@ const raw = pdf.toString('latin1') const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]]) const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length const landscape = boxes.filter(([w, h]) => h <= w).length +// ★ THE PRINT LAYER, WHICH NOTHING ASSERTED. This gate already GENERATED a PDF and then +// read only its STRUCTURE — page count and MediaBox. The packet exists to be PRINTED (it +// is a standalone file with photos and fonts inlined precisely so it prints offline), so +// "8 pages of the right size" is satisfied by eight blank sheets. +// FontFile2 = a TrueType face actually EMBEDDED in the document. Zero means the text was +// either rasterised or left to whatever fonts the printer happens to have, which for a +// packet carrying a vendored Devanagari face is the difference between Hindi and tofu. +const pdfFonts = (raw.match(/\/FontFile2/g) || []).length +const pdfImages = (raw.match(/\/Subtype\s*\/Image/g) || []).length +const pdfBytes = pdf.length // Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to // one zone's group count — a per-zone packet has a different number of both. @@ -426,6 +444,30 @@ if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`) if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`) +// ── THE PRINTED ARTEFACT ──────────────────────────────────────────────────── +// TEXT MUST BE EMBEDDED TEXT. Measured across scripts: en 7 FontFile2, ar/hi/zh all +// embed too. Zero would mean the packet prints with whatever the printer has, which +// defeats the reason photos and fonts are inlined in the first place. +if (pdfFonts === 0) { + fails.push('the PDF embeds NO fonts (0 /FontFile2) — the packet is a standalone file ' + + 'meant to print offline, so its text must travel with it rather than depend on the ' + + "printer's font set") +} +// THE PHOTOS MUST SURVIVE PRINTING. Compared against DISTINCT srcs, because the PDF +// de-duplicates identical images — see the note on distinctImgSrc above. A small +// allowance covers the map and page furniture, which are images too. +if (expectPhotos && r.distinctImgSrc > 0 && pdfImages < r.distinctImgSrc) { + fails.push(`${pdfImages} image(s) in the PDF for ${r.distinctImgSrc} distinct photo source(s) — ` + + 'photos are being dropped at PRINT time, which no on-screen check can see') +} +// AND IT MUST NOT BE AN EMPTY SHELL. A packet with 95 inlined photos is megabytes; a +// few hundred KB means the content did not make it into the print output at all. +const PDF_MIN_KB = expectPhotos ? 500 : 40 +if (pdfBytes / 1024 < PDF_MIN_KB) { + fails.push(`the PDF is only ${Math.round(pdfBytes / 1024)}KB, expected at least ${PDF_MIN_KB}KB — ` + + 'the pages are the right size and count but carry almost nothing') +} + // ── THE PAGE SIZE WAS PRINTED BUT NEVER CHECKED ───────────────────────────── // `landscape` only catches h <= w, so a WRONG PORTRAIT size sailed through: A4 is // 595x842pt, taller-than-wide like Letter, and would have been reported as a clean @@ -521,6 +563,8 @@ if (r.ranges.length) { } console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`) +console.log(` printed artefact : ${(pdfBytes / 1024 / 1024).toFixed(1)}MB, ${pdfFonts} embedded font(s), ` + + `${pdfImages} image(s) for ${r.distinctImgSrc} distinct source(s)`) console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`) console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`) console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`)