doxa-maps/internal/check-packet-render.mjs
doxa-maps-88655c70 53fea6ee38
Some checks are pending
build-gate / build (push) Waiting to run
packet gate: a prayer wheel with no words on it is not a prayer wheel
Blanking every wheel segment label and regenerating left check:packet-render
reporting "12 segments, 11/11 rhythms, content intact — PASS" on a wheel that
was a twelve-colour pie chart.

Both printed numbers stayed true, which is why nothing caught it. The twelve
segments are <path> elements and survive their labels being removed. And the
rhythm assertion searched document.body.innerText, where the SAME twelve rhythm
words are rendered TWICE in every packet — once per wheel segment, once as a
prayer-point heading. So "every rhythm word appears in the document" was
satisfied by the points page alone and said nothing about the wheel.

That is the self-consistency-vs-completeness family again, with a new twist:
when two surfaces share one vocabulary, a document-wide string search cannot
attribute a hit to the surface being asserted. The fix is scoping the query to
the surface, not a better word list.

wheelLabels counts the non-numeric <text> runs inside the .wheel-title sheet's
own svg, excluding the twelve "5" pips and the "5 minutes x 12" footer. It
counts ELEMENTS, NOT DISTINCT WORDS, deliberately: `praise` and `praise_end`
collapse to one word in English — which is exactly why the existing line reads
11/11 and not 12/12 — and that collision is locale-dependent, so a distinct-word
count would be a different number per language.

The Driver's spec names the wheel as one of the packet's four sections, so 12
labels is a contract. Mutated packet reports 0/12 and fails; restored reports
12/12.

i18n verified at the rendered layer with a partial locale block (4 of 12 rhythms
translated): the wheel painted four XX- labels with the other eight falling back
to English and zero blank — and the split pair is visible, segment 1 English
PRAISE beside segment 12 XX-PRAISE_END, which is what per-key resolution looks
like. All 11 locales regenerated with 95 photos each: check:packets-all 11/11,
every one 12/12 labelled, including RTL ar and Devanagari hi. Wave 34/34.
2026-09-24 13:23:57 +00:00

497 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate.
//
// Every bug this gate exists for produced a file of the right size, with no error, no
// broken image, and a generator that reported success. They were only visible at the
// RENDERED layer, which is why this drives a real browser and reads computed style.
//
// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier
// version of this probe PRINTED "PDF pages: 16" for several runs while reporting
// "✓ packet verified", because the failure counter never looked at that value. A number
// that is displayed but not asserted on is decoration, not verification.
//
// What it catches, each measured on a real regression:
// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its
// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages,
// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.)
// The packet is printed front-and-back, so an exact page count is load-bearing.
// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled
// from the poster document's stylesheet. Lift the <svg> without those rules and SVG
// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid
// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first
// attempt at this check tested one hand-picked <rect> (the one just repaired) and went
// green while 647 others were still black.
// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments,
// the prayer rhythms, and exactly two minimal metacamp.org attributions.
//
// Usage: node internal/check-packet-render.mjs [path/to/packet.html]
// Default path is overridable with PACKET_HTML so this is not machine-specific.
import { chromium } from 'playwright'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { pathToFileURL } from 'node:url'
const target = process.argv[2] || process.env.PACKET_HTML
if (!target) {
console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).')
console.log(' The packet is generated on demand, so this gate is opt-in rather than')
console.log(' failing a build on a file that is not expected to exist.')
process.exit(0)
}
const file = resolve(target)
if (!existsSync(file)) {
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
process.exit(1)
}
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' })
// Resolve the rhythm words for THIS packet's language from the shared catalog.
// Per-key fallback to English, the same contract the renderer honours.
const lang = await page.evaluate(() => document.documentElement.lang || 'en')
// RTL languages must actually be laid out RTL. Without dir="rtl" an Arabic packet
// renders readable glyphs LEFT-aligned — it looks like a styling quirk rather than
// the bug it is, and no other assertion here would notice.
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'])
const dir = await page.evaluate(() => document.documentElement.dir || 'ltr')
// ── AND THE TEXT MUST ACTUALLY BE LAID OUT THAT WAY ─────────────────────────
// dir="rtl" is a DECLARATION, not a layout. It can be set correctly on <html>
// while an inner element overrides it, or while the sheet keeps its LTR flow —
// and every other assertion here would still pass, because an Arabic packet with
// readable glyphs in LTR order looks like a styling quirk rather than the bug it
// is. So measure where the painted text actually sits.
//
// ⚠ MEASURE THE INLINE TEXT RUN, NOT THE BLOCK. A first attempt compared each
// element's own box to its parent's and got IDENTICAL numbers for Arabic and
// English — it was measuring container geometry, which does not move when
// direction flips. A Range over the text node gives the painted extent, which
// does: in ar the run pins to the RIGHT edge (rightGap 0, slack on the left), in
// en/hi to the LEFT. Centred text is skipped because it proves nothing either
// way, and so is text that fills its box — no room to tell.
const layout = await page.evaluate(() => {
const out = { pinnedRight: 0, pinnedLeft: 0, sampled: 0, rtlComputed: 0 }
for (const el of document.querySelectorAll('.sheet *')) {
if (el.childElementCount) continue
if ((el.textContent || '').trim().length < 8) continue
const cs = getComputedStyle(el)
if (cs.textAlign === 'center') continue
const box = el.getBoundingClientRect()
const rng = document.createRange(); rng.selectNodeContents(el)
const tb = rng.getBoundingClientRect()
if (tb.width < 20 || box.width < 50 || box.width - tb.width < 12) continue
if (cs.direction === 'rtl') out.rtlComputed += 1
if (Math.round(box.right - tb.right) <= 1) out.pinnedRight += 1
else if (Math.round(tb.left - box.left) <= 1) out.pinnedLeft += 1
out.sampled += 1
if (out.sampled >= 25) break
}
return out
})
// SCRIPT FONT. Some languages need a face the system stack does not carry — Hindi
// renders as tofu boxes in Georgia/Palatino/serif. When the packet declares one,
// it must actually LOAD; a declared-but-failed @font-face is silent, and the page
// then prints as empty rectangles with every other assertion still green.
//
// ⚠ This asserts the FONT LOADED, not that glyphs are correct. A general tofu
// detector was attempted and abandoned: comparing shaped width against a missing
// font false-positives on every system-covered script (Cyrillic, CJK, Arabic all
// legitimately measure the same in both stacks), and an ink-profile heuristic
// scored CORRECTLY-RENDERED Devanagari the same as tofu, because its baseline bar
// gives a uniform column profile. A detector that blocks working packets is worse
// than none. Read the pixels for that; this covers the mechanical half.
const fontStatus = await page.evaluate(async () => {
await document.fonts.ready
const faces = [...document.fonts]
return { declared: faces.length, loaded: faces.filter((f) => f.status === 'loaded').length,
names: faces.map((f) => `${f.family}:${f.status}`) }
})
// The catalog sits beside the generator that produced this packet, so derive it
// from the packet's own path rather than hardcoding a machine-specific location.
const catalogPath = process.env.PACKET_STRINGS
|| resolve(dirname(file), '..', '..', 'packet-strings.json')
if (!existsSync(catalogPath)) {
console.error(`PACKET-RENDER: FAIL — no strings catalog at ${catalogPath}`)
console.error(' Set PACKET_STRINGS if the packet lives outside the generator tree.')
process.exit(1)
}
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
const enR = catalog.en.rhythms
const langR = (catalog[lang] || {}).rhythms || {}
const expected = [...new Set(Object.keys(enR).map((k) => langR[k] ?? enR[k]))]
// ★ A NON-ENGLISH PACKET MUST ACTUALLY DIFFER FROM ENGLISH. The failure this
// catches is a language block that exists but does nothing — a typo'd key, a
// block under the wrong ISO code, or a renderer that ignored the lang argument.
// Every assertion below would still pass in that case, because an all-English
// packet is a perfectly valid packet; only the comparison reveals it.
const translated = lang !== 'en'
? Object.keys(catalog.en).filter((k) => typeof catalog.en[k] === 'string'
&& (catalog[lang] || {})[k] && catalog[lang][k] !== catalog.en[k]).length
: null
// ── THE LARGEST BLOCK OF READER-FACING TEXT, REPORTED SEPARATELY ────────────
// point4_passage is Mark 5:1-20 in full — 2,141 characters, by far the longest string
// in the packet and the one the Driver singled out ("prayer point number four is
// actually read the word"). It is ABSENT from all ten non-English blocks, so every
// translated packet prints its longest passage in English. The per-locale "19 strings
// translated" headline looked healthy while this was true, because 19 short keys and
// one 2,141-character one count the same.
// Reported, NOT failed: a Bible passage cannot be machine-translated or written from
// memory — it must be an exact, correctly-licensed text, and the English here is
// deliberately public-domain (World English Bible). Picking a translation per language
// is a sourcing decision with licensing consequences, not a code change. Failing the
// build over it would only pressure someone into pasting a copyrighted text.
const scriptureIsEnglish = lang !== 'en'
&& !(catalog[lang] || {}).point4_passage
await page.evaluate((words) => { window.__EXPECTED_RHYTHMS = words }, expected)
const r = await page.evaluate(() => {
const body = document.body.innerText
const imgs = [...document.querySelectorAll('.card img')]
// ⚠ The expected rhythm words come from the STRINGS CATALOG FOR THIS PACKET'S
// LANGUAGE, never a hardcoded English list. A fixed list tests the opposite of
// what i18n means: it passed a packet whose translation silently did nothing and
// FAILED every correctly-translated one (measured — the Spanish packet reported
// 0/11 rhythms while rendering all twelve correctly as ALABANZA/ESPERAR/...).
// Read the packet's own <html lang> and resolve per-key, exactly as the renderer
// does, so this asserts the PROPERTY (all rhythms present, in this language)
// rather than a vocabulary.
const RHYTHMS = window.__EXPECTED_RHYTHMS || []
// Whole-map property, NOT one sampled element — see header note 2.
const mapFaults = (() => {
const svg = document.querySelector('.sheet svg')
if (!svg) return ['no map svg found']
const faults = []
const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))]
const cs = getComputedStyle(svg)
for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`)
const all = [...svg.querySelectorAll('path,circle,text,rect,line')]
const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)')
if (all.length && black.length > all.length * 0.25) {
faults.push(`${black.length}/${all.length} map elements fill BLACK`)
}
return faults
})()
return {
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
imgs: imgs.length,
broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
nophoto: document.querySelectorAll('.nophoto').length,
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
rhythmsTotal: RHYTHMS.length,
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
// ⚠ COUNT THE WHEEL'S OWN LABELS, SCOPED TO THE WHEEL SHEET. `rhythms` below
// searches document.body.innerText, and the SAME rhythm vocabulary is rendered
// TWICE — once per wheel segment, and once per prayer point (each point prints
// tr(p.rhythm) as its heading). So "every rhythm word appears in the document" is
// satisfied by the POINTS page alone and says nothing about the wheel.
// Measured 2026-09-24: blanking every wheel segment label left the gate reporting
// "12 segments, 11/11 rhythms, content intact — PASS" on a prayer wheel that was a
// twelve-colour pie chart with no words on it. Both numbers stayed true: the
// segments are <path> elements and survive, and the words were still on the points
// page. The Driver's spec names the wheel as one of the packet's four sections.
//
// COUNT ELEMENTS, NOT DISTINCT WORDS. `order` renders 12 segments from 12 keys, but
// `praise` and `praise_end` collapse to the same word in English (which is why the
// line below prints 11/11, not 12/12) — and that collision is LOCALE-DEPENDENT. A
// distinct-word count would be a different number per language.
wheelLabels: (() => {
const title = document.querySelector('.wheel-title')
const sheet = title ? title.closest('.sheet') : null
const svg = sheet ? sheet.querySelector('svg') : null
if (!svg) return -1
// The segment labels are the non-numeric <text> runs: the pips are all "5" and
// the footer reads "5 <minutes> x 12".
return [...svg.querySelectorAll('text')]
.map((t) => (t.textContent || '').trim())
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
})(),
// ⚠ NOT body.includes('Gadarenes'). That was an ENGLISH proper noun, so this
// assertion would go RED on a correctly-translated packet and stays green today
// only because every locale falls back to English for this key. The check and the
// bug were locked together. Assert the STRUCTURE instead — the passage block exists
// and carries a full passage's worth of text — which holds in any language.
mark5Len: (document.querySelector('.p4-passage')?.textContent || '').trim().length,
// ⚠ COUNT THE POINTS. The sheet-sum invariant below already required a points PAGE —
// and a page with three points on it is still exactly one sheet, so a packet headed
// "12 Prayer Points" printed THREE and every number this gate asserted stayed correct.
// Measured 2026-09-24 against a deliberately partial locale block: the renderer did
// `L.points || EN.points`, which is a whole-block fallback when the key is ABSENT but
// TRUNCATES when a translator has done some of the 12 (a 3-element array is truthy).
// Counted from the rendered numbered rows, not from the strings file, because the
// strings file was complete in all 11 locales — the defect only exists downstream.
points: (() => {
const title = document.querySelector('.points-title')
if (!title) return -1
const host = title.parentElement?.parentElement
if (!host) return -1
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
})(),
metacamp: (body.match(/metacamp\.org/g) || []).length,
mapFaults,
// The per-page "{from}-{to} of {count}" headers, as DIGIT TRIPLES rather than text.
// Read from the rendered DOM (not the source template) so what is checked is what a
// reader would see. Locale digits are normalised to ASCII first: several catalogs
// render through toLocaleString, and Arabic-Indic digits would not match \d.
ranges: [...document.querySelectorAll('.hd-r')].map((el) => {
const ascii = el.textContent
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[०-९]/g, (d) => String(d.charCodeAt(0) - 0x0966))
.replace(/[0-9]/g, (d) => String(d.charCodeAt(0) - 0xff10))
.replace(/[,٬   ]/g, '') // thousands separators
return (ascii.match(/\d+/g) || []).map(Number)
}).filter((nums) => nums.length === 3),
}
})
const pdf = await page.pdf({ preferCSSPageSize: true })
await browser.close()
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
// 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.
const fails = []
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
if (!r.cards) fails.push('no people-group cards')
if (r.broken) fails.push(`${r.broken} broken photo(s)`)
// ── "20 PER PAGE **WITH IMAGES**" WAS PRINTED, NEVER ASSERTED ────────────────
// The packet spec names the photos explicitly, and this gate printed
// "95 cards (0 photos, 95 typographic)" and PASSED — every locale, every run.
// Measured 2026-09-24: all 2,107 source records carry an image_url, so zero photos
// means the fetch was skipped or failed wholesale, not that the groups have none.
//
// ⚠ THE COUNT CANNOT BE PINNED. Roughly half the groups genuinely have no photo,
// the CDN drops some, and NOPHOTOS=1 is a SUPPORTED dev mode for fast layout
// iteration (packet.mjs:35). A packet built that way is a valid artefact, not a
// defect — so the assertion has to tell "deliberately none" from "silently none".
// Two invariants do that without pinning a number:
// 1. every card is one or the other — photos + typographic === cards. A card that
// is neither means the renderer emitted a third shape nobody is checking.
// 2. a packet with SOME photos proves the pipeline ran. Zero is only acceptable
// when EVERY card is typographic AND the run was declared photo-less, which is
// what PACKET_EXPECT_PHOTOS=0 says out loud.
if (r.cards && r.imgs + r.nophoto !== r.cards) {
fails.push(`${r.cards} people cards but ${r.imgs} photo + ${r.nophoto} typographic = `
+ `${r.imgs + r.nophoto} — every card must be exactly one of the two`)
}
const expectPhotos = process.env.PACKET_EXPECT_PHOTOS !== '0'
if (r.cards && expectPhotos && r.imgs === 0) {
fails.push(`${r.cards} people cards and NOT ONE photo — the packet spec is "20 per page `
+ 'WITH IMAGES", and every source record carries an image_url, so this is a skipped or '
+ 'failed photo fetch, not a zone without pictures. Re-run without NOPHOTOS=1, or set '
+ 'PACKET_EXPECT_PHOTOS=0 to declare this a deliberately photo-less packet.')
}
if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`)
// A SEGMENT WITHOUT ITS WORD IS A COLOUR. 12 labels, one per segment, on the wheel
// sheet itself — not merely somewhere in the document.
const WHEEL_LABELS = 12
if (r.wheelLabels !== WHEEL_LABELS) {
fails.push(r.wheelLabels === -1
? 'no prayer-wheel svg found (no .wheel-title sheet) — the wheel page did not render'
: `the prayer wheel paints ${r.wheelLabels} segment label(s), expected ${WHEEL_LABELS} — `
+ 'the rhythm words are rendered TWICE in this packet (wheel segments and prayer-point '
+ 'headings), so the document-wide rhythm check below passes on the points page alone '
+ 'and cannot see an unlabelled wheel')
}
if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`)
// Mark 5:1-20 in full is a Driver requirement for point 4: "it actually lists out the
// full scripture, because prayer point number four is actually read the word." The
// English passage is 2,141 chars; 1,200 is a floor that no truncation or placeholder
// survives while leaving room for languages that render the same text more compactly
// (zh is typically ~40% shorter in characters).
const MARK5_MIN = 1200
if (r.mark5Len < MARK5_MIN) {
fails.push(r.mark5Len === 0
? 'Mark 5:1-20 is NOT printed in full (Driver requirement for point 4) — no .p4-passage block'
: `Mark 5:1-20 looks truncated: ${r.mark5Len} chars, expected at least ${MARK5_MIN}`)
}
// THE PRODUCT IS NAMED FOR THIS NUMBER. The page header says "12 Prayer Points" in every
// locale, so 12 is a contract, not a preference — and the failure mode it guards is SILENT:
// a partial translation rendered fewer, with nothing blank and no error raised.
const POINTS_EXPECT = 12
if (r.points !== POINTS_EXPECT) {
fails.push(r.points === -1
? 'no .points-title block — the 12 prayer points page did not render at all'
: `${r.points} prayer point(s) rendered, expected ${POINTS_EXPECT} — a partial locale `
+ 'block truncates the list instead of falling back per key, and the page still '
+ 'carries its "12 Prayer Points" heading')
}
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`)
if (fontStatus.declared && fontStatus.loaded !== fontStatus.declared) {
fails.push(`a declared @font-face did not load (${fontStatus.names.join(', ')}) — `
+ 'text in that script will print as empty boxes')
}
if (RTL.has(lang) && dir !== 'rtl') fails.push(`lang="${lang}" is right-to-left but dir="${dir}"`)
if (!RTL.has(lang) && dir === 'rtl') fails.push(`lang="${lang}" is not RTL but dir="rtl"`)
// The attribute is necessary; this is the part that proves it took effect.
if (layout.sampled >= 5) {
const wantRight = RTL.has(lang)
const pinned = wantRight ? layout.pinnedRight : layout.pinnedLeft
if (pinned < Math.ceil(layout.sampled * 0.8)) {
fails.push(`lang="${lang}" should paint text to the ${wantRight ? 'RIGHT' : 'LEFT'}, but only `
+ `${pinned}/${layout.sampled} sampled runs do (right ${layout.pinnedRight}, left ${layout.pinnedLeft}) — `
+ `dir="${dir}" is declared and not taking effect`)
}
if (RTL.has(lang) && layout.rtlComputed < layout.sampled) {
fails.push(`${layout.sampled - layout.rtlComputed}/${layout.sampled} sampled elements compute `
+ 'direction:ltr inside an RTL packet — something overrides the document direction')
}
}
if (translated === 0) {
fails.push(`lang="${lang}" but no string differs from English — `
+ 'the language block is missing, mis-keyed, or the renderer ignored it')
}
// ── "N strings translated" WAS PRINTED, AND ONLY ZERO WAS ASSERTED ──────────
// A locale regressing from 19 translated keys to 1 passed silently: the only check
// was `=== 0`. But the count CANNOT be pinned — measured 2026-09-24, de translates
// 18 of the same 19 keys and fr 17, because a word or two legitimately matches
// English. Hardcoding 19 would fail two correct catalogs.
// So assert a FLOOR derived from the catalog itself: most of what a block carries
// must actually differ from English. Two-thirds leaves generous room for shared
// proper nouns while still catching a block that has quietly emptied out.
// The structural half of this — every key PRESENT, all 12 points, all 12 rhythms —
// is check:packet-strings-parity, which does not need a rendered packet to run.
if (translated !== null && translated > 0) {
const present = Object.keys(catalog.en).filter((k) => typeof catalog.en[k] === 'string'
&& typeof (catalog[lang] || {})[k] === 'string').length
const floor = Math.ceil(present * 2 / 3)
if (present && translated < floor) {
fails.push(`lang="${lang}" carries ${present} string(s) but only ${translated} differ from `
+ `English (floor ${floor}) — most of this block has reverted to English text`)
}
}
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 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
// pass on a packet that prints with the wrong margins on every US printer.
// That is not hypothetical here — this repo has already paid for it once. `@page
// {size:...}` silently falls back to Letter in Chromium for any named size beyond
// letter/A4, so the declared size and the produced size can disagree with no error
// anywhere. The renderer declares `@page{size:letter portrait}` (packet-render.js:97);
// this asserts the PDF actually came out that way.
// Tolerance is 1pt for float formatting in the MediaBox, not for real drift.
const LETTER = [612, 792]
if (!boxes.length) {
fails.push('the PDF declares no MediaBox — page geometry could not be read at all')
} else {
const wrong = boxes.filter(([w, h]) => Math.abs(w - LETTER[0]) > 1 || Math.abs(h - LETTER[1]) > 1)
if (wrong.length) {
fails.push(`${wrong.length}/${boxes.length} page(s) are not US Letter — got `
+ `${wrong[0].join(' x ')}pt, expected ${LETTER.join(' x ')}pt `
+ '(@page{size:letter portrait} did not take effect)')
}
}
// ── THE CARD COUNT WAS PRINTED BUT ONLY CHECKED FOR ZERO ────────────────────
// `if (!r.cards)` passes a packet carrying ONE card. The existing comment is right
// that counts must stay RELATIVE — a per-zone packet has a different group count, so
// pinning 95 would make this gate zone-specific. But relative does not mean unasserted:
// the renderer lays out PER_PAGE cards per list page (packet-render.js:59, COLS*ROWS),
// and the packet is map + list pages + points + wheel. So the whole structure is
// derivable from the card count alone, for ANY zone:
//
// sheets === 1 + ceil(cards / PER_PAGE) + 2
//
// That ties the printed number to the sheet count without pinning either. It catches
// silently dropped groups (a selector that resolves 95 but renders 40 keeps the same
// page arithmetic only if the pages drop too) and a mis-sized grid, which is the way
// "20 per page WITH IMAGES" actually regresses. Verified against all 11 locale packets
// of laos (95 cards -> 8 sheets) before being turned on.
const PER_PAGE = 20
const expectedSheets = 1 + Math.ceil(r.cards / PER_PAGE) + 2
if (r.cards && r.sheets !== expectedSheets) {
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
+ `(1 map + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), but the packet has ${r.sheets} — `
+ `either groups were dropped from a page or the ${PER_PAGE}-per-page grid changed`)
}
// ── ...AND THAT ALONE IS NOT ENOUGH: IT ONLY PROVES SELF-CONSISTENCY ────────
// ⚠ MUTATION-TESTED, AND THE FIRST VERSION OF THIS GATE FAILED THE TEST. Deleting a
// whole list sheet from a finished packet removed 15 cards AND one sheet, so
// `sheets === 1 + ceil(cards/20) + 2` still held: 80 cards / 7 sheets is internally
// valid arithmetic, and the packet PASSED while missing 15 people groups. A packet can
// be perfectly self-consistent and still have lost content — exactly the shape of the
// index.json "declares 16, 15 on disk" bug.
// So compare against an INDEPENDENT WITNESS the renderer prints for the reader: each
// list page's header carries {from}-{to} of {count} (packet-render.js:219). `count` is
// the zone's TRUE group total, derived from groups.length rather than from the pages,
// so it does not move when a page is lost.
//
// ⚠ PARSE THE NUMBERS, NEVER THE WORD ORDER. That header is TRANSLATED and the
// languages disagree on order: en/ar read "1-20 of 95", hi reads "95 में से 1–20" and
// zh reads "95 之中的 1–20" — the total comes FIRST. A positional regex would read hi
// and zh backwards and assert against 1 instead of 95. Since count >= to >= from always,
// max() recovers the total without knowing any locale's grammar.
if (r.ranges.length) {
const totals = [...new Set(r.ranges.map((t) => Math.max(...t)))]
if (totals.length > 1) {
fails.push(`list pages disagree on the zone total: ${totals.join(' vs ')}`)
} else {
const total = totals[0]
if (r.cards !== total) {
fails.push(`the packet's own page headers say this zone has ${total} people groups, `
+ `but only ${r.cards} cards rendered — ${total - r.cards} were silently dropped`)
}
// The pages must TILE [1..total] with no gap and no overlap: a lost middle page
// leaves a hole that the totals check above cannot see.
const spans = r.ranges.map((t) => { const s = [...t].sort((a, b) => a - b); return [s[0], s[1]] })
.sort((a, b) => a[0] - b[0])
let cursor = 0
for (const [from, to] of spans) {
if (from !== cursor + 1) {
fails.push(`list pages do not tile: expected a page starting at ${cursor + 1}, got ${from} — `
+ 'a page is missing or duplicated')
break
}
cursor = to
}
if (cursor !== total && !fails.some((f) => f.includes('do not tile'))) {
fails.push(`list pages stop at ${cursor} but the zone has ${total} groups`)
}
}
} else if (r.cards) {
fails.push('no list-page range headers found — the "N of M" witness this gate '
+ 'cross-checks the card count against is missing')
}
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
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)`)
console.log(` Mark 5 in full : ${r.mark5Len} chars`
+ `${scriptureIsEnglish ? ' ⚠ UNTRANSLATED (English fallback)' : ''}`)
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`)
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`
+ `${boxes.length ? ` (all ${boxes.length} pages, asserted US Letter)` : ''}`)
console.log(` layout : ${r.cards} cards / ${PER_PAGE} per page `
+ `= ${expectedSheets} sheets expected, ${r.sheets} rendered`)
console.log(` script font : ${fontStatus.declared ? fontStatus.names.join(', ') : 'system stack'}`)
console.log(` text layout : ${layout.pinnedRight} right / ${layout.pinnedLeft} left of `
+ `${layout.sampled} sampled run(s)${layout.sampled < 5 ? ' (too few to assert)' : ''}`)
console.log(` language : ${lang} (dir=${dir}) (${r.rhythms}/${r.rhythmsTotal} rhythms in ${lang}`
+ `${translated === null ? '' : `, ${translated} strings translated`})`)
if (fails.length) {
console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
process.exit(1)
}
console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, map painted, content intact.`)