451 lines
27 KiB
JavaScript
451 lines
27 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]);
|
||
// ⚠ 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, '>').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 — 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(/<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 agent 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>`);
|
||
}
|
||
|
||
// ── PAGE 2: the funnel — how this list was narrowed out of every people group ─
|
||
//
|
||
// WHY THIS PAGE EXISTS. the agent 2026-09-26, via the shepherd: the packets go to ALL partners for
|
||
// the largest prayer campaign, and a partner holding one list needs to see where their list sits
|
||
// in the whole. The narrowing IS the argument: 12,379 people groups on earth, 7,341 of them
|
||
// unreached, 4,754 frontier, 2,107 on this network's list, and N of those in the reader's hand.
|
||
//
|
||
// ★ IT RENDERS NUMBERS IT DID NOT COMPUTE, ON PURPOSE. Every count is READ from the caller's
|
||
// `funnel` object, which comes from data/people-groups/vision-funnel.json — the file
|
||
// library/scripts/people-groups/build-vision-funnel.mjs generates from the IMB CSV and the live
|
||
// DOXA list, and which ALREADY feeds the Vision Card. Recomputing them here would create a second
|
||
// source of truth for the same fact, and the two would drift into "two honest surfaces that
|
||
// disagree" — each internally consistent, together telling a reader the project cannot count.
|
||
// If a number is wrong, it is wrong in the generator, and it is wrong in the Vision Card too.
|
||
//
|
||
// ⚠ NO STEP IS HARDCODED AND NEITHER IS THE NUMBER OF STEPS. `funnel.steps` is walked as given,
|
||
// so when the `list` prop lands (exp-shepherd, Tier A: profile-config.list / ?list= /
|
||
// window.MAP_LIST, default 'doxa') and the 7k / 4.7k / 700 lists plug in, their own step chains
|
||
// render with no change here. A step whose id has no string falls back to the id itself rather
|
||
// than rendering blank.
|
||
//
|
||
// ⚠ K AND N ARE READ AT RENDER TIME, NEVER TYPED. Measured 2026-09-26: the brief said "~30 lists
|
||
// of 50-100" and the catalogue says K=27 with counts from 8 to 118 — west-africa 118,
|
||
// asia-india-rajasthan 116, uncovered-scatter 8. Only about half sit inside 50-100, so this page
|
||
// never claims that band; it states the share the data can defend. Shepherd approved exactly this
|
||
// (ENDTOKEN-DXS-UPG-KDECIDE), K=27 including the two residual buckets until the agent rules.
|
||
//
|
||
// ⚠ NUMERALS STAY WESTERN. num() is toLocaleString(lang), and for the bare `ar`/`hi` tags CLDR's
|
||
// default numbering system is `latn` — verified: 12,379 and 2,107 in both. Arabic-Indic digits
|
||
// need an explicit -u-nu-arab, which nothing here asks for. The brief requires numerals untouched,
|
||
// and reusing num() is also what keeps the funnel's digits identical to every other packet page.
|
||
function funnelPage(zone, groups, funnel) {
|
||
if (!funnel || !Array.isArray(funnel.steps) || !funnel.steps.length) return '';
|
||
const steps = funnel.steps;
|
||
const top = steps[0] && steps[0].groups;
|
||
// The widest bar is the first step; every bar is drawn proportional to it, with a floor so the
|
||
// smallest step is still a visible bar rather than a hairline.
|
||
const BAR_W = W - 2 * M - 300;
|
||
const width = (n) => Math.max(26, Math.round(BAR_W * (Number(n) / Number(top || 1))));
|
||
|
||
const rows = steps.map((s, i) => {
|
||
const label = t(`funnel_${s.id}`) !== undefined ? t(`funnel_${s.id}`) : s.id;
|
||
const pop = Number.isFinite(Number(s.population))
|
||
? `<div style="font:400 15px Georgia,serif;color:#a89f8c;margin-top:1px">${num(s.population)} ${esc(t('funnel_people'))}</div>`
|
||
: '';
|
||
// The last IMB step is the network's own list — tint it so the eye lands where the reader is.
|
||
const fillCol = i === steps.length - 1 ? '#7a3b2e' : '#cdbf9e';
|
||
return `<div style="margin-bottom:22px">
|
||
<div style="font:700 34px Georgia,serif;color:#1b1a17;line-height:1">${num(s.groups)}</div>
|
||
<div style="font:400 18px Georgia,serif;color:#3b3226;margin-top:2px">${esc(label)}</div>
|
||
${pop}
|
||
<div style="height:12px;width:${width(s.groups)}px;background:${fillCol};margin-top:6px;border-radius:2px"></div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// THIS list, the step the reader is holding. `groups.length` is the packet's own row count, so
|
||
// the page cannot disagree with the pages that follow it.
|
||
const mine = groups.length;
|
||
const total = Number(funnel.listTotal ?? (steps[steps.length - 1] || {}).groups) || 0;
|
||
const pct = total ? (100 * mine / total) : 0;
|
||
// ⚠ A SHARE MUST NEVER ROUND TO ZERO — zero reads as "this packet covers none of them", which is
|
||
// false for every list. Measured against the real catalogue (counts 1..118 of 2,107): one decimal
|
||
// gave the single-group fixture "0.0%" when the true value is 0.0475%. So: integer at or above
|
||
// 10%, one decimal from 1%, two below that, then strip trailing zeros so 0.9967 prints "1" rather
|
||
// than "1.00". Verified across 1, 2, 8, 20, 21, 43, 95, 118 and 300 groups.
|
||
const pctStr = pct >= 10
|
||
? String(Math.round(pct))
|
||
: pct.toFixed(pct >= 1 ? 1 : 2).replace(/\.?0+$/, '');
|
||
|
||
const share = funnel.listCount
|
||
? `<div class="funnel-share" style="font:700 22px Georgia,serif;color:#7a3b2e;margin-top:4px">
|
||
${esc(fill(t('funnel_share'), { k: num(funnel.listCount), pct: pctStr, total: num(total) }))}</div>`
|
||
: '';
|
||
const foot = (funnel.sources && funnel.sources.imb)
|
||
? esc(fill(t('funnel_foot'), {
|
||
imb_pub: funnel.sources.imb.publisher || '',
|
||
imb_date: funnel.sources.imb.captured || '',
|
||
unassigned: num(funnel.unassigned ?? 0),
|
||
total: num(total),
|
||
}))
|
||
: '';
|
||
|
||
return sheet(`
|
||
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
|
||
<div class="funnel-title" style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('funnel_title'))}</div>
|
||
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('funnel_intro'))}</div>
|
||
</div>
|
||
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px">${rows}</div>
|
||
<div style="position:absolute;left:${M}px;right:${M}px;bottom:74px">
|
||
<div class="funnel-mine" style="font:700 34px Georgia,serif;color:#7a3b2e;line-height:1">${num(mine)}</div>
|
||
<div style="font:400 18px Georgia,serif;color:#3b3226;margin-top:2px">${esc(t('funnel_this'))}</div>
|
||
${share}
|
||
</div>
|
||
<div class="fine">${foot}</div>`);
|
||
}
|
||
|
||
// ── PAGES 3..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. the agent: "it actually lists out the full
|
||
// scripture, because prayer point number four is actually read the word."
|
||
// class="p4-passage" is a TEST HOOK, not styling — the block is styled inline.
|
||
// Without it the only way to assert the passage rendered was to search for the
|
||
// English word "Gadarenes", which would go red the moment this key is translated.
|
||
? `<div class="p4-passage" 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 class="p4-credit" 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 class="points-title" 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 class="wheel-title" 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).
|
||
// `funnel` is OPTIONAL and the page is omitted when it is absent, so every existing caller keeps
|
||
// working unchanged and a host with no funnel data still gets a complete, correct packet. A page
|
||
// that cannot state true numbers must not appear at all — better a 4-section packet than one
|
||
// asserting figures it could not read.
|
||
function buildPacket(zone, groups, photos, mapSvg, funnel = null) {
|
||
const pages = [
|
||
mapPage(zone, groups, mapSvg),
|
||
funnelPage(zone, groups, funnel),
|
||
...listPages(zone, groups, photos),
|
||
pointsPage(),
|
||
wheelPage(),
|
||
].filter(Boolean);
|
||
// ⚠ 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, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
|
||
}
|