doxa-maps/library/packet/packet-render.js
Doxa Maps 6d6b656721 A packet says which list it holds, in the reader's language
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created
in the same commit where I argued against it.

I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is
printed and handed to someone" — and then wrote the frontier packet to the SAME
FILENAME as the default-list packet of that zone and language. rega measured the
result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar`
file that was still the default list, and nothing in the name could tell them
apart. Guarding the input while leaving the output ambiguous protects against
asking for the wrong list, not against being unable to tell what you got.
  Fixed both ways. The list is in the FILENAME
(asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE
DOCUMENT as data-packet-list, because a filename can be renamed, copied or
emailed while the packet itself should be able to say what it is. Same reasoning
as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen
to be there, and a packet that cannot describe its own provenance makes an
accidental set indistinguishable from a current one. The gate prints it too.

THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier
people groups" sat among Devanagari labels in a Latin serif face, because it came
verbatim from library/lists/lists.json — which carries ONE label per list and has
no locale dimension at all. The gate passed because it counts rows, not the row's
language.
  It is translated in packet-strings.json rather than by restructuring the shared
lists.json: that file is the packet's OWN catalogue, where a new language is a new
block and never a code change, and it degrades per key like every other string. An
unknown list still falls back to the data's English label.
  THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" —
because it names the campaign, while "frontier people groups" is prose. That is
the same line check-i18n-never-translated draws around "UUPGs": translate what a
bilingual reader would not dispute, keep what is an identity.

rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37
of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars
1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no
"1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list.
And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so
hi+frontier, the tightest combination that exists (128px rows, gap down to 147px),
still clears by 222px and no label wraps.

Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population.
The 11 laos packets FAILED the zero-population assertion until regenerated — they
predated the fix, which is the gate distinguishing stale artefacts from current
ones exactly as data-packet-build was added to do. Default path unchanged (43
groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in
each script. check:packet-strings-parity PASS with the 5 new keys.
2026-09-26 08:39:54 +00:00

573 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// ⚠ `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>`);
}
// ── PAGE 2: the funnel — how this list was narrowed out of every people group ─
//
// WHY THIS PAGE EXISTS. Driver 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 Driver rules.
//
// ⚠ "NUMERALS UNTOUCHED" IS ABOUT DIGITS, NOT GROUPING, and those are two properties. I verified the
// digits and assumed the rest. rega measured the rendered hi sheet and found Indian grouping —
// "8,28,85,63,969 लोग" (lakh/crore, 2-2-3) rather than Western 3-3-3. That is CORRECT hi-IN
// formatting, so changing it would be worse than leaving it, and the real risk was never the
// grouping itself but INCONSISTENCY: the same number printed two ways in one document.
// Measured 2026-09-26, and it is consistent: num() is the ONLY formatter in this renderer, so the
// funnel's populations and the people-group cards' populations group identically. Confirmed against
// the rendered hi packet — card populations 1,70,000 / 3,37,000 / 8,10,000 are Indian-grouped like
// the funnel's. (Only values at or above 100,000 can distinguish the two systems at all; my first
// check classified "1,400" and read a mismatch that did not exist.)
//
// ⚠ 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))));
// 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;
// ⚠ THE DENOMINATOR MUST BE THE LIST THE PACKET IS ACTUALLY DRAWN FROM, and it is an EXPLICIT
// input for that reason. An earlier version fell back to the last funnel step
// (`funnel.listTotal ?? steps[last].groups`), which is 2,107 — DOXA's list. That is right today
// because every map shows DOXA, and it becomes WRONG the moment the universal `list` prop lands
// (exp-shepherd, library/lists/peopleGroupList.js): under ?list=frontier a Tier A map shows the
// DOXA groups that are ALSO frontier, which SPEC-PEOPLE-GROUP-LIST-PROP.md measures at 1,753. The
// page would have printed "N of the 2,107" with total confidence while the map showed a different
// set — a faithful proxy that silently changes meaning when a new path appears, which is the same
// family as inferring "did it work" from a field that only answers "did it isolate".
//
// So a caller that knows the active list says so. A caller that does not gets NO SHARE LINE rather
// than a plausible wrong one: `mine` is always trustworthy (it is this packet's own row count) but
// a percentage needs both terms, and half a fraction is not a fact.
const total = Number(funnel.listTotal) || 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+$/, '');
// ⚠ THE SHARE HAS TWO CLAUSES AND THEY ARE PROVABLE SEPARATELY. "1 of K lists" needs the zone
// catalogue, which only describes the DEFAULT list; "{pct}% of the {total}" needs only this
// packet's own row count and the active list's total, both of which are always known. A first
// version suppressed the whole line whenever listCount was absent, which threw away a true
// statement to avoid an unprovable one — the same over-correction as defaulting the unassigned
// count to 0, in the opposite direction.
const share = !total ? '' : `<div class="funnel-share" style="font:700 26px Georgia,serif;color:#7a3b2e;margin-top:10px">
${esc(funnel.listCount
? fill(t('funnel_share'), { k: num(funnel.listCount), pct: pctStr, total: num(total) })
: fill(t('funnel_share_pct'), { pct: pctStr, total: num(total) }))}</div>`;
// ⚠ THE FOOTNOTE IS TWO INDEPENDENT FACTS AND ONLY ONE IS ALWAYS KNOWABLE. Provenance ("counts
// from X, captured Y") holds for every list; coverage ("N of the M are not yet in any list") is a
// fact about the UPG-100 zone catalogue, which partitions DOXA and nothing else. The first version
// filled it with `funnel.unassigned ?? 0`, so a non-default list would have printed
// "0 of the 1,745 are not yet in any list" — a confident falsehood, and the zero makes it read as
// a completeness claim rather than a missing measurement.
// So an unknown coverage drops to the provenance-only string rather than defaulting the number.
// ⚠ NOT BY SPLITTING THE TRANSLATED SENTENCE on punctuation: hi ends with "।" and zh with "。",
// and parsing translated prose for structure is the same trap as reading a localized page header
// positionally (see check-packet-render's "PARSE THE NUMBERS, NEVER THE WORD ORDER").
const imbSrc = funnel.sources && funnel.sources.imb;
const foot = !imbSrc ? '' : esc(
Number.isFinite(Number(funnel.unassigned))
? fill(t('funnel_foot'), {
imb_pub: imbSrc.publisher || '',
imb_date: imbSrc.captured || '',
unassigned: num(funnel.unassigned),
total: num(total),
})
: fill(t('funnel_foot_source'), {
imb_pub: imbSrc.publisher || '',
imb_date: imbSrc.captured || '',
}),
);
// ★ THE READER'S OWN STEP IS THE LAST ROW OF THE FUNNEL, NOT A FOOTER. rega measured the first
// version in print (2026-09-26): the four global steps ended at y≈330 of 1056 while "43 · are in
// THIS packet" sat alone at y≈965, leaving the middle ~60% of the sheet empty and making the
// reader's own step read as a detached footnote. Worse, it had NO BAR, so the funnel never
// visibly narrowed to "yours" — a funnel whose final term is not drawn as part of the funnel
// fails at the one thing it is for.
// So the five steps are ONE list, the reader's included, and the row rhythm is derived from the
// space actually available rather than a fixed 22px gap: measured, four rows filled 451 of the
// 1605px between the intro and the footer, i.e. 28% of the page.
const ROWS_TOP = M + 110;
const FOOT_RESERVE = 210; // share line + source footnote
// ★ A NON-DEFAULT LIST GETS ITS OWN STEP, so the narrowing does not jump from one list's total to
// another list's packet. The four IMB steps stay true under every list — they are global counts,
// unaffected by which list the reader holds — but the LAST one is DOXA's 2,107, and going straight
// from it to "71 in THIS packet" implies DOXA is the parent set of the reader's rows. It is not:
// under ?list=frontier-500k the parent is that list's own DOXA-side total. packetFunnel supplies
// it as listTotal + listLabel when the active list is not the default; when it IS the default,
// listTotal equals the DOXA step and inserting it would repeat the row.
// ⚠ AND IT IS OMITTED WHEN IT WOULD REPEAT A NUMBER ALREADY ON THE PAGE — either the DOXA step
// above it (the default list, where listTotal IS that step) or the reader's row below it. Measured:
// under ?list=frontier-500k the whole list is 71 DOXA groups and a Laos packet drawn from it holds
// all 71, so the page rendered "71 -> 71", two identical bars reading as a funnel that narrows to
// nothing. A step that repeats its neighbour is not a step.
const lastImb = (steps[steps.length - 1] || {}).groups;
// ⚠ THE LIST'S LABEL IS TRANSLATED HERE, NOT TAKEN FROM THE DATA. rega measured the rendered hi
// packet (2026-09-26): "Project 4700: frontier people groups" sat among Devanagari labels in a
// Latin serif face, because it comes verbatim from library/lists/lists.json, which carries ONE
// label per list and has no locale dimension at all. The same would happen in all ten non-English
// locales, and the gate passed because it counts rows, not the row's language.
// packet-strings.json is the packet's OWN catalogue and the place this belongs — a new language
// is a new block there, never a code change — so funnel_list_<id> supplies it and falls back to
// the data's English label for any list this catalogue has not met. The numeric designation stays
// ("Project 4700" / "4700 计划"): it names the campaign, while "frontier people groups" is prose.
// That is the same line check-i18n-never-translated draws around "UUPGs".
const listLabel = funnel.listId
? (t(`funnel_list_${funnel.listId}`) ?? funnel.listLabel)
: funnel.listLabel;
const ownStep = (listLabel && total && total !== lastImb && total !== mine)
? [{ groups: total, population: null, label: listLabel, tint: true }]
: [];
const allRows = [
...steps.map((s, i) => ({
groups: s.groups,
population: s.population,
label: t(`funnel_${s.id}`) !== undefined ? t(`funnel_${s.id}`) : s.id,
// The network's own list is the hinge between "the world" and "yours" — tint it, and tint
// the reader's row below it the same, so the last two read as one thought. With a
// non-default list the hinge moves down to that list's own row instead.
tint: i === steps.length - 1 && !ownStep.length,
})),
...ownStep,
{ groups: mine, population: null, label: t('funnel_this'), tint: true, mine: true },
];
// ⚠ THE GAP IS DERIVED FROM THE MEASURED ROW HEIGHTS, NOT FROM A GUESS AT THEM. A first version
// subtracted a magic 118 as "about one row" and left 418px of dead space at the foot — the same
// emptiness rega reported, just redistributed. Summing the real heights closes it.
//
// ⚠ AND IT DELIBERATELY KEEPS SLACK, because two runtime facts are unknowable here: whether a
// step carries a population line, and how many LINES its translated label wraps to. Arabic and
// Hindi labels are longer than the English ("مجموعات حدودية — 0.1% أو أقل، ولا يكاد أحد يذهب
// إليها" against "are frontier"), so a label that wraps on two rows must not push the footnote
// off the sheet. LINE_SLACK is that room: one extra wrapped line per row. Tight-packing this
// page would trade rega's "60% empty" for an overflow in exactly the locales I cannot eyeball.
const LINE_SLACK = 30;
const rowHeight = (r) => (r.mine ? 46 : 40) + 3 + (r.mine ? 22 : 20)
+ (Number.isFinite(Number(r.population)) ? 19 : 0) + 8 + 16 + LINE_SLACK;
const rowsHeight = allRows.reduce((sum, r) => sum + rowHeight(r), 0);
const GAP = Math.max(18, Math.floor((H - ROWS_TOP - FOOT_RESERVE - rowsHeight) / (allRows.length - 1)));
const rows = allRows.map((r) => {
// ⚠ `Number.isFinite(Number(x))` IS THE WRONG TEST and it printed "0 people" on the reader's row
// of every packet (rega, rendered layer, 2026-09-26). Number(null) is 0 — only Number(undefined)
// is NaN — so the deliberate `population: null` marker on the reader's row passed the guard and
// rendered as the number zero. I asked "is this a finite number?" when the question was "did
// anyone give me a population?". Presence and value are different properties; this is the same
// confusion as inferring "did it work" from a field that answers "did it isolate".
// AND ZERO IS ALSO SUPPRESSED: a people group with no people is not a fact, so a genuine 0 is
// missing data wearing a number's clothes.
const popN = (r.population === null || r.population === undefined || r.population === '')
? null : Number(r.population);
const pop = (popN !== null && Number.isFinite(popN) && popN > 0)
? `<div style="font:400 17px Georgia,serif;color:#a89f8c;margin-top:2px">${num(popN)} ${esc(t('funnel_people'))}</div>`
: '';
// ⚠ THE LAST ROW DOES NOT KEEP THE FULL GAP. Measured in print (rega): the share line sat a full
// 218px below the reader's row but only 42px above the footnote, so it read as part of the
// FOOTNOTE rather than as the conclusion of the "43" row. The gap belongs BETWEEN rows; the share
// is that last row's conclusion and should sit close under it. Proximity is the only thing
// grouping them, so an even gap everywhere groups the share with the wrong element.
return `<div style="margin-bottom:${r.mine ? 14 : GAP}px"${r.mine ? ' class="funnel-mine-row"' : ''}>
<div${r.mine ? ' class="funnel-mine"' : ''} style="font:700 ${r.mine ? 46 : 40}px Georgia,serif;color:${r.tint ? '#7a3b2e' : '#1b1a17'};line-height:1">${num(r.groups)}</div>
<div style="font:400 ${r.mine ? 22 : 20}px Georgia,serif;color:#3b3226;margin-top:3px">${esc(r.label)}</div>
${pop}
<div style="height:16px;width:${width(r.groups)}px;background:${r.tint ? '#7a3b2e' : '#cdbf9e'};margin-top:8px;border-radius:2px"></div>
</div>`;
}).join('');
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:${ROWS_TOP}px;right:${M}px">${rows}${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. Driver: "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 };
}