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.
This commit is contained in:
parent
62f8f9483c
commit
4a99453cda
3 changed files with 296 additions and 24 deletions
163
internal/check-profile-table.mjs
Normal file
163
internal/check-profile-table.mjs
Normal file
|
|
@ -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">([^<]*)</)
|
||||
return m ? { px: +m[1], text: m[2] } : { fallback: /d-fb/.test(html) }
|
||||
}
|
||||
const words = (n) => '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.')
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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, '>').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 `<div class="d-fb">${esc(flat(g.religion))} · ${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>`;
|
||||
}
|
||||
const f = fitDescription(sentence);
|
||||
return `<div class="d-tx" style="font-size:${f.px}px">${esc(f.text)}</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<img src="${src}" alt="">`
|
||||
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
|
||||
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
|
||||
return `<div class="card" style="left:${M}px;top:${top.toFixed(0)}px;
|
||||
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
|
||||
<div style="min-width:0">
|
||||
<div class="d-cell">
|
||||
<div class="nm">${i + 1}. ${esc(g.name)}</div>
|
||||
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
|
||||
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
|
||||
${descCell(g)}
|
||||
</div>
|
||||
<div class="pray-cols">${PRAY_COLS.map((k) => `<span class="pray-col"
|
||||
><i class="pray-box"></i><em>${esc(t(k))}</em></span>`).join('')}</div>
|
||||
<div class="pray-cols">${PRAY_COLS.map((key) => `<span class="pray-col"
|
||||
><i class="pray-box"></i><em>${esc(t(key))}</em></span>`).join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
pages.push(sheet(`
|
||||
|
|
|
|||
Loading…
Reference in a new issue