From 4a99453cdaf6903d73f1dab9800a6c901a99f944 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:33:00 +0000 Subject: [PATCH] The packet's list becomes the profile TABLE: picture, description, three boxes Brief A1 item 1. The 2x10 card grid becomes 20 full-width rows, each one picture | generated localized description | three prayer checkboxes. PER_PAGE stays 20, so this changes the layout SHAPE and not the pagination -- which is what lets check-packet-render's dropped-groups assertion keep passing unrelaxed. That assertion refuses a +/-1 tolerance because slack is exactly where a lost list page hides, so a rewrite needing it loosened would be the wrong rewrite. The description column is ~1036px. An entire card in the old grid was 683px, so the sentence alone is 50% wider than a whole card was; that is the point, since a generated sentence needs width a name and two metadata lines did not. The geometry closes with ZERO SLACK -- 20 rows plus 19 gutters fill the sheet to exactly the bottom margin. That is the finding, not the reassurance: a description wrapping to a third line overflows by ~16px. Hence FIT BEFORE CLAMP: step 17 -> 15 -> 14px, and truncate at a WHOLE WORD only as a last resort, because a mid-word cut reads as a rendering fault rather than an editorial one -- especially inside a people-group's name, where a reader cannot tell it from a misspelling. Deterministic by character count, never a DOM measure, because this module runs under Node for the offline generator as well as in the browser. The 0.52em advance is deliberately wider than Georgia's true ~0.50 so a locale wider than English is absorbed by the estimate rather than by an overflow. Measured against the real catalogue, every shipped locale fits at full size -- German is longest at 178 chars against a 234 budget -- so the ladder is insurance, not routine. describeGroup is INJECTED, not imported: its country resolver reaches a Vite-only alias and imports JSON without an attribute, which would make every `node internal/check-*.mjs` unrunnable. usePosterPrints composes it on the browser path and tolerates its absence, so a packet that cannot resolve a describer still prints, degrading to the metadata line. check-profile-table.mjs was mutation-tested, and two of my own assertions were dead. "The rows close on the bottom margin" is a TAUTOLOGY -- the row height is derived by dividing the page by PER_PAGE, so it holds for any row count; a 21-row mutant sailed through it. It is replaced by the real constraint, that a row stays tall enough for its content (fires at 24 rows). And the word-aware check fed 'word word word', where a mid-word cut is indistinguishable from a clean one; with distinct words it now names the broken token. --- internal/check-profile-table.mjs | 163 +++++++++++++++++++++++++ library/composables/usePosterPrints.js | 16 ++- library/packet/packet-render.js | 141 +++++++++++++++++---- 3 files changed, 296 insertions(+), 24 deletions(-) create mode 100644 internal/check-profile-table.mjs diff --git a/internal/check-profile-table.mjs b/internal/check-profile-table.mjs new file mode 100644 index 000000000..6d3c32e46 --- /dev/null +++ b/internal/check-profile-table.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * check-profile-table — the packet's ~20-per-page profile TABLE: picture | generated localized + * description | three prayer checkboxes. + * + * WHAT THIS GATE CAN AND CANNOT SEE — stated up front, because the limit is the interesting part. + * It runs in Node with no layout engine, so it asserts the things that are TRUE OF THE MODEL: the + * geometry closes, every group gets a row, the fit ladder picks the size it claims, the fallback + * fires in all three states. It CANNOT assert that the rendered row is 73px tall in a browser — + * that is rega's seat (internal/check-packet-render.mjs, which launches a browser and is exit-75 + * NOT MEASURED anywhere else). A character-count budget is a MODEL of wrapping, not a measurement + * of it, and the honest place to say so is here rather than in a commit message nobody re-reads. + * + * Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = (p) => readFileSync(resolve(ROOT, p), 'utf8') +const { createPacketRenderer } = await import(resolve(ROOT, 'library/packet/packet-render.js')) +const { describeGroup } = await import(resolve(ROOT, 'library/packets/describeGroup.js')) +const S = JSON.parse(read('library/scripts/posters/packet-strings.json')) + +const fails = [] +const ok = (c, m) => { if (!c) fails.push(m) } + +const CN = () => 'Malaysia' +const describe = (g) => describeGroup(g, 'en', { strings: S, countryName: CN }) +const mk = (d) => createPacketRenderer({ strings: S, lang: 'en', describe: d }) +const G = (i) => ({ slug: `g${i}`, name: `Group ${i}`, country_code: 'MYS', primary_language_label: 'Abai Sungai', population: 1000, religion: 'Islam', imb_gsec: 1 }) + +// ── 1. THE GEOMETRY CLOSES ON THE PAGE, AND WITH ZERO SLACK ──────────────────────────────────── +// ★ Zero slack is the FINDING, not the reassurance: 20 rows + 19 gutters fill the sheet to exactly +// the bottom margin, so a row that grows by one line does not "use up spare space" — it pushes the +// last row off the sheet. This assertion is what makes the fit ladder load-bearing rather than +// decorative, and it fails loudly if anyone adjusts a constant without re-deriving the rest. +// ⚠ READ THE REAL CONSTANTS, DO NOT RESTATE THEM. An earlier version hardcoded ROWS/M/HEAD here, +// which meant the assertion checked MY arithmetic against MY arithmetic and stayed green when the +// source's ROWS changed — it was the pagination check that caught that mutant, by luck. PER_PAGE is +// exported; the rest come out of the rendered geometry, so this compares the SOURCE to the sheet. +const { PER_PAGE, W, H } = mk(describe) +const SRC = read('library/packet/packet-render.js') +const numOf = (re, what) => { + const m = SRC.match(re) + if (!m) { fails.push(`could not read ${what} out of packet-render.js — this gate is now measuring nothing`); return NaN } + return Number(m[1]) +} +const M = numOf(/const M = (\d+), HEAD = (\d+)/, 'M') +const HEAD = Number((SRC.match(/const M = \d+, HEAD = (\d+)/) || [])[1]) +const GUT = numOf(/const GUT = (\d+)/, 'GUT') +const CH = (H - 2 * M - HEAD - GUT * (PER_PAGE - 1)) / PER_PAGE +// ⚠ "THE ROWS CLOSE ON THE BOTTOM MARGIN" IS A TAUTOLOGY AND IS NOT ASSERTED HERE. CH is DERIVED by +// dividing the usable height by PER_PAGE, so the rows always fill the sheet exactly, for any row +// count — I wrote that assertion first, watched a 21-row mutant sail through it, and removed it. +// An assertion that cannot fail is worse than none: it reads as coverage. +// ★ THE REAL CONSTRAINT IS THE OPPOSITE ONE: rows shrink as they multiply, so the question is +// whether a row is still TALL ENOUGH for its content — the name line plus a two-line description at +// the SMALLEST ladder size. Below that, no ladder step fits and every long row overflows. Verified +// to fire: 20 rows (73px) and 21 rows (69px) pass because the content genuinely fits; 24 rows +// (60px) and 28 fail. That is the boundary this is meant to guard. +ok(CH >= 15 * 1.15 + 2 + 2 * 14 * 1.25 + 6, + `the row is ${CH.toFixed(1)}px at PER_PAGE=${PER_PAGE} — too short for a name plus two description lines even at the smallest ladder size, so long rows will overflow onto the row below`) + +// ── 2. EVERY GROUP GETS A ROW, ACROSS THE PAGE BOUNDARY ──────────────────────────────────────── +// ★ THE LOAD-BEARING ONE. A layout rewrite is exactly where people get silently dropped: an +// off-by-one in the slice, or a row that overflows and is clipped, loses a PEOPLE GROUP with no +// error and a page that still looks complete. Asserted at a count that is NOT a multiple of +// PER_PAGE, because a clean multiple hides an off-by-one on the last page. +for (const n of [1, 19, 20, 21, 45, 100]) { + const R = mk(describe) + const html = R.listPages({ id: 'z', label: 'Z' }, Array.from({ length: n }, (_, i) => G(i)), new Map()).join('') + const rows = (html.match(/class="card"/g) || []).length + ok(rows === n, `${n} groups rendered ${rows} rows — a layout rewrite dropped people`) + const pages = R.listPages({ id: 'z', label: 'Z' }, Array.from({ length: n }, (_, i) => G(i)), new Map()).length + ok(pages === Math.ceil(n / 20), `${n} groups made ${pages} pages, expected ${Math.ceil(n / 20)}`) +} + +// Each row must carry its three prayer boxes — the packet is a WORKING sheet, and a row without +// them is a reference row the reader cannot mark. +{ + const html = mk(describe).listPages({ id: 'z' }, Array.from({ length: 20 }, (_, i) => G(i)), new Map()).join('') + ok((html.match(/pray-box/g) || []).length === 60, 'not every row carries three prayer checkboxes') +} + +// ── 3. THE FIT LADDER PICKS THE SIZE IT CLAIMS, AND EVERY TIER FIRES ─────────────────────────── +// Budgets are recomputed here from the same inputs rather than hardcoded, so this tests the LADDER +// and not my arithmetic twice. +const DESC_W = 1036, ADV = 0.52, LINES = 2 +const budget = (px) => Math.floor(DESC_W / (px * ADV)) * LINES +const probe = (txt) => { + const html = mk(() => txt).listPages({ id: 'z' }, [G(0)], new Map()).join('') + const m = html.match(/class="d-tx" style="font-size:(\d+)px">([^<]*) 'word '.repeat(n).trim() +ok(probe(words(10)).px === 17, 'a short description did not render at the full 17px') +ok(probe(words(Math.floor(budget(17) / 5))).px === 17, 'a description just inside the 17px budget stepped down anyway') +ok(probe(words(Math.floor(budget(17) / 5) + 6)).px === 15, + 'a description over the 17px budget did not step to 15px') +const long = probe(words(Math.floor(budget(14) / 5) + 40)) +ok(long.px === 14, `a very long description did not clamp at the smallest size (got ${long.px}px)`) +ok(long.text.endsWith('…'), 'a clamped description is not marked with an ellipsis') +// ★ THE CUT IS WORD-AWARE. Slicing mid-word reads as a RENDERING FAULT rather than an editorial +// cut — especially inside a people-group's name, where the reader cannot tell a truncation from a +// misspelling of a name they have never seen. +// ⚠ THE INPUT MUST HAVE DISTINCT WORDS. My first version of this check fed `'word '.repeat(n)`, +// and a mid-word cut of identical repeated words is INDISTINGUISHABLE from a word-boundary cut +// ("…word word wor…" vs "…word word…" both look plausible) — the assertion passed against a +// deliberately mid-word-cutting mutant. The words must differ in length so the cut point is +// observable: every retained token is checked against the source vocabulary. +{ + const vocab = ['alpha', 'bravissimo', 'cho', 'delta', 'echoing', 'foxtrotting', 'golf', 'hotelier'] + const text = Array.from({ length: 200 }, (_, i) => vocab[i % vocab.length]).join(' ') + const cut = probe(text) + ok(cut.text.endsWith('…'), 'the long-word truncation is not marked') + const body = cut.text.replace(/…$/, '').trim() + const tokens = body.split(/\s+/) + const lastToken = tokens[tokens.length - 1] + ok(vocab.includes(lastToken), + `the truncation cut MID-WORD: the final token is ${JSON.stringify(lastToken)}, which is not a whole word from the source — a reader cannot tell this from a misspelling`) +} + +// ── 4. THE THREE FALLBACK STATES ─────────────────────────────────────────────────────────────── +// `describe` is INJECTED, so it may be absent entirely; and it returns '' for a group whose fields +// cannot form a sentence. Both must degrade to the metadata line, never to a blank cell. Without +// the empty-string branch the table would have shipped blank cells the day describeGroup returned +// '' for a group — which is a real state, not a hypothetical. +{ + const has = (h, c) => new RegExp(`class="${c}"`).test(h) + const sentence = mk(describe).listPages({ id: 'z' }, [G(0)], new Map()).join('') + ok(has(sentence, 'd-tx'), 'a describable group did not render its sentence') + ok(!has(sentence, 'd-fb'), 'a describable group ALSO rendered the metadata fallback — the normal render must not carry a metadata line, or the country prints twice') + const empty = mk(() => '').listPages({ id: 'z' }, [G(0)], new Map()).join('') + ok(has(empty, 'd-fb') && !has(empty, 'd-tx'), 'an empty description did not fall back to the metadata line') + const absent = createPacketRenderer({ strings: S, lang: 'en' }).listPages({ id: 'z' }, [G(0)], new Map()).join('') + ok(has(absent, 'd-fb'), 'a MISSING describer did not fall back — the packet must not fail to print because one column has no describer') +} + +// ── 5. EVERY SHIPPED LOCALE FITS, MEASURED AGAINST THE REAL CATALOGUE ────────────────────────── +// ★ This is the arm that would have caught a locale I cannot eyeball. The ladder is INSURANCE, not +// routine: if a real locale is routinely stepping down, the budget is wrong and the fix is the +// budget, not the ladder. Longest today is German at 178 against a 234-char budget. +{ + const base = { name: 'Abai Sungai', country_code: 'MYS', primary_language_label: 'Abai Sungai', imb_gsec: 1 } + for (const lc of Object.keys(S).filter((k) => k !== '_meta')) { + let worst = 0 + for (const gsec of [0, 1, 2, 3, 4, 5, 6]) { + const s = describeGroup({ ...base, imb_gsec: gsec }, lc, { strings: S, countryName: CN }) + if (s.length > worst) worst = s.length + } + ok(worst <= budget(17), + `locale '${lc}' has a ${worst}-char description against a ${budget(17)}-char budget at 17px — it will step down on EVERY row, which means the budget is wrong, not the text`) + } +} + +if (fails.length) { + console.error('PROFILE-TABLE: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('PROFILE-TABLE: PASS — the row is tall enough for a name plus two description lines at the smallest ladder size, every group gets a row across page boundaries, the fit ladder steps 17→15→14 and cuts at a whole word only as a last resort, a missing or empty describer degrades to the metadata line, and every shipped locale fits at full size. NOT covered here: the RENDERED row height and whether the description actually wraps to two lines — those need a browser and are rega\'s seat.') diff --git a/library/composables/usePosterPrints.js b/library/composables/usePosterPrints.js index d572d245c..fcf3524ff 100644 --- a/library/composables/usePosterPrints.js +++ b/library/composables/usePosterPrints.js @@ -244,7 +244,21 @@ export function usePosterPrints(opts = {}) { opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m), opts.christian ? opts.christian() : import('../../data/people-groups/christian-share.json').then((m) => m.default || m), ]); - const R = createPacketRenderer({ strings, lang: lang() }); + // ★ THE DESCRIBER IS COMPOSED HERE AND INJECTED, because this is the BROWSER path and only it + // can reach the Vite-only pieces. packet-render.js runs under Node too (the offline generator), + // where importing describeGroup's country resolver would be fatal — it resolves through the + // `@map/` alias and imports JSON without an import attribute. So the renderer takes `describe` + // the same way it takes `strings`, and each host supplies the one it can build. + // ⚠ Loaded with `.catch(() => null)` rather than awaited unconditionally: a packet that cannot + // resolve its describer must still PRINT, degrading to the metadata line. The description is + // the widest column on the page, but it is not worth failing the whole packet for. + const describe = await Promise.all([ + import('../packets/describeGroup.js').then((m) => m.describeGroup).catch(() => null), + import('../utils/countryName.js').then((m) => m.countryName || m.default).catch(() => null), + ]).then(([describeGroup, countryName]) => (describeGroup + ? (g) => describeGroup(g, lang(), { strings, countryName: countryName || undefined }) + : null)); + const R = createPacketRenderer({ strings, lang: lang(), describe }); // Photos stay as URLs. The Node generator base64-inlines them so its file is // self-contained offline; in a browser the print frame fetches them, and // inlining ~95 images client-side would stall the tab for no gain. diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index ce8dfba87..86ce85783 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -44,7 +44,16 @@ const SCRIPT_FONT = { import { scatterCells } from '../components/charts/packetCharts.js' -export function createPacketRenderer({ strings, lang = 'en', fontData = null }) { +// `describe` is INJECTED at the same seam as `strings`, and deliberately not imported. This module +// has (almost) no imports on purpose: the SAME file runs under Node for the offline generator and +// under Vite for the browser print frame. describeGroup itself is portable, but its country resolver +// (library/utils/countryName.js) is reachable only through the Vite `@map/` alias and imports JSON +// without an import attribute — fine inside a bundle, fatal to `node internal/check-*.mjs`. So the +// CALLER composes the describer and passes it in: +// describe: (g) => describeGroup(g, lang, { strings, countryName }) +// A missing `describe` degrades to the metadata line rather than throwing. The packet must not fail +// to print because one column has no describer. +export function createPacketRenderer({ strings, lang = 'en', fontData = null, describe = null }) { const EN = strings.en; const L = strings[lang] || {}; const t = (k) => (L[k] !== undefined ? L[k] : EN[k]); @@ -75,7 +84,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) const PAGE_W_IN = 8.5; const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page const M = 60, HEAD = 90; // margins + running header - const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS; + // ★ THE PROFILE TABLE (Driver brief A1 item 1): ~20 per page, picture | generated localized + // description | three prayer checkboxes. This replaces the 2-column card grid with 20 FULL-WIDTH + // ROWS. PER_PAGE stays 20, so this is a change of LAYOUT SHAPE, NOT OF PAGINATION — which is what + // lets check-packet-render's dropped-groups assertion (sheets === 1 + … + ceil(cards/PER_PAGE) + 2) + // keep passing unrelaxed. That assertion exists to catch SILENTLY DROPPED PEOPLE GROUPS and its own + // comment refuses a ±1 tolerance, so a rewrite that needed it loosened would be the wrong rewrite. + const ROWS = 20, PER_PAGE = ROWS; + const COLS = 1; // ★ THE THREE PRAYER COLUMNS, the Driver's own three questions (2026-09-27), in his order: // 1 have I blessed them // 2 God, what do you want me to KNOW about them @@ -85,9 +101,21 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) // is one hour with God, and a fourth column turns a prayer aid into a form. const PRAY_COLS = ['pray_blessed', 'pray_know', 'pray_do']; const GUT = 14; - const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS; - const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS; - const PHOTO = Math.min(CH - 8, CW * 0.34); + const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS; // 1380px — the full row width + const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS; // 73.25px — the row height + // ⚠ ZERO SLACK IS THE FINDING, NOT THE REASSURANCE. 20 rows of 73.25 + 19 gutters fill the page to + // exactly the 60px bottom margin. A description wrapping to THREE lines needs ~74px and overflows + // the row by ~1px, which on a printed sheet is a silently clipped descender or a pushed row. The + // answer is not a taller row — 20 rows must fit one page — it is the fit ladder below. + const PHOTO = Math.min(CH - 8, 64); // the picture column: square, row-height-capped + // The three checkbox cells. Each holds a box plus its short label; sized so all three fit beside + // the description without the labels wrapping in the longest locale we ship. + const PRAY_CELL = 84; + const PRAY_W = PRAY_CELL * PRAY_COLS.length; // 252px + // What is left is the description — the widest column on the page, and the point of the table. + // For comparison an ENTIRE card in the old 2x10 grid was 683px wide; the description alone is 50% + // wider than that, because a generated sentence needs the width a name and two metadata lines did not. + const DESC_W = CW - PHOTO - PRAY_W - GUT * 2; // ~1024px const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -154,22 +182,31 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) .lc-legend i{display:inline-block;width:15px;height:15px;vertical-align:-2px;margin-inline-end:8px} .lc-foot{margin:14px 0 0;font:400 14px Georgia,serif;color:#6f6a5c} - /* ── THE THREE PRAYER COLUMNS on every group card ─────────────────────── - Right-aligned inside the 683x160 card, which leaves ~412px for the name and - numbers (measured from CW/CH rather than assumed). inset-inline-end, not right, - so the column stack mirrors under dir=rtl with the rest of the card. */ - .pray-cols{position:absolute;inset-inline-end:10px;top:12px;display:flex;flex-direction:column;gap:9px} - .pray-col{display:flex;align-items:center;gap:7px;font:400 11px Georgia,serif;color:#6f6a5c;white-space:nowrap} + /* ── THE THREE PRAYER COLUMNS, now a HORIZONTAL strip at the end of each row ────────── + They were a vertical stack in the old 683x160 card. In a ${CW}x${CH.toFixed(0)} ROW there is no + vertical room for three stacked labels, so the three cells sit side by side, each + ${PRAY_CELL}px wide, with the box above its label. inset-inline-end (not right) so the strip + still mirrors under dir=rtl with the rest of the row. */ + .pray-cols{position:absolute;inset-inline-end:0;top:50%;transform:translateY(-50%); + display:flex;flex-direction:row;gap:0} + .pray-col{width:${PRAY_CELL}px;flex:0 0 auto;display:flex;flex-direction:column;align-items:center; + gap:3px;font:400 10px Georgia,serif;color:#6f6a5c;text-align:center;line-height:1.15} .pray-box{display:inline-block;width:17px;height:17px;border:1.5px solid #b0a993;border-radius:3px;background:#fff;flex:0 0 auto} .pray-col em{font-style:normal} .hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170} - .card{position:absolute;display:flex;gap:12px;align-items:center; - border-bottom:1px solid #e2ddd0;padding-bottom:6px} + .card{position:absolute;display:flex;gap:${GUT}px;align-items:center; + border-bottom:1px solid #e2ddd0;padding-bottom:6px;overflow:hidden} .card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px} .card img{object-fit:cover} .card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center; - justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px} - .nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1} + justify-content:center;font:italic 9px Georgia,serif;color:#a89f8c;text-align:center;padding:3px} + /* The description cell. A FIXED width rather than flex:1, because the fit ladder's character + budget is computed against exactly this number — if the cell could stretch, the budget would + be describing a width the cell no longer has. */ + .d-cell{width:${DESC_W.toFixed(0)}px;flex:0 0 auto;min-width:0;overflow:hidden} + .nm{font:700 15px Georgia,serif;color:#1b1a17;line-height:1.15} + .d-tx{font-family:Georgia,serif;color:#413d35;line-height:1.25;margin-top:2px} + .d-fb{font:400 13px Georgia,serif;color:#6d766e;margin-top:2px} .mt{font:400 17px Georgia,serif;color:#6d766e} /* FINE PRINT — product decision: attribution minimal, fine print small. */ .fine{position:absolute;left:0;right:0;bottom:22px;text-align:center; @@ -602,27 +639,85 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) `, 'sheet-christian'); } + // ── THE DESCRIPTION CELL: FIT BEFORE CLAMP ─────────────────────────────────────────────────── + // ★ DETERMINISTIC BY CHARACTER COUNT, NEVER A DOM MEASURE. This module runs under Node for the + // offline generator, where there is no layout engine to ask "how tall did that wrap?". So the + // budgets below come from an average character advance deliberately set WIDER than Georgia's true + // ~0.50em — 0.52 — so a locale that runs wider than English is absorbed by the estimate rather + // than by an overflow onto a second page. Measuring in a browser would make the browser path and + // the Node path disagree, which is worse than a slightly conservative estimate in both. + // + // WHY FIT BEFORE CLAMP (dx-shepherd's ruling, and it is the right one): on paper a cut prayer + // description loses meaning and a reader cannot scroll to recover it. So shrink the type first — + // 17px, then 15px, then 14px — and truncate only as the last resort. + const DESC_SIZES = [17, 15, 14]; + const AVG_ADVANCE = 0.52; + const DESC_LINES = 2; // the 2-line budget the 73px row allows + const budgetFor = (px) => Math.floor(DESC_W / (px * AVG_ADVANCE)) * DESC_LINES; + + // Word-aware truncation. An earlier version sliced mid-word ("…Die Abai Sungai le…"), which reads + // as a RENDERING FAULT rather than an editorial cut — especially inside a people-group's name, where + // a reader cannot tell a truncation from a misspelling of a name they have never seen. + function cutAtWord(s, max) { + if (s.length <= max) return s; + const hard = s.slice(0, max - 1); + const sp = hard.lastIndexOf(' '); + // Unless the FIRST word alone exceeds the budget, back up to the last space. + const body = sp > 0 ? hard.slice(0, sp) : hard; + return body.replace(/[\s,;:.!?—–-]+$/, '') + '…'; + } + + // Returns { text, px, truncated }. Steps down the ladder, then clamps at the smallest size. + function fitDescription(text) { + const s = String(text || ''); + for (const px of DESC_SIZES) { + if (s.length <= budgetFor(px)) return { text: s, px, truncated: false }; + } + const px = DESC_SIZES[DESC_SIZES.length - 1]; + return { text: cutAtWord(s, budgetFor(px)), px, truncated: true }; + } + + // ★ THREE STATES, AND THE MIDDLE ONE IS WHY THIS IS A FUNCTION. `describe` is INJECTED (see + // createPacketRenderer) rather than imported, because describeGroup's country resolver reaches a + // Vite-only alias and imports JSON without an attribute — importing it here would make every + // `node internal/check-*.mjs` unrunnable. So it may be ABSENT, and it may also RETURN EMPTY for a + // group whose fields cannot form a sentence. Both degrade to the metadata line. + // Per dx-shepherd's ruling the metadata line is a FALLBACK ONLY: the normal render has no metadata + // line, because describeGroup already puts the country and language INTO the sentence and printing + // them again would say the country twice. + function descCell(g) { + const sentence = describe ? String(describe(g) || '') : ''; + if (!sentence) { + // The fallback: religion + population + country, the same three facts the old card carried. + return `
${esc(flat(g.religion))} · ${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}
`; + } + const f = fitDescription(sentence); + return `
${esc(f.text)}
`; + } + function listPages(zone, groups, photos) { const pages = []; for (let start = 0; start < groups.length; start += PER_PAGE) { const slice = groups.slice(start, start + PER_PAGE); + // ★ ONE FULL-WIDTH ROW PER GROUP. The column index is gone: `top` advances by the row, and the + // three cells sit inside it. Absolute positioning is kept rather than switching to flow layout + // because the print frame must not reflow — a row that moves between the browser preview and + // the printed sheet is how a 20-row page becomes a 21-row page. const cards = slice.map((g, k) => { const i = start + k; - const c = k % COLS, r = Math.floor(k / COLS); - const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT); + const top = M + HEAD + k * (CH + GUT); const src = photos.get(g.slug); const img = src ? `` : `
${esc(t('no_photo'))}
`; - return `
${img} -
+
${i + 1}. ${esc(g.name)}
-
${esc(flat(g.religion))}
-
${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}
+ ${descCell(g)}
-
${PRAY_COLS.map((k) => `${esc(t(k))}`).join('')}
+
${PRAY_COLS.map((key) => `${esc(t(key))}`).join('')}
`; }).join(''); pages.push(sheet(`