// 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; 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} .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 — the agent: "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')}`; } return { buildPacket, mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H }; }