diff --git a/internal/check-zone-census.mjs b/internal/check-zone-census.mjs index 6da27326a..41d8f4d15 100644 --- a/internal/check-zone-census.mjs +++ b/internal/check-zone-census.mjs @@ -42,7 +42,12 @@ if (!existsSync(POSTERS)) { console.log(`ZONE-CENSUS: SKIP — no posters at ${POSTERS} (run generate.mjs first).`) process.exit(0) } -const built = new Set(readdirSync(POSTERS).filter((f) => f.endsWith('.html')).map((f) => f.slice(0, -5))) +// ⚠ POSTERS ONLY. generate.mjs also writes PAPER sheets beside them (.packet.html, packet page 1; +// .letter.html), and counting those as posters would print "posters built: 56" for 28 zones. +const PAPER = /\.(packet|letter)\.html$/ +const htmls = readdirSync(POSTERS).filter((f) => f.endsWith('.html')) +const built = new Set(htmls.filter((f) => !PAPER.test(f)).map((f) => f.slice(0, -5))) +const paperSheets = htmls.filter((f) => PAPER.test(f)).length const missing = leaves.map((z) => z.id).filter((id) => !built.has(id)) // Definitions that exist in the data but are NOT reachable — reported as context, @@ -55,7 +60,7 @@ for (const k of ['lists', 'deaf_lists', 'asia_lists', 'uncovered_lists']) { const unreachable = [...declared].filter((id) => !leaves.some((z) => z.id === id)) console.log(` selectable zones : ${leaves.length}`) -console.log(` posters built : ${built.size}`) +console.log(` posters built : ${built.size}${paperSheets ? ` (+ ${paperSheets} paper sheet(s), not counted)` : ''}`) console.log(` unreachable defs : ${unreachable.length}${unreachable.length ? ` (${unreachable.join(', ')}) — superseded, not a gap` : ''}`) if (missing.length) { diff --git a/library/scripts/posters/generate.mjs b/library/scripts/posters/generate.mjs index 52b5ae867..ef94a2c4d 100644 --- a/library/scripts/posters/generate.mjs +++ b/library/scripts/posters/generate.mjs @@ -343,8 +343,31 @@ const FORMATS = { // match it, which is what makes an ordinary printer produce a full-bleed-free, // correctly-scaled sheet instead of "fit to page" guesswork. letter: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5 }, + // ★ PACKET PAGE 1 — THE MAP ALONE, ON A VERTICAL PAGE. Not the poster: page 1 of a prayer packet is + // a lightweight one-pager showing the geography and every pin with its numbered name, and nothing of + // the poster's furniture (the facts table, the two vision columns, the three networks, the closing + // verse). Those are English prose baked into the artwork, which is also why the poster could never be + // page 1 of a translated packet: the packet draws its OWN title into the reserved band, from the + // locale's strings, and this sheet supplies only what needs no translation — the map, the pins and + // the people-group names. + // + // SAME 1500x1941 AS `letter` AND AS THE PACKET'S OWN SHEET (packet-render.js W/H), so it drops onto + // page 1 at 1:1 with no rescale. `header` is the band left EMPTY for the packet's title block and + // `footer` the band for its fine print; everything between goes to the map and its label columns — + // about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow. + // Output: .packet.html beside the posters (see FILE_SUFFIX), never over them. + packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 }, }; -const FMT = FORMATS[process.env.FORMAT || '3x4'] || FORMATS['3x4']; +const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4'; +const FMT = FORMATS[FMT_KEY]; +// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet. +const MAP_ONLY = FMT.mapOnly === true; +// ★ A FORMAT THAT IS NOT A POSTER MUST NOT OVERWRITE THE POSTER. Every format used to write +// out/posters/.html, so `FORMAT=letter` silently replaced the 3x4 poster the wall and the site +// serve — which is part of why no letter or packet sheet was ever generated alongside them. The paper +// formats get their own names. 3x4 and 3x3 keep the bare name: they ARE the poster, whichever was +// built last, and renaming them would break every consumer that fetches posters/.html. +const FILE_SUFFIX = { letter: '.letter', packet: '.packet' }[FMT_KEY] || ''; // Stamped at generation time. Overridable so a rebuild can reproduce an existing // sheet byte-for-byte — print determinism matters more than always-now. const POSTER_DATE = process.env.POSTER_DATE @@ -458,7 +481,7 @@ const HEADER_BASE = SPARSE ? 355 : 460; // no city poster rendered at all. 280 clears it with ~8px of slack for the // per-zone wrapping the block's height depends on. This costs 25px of map height, // which is the documented trade this constant exists to make. -const FOOTER = SPARSE ? 316 : 280; +const FOOTER = MAP_ONLY ? FMT.footer : SPARSE ? 316 : 280; // GUTTER was 300px A SIDE — 600 of 1350 (44% of the sheet) given to text, leaving // the map only 750px wide. The names are short and do not need that; the MAP does. // At 210 a side the map grows 750 -> 930 (+24% width, +24% linear scale), which is @@ -470,7 +493,7 @@ const GUTTER = FMT.gutter, GPAD = 16; const MAP_X = GUTTER; const MAP_W = PW - 2 * GUTTER; // Set per-zone by setMapBox() before each poster renders. -let HEADER = SPARSE ? 355 : 460; +let HEADER = MAP_ONLY ? FMT.header : SPARSE ? 355 : 460; let MAP_Y = HEADER; let MAP_H = PH - HEADER - FOOTER; const setMapBox = (headerPx) => { @@ -712,7 +735,9 @@ function layoutLabels(pins, project) { // sheet's largest index. `pins.length` is the count for the whole sheet. const MAXNUM_L = pins.length; col.forEach((it) => { it.maxNum = MAXNUM_L; }); - const MAXF = SPARSE ? 19 : 15; + // A map-only sheet frees the header and footer bands the same way SPARSE frees the vision + // columns, so it gets SPARSE's ceiling; the solver still only uses what the column can hold. + const MAXF = (SPARSE || MAP_ONLY) ? 19 : 15; // AVAIL IS THE COLUMN, AND THE NAME DOES NOT GET ALL OF IT. // The renderer draws the name starting one number-column IN from the column's // map-facing edge (`nameEdge = tx ± numColW`) but does not shrink its budget by @@ -867,7 +892,9 @@ async function renderPoster(zone, pins, zoneFC) { // derived from the prediction, so a smaller header automatically yields a // taller map — and the label columns re-pitch to the same band, which is what // spaces the names out to match. - setMapBox(measureHeader(zone, pins, ctry, prov)); + // A map-only sheet has no header CONTENT to measure — the band is reserved, at a fixed height, + // for the title the packet draws itself. + setMapBox(MAP_ONLY ? FMT.header : measureHeader(zone, pins, ctry, prov)); } // bbox: prefer resolved polygons; fallback to pins let bbox; @@ -2151,7 +2178,9 @@ async function renderPoster(zone, pins, zoneFC) { // did once: the last "Ways to use this map" line landed on the map frame and the // markup checks all passed, because an overlap is valid SVG. Only the render // showed it. This turns that class of regression into a build error. - if (HEAD_BOTTOM > HEADER) { + // A map-only sheet computes the header content but never EMITS it (see the assembly below), so + // there is nothing to overflow — the guard would only be measuring a block that is thrown away. + if (!MAP_ONLY && HEAD_BOTTOM > HEADER) { throw new Error( `[generate] header overflows the map frame: content ends at y=${HEAD_BOTTOM} ` + `but HEADER=${HEADER} (zone ${zone.zoneKey}). Raise HEADER or tighten the header copy.` @@ -2268,7 +2297,8 @@ async function renderPoster(zone, pins, zoneFC) { // The reference baseline sits at VERSE_RULE_Y + 44; the attribution line is at // PH - 4. Keeping the ref's descender clear of it needs +44 <= PH - 12, so the // real limit is +56, not the +46/PH-8 pair that was guarding an imagined 8px. - if (VERSE_RULE_Y + 56 > PH) { + // Same reasoning as the header guard: map-only sheets never emit the below-map block. + if (!MAP_ONLY && VERSE_RULE_Y + 56 > PH) { throw new Error( `[generate] below-map block runs off the sheet: verse rule would sit at ` + `y=${VERSE_RULE_Y.toFixed(0)} on a ${PH}px sheet (zone ${zone.zoneKey}). ` @@ -2377,7 +2407,15 @@ async function renderPoster(zone, pins, zoneFC) { } } - const svg = ` + // data-sheet="map-only" tells the packet this sheet reserves the top band for the packet's own + // title (packet-render.js liftMap), as opposed to a poster that carries its own. + // ★ EVERYTHING FROM THE TITLE TO THE CLOSING VERSE, AND THE WHITE EDGE, IS POSTER FURNITURE, and a + // map-only sheet (packet page 1) omits all of it: the title, the facts table and vision columns + // (headBlock), the goal question and three networks (footBlock), and the verse. The packet supplies + // its own title in the reserved band, in the reader's language. + // ⚠ These notes are JS comments, NOT inside the literal: an HTML comment there is EMITTED + // into every poster, which made the default 3x4 output differ from before this mode existed. + const svg = ` - ${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')} + ${MAP_ONLY ? '' : `${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')} ${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')} ${esc(title)} - “${esc(verse.text)}” ${esc(verse.ref)} + “${esc(verse.text)}” ${esc(verse.ref)}`} - + ${MAP_ONLY ? '' : ``} `; const html = ` @@ -2933,13 +2971,14 @@ for (const z of zones) { continue; } const { html, title, octiOK, crossings, diagFrac } = rendered; - const file = `${z.zoneKey}.html`; + const file = `${z.zoneKey}${FILE_SUFFIX}.html`; writeFileSync(join(POSTERS, file), html); - manifest.push({ zoneKey: z.zoneKey, title, count: pins.length, octilinear: octiOK, crossings, diagFrac, html: `posters/${file}`, png: `posters/${z.zoneKey}.png` }); + manifest.push({ zoneKey: z.zoneKey, title, count: pins.length, octilinear: octiOK, crossings, diagFrac, html: `posters/${file}`, png: `posters/${z.zoneKey}${FILE_SUFFIX}.png` }); made++; console.log(` ✓ ${z.zoneKey.padEnd(34)} ${String(pins.length).padStart(3)} pins octi=${octiOK?'Y':'N'} d=${diagFrac} cross=${crossings}`); } -if (!ONLY) writeFileSync(join(OUT, 'manifest.json'), JSON.stringify({ generated: 'live', count: manifest.length, posters: manifest }, null, 2)); +// The poster manifest is what the wall reads; a paper-format run writes its own beside it. +if (!ONLY) writeFileSync(join(OUT, `manifest${FILE_SUFFIX}.json`), JSON.stringify({ generated: 'live', count: manifest.length, posters: manifest }, null, 2)); console.log(`\n${made} poster(s) → ${POSTERS}${ONLY ? ' (ONLY=' + ONLY + ')' : ', manifest.json written'}`); // FAIL THE RUN, but only after every good poster is on disk. A non-zero exit keeps // this a build error rather than a warning somebody scrolls past, and the census diff --git a/library/scripts/posters/publish-html.mjs b/library/scripts/posters/publish-html.mjs index 49a710c68..d3eaf9223 100644 --- a/library/scripts/posters/publish-html.mjs +++ b/library/scripts/posters/publish-html.mjs @@ -85,7 +85,11 @@ let bytes = 0; const skipped = []; for (const file of readdirSync(SRC)) { if (!file.endsWith('.html')) continue; - const id = file.slice(0, -5); + // ★ PAPER FORMATS CARRY A SUFFIX (generate.mjs FILE_SUFFIX): .packet.html is page 1 of the + // prayer packet, .letter.html the poster on letter paper. The zone id is what precedes it — + // taking everything before ".html" as the id reported every packet sheet as an unknown zone and + // silently skipped it. + const id = file.slice(0, -5).replace(/\.(packet|letter)$/, ''); if (ONLY && id !== ONLY) continue; if (!zoneIds.has(id)) { skipped.push(id); continue; } const html = readFileSync(join(SRC, file), 'utf8');