// 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 = '') => `