check:i18n-parity covers library/i18n/locales — the APP catalogs. Nothing covered library/scripts/posters/packet-strings.json, which is a separate file with its own shape and is where the 12 PRAYER POINTS and the PRAYER WHEEL live. SIX gates read it and not one checked that a language block is complete. All ten locales happened to be healthy — 19/21 string keys, 12 points with every field, 12 rhythms — but nothing held them there. A point dropped from one block renders that slot through the per-key English fallback: correct runtime behaviour, invisible defect, on a page the reader is meant to pray through. ★ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that is the load-bearing choice. check-packet-render printed "19 strings translated" and only ever asserted `!== 0`, so a locale regressing from 19 to 1 passed silently. The fix is NOT to pin 19: measured, de translates 18 of the same 19 keys and fr 17, because a word or two legitimately matches English — a hardcoded count fails two CORRECT catalogs. So parity asserts every English key EXISTS in every block, a structural fact no translation choice can change, and the render gate gains a FLOOR of two-thirds derived from the catalog itself. point4_passage + point4_passage_credit are DECLARED-ABSENT with the reason on record (Mark 5:1-20, public-domain licensing per language). Naming the exception beats tolerating it silently: the day a licensed translation lands, deleting the entry tightens the gate by itself. MUTATION-TESTED, five defect classes, each caught with a precise message: a dropped string key, an 11th-of-12 prayer point, an EMPTY point text, a missing wheel rhythm, and a block reverted wholesale to English. The floor was tested separately by reverting de to 3 translated of 19 — "only 3 differ from English (floor 13)". ★ THE TWO GATES ANSWER DIFFERENT QUESTIONS, and a partial language shows both at once. Swahili at 3 of 21 keys, no points, no rhythms: check-packet-render PASSES — a partial translation renders, Swahili where translated, English where not, zero blank slots — while check:packet-strings-parity FAILS and names exactly what is missing, including that the points and wheel would fall back wholesale. Before this, only the first question had a gate, so a half-finished block was indistinguishable from a complete one at build time. gates:post is now 22 steps; 11/11 packets still pass the sweep.
384 lines
23 KiB
JavaScript
384 lines
23 KiB
JavaScript
// 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')
|
||
// 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,
|
||
// ⚠ 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,
|
||
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`)
|
||
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}`)
|
||
}
|
||
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"`)
|
||
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(` wheel segments : ${r.wheelSegs}`)
|
||
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(` 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.`)
|