doxa-maps/internal/check-packet-render.mjs
doxa-maps-88655c70 6732c8d430 Guard the packet catalog: the 12 prayer points and the wheel had no parity gate
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.
2026-09-24 08:57:34 +00:00

384 lines
23 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')
// 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.`)