diff --git a/internal/check-evangelical-share.mjs b/internal/check-evangelical-share.mjs index 473264fdf..5bf81153b 100644 --- a/internal/check-evangelical-share.mjs +++ b/internal/check-evangelical-share.mjs @@ -90,17 +90,28 @@ const notes = [] if (!partial.includes(`5 ${ppl}`) || !partial.includes(`995 ${ppl}`)) { fails.push(`a 0.5% list of 1,000 people does not state both totals ("5 ${ppl}" who follow Jesus, "995 ${ppl}" who do not yet)`) } else notes.push(' ok both totals are stated in people: 5 who follow Jesus, 995 who do not yet') - // The grid is one SVG now (lifeGrid): its week and year numbers are , and its markup names - // `data-weeks` as geometry, so the WORDS a reader sees are read with the tags stripped. - if (/week/i.test(partial.replace(/<[^>]+>/g, ' ')) || /class="lc-t/.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis') + // The grid is one SVG now (lifeGrid): its week numbers are , its year numbers + // "lc-t lc-ty", and its markup names `data-weeks` as geometry, so the WORDS a reader sees are read with the + // tags stripped. + const axis = /class="lc-t lc-t[wy]\b/ + if (/week/i.test(partial.replace(/<[^>]+>/g, ' ')) || axis.test(partial)) fails.push('the page still counts in weeks or draws a week/year axis') else notes.push(' ok no weeks and no week/year axis on the page') + // ★ ITS SIDE LABELS ARE SHARES (2026-10-02): each 10-row block is 520 boxes, a tenth of the list's people, + // labelled 10% ... 100% in the page's own numerals; exactly those ten, in order. + const shares = [...partial.matchAll(/class="lc-t lc-tp[^"]*"[^>]*>([^<]*) m[1]) + const want = Array.from({ length: 10 }, (_, i) => `${((i + 1) * 10).toLocaleString('en')}%`) + if (shares.join('|') !== want.join('|')) fails.push(`the people grid's side labels should read ${want.join(' ')}, got ${shares.join(' ') || 'none'}`) + else notes.push(' ok the people grid labels each 10-row block with its share: 10% ... 100%') // A TINY SHARE: the totals come from the unrounded figure. 0.004% of 1,000,000,000 is 40,000 people, // which must not print as 0 because the percentage rounds to 0.00. const tiny = page({ groupsInList: 5, groupsMatched: 5, population: 1e9, evangelicalPopulation: 40000, pctEvangelical: 0.004 }) if (!tiny.includes(`40,000 ${ppl}`) || !/ev-pct"><0\.01% { window.__EXPECTED_RHYTHMS = words }, expected) // the SVG declares. Used by the life-calendar checks below. await page.evaluate(() => { window.__lcBoxes = (svg) => { - const [x0, y0, p, dec] = String(svg.dataset.geom || '').split(',').map(Number) + // data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent). + const [x0, y0, px, dec, py = px] = String(svg.dataset.geom || '').split(',').map(Number) const weeks = Number(svg.dataset.weeks) const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => { const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0 - const b = Math.floor(y / (10 * p + dec)) - return (b * 10 + Math.round((y - b * (10 * p + dec)) / p)) * weeks + Math.round(x / p) + const b = Math.floor(y / (10 * py + dec)) + return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px) }) const off = idx((svg.querySelector('path.lc-off') || {}).getAttribute?.('d')) const sh = svg.querySelector('path.lc-on, path.lc-reached') @@ -271,7 +272,7 @@ const r = await page.evaluate(() => { setsSheets: document.querySelectorAll('.sheet-sets').length, christianSheets: document.querySelectorAll('.sheet-christian').length, // ★ THE LIFE GRIDS ARE ONE SVG EACH (2026-10-02): two paths of square subpaths (empty and shaded) placed by - // the geometry the SVG states (data-geom x0,y0,pitch,decadeGap). Each subpath is decoded back to its box index + // the geometry the SVG states (data-geom x0,y0,px,decadeGap,py). Each subpath is decoded back to its box index // in reading order, so the checks below still see box by box what is drawn, not a count the renderer asserts. lifeCells: [...document.querySelectorAll('.lc-frame .lc-svg')].reduce((a, g) => a + window.__lcBoxes(g).total, 0), prayBoxes: document.querySelectorAll('.pray-box').length, @@ -284,6 +285,12 @@ const r = await page.evaluate(() => { return Math.max(...bs.map((b) => { const top = b.getBoundingClientRect().top return Math.round(Math.max(b.scrollHeight - b.clientHeight, ...[...b.children].map((k) => k.getBoundingClientRect().bottom - top - b.clientHeight))) })) })(), pointsSheets: document.querySelectorAll('.sheet-points').length, + // ★ THE POINTS PAGE SIZES ITSELF FROM AN ESTIMATE (2026-10-03, data-pt-est): the page must never be taller + // than the estimate it was sized by, or the estimate is drifting and only its 3% margin still saves the page. + pointsUnder: (() => { const b = document.querySelector('.points-body[data-pt-est]'); if (!b) return null + const top = b.getBoundingClientRect().top, kids = [...b.children] + const real = Math.max(...kids.map((k) => k.getBoundingClientRect().bottom - top)) + parseFloat(getComputedStyle(kids[kids.length - 1]).marginBottom) + return { real: Math.round(real), est: Number(b.dataset.ptEst), tier: Number(b.dataset.ptTier) } })(), funnelTip: document.querySelectorAll('.funnel-tip').length, lifeShaded: (() => { const g = document.querySelector('.sheet-christian .lc-svg'); return g ? window.__lcBoxes(g).shaded.length : null })(), // ★ FILLED FROM THE TOP, in reading order (the packet's design), measured on the DOM order of the cells: @@ -782,6 +789,7 @@ const listSheets = r.cards ? fdPages(r.cards) : wsPages(r.wsRows) // The Full detail's first page reserves a block for the explanation and CLIPS what outgrows it (rather than // let it overlap the rows): only a measurement sees a clipped line, so measure it. if (r.pointsOver !== null && r.pointsOver > 0) fails.push(`the prayer-points page outgrows its sheet by ${r.pointsOver}px — the twelve points and the themes must fit ONE page in every language`) +if (r.pointsUnder && r.pointsUnder.real > r.pointsUnder.est) fails.push(`the prayer-points page is ${r.pointsUnder.real}px tall, over the ${r.pointsUnder.est}px its size tier ${r.pointsUnder.tier} was chosen by — the estimate (packet-render pointsHeight) under-measures this language`) if (r.fdTopOver > 0) fails.push(`the Full detail's explanation block outgrows its reserved height by ${r.fdTopOver}px — text is clipped (raise FD_TOP_H)`) if (r.wsRows && r.wsSheets !== wsPages(r.wsRows)) fails.push(`${r.wsRows} worksheet rows should take ${wsPages(r.wsRows)} page(s) but the packet has ${r.wsSheets}`) // + 2 again (2026-09-30, the second revision): point 4's Mark 5:18-20 and the Father's-heart list are back ON the @@ -962,6 +970,7 @@ console.log(` funnel (page 2) : ${r.funnel ? `${r.funnelRows} row(s), ${r.funnelBars} bar(s)${r.funnelShare ? ` — "${r.funnelShare}"` : ' — no share line'}` : 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`) console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`) +if (r.pointsUnder) console.log(` points size tier : ${r.pointsUnder.tier} (estimated ${r.pointsUnder.est}px, measured ${r.pointsUnder.real}px)`) console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`) console.log(` point 4 : Mark 5:18-20 ${r.mark5Len} chars, verse 19 bold ${r.v19Len} chars, ${r.heartItems} readings`) console.log(` metacamp.org : ${r.metacamp}`) diff --git a/internal/check-packet-variants.mjs b/internal/check-packet-variants.mjs index 39622401b..a18be11b6 100644 --- a/internal/check-packet-variants.mjs +++ b/internal/check-packet-variants.mjs @@ -163,11 +163,12 @@ ok(!/[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't return a < 0 || i < 0 ? '' : html.slice(a, html.indexOf('', a)) } const boxes = (svg) => { - const [x0, y0, p, dec] = ((svg.match(/data-geom="([^"]+)"/) || [])[1] || '').split(',').map(Number) + // data-geom x0,y0,px,dec[,py]: the box pitch across and down (one pitch when py is absent). + const [x0, y0, px, dec, py = px] = ((svg.match(/data-geom="([^"]+)"/) || [])[1] || '').split(',').map(Number) const weeks = Number((svg.match(/data-weeks="(\d+)"/) || [])[1]) const idx = (d) => [...String(d || '').matchAll(/M([\d.]+) ([\d.]+)/g)].map((m) => { - const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0, b = Math.floor(y / (10 * p + dec)) - return (b * 10 + Math.round((y - b * (10 * p + dec)) / p)) * weeks + Math.round(x / p) + const x = Number(m[1]) - 0.5 - x0, y = Number(m[2]) - 0.5 - y0, b = Math.floor(y / (10 * py + dec)) + return (b * 10 + Math.round((y - b * (10 * py + dec)) / py)) * weeks + Math.round(x / px) }) const off = idx((svg.match(/ a - b) @@ -185,6 +186,27 @@ ok(!/[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't ok(vb(grid(full, 'sheet-life')) && vb(grid(full, 'sheet-life')) === vb(grid(full, 'sheet-christian')), 'the people grid should have the life grid\'s exact shape') } +// ★ THE POINTS PAGE FITS ITSELF (2026-10-03): per language it takes the LARGEST size tier whose estimate fits +// one sheet, from the one-sheet sizes up to the two-sheet sizes before them (the cap). Asserted both ways: the +// tier chosen is the largest that fits, and SHORTER words print LARGER (halving the English points raises +// the English tier) while the real words keep theirs. check:packet-render holds the estimate to the real page. +{ + for (const lang of ['en', 'ar', 'zh', 'fr']) { + const R = createPacketRenderer({ strings, lang }) + const f = R.pointsFit, k = f.chosen(), budget = f.bodyH * f.safe + ok(k === f.tiers || f.height(k + 1) > budget, `${lang}: points tier ${k} is not the largest that fits (tier ${k + 1} estimates ${Math.round(f.height(k + 1))}px of ${Math.round(budget)})`) + ok(k === 0 || f.height(k) <= budget, `${lang}: points tier ${k} estimates ${Math.round(f.height(k))}px, over the ${Math.round(budget)}px budget`) + ok(R.pointsPage().includes(`data-pt-tier="${k}"`), `${lang}: the points page does not print at the tier it chose (${k})`) + } + const top = (lang, s) => createPacketRenderer({ strings: s, lang }).pointsFit + const short = JSON.parse(JSON.stringify(strings)) + short.en.points = short.en.points.map((p) => ({ ...p, text: p.text.slice(0, Math.ceil(p.text.length / 2)) })) + const k0 = top('en', strings).chosen(), k1 = top('en', short).chosen() + ok(k1 > k0, `halving the English points' text should raise the English points tier, got ${k0} -> ${k1}`) + // the cap is the two-sheet sizes: the largest tier prints the point text at 22.5px + ok(/font:400 22\.5px Georgia/.test(createPacketRenderer({ strings, lang: 'en' }).pointsPage(top('en', strings).tiers)), 'the largest points tier should print the text at 22.5px (the two-sheet size, the cap)') +} + // ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no // x/y — deliberately, since `reached` undefined means we do not know which side they belong on and // drawing them anywhere would assert exactly what is unknown. On a printed page there is no hover, diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index 803a0e2a5..89f12d3e0 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -212,16 +212,17 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de Height budget, measured on paper not guessed: head+lede end near y=210, the frame is ~1,560px, the legend and foot ~80px, which lands above the ${H - M}px margin with room to spare. */ .pg-head-c,.pg-lede-c{text-align:center;margin-inline:auto} - .lc-frame{width:max-content;margin:0 auto;padding:14px 22px 18px;border:1.5px solid #3b463d;background:#fff} + .lc-frame{width:max-content;margin:0 auto;padding:10px 3px;border:1.5px solid #3b463d;background:#fff} /* The life grid is one SVG (lifeGrid): two paths of boxes, hairlines between decades, small numbers. */ .lc-svg{display:block} .lc-svg .lc-off{fill:#fff;stroke:#8f8a78;stroke-width:1} .lc-svg .lc-on{fill:#c9a13b;stroke:#a8832a;stroke-width:1} .lc-svg .lc-reached{fill:#2f6d4f;stroke:#2f6d4f;stroke-width:1} - .lc-svg .lc-rule{stroke:#d8d3c4;stroke-width:1} - .lc-svg .lc-t{font:400 10px Georgia,serif;fill:#6f6a5c} - .lc-svg .lc-ty{fill:#8a8577} - .lc-c{display:block;box-sizing:border-box;width:11px;height:11px;border:1px solid #8f8a78;background:#fff} + .lc-svg .lc-rule{stroke:#3b463d;stroke-width:1.6} + .lc-svg .lc-rule-q{stroke:#cfc8b4;stroke-width:1;stroke-dasharray:3 3} + .lc-svg .lc-t{font:700 18px Georgia,serif;fill:#3b463d} + .lc-svg .lc-big{font-size:20px;fill:#1b1a17} + .lc-c{display:block;box-sizing:border-box;width:22px;height:11px;border:1px solid #8f8a78;background:#fff} /* Jesus' 33 years: warm gold, the one shading on the life page. */ .lc-on{background:#c9a13b;border-color:#a8832a} /* The shaded share on the %-evangelical grid is the REACHED share, so it takes the accent colour and the @@ -287,12 +288,12 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de .xb-ex-note{margin-top:4px;font-size:14px;color:#6f6a5c} /* The prayer themes and scenes above the twelve points: a thin numbered list, ruled off below. */ .pt-themes{margin:0 0 10px;padding:0 0 6px;border-bottom:1.5px solid #c9bfa4} - .pt-th-h{font:400 19px/1.3 Georgia,serif;color:#413d35} - .pt-th-h b,.pt-sc-h b{font:700 20px Georgia,serif;color:#1b1a17;letter-spacing:.02em} + .pt-th-h{font:400 var(--pt-th,19px)/1.3 Georgia,serif;color:#413d35} + .pt-th-h b,.pt-sc-h b{font:700 var(--pt-thb,20px) Georgia,serif;color:#1b1a17;letter-spacing:.02em} .pt-th-i{font-style:italic;color:#6d766e} - .pt-th-list{display:flex;flex-wrap:wrap;gap:1px 20px;margin:2px 0 0;padding:0;list-style:none;font:400 19px/1.3 Georgia,serif;color:#3b3226} + .pt-th-list{display:flex;flex-wrap:wrap;gap:1px 20px;margin:2px 0 0;padding:0;list-style:none;font:400 var(--pt-th,19px)/1.3 Georgia,serif;color:#3b3226} .pt-n{display:inline-block;min-width:1.3em;font-weight:700;color:#7a3b2e} - .pt-sc-r{font:600 18px Georgia,serif;color:#7a3b2e} + .pt-sc-r{font:600 var(--pt-scr,18px) Georgia,serif;color:#7a3b2e} .ws-cols{margin:0;padding:0;list-style:none} .ws-cols li{margin:0 0 6px;font:400 19px/1.45 Georgia,serif;color:#413d35} .ws-cols li b{color:#1b1a17} @@ -975,46 +976,64 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // contiguous run looked like a bar; the opposite has now been asked for, and the grid is the // product's picture. Filling from the top also makes the two pages read the same way: Jesus' 33 years // are the first rows of a life, and the evangelical share is the first boxes of the same life. - // ★ axes:false DRAWS THE SAME BOXES WITH NO WEEK OR YEAR LABELS, for a page where a box is a share + // ★ people:true DRAWS THE SAME BOXES WITH NO WEEK OR YEAR NUMBERS, for a page where a box is a share // of PEOPLE rather than a week of a life (the knows-Christ page): an axis numbered in weeks and years - // there would describe a unit the page is not using. + // there would describe a unit the page is not using. Its side labels state each 10-row block's share. // ★ ONE PICTURE, NOT 5,200 BOXES (2026-10-02). The grid was 5,200 bordered HTML boxes in a CSS grid, twice // in the full packet: heavy to lay out and to hold in a phone's memory. It is now ONE inline SVG per page whose // boxes are two s (the empty weeks and the shaded ones, a square subpath each): the same 100 x 52 boxes, // vector on paper, a handful of nodes in the page. Both pages use the SAME geometry (LC), so the people grid // has exactly the life grid's shape; it simply prints no week or year numbers, since its box is a share of // people. data-* attributes state what is drawn, and the gates read the subpaths back, box by box. - const LC = { cell: 11, pitch: 14, x0: 36, y0: 22, dec: 12 }; - function lifeGrid({ shaded = 0, shadeClass = 'lc-on', axes = true } = {}) { + // ★ FULL WIDTH, ONE PAGE (2026-10-02). The calendar now spans the page's printable width: each week is a box + // twice as wide as it is tall (22 x 11px, ~3.2 x 1.6mm on letter). It could not be a bigger SQUARE: 100 rows + // are what limit a box, and 26px squares would need ~2,650px of grid on a ~1,550px page. Read at a glance: + // bold year numbers every 5 years (larger every 10), week numbers 1 / 13 / 26 / 39 / 52 across the top, + // a clear rule every 10 years and light rules at the quarters. Labels are 18-20px (7.3-8.2pt on letter, + // 7.1-8pt on A4, which prints the letter sheet at 97%). + // ONE PATH, TWO PAGES: the reached chart is this grid with the same boxes, rules and shape; its box is a share + // of the list's PEOPLE, so it has no week numbers and labels each 10-row block with its share: 10% ... 100%. + // cw x ch is a box, px / py the pitch, x0 the label column, y0 the week-number row, dec the extra gap per decade; + // pr / pb keep the last column's week number and the last row's label inside the picture. + const LC = { cw: 22, ch: 11, px: 25, py: 14, x0: 66, y0: 30, dec: 8, pr: 8, pb: 8 }; + function lifeGrid({ shaded = 0, shadeClass = 'lc-on', people = false } = {}) { const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS; const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0))); - // Without axes the label margins are split evenly round the boxes, so the grid sits centred in the same frame. - const x0 = axes ? LC.x0 : LC.x0 / 2, y0 = axes ? LC.y0 : LC.y0 / 2; - const at = (i) => { const r = Math.floor(i / WEEKS), c = i % WEEKS; return [x0 + c * LC.pitch, y0 + r * LC.pitch + Math.floor(r / 10) * LC.dec]; }; - const sq = (i) => { const [x, y] = at(i); return `M${x + 0.5} ${y + 0.5}h${LC.cell - 1}v${LC.cell - 1}h${1 - LC.cell}z`; }; + // The people grid has no week-number row: its top and bottom margins are evened out, so it sits centred in the same frame. + const x0 = LC.x0, y0 = people ? (LC.y0 + LC.pb) / 2 : LC.y0; + const at = (i) => { const r = Math.floor(i / WEEKS), c = i % WEEKS; return [x0 + c * LC.px, y0 + r * LC.py + Math.floor(r / 10) * LC.dec]; }; + const sq = (i) => { const [x, y] = at(i); return `M${x + 0.5} ${y + 0.5}h${LC.cw - 1}v${LC.ch - 1}h${1 - LC.cw}z`; }; let on = '', off = ''; for (let i = 0; i < TOTAL; i++) { if (i < n) on += sq(i); else off += sq(i); } - const gap = LC.pitch - LC.cell; - const W = LC.x0 + WEEKS * LC.pitch - gap, H = LC.y0 + YEARS * LC.pitch + (YEARS / 10 - 1) * LC.dec - gap; + const gx = LC.px - LC.cw, gy = LC.py - LC.ch; + const gridW = WEEKS * LC.px - gx, gridH = YEARS * LC.py + (YEARS / 10 - 1) * LC.dec - gy; + const W = LC.x0 + gridW + LC.pr, H = LC.y0 + gridH + LC.pb; + // A side label sits beside its row, vertically centred on the box. + const side = (row, text, cls) => { const [, top] = at(row * WEEKS); return `${text}`; }; let marks = ''; - if (axes) { - // Week numbers over their own column; year numbers (1, 5, 10, …) beside their row. - for (const w of [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]) marks += `${w}`; - for (let y = 1; y <= YEARS; y++) { - if (y !== 1 && y % 5) continue; - const [, top] = at((y - 1) * WEEKS); - marks += `${num(y)}`; - } + if (!people) { + for (const w of [1, 13, 26, 39, 52]) marks += `${num(w)}`; + for (let y = 1; y <= YEARS; y++) if (y === 1 || y % 5 === 0) marks += side(y - 1, num(y), y % 10 ? 'lc-ty' : 'lc-ty lc-big'); + } else { + // lc-tp, not lc-ty: a share of the people, never a year (check:evangelical-share tells the two apart). + // 18px like the 5-year labels: "100%" at 20px overran the label column in a fallback serif. + for (let d = 1; d <= YEARS / 10; d++) marks += side(d * 10 - 1, `${num(d * 10)}%`, 'lc-tp'); } - // A hairline between decades, in the extra gap, so ten years read as one block. + // A clear rule between decades, in the extra gap, so ten years read as one block; light rules at the quarters. let rules = ''; + for (const w of [13, 26, 39]) { + const x = x0 + w * LC.px - gx / 2; + rules += ``; + } for (let d = 1; d < YEARS / 10; d++) { const [, top] = at(d * 10 * WEEKS); - rules += ``; + const y = top - LC.dec / 2 - gy / 2; + rules += ``; } - const svg = `` - + `${marks}${rules}${on ? `` : ''}`; - return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `
${svg}
` }; + // data-geom: x0,y0,px,dec,py. The gates decode every box back to its index from it. + const svg = `` + + `${rules}${marks}${on ? `` : ''}`; + return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `
${svg}
` }; } // Page 2 — a human life, unshaded except for the weeks already lived if the caller supplies an age. @@ -1072,7 +1091,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // people), and drift every other total. A real share below the last printed digit reads "<0.01%". const share = known ? Number(raw) : null; const pct = known ? Math.round(share * 100) / 100 : null; - const g = lifeGrid({ shaded: known ? (share / 100) * 5200 : 0, shadeClass: 'lc-reached', axes: false }); + const g = lifeGrid({ shaded: known ? (share / 100) * 5200 : 0, shadeClass: 'lc-reached', people: true }); const fmtPct = (x) => `${Number(x).toLocaleString(lang, { maximumFractionDigits: 2 })}%`; const tiny = known && share > 0 && pct === 0; const pop = known && Number(christian.population) > 0 ? Number(christian.population) : null; @@ -1356,7 +1375,102 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // the point text 18.5px (~7.5pt on letter), the themes 19px, 5px between points, the Mark 5 passage 16px; the // credit line stays 0.28in above the edge. Measured in all 11 locales: the tightest (fr 75px, hi 79px) keep // that much under the last point; check:packet-render fails overflow (pointsOver). - const PT = { head: 22, sub: 19, text: 18.5, refs: 16, gap: 5, passage: 16, top: 82 }; + // ★ THE PAGE FITS ITSELF (2026-10-03). In its own language the page takes the LARGEST sizes that still fit + // ONE sheet: from PT_MIN (the one-sheet sizes above, measured to fit all 11 locales) up to PT_MAX (the + // two-sheet sizes before them, the cap), in PT_TIERS even steps, so shorter words print larger by + // themselves. The credits keep their size. th / thb / scr are the themes' text, titles and scene refs. + // ★ ESTIMATED, NEVER MEASURED, like the description cell (FIT BEFORE CLAMP): this module also runs under + // Node. pointsHeight() lays the page out by character count, each block's lines from a greedy word wrap at + // its own width, with per-script advances measured on this page in the packet's fonts and rounded UP; a + // tier is taken only when the estimate fits with PT_SAFE to spare. check:packet-render measures the real + // page in all 11 locales behind it (pointsOver). + const PT_MIN = { head: 22, sub: 19, text: 18.5, refs: 16, gap: 5, passage: 16, th: 19, thb: 20, scr: 18 }; + const PT_MAX = { head: 25, sub: 21, text: 22.5, refs: 18.5, gap: 18, passage: 20, th: 21, thb: 22, scr: 19 }; + const PT_TOP = 82, PT_TIERS = 10, PT_SAFE = 0.97; + const PT_BODY_H = H - (M + PT_TOP) - 80; // the .points-body box: 1719px + const ptTier = (k) => { + const o = {}; + for (const n of Object.keys(PT_MIN)) o[n] = Math.round((PT_MIN[n] + (PT_MAX[n] - PT_MIN[n]) * k / PT_TIERS) * 4) / 4; + return o; + }; + // em per character by script, measured on this page (2026-10-03: Latin text 0.50-0.53, Cyrillic 0.58, + // Arabic 0.44, Devanagari 0.54, Chinese 1.03) and rounded up; bold and semibold run wider. + const CJK = /[\u2E80-\u9FFF\u3000-\u303F\uF900-\uFAFF\uFF00-\uFFEF]/; + const ptAdv = (ch) => { + const c = ch.codePointAt(0); + if (CJK.test(ch)) return 1.05; + if (c >= 0x0400 && c <= 0x052F) return 0.6; + if ((c >= 0x0600 && c <= 0x06FF) || (c >= 0xFB50 && c <= 0xFEFF)) return 0.47; + if (c >= 0x0900 && c <= 0x097F) return 0.56; + return 0.54; + }; + const PT_BOLD = 1.2, PT_SEMI = 1.12; + const ptW = (str, fs, wt) => [...str].reduce((a, ch) => a + ptAdv(ch), 0) * fs * wt; + // Lines a run of segments ([text, px, weight]) takes at `width`: words wrap at spaces, and a word with Chinese + // in it breaks between any two characters, as the browser breaks it. + function ptLines(segs, width) { + let lines = 0, x = 0; + const put = (w) => { if (!lines) lines = 1; if (x > 0 && x + w > width) { lines++; x = 0; } x += w; }; + for (const [str, fs, wt] of segs) { + const words = String(str || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean); + for (const word of words) { + if (CJK.test(word)) for (const ch of word) put(ptW(ch, fs, wt)); + else put(ptW(word, fs, wt)); + x += ptW(' ', fs, wt); + } + } + return lines; + } + // The flex rows a wrapping list takes: items of the given widths, 20px apart. + const ptRows = (widths, width) => { + let rows = 1, x = 0; + for (const w of widths) { if (x > 0 && x + 20 + w > width) { rows++; x = 0; } x += (x > 0 ? 20 : 0) + w; } + return rows; + }; + // A line's "normal" height, measured: 1.18-1.19 x its size in the serif faces, 1.31-1.32 where it is Chinese. + const ptNormal = (str) => (CJK.test(String(str || '')) ? 1.33 : 1.19); + function pointsHeight(sz) { + const BW = W - 2 * M; // the body's width + const themes = Array.isArray(L.themes) && L.themes.length === 7 ? L.themes : EN.themes || []; + const scenes = Array.isArray(L.scenes) && L.scenes.length === 3 ? L.scenes : EN.scenes || []; + const sref = EN.scene_refs || []; + let h = 0; + if (themes.length) { + const row = sz.th * 1.3, numW = 1.3 * sz.th + ptW(' ', sz.th, 1); + h += ptLines([[t('themes_title'), sz.thb, PT_BOLD], [t('themes_intro'), sz.th, 1]], BW) * Math.max(row, sz.thb * ptNormal(t('themes_title'))); + const tRows = ptRows(themes.map((x) => numW + ptW(String(x), sz.th, 1)), BW); + const sRows = ptRows([ptW(String(t('scenes_title')), sz.thb, PT_BOLD), ...scenes.map((x, i) => numW + ptW(String(x), sz.th, 1) + + ptW(` ${(sref[i] || []).map(refText).join(' · ')}`, sz.scr, PT_SEMI))], BW); + h += 2 + tRows * row + (tRows - 1) + 2 + sRows * row + (sRows - 1) + 6 + 1.5 + 10; + } + for (const p of POINTS) { + const refs = (p.refs || []).map(refText).join(' · '); + h += ptLines([[`${p.n}. ${tr(p.rhythm)}`, sz.head, PT_BOLD], ...(p.sub ? [[`— ${p.sub}`, sz.sub, 1]] : [])], BW) * sz.head * ptNormal(tr(p.rhythm)); + h += 2 + ptLines([[p.text, sz.text, 1]], BW) * sz.text * 1.3; + h += 2 + ptLines([[refs, sz.refs, PT_SEMI]], BW) * sz.refs * ptNormal(refs); + if (p.print_passage) { + const IW = BW - 15; // the passage and heart blocks' 12px inset and 3px rule + const passage = String(t('point4_passage') || ''), bold = String(t('point4_passage_bold') || ''); + const at = bold ? passage.indexOf(bold) : -1; + const segs = at < 0 ? [[passage, sz.passage, 1]] + : [[passage.slice(0, at), sz.passage, 1], [bold, sz.passage, PT_BOLD], [passage.slice(at + bold.length), sz.passage, 1]]; + h += 6 + ptLines(segs, IW) * sz.passage * 1.4 + 2 + ptLines([[t('point4_passage_credit'), 13, 1]], IW) * 13 * ptNormal(t('point4_passage_credit')); + h += 8 + ptLines([[t('point4_heart_title'), sz.refs, PT_BOLD + 0.15]], IW) * sz.refs * ptNormal(t('point4_heart_title')); + h += 2 + ptLines([[t('point4_heart_lede'), sz.passage, 1]], IW) * sz.passage * 1.35; + h += 6 + ptLines([[t('point4_heart_leadin'), sz.passage - 1, 1]], IW) * (sz.passage - 1) * 1.3; + const CWH = (IW - 28 - 40) / 2; // the heart list's two columns + const items = HEART.reduce((a, x) => a + ptLines([[x.who, sz.passage - 1, PT_BOLD], [` · ${refText(x.ref)}`, sz.passage - 1, 1]], CWH), 0); + h += 3 + Math.ceil(items / 2) * (sz.passage - 1) * 1.4; + } + h += sz.gap; + } + return h; + } + // The largest tier whose estimate fits; the floor when none does (the gate then reports the overflow). + const fitPoints = () => { + for (let k = PT_TIERS; k > 0; k--) if (pointsHeight(ptTier(k)) <= PT_BODY_H * PT_SAFE) return { k, sz: ptTier(k) }; + return { k: 0, sz: ptTier(0) }; + }; // ⚠ 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 @@ -1368,10 +1482,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // time from its `books` table: a Chinese packet cited "Matthew 27:27", English in every locale. // A localized name in a right-to-left script is isolated rtl, so the book reads on the right and // the chapter:verse keeps its digit order; a Latin reference stays the ltr isolate described above. - const refSpan = (r) => { + const refBook = (r) => { const m = String(r).match(/^(.*?)\s+(\d[\d:–\-,; ]*)$/); const book = m && (L.books || {})[m[1]]; - const text = book ? `${book} ${m[2]}` : String(r); + return { book, text: book ? `${book} ${m[2]}` : String(r) }; + }; + const refText = (r) => refBook(r).text; + const refSpan = (r) => { + const { book, text } = refBook(r); const dir = book && RTL.has(lang) ? 'rtl' : 'ltr'; return `${esc(text)}`; }; @@ -1394,7 +1512,11 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de `${esc(x)} ${(sref[i] || []).map(refSpan).join(' · ')}`)).join('')} `; } - function pointsPage() { + // `tier` forces a size tier (0..PT_TIERS); probes and gates only. data-pt-tier states the tier chosen and + // data-pt-est its estimated height, so check:packet-render can hold the estimate to the real page. + function pointsPage(tier) { + const k = Number.isInteger(tier) ? Math.max(0, Math.min(PT_TIERS, tier)) : fitPoints().k; + const PT = ptTier(k); const rows = POINTS.map((p) => { const refs = (p.refs || []).map(refSpan).join(' · '); const sub = p.sub ? ` — ${esc(p.sub)}` : ''; @@ -1403,7 +1525,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de ${p.n}. ${esc(tr(p.rhythm))}${sub}
${esc(p.text)}
${refs}
- ${p.print_passage ? passageBlock() + heartBlock() : ''} + ${p.print_passage ? passageBlock(PT) + heartBlock(PT) : ''} `; }); return sheet(` @@ -1411,7 +1533,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
${esc(t('points_title'))}
${esc(t('points_intro'))}
-
${themesBlock()}${rows.join('')}
+
${themesBlock()}${rows.join('')}
${esc(t('points_attrib'))}
`, 'sheet-points'); } @@ -1420,7 +1542,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // through one person. The person is named in the packet's language; the passage is addressed in the // reader's own Bible (refs stay canonical English in the data and the book is localized, like the point // refs). class="p4-heart" is a TEST HOOK: the gates count its seven items. - const heartBlock = () => `
+ const heartBlock = (PT) => `
${esc(t('point4_heart_title'))}
${esc(t('point4_heart_lede'))}
${esc(t('point4_heart_leadin'))}
@@ -1434,7 +1556,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // WEB text where a language has none on file, as the packet always did). Verse 19 in bold: // point4_passage_bold holds it verbatim from the same text, so the bold is a substring match and never a // second rendering of scripture. class="p4-passage" / "p4-v19" are TEST HOOKS. - function passageBlock() { + function passageBlock(PT) { const passage = String(t('point4_passage') || ''); const bold = String(t('point4_passage_bold') || ''); const at = bold ? passage.indexOf(bold) : -1; @@ -1774,5 +1896,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // VARIANTS is exported so the UI enumerates the packets it can print instead of hardcoding two // strings in a menu — add a third variant here and the caret gains an entry with no UI change. - return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, liftMap, mapFromImage, mapSheetFiles, PER_PAGE, FD_FIRST, WS_FIRST, WS_REST, W, H, VARIANTS }; + // pointsFit: the points page's estimate per tier, for the gates and probes that check it against the real page. + const pointsFit = { tiers: PT_TIERS, bodyH: PT_BODY_H, safe: PT_SAFE, height: (k) => pointsHeight(ptTier(k)), chosen: () => fitPoints().k }; + return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, pointsFit, wheelPage, css, mapCss, liftMap, mapFromImage, mapSheetFiles, PER_PAGE, FD_FIRST, WS_FIRST, WS_REST, W, H, VARIANTS }; } diff --git a/library/packet/poster-render.js b/library/packet/poster-render.js index f8fdce093..7b0871815 100644 --- a/library/packet/poster-render.js +++ b/library/packet/poster-render.js @@ -835,6 +835,22 @@ const HC = { gut: 32, fs: 22, lh: 27, keyFs: 19, keyLh: 24, headLh: 30, gap: 8, // these columns 2-6% wider (measured 1.05-1.06x in DejaVu Sans). Each block is centred on its // width at 1.03x, halfway, so any overrun splits across both sides instead of all running right. const HC_FALLBACK = 1.03; +// The two explainer columns' text, ONE copy for both header layouts (the head-columns band and the classic +// header), so the two can no longer drift apart. A series may set its own Jesus column in series.json +// (poster.strategyBody: one paragraph, or a list of them), as UPG100 does; the series that do not, the +// Country and India-state posters, print this default. +// COPY IS THE OWN (05-poster-vision/source-material/01). It KEEPS THE MIRACLE: earlier drafts reduced the +// account to a seating arrangement, when the point is that Jesus supernaturally MULTIPLIED the offering +// and the grouping is how it was distributed. +const STRATEGY_DEFAULT = [ + 'Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.', + 'Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).', +]; +const strategyParas = () => (POSTER_TEXT.strategyBody ? [].concat(POSTER_TEXT.strategyBody) : STRATEGY_DEFAULT); +// The explainer's further paragraphs (its first is series.json's sideBoxBody). A series may set its own in +// series.json (poster.sideBoxMore: one paragraph, or a list; [] prints none), as UPG100 does to keep its band even. +const whatIsMore = () => (POSTER_TEXT.sideBoxMore !== undefined ? [].concat(POSTER_TEXT.sideBoxMore) : [WHAT_IS_MORE]); +const WHAT_IS_MORE = 'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.'; function headColumnsBand(top, facts, approxLists) { const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G; const CW = (GRID_W - 2 * HC.gut) / 3; @@ -844,16 +860,13 @@ function headColumnsBand(top, facts, approxLists) { const keyW = (k) => textW(k.toUpperCase(), HC.keyFs) + k.length * HC.keyFs * 0.14; // Column order (2026-10-02): Jesus' strategy | the facts | what the series is. const cols = [ - [{ head: 'Jesus’ Strategy to Feed the 5000', paras: [ - 'Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.', - 'Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).', - ] }], + [{ head: 'Jesus’ Strategy to Feed the 5000', paras: strategyParas() }], facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, TW) })), [{ head: POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?', paras: [ (POSTER_TEXT.sideBoxBody || 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.') .replace('{approxLists}', String(approxLists)).replace('{seriesName}', SERIES_NAME), - 'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.', + ...whatIsMore(), ] }], ]; let frags = '', bottom = top; @@ -2327,33 +2340,25 @@ async function renderPoster(zone, pins, zoneFC) { headBlock += `${esc(POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?')}`; const bodyTop = py + 20; - // COPY IS THE OWN (05-poster-vision/source-material/01). It KEEPS THE - // MIRACLE — earlier drafts reduced the account to a seating arrangement, when - // the point is that Jesus supernaturally MULTIPLIED the offering and the - // grouping is how it was distributed. + // The text is STRATEGY_DEFAULT unless the series sets its own (see strategyParas). let cy0 = bodyTop; - cy0 = paraInto(push, - `Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.`, - { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); - cy0 += 5; - cy0 = paraInto(push, - `Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).`, - { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); + strategyParas().forEach((text, k) => { + if (k) cy0 += 5; + cy0 = paraInto(push, text, { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); + }); let cy1 = bodyTop; - cy1 = paraInto(push, - // {approxLists} and {seriesName} are filled here rather than in series.json, so the sentence - // still states the MEASURED list count (approxLists is derived from the zones actually built) - // and can never carry a typed number that drifts from the sheet it is printed on. - (POSTER_TEXT.sideBoxBody - || 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.') - .replace('{approxLists}', String(approxLists)) - .replace('{seriesName}', SERIES_NAME), - { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); - cy1 += 5; - cy1 = paraInto(push, - `Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.`, - { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); + // {approxLists} and {seriesName} are filled here rather than in series.json, so the sentence + // still states the MEASURED list count (approxLists is derived from the zones actually built) + // and can never carry a typed number that drifts from the sheet it is printed on. + const whatIs = (POSTER_TEXT.sideBoxBody + || 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.') + .replace('{approxLists}', String(approxLists)) + .replace('{seriesName}', SERIES_NAME); + [whatIs, ...whatIsMore()].forEach((text, k) => { + if (k) cy1 += 5; + cy1 = paraInto(push, text, { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' }); + }); const b1Bot = Math.max(cy0, cy1); headBlock += colRule(GRID_L + B1_CW + B1_GUT / 2, b1Top - 12, b1Bot - 12); diff --git a/library/scripts/posters/series.json b/library/scripts/posters/series.json index a6c7dff54..21c598936 100644 --- a/library/scripts/posters/series.json +++ b/library/scripts/posters/series.json @@ -29,7 +29,9 @@ "titleQualifier": "the coloured words before the region name, e.g. 'Unengaged of' (:2427)", "seriesName": "the name in quotes on the Series line and in the side box (:1877, :1907, :2115)", "sideBoxHeading": "the right-hand explainer's heading (:2097)", - "sideBoxBody": "that explainer's first paragraph (:2115)" + "sideBoxBody": "that explainer's first paragraph (:2115)", + "strategyBody": "the Jesus column's text: one paragraph, or a list of them; absent = poster-render's STRATEGY_DEFAULT", + "sideBoxMore": "the explainer's further paragraphs: one, or a list; [] prints none; absent = poster-render's WHAT_IS_MORE" }, "one_switch": [ "SERIES is the ONE switch (2026-09-28). SERIES=country implies the country zone", @@ -67,6 +69,8 @@ "seriesName": "UPG100 List", "sideBoxHeading": "What is a UPG100 List?", "sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.", + "strategyBody": "Jesus fed the 5,000 by multiplying a small offering. He had the crowd sit in groups of hundreds and fifties (Mark 6:40). So we pray by name, a few groups at a time.", + "sideBoxMore": [], "headColumns": true }, "networks": [