Some checks are pending
build-gate / build (push) Waiting to run
The packet now ships in every locale the app ships: en es fr pt de it ro ru ar hi zh. Each carries all 21 translatable keys, the twelve prayer points, the eleven rhythms, both attributions and the privacy note. point4_passage stays omitted everywhere and falls back to the licensed English WEB text, for the reason already recorded — translating scripture here would put unlicensed, unverified Bible text into a printed prayer packet. HINDI WAS BLOCKED ON A FONT, not on a translation. Devanagari renders as tofu boxes in the packet's Georgia/Palatino/serif stack because this machine has no Devanagari font at all. Noto Serif Devanagari (SIL OFL) is now vendored — devanagari subset only, 124 KB, licence and provenance beside it, matching how poppins/ ships latin and latin-ext separately. It is base64-INLINED into the packet, not linked. A packet is a standalone file meant to print offline — the Node generator writes HTML with no server, and the photos are already inlined for exactly this reason — so a URL would break precisely where the packet is most used. Cost: ~165 KB on a 3.5 MB packet, and only for that language. One rule prepends the face to the stack rather than editing 22 hardcoded Georgia declarations, and the browser falls back PER CHARACTER, so Latin scripture references in a Hindi packet still set in Georgia. mr and ne map to the same face; a missing font warns loudly rather than silently producing rectangles. Chinese needed no font work — CJK already shapes in the system stack — only the translation. ★ TWO MEASUREMENTS LIED ABOUT SCRIPT COVERAGE, recorded so nobody automates this cheaply. Comparing shaped width against a missing font reported ar and zh BROKEN and hi FINE — exactly backwards, because Devanagari tofu boxes happen to match real glyph advance. An ink-column-profile heuristic then scored CORRECTLY RENDERED Devanagari the same as tofu (3 vs 2 distinct heights), because its baseline bar gives a uniform profile. Both were abandoned: a detector that blocks working packets is worse than none. Verified: all 11 languages render 8 sheets -> 8 letter portrait pages, map painted, zero overflow. The Hindi prayer-points page was READ AS A PICTURE — conjuncts and matras shaped correctly, no boxes, Latin references still Georgia.
330 lines
19 KiB
JavaScript
330 lines
19 KiB
JavaScript
// 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]);
|
||
const POINTS = L.points || EN.points;
|
||
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, '>').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 = '') =>
|
||
`<div class="sheet ${cls}">${inner}</div>`;
|
||
|
||
// 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 — Driver: "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(/<style>([\s\S]*?)<\/style>/);
|
||
if (!block) return '';
|
||
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
|
||
|
||
// Drop @media (the poster's own print-scaling rules, which do not apply here).
|
||
// Depth-aware because @media bodies nest a further level of braces.
|
||
let out = '', depth = 0, i = 0;
|
||
while (i < css.length) {
|
||
if (css.startsWith('@media', i)) {
|
||
let d = 0, started = false;
|
||
while (i < css.length) {
|
||
if (css[i] === '{') { d++; started = true; }
|
||
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
|
||
i++;
|
||
}
|
||
continue;
|
||
}
|
||
out += css[i++];
|
||
}
|
||
|
||
// Drop document-level rules: these style the POSTER page, not the map, and would
|
||
// override the packet's background, box-sizing and stage geometry.
|
||
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
|
||
return out.split('}')
|
||
.map((r) => {
|
||
const at = r.indexOf('{');
|
||
if (at < 0) return '';
|
||
const sel = r.slice(0, at).trim();
|
||
if (!sel) return '';
|
||
// :root must be KEPT — it carries the palette the map's var()s resolve against.
|
||
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
|
||
return `${sel}{${r.slice(at + 1).trim()}}`;
|
||
})
|
||
.filter(Boolean).join('\n');
|
||
}
|
||
|
||
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
|
||
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
|
||
// the geography with all the pins", explicitly NOT the poster. The poster remains
|
||
// its own artefact (generate.mjs); this is the packet's cover.
|
||
function mapPage(zone, groups, mapSvg) {
|
||
const count = groups.length;
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
|
||
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
|
||
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
|
||
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
|
||
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
|
||
</div>
|
||
${mapSvg}
|
||
<div class="fine">${esc(t('privacy_note'))}</div>`);
|
||
}
|
||
|
||
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
|
||
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);
|
||
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 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;
|
||
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
|
||
<div style="min-width:0">
|
||
<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>
|
||
</div></div>`;
|
||
}).join('');
|
||
pages.push(sheet(`
|
||
<div class="hd">${esc(zone.label || zone.id)}</div>
|
||
<div class="hd-r">${esc(fill(t('list_header'),
|
||
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
|
||
${cards}`));
|
||
}
|
||
return pages;
|
||
}
|
||
|
||
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
|
||
function pointsPage() {
|
||
const rows = POINTS.map((p) => {
|
||
// ⚠ ISOLATE EACH REFERENCE FOR BIDI. Scripture references stay in English
|
||
// (a reader looks them up in their own Bible), so on an RTL page they are
|
||
// LTR runs inside an RTL paragraph. Without isolation the Unicode bidi
|
||
// algorithm reorders a run that STARTS WITH A DIGIT: measured on the Arabic
|
||
// page, "1 Peter 5:6–11" rendered as "Peter 5:6-11 1". dir="ltr" on each
|
||
// reference pins its internal order without affecting the line's direction.
|
||
const refs = (p.refs || [])
|
||
.map((r) => `<span dir="ltr" style="unicode-bidi:isolate">${esc(r)}</span>`)
|
||
.join(' · ');
|
||
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
|
||
const passage = p.print_passage
|
||
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
|
||
// scripture, because prayer point number four is actually read the word."
|
||
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
|
||
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
|
||
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
|
||
${esc(t('point4_passage_credit'))}</div></div>`
|
||
: '';
|
||
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
|
||
<div style="font:700 19px Georgia,serif;color:#1b1a17">
|
||
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
|
||
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
|
||
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
|
||
${passage}
|
||
</div>`;
|
||
}).join('');
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
|
||
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
|
||
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
|
||
</div>
|
||
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
|
||
<div class="fine">${esc(t('points_attrib'))}</div>`);
|
||
}
|
||
|
||
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
|
||
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
|
||
// be translated, and this packet must generate in any language. Twelve equal
|
||
// segments, the rhythm name set along each, 5 minutes each = one hour.
|
||
function wheelPage() {
|
||
const cx = W / 2, cy = 1090, R = 470;
|
||
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
|
||
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
|
||
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
|
||
const seg = order.map((key, i) => {
|
||
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
|
||
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
|
||
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
|
||
const hue = Math.round(i * 30);
|
||
const mid = (a0 + a1) / 2;
|
||
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
|
||
let deg = mid * 180 / Math.PI;
|
||
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
|
||
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
|
||
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
|
||
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
|
||
text-anchor="middle" dominant-baseline="middle"
|
||
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
|
||
}).join('');
|
||
const pips = order.map((_, i) => {
|
||
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
|
||
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
|
||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
|
||
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
|
||
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
|
||
}).join('');
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
|
||
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
|
||
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
|
||
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
|
||
</div>
|
||
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
|
||
${seg}${pips}
|
||
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
|
||
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
|
||
</svg>
|
||
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
|
||
}
|
||
|
||
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
|
||
// Assemble the whole document. photos is a Map(slug -> dataURL-or-URL); mapSvg is
|
||
// the poster's <svg> plus the stylesheet its var()s resolve against (see mapCss).
|
||
function buildPacket(zone, groups, photos, mapSvg) {
|
||
const pages = [
|
||
mapPage(zone, groups, mapSvg),
|
||
...listPages(zone, groups, photos),
|
||
pointsPage(),
|
||
wheelPage(),
|
||
];
|
||
// ⚠ dir MUST be set for right-to-left languages or the packet renders Arabic
|
||
// left-aligned — readable glyphs in the wrong place, which looks like a
|
||
// styling quirk rather than the bug it is. Verified in a browser: with
|
||
// dir="rtl" the text right-aligns, shapes correctly, and a Latin scripture
|
||
// reference stays LTR inside the RTL line (correct bidi) with no extra work.
|
||
return `<!DOCTYPE html><html lang="${esc(lang)}" dir="${RTL.has(lang) ? 'rtl' : 'ltr'}"><head><meta charset="utf-8">
|
||
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
|
||
<body>${pages.join('\n')}</body></html>`;
|
||
}
|
||
|
||
return { buildPacket, mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
|
||
}
|