// packet-render.js — THE prayer packet renderer, shared by both callers.
//
// ★ ONE RENDERER, TWO CALLERS. The packet is produced in two places:
// - library/scripts/posters/packet.mjs (build time, Node, writes files)
// - the UPG-100 legend's Export > Prayer packet action (click time, browser)
// Both import THIS module, so a change to a page lands in both. The backlog card
// asked for exactly this: "reuse the existing print actions rather than growing a
// fourth implementation."
//
// It is pure: no fs, no process, no DOM. Everything it needs is passed in —
// strings, language, the zone, its groups, photos, and the map SVG. That is what
// lets the same code run under Node and in a browser tab.
//
// PAGE ORDER IS THE PRODUCT SPEC, do not reorder:
// 1 the map one-pager
// 2..n the people groups, 20 per page, with photos
// n+1 the 12 prayer points
// n+2 the prayer wheel
//
// I18N: every human-readable string comes from packet-strings.json. A new language
// is a NEW BLOCK there, never a code change, and fallback is PER KEY so a
// half-finished translation renders its finished keys instead of blanking.
// Right-to-left locales. Kept as data rather than a per-language flag in the
// catalog: script direction is a property of the LANGUAGE, not of a translation,
// so a new Arabic-script locale is one entry here and not a schema change.
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi']);
// Scripts with no coverage in the packet's Georgia/Palatino/serif stack, mapped to
// the vendored face that does cover them. MEASURED by rendering each script at 48px
// and reading the PIXELS: Latin, Cyrillic, Arabic and CJK all shape correctly with
// system fonts; DEVANAGARI renders as tofu boxes.
// ⚠ A width heuristic got this exactly backwards — it reported Arabic and Chinese
// broken and Hindi fine, because Devanagari tofu boxes happen to match real glyph
// advance. Never infer glyph coverage from measurements; look at the pixels.
const SCRIPT_FONT = {
hi: { family: 'Noto Serif Devanagari', dir: 'noto-serif-devanagari',
file: 'noto-serif-devanagari-400-devanagari.woff2' },
mr: { family: 'Noto Serif Devanagari', dir: 'noto-serif-devanagari',
file: 'noto-serif-devanagari-400-devanagari.woff2' }, // Marathi, same script
ne: { family: 'Noto Serif Devanagari', dir: 'noto-serif-devanagari',
file: 'noto-serif-devanagari-400-devanagari.woff2' }, // Nepali, same script
};
export function createPacketRenderer({ strings, lang = 'en', fontData = null }) {
const EN = strings.en;
const L = strings[lang] || {};
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythms[k]);
// ⚠ PER-POINT, PER-FIELD — never `L.points || EN.points`. That expression reads as a
// whole-block fallback and behaves as one only when the key is ABSENT; when a translator
// has done SOME of the 12 it is TRUNCATING, because a 3-element array is truthy. Measured
// 2026-09-24 with a partial block: the packet rendered a page headed "12 Prayer Points"
// containing THREE — points 4..12 silently gone, including the Mark 5 passage point 4
// prints in full. Nothing was blank and no error was raised, so it looked translated.
// A half-finished translation is the NORMAL state of a language in progress, and
// "a new language is a NEW BLOCK, never a code change" only holds if a partial block
// degrades per key. English fixes the LENGTH (12) and supplies every missing field.
const LP = Array.isArray(L.points) ? L.points : [];
const POINTS = EN.points.map((en, i) => {
const loc = LP[i];
if (!loc || typeof loc !== 'object') return en;
// Field-level merge: a point translated for `text` but not `sub` keeps the English
// `sub` rather than losing it. refs/n/print_passage stay authoritative from English —
// they are structure and scripture addresses, not prose.
return { ...en, ...Object.fromEntries(Object.entries(loc).filter(([, v]) => v !== undefined && v !== '')) };
});
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
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 THREE PRAYER COLUMNS, the agent'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
// 3 God, what do you want me to DO to help them
// A tick box per group per question, printed on the card so the packet is a WORKING sheet rather
// than a reference: the reader marks it as they pray. Deliberately three and not more — the brief
// 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 esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
// ⚠ `lang` (the factory parameter), never a module-level LANG — and never
// process.env.LANG, which is the SHELL's POSIX locale ("en_US.UTF-8"): not a valid
// BCP-47 tag, and it throws inside toLocaleString.
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(lang) : '—');
const sheet = (inner, cls = '') =>
`
${inner}
`;
// The script font for THIS language, if it needs one. `fontData` is a base64
// woff2 the caller supplies: the packet must stay a SELF-CONTAINED file that
// prints offline (the Node generator writes standalone HTML with no server, and
// photos are already inlined for the same reason), so a URL would break exactly
// where the packet is most used. Absent fontData, the rule is simply omitted and
// the stack falls back to serif — a legible wrong font beats a broken document.
const SF = SCRIPT_FONT[lang] || null;
const fontFace = (SF && fontData)
? `@font-face{font-family:'${SF.family}';font-style:normal;font-weight:400;`
+ `font-display:swap;src:url(data:font/woff2;base64,${fontData}) format('woff2')}`
: '';
// Prepend the script face to EVERY element's stack in one rule rather than
// editing 22 hardcoded Georgia declarations. The browser falls through per
// CHARACTER, so Latin text in a Hindi packet still renders in Georgia.
const scriptStack = SF
? `.sheet,.sheet *{font-family:'${SF.family}',Georgia,'Palatino Linotype',serif !important}`
: '';
function css() {
return `
${fontFace}
${scriptStack}
@page{size:letter portrait;margin:0}
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
/* ── LIFE CALENDAR (pages 2 and 3) ─────────────────────────────────────
5,200 cells must fit one sheet and still read as countable boxes. 52 columns
is fixed by the DATA (weeks in a year), so the cell size is derived from the
printable width rather than chosen: (1500 - 2*60) / 52 = 26.5px per column,
leaving a 21px box with a 2px gap. Measured, not eyeballed — a hardcoded box
size that happened to fit at one margin would break the moment M changed. */
.pg-head{margin:0 0 10px}
.pg-kicker{font:700 15px Georgia,serif;letter-spacing:.14em;text-transform:uppercase;color:#8a8577}
.pg-h1{margin:2px 0 0;font:700 40px Georgia,serif;color:#1b1a17}
.pg-lede{margin:0 0 22px;max-width:1180px;font:400 19px/1.5 Georgia,serif;color:#413d35}
/* ⚠ THE CELLS ARE RECTANGLES, NOT SQUARES, and that is forced by the sheet rather than chosen.
52 columns is fixed by the data (weeks in a year) and 100 rows by the product's "100 year life" framing,
so both dimensions are given and only the SHAPE is free. Squares would need
(1380/52)=26.5px per side x 100 rows = 2,654px of grid in 1,521px of printable height — it
overflows the page by 1.7x. Caught by computing the height before rendering rather than by
looking at an overflowing PDF. 13px rows put 100 rows in 1,300px with room for the legend. */
.lc-grid{display:grid;grid-template-columns:repeat(52,1fr);grid-auto-rows:13px;gap:2px;width:100%}
.lc-c{display:block;width:100%;height:100%;border:1px solid #d8d3c4;border-radius:1px;background:#fff}
.lc-on{background:#b9b2a0;border-color:#b9b2a0}
/* The shaded share on page 3 is the REACHED share, so it takes the accent colour and the
unshaded majority stays quiet — the visual weight belongs to what is still waiting. */
.lc-reached{background:#2f6d4f;border-color:#2f6d4f}
.lc-legend{display:flex;gap:34px;margin:20px 0 0;font:400 17px Georgia,serif;color:#413d35}
.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}
.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 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}
.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;
font:400 13px Georgia,serif;color:#a89f8c}
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
completely differently under pagination:
transform is paint-time — the element still RESERVES its full ${H}px layout
box, so the paginator walks ${H}px of content and breaks it across
two sheets, inking only the top of the first. Measured: 8 sheets
came out as 16 PDF pages. Negative margins do NOT fix this — they
collapse the outer space, not the fragmentation box.
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
@media print{
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
page-break-after:always;break-after:page}
.sheet:last-child{page-break-after:auto;break-after:auto}
}`;
}
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
// dropping only the rules that would fight the packet's own page shell.
//
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
// prose that contains braces and selector-like text, so any brace-counting or
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
function mapCss(posterHtml) {
const block = posterHtml.match(/
${pages.join('\n')}