UPG100 packet: points on one sheet, page-1 columns reordered

- The twelve prayer points and the themes fit one sheet again, as large
  as the page allows (points 18.5px, themes 19px); measured in all 11
  languages, the tightest keeps 75px under the last point.
- Page 1 header columns read: Jesus' Strategy to Feed the 5000 | the
  facts | What is a UPG100 List? The facts' labels are bold.
- The header columns are measured for a fallback font too, so the right
  column no longer runs to the border when Lato is absent.
- check:packet-render expects one points sheet.
This commit is contained in:
Doxa Maps 2026-10-02 23:38:26 +00:00
parent 789c3b8744
commit f22dc7db00
3 changed files with 32 additions and 33 deletions

View file

@ -279,7 +279,7 @@ const r = await page.evaluate(() => {
return Math.round(Math.max(0, ...[...b.children].map((k) => k.getBoundingClientRect().bottom + parseFloat(getComputedStyle(k).marginBottom))) - top - b.clientHeight) })(), return Math.round(Math.max(0, ...[...b.children].map((k) => k.getBoundingClientRect().bottom + parseFloat(getComputedStyle(k).marginBottom))) - top - b.clientHeight) })(),
// The points page is ONE sheet by design and fits only because its sizes were tuned to the longest locale; // The points page is ONE sheet by design and fits only because its sizes were tuned to the longest locale;
// measure its content against its box (studio's ask), as fdTopOver does for the list's first page. // measure its content against its box (studio's ask), as fdTopOver does for the list's first page.
// The points flow over TWO sheets (2026-10-02); each sheet's body is measured, the worst one reported. // Each points sheet's body is measured, the worst one reported (one sheet today).
pointsOver: (() => { const bs = [...document.querySelectorAll('.points-body')]; if (!bs.length) return null pointsOver: (() => { const bs = [...document.querySelectorAll('.points-body')]; if (!bs.length) return null
return Math.max(...bs.map((b) => { const top = b.getBoundingClientRect().top 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))) })) })(), return Math.round(Math.max(b.scrollHeight - b.clientHeight, ...[...b.children].map((k) => k.getBoundingClientRect().bottom - top - b.clientHeight))) })) })(),
@ -786,9 +786,9 @@ if (r.fdTopOver > 0) fails.push(`the Full detail's explanation block outgrows it
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}`) 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 // + 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
// points page, so the back matter is the wheel and the points. // points page, so the back matter is the wheel and the points.
// + 3 (2026-10-02): the wheel, and the points on TWO sheets (themes + points 1–6, then 7–12), read at print size. // + 2: the wheel and the points, the twelve points and the themes on ONE sheet (2026-10-02, at the demo).
if (r.pointsSheets !== 2) fails.push(`the prayer points should flow over 2 sheets, found ${r.pointsSheets}`) if (r.pointsSheets !== 1) fails.push(`the twelve prayer points and the themes should fit ONE sheet, found ${r.pointsSheets}`)
const expectedSheets = 1 + lifeSheets + funnelSheets + listSheets + setsSheets + 3 const expectedSheets = 1 + lifeSheets + funnelSheets + listSheets + setsSheets + 2
// ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ────────────────────────────────── // ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ──────────────────────────────────
// A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a // A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a
// christian page whose shading silently fell to zero would all still be one sheet. 5,200 is the // christian page whose shading silently fell to zero would all still be one sheet. 5,200 is the

View file

@ -287,12 +287,12 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
.xb-ex-note{margin-top:4px;font-size:14px;color:#6f6a5c} .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. */ /* 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-themes{margin:0 0 10px;padding:0 0 6px;border-bottom:1.5px solid #c9bfa4}
.pt-th-h{font:400 21px/1.3 Georgia,serif;color:#413d35} .pt-th-h{font:400 19px/1.3 Georgia,serif;color:#413d35}
.pt-th-h b,.pt-sc-h b{font:700 22px Georgia,serif;color:#1b1a17;letter-spacing:.02em} .pt-th-h b,.pt-sc-h b{font:700 20px Georgia,serif;color:#1b1a17;letter-spacing:.02em}
.pt-th-i{font-style:italic;color:#6d766e} .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 21px/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 19px/1.3 Georgia,serif;color:#3b3226}
.pt-n{display:inline-block;min-width:1.3em;font-weight:700;color:#7a3b2e} .pt-n{display:inline-block;min-width:1.3em;font-weight:700;color:#7a3b2e}
.pt-sc-r{font:600 19px Georgia,serif;color:#7a3b2e} .pt-sc-r{font:600 18px Georgia,serif;color:#7a3b2e}
.ws-cols{margin:0;padding:0;list-style:none} .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{margin:0 0 6px;font:400 19px/1.45 Georgia,serif;color:#413d35}
.ws-cols li b{color:#1b1a17} .ws-cols li b{color:#1b1a17}
@ -1352,13 +1352,11 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// Mark 5:18-20 and the Father's-heart list share this ONE sheet, in every locale: the sizes below are the // Mark 5:18-20 and the Father's-heart list share this ONE sheet, in every locale: the sizes below are the
// largest that fit the longest locale. class="points-body" is a TEST HOOK: the page must fit ONE sheet in // largest that fit the longest locale. class="points-body" is a TEST HOOK: the page must fit ONE sheet in
// every locale (its scrollHeight within its clientHeight). // every locale (its scrollHeight within its clientHeight).
// ★ READABLE ON PAPER, ON TWO SHEETS (2026-10-02): the point text at 22.5px (~9.2pt on letter), the themes at // ★ ONE SHEET AGAIN (2026-10-02): the twelve points and the themes fit ONE page, as large as one page allows:
// 21px (~8.6pt), the Mark 5 passage at 20px; that no longer fits one sheet, so the points FLOW onto a second: the // the point text 18.5px (~7.5pt on letter), the themes 19px, 5px between points, the Mark 5 passage 16px; the
// themes and points 1–6 (point 4 carries its passage and the Father's-heart list) on the first, 7–12 on the // credit line stays 0.28in above the edge. Measured in all 11 locales: the tightest (fr 75px, hi 79px) keep
// second, which carries the credit line. Measured in all 11 locales: the fullest first sheet (fr, de) keeps // that much under the last point; check:packet-render fails overflow (pointsOver).
// ~350px; check:packet-render fails either sheet's overflow (pointsOver). const PT = { head: 22, sub: 19, text: 18.5, refs: 16, gap: 5, passage: 16, top: 82 };
const PT = { head: 25, sub: 21, text: 22.5, refs: 18.5, gap: 18, passage: 20 };
const POINTS_FIRST = 6;
// ⚠ ISOLATE EACH REFERENCE FOR BIDI. Scripture references stay in English // ⚠ 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 // (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 // LTR runs inside an RTL paragraph. Without isolation the Unicode bidi
@ -1408,16 +1406,12 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
${p.print_passage ? passageBlock() + heartBlock() : ''} ${p.print_passage ? passageBlock() + heartBlock() : ''}
</div>`; </div>`;
}); });
// Each sheet names its range (1–5, 6–12) beside the title, in the language's digits. return sheet(`
const head = (from, to, intro) => `
<div style="position:absolute;left:${M}px;top:44px;right:${M}px"> <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 class="points-title" style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
<span class="pt-range" style="font:400 24px Georgia,serif;color:#8c8170">${num(from)}–${num(to)}</span></div> <div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
${intro ? `<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>` : ''} </div>
</div>`; <div class="points-body" style="position:absolute;left:${M}px;top:${M + PT.top}px;right:${M}px;bottom:80px">${themesBlock()}${rows.join('')}</div>
const body = (html, top) => `<div class="points-body" style="position:absolute;left:${M}px;top:${top}px;right:${M}px;bottom:80px">${html}</div>`;
return sheet(`${head(1, POINTS_FIRST, true)}${body(themesBlock() + rows.slice(0, POINTS_FIRST).join(''), M + 96)}`, 'sheet-points')
+ '\n' + sheet(`${head(POINTS_FIRST + 1, POINTS.length, false)}${body(rows.slice(POINTS_FIRST).join(''), M + 70)}
<div class="fine">${esc(t('points_attrib'))}</div>`, 'sheet-points'); <div class="fine">${esc(t('points_attrib'))}</div>`, 'sheet-points');
} }

View file

@ -288,7 +288,7 @@ const HEAD_COLUMNS_CSS = `
// series-wide rules above stay as they were, so the series' SPARSE posters are byte-identical. // series-wide rules above stay as they were, so the series' SPARSE posters are byte-identical.
// Print size = sheet px x 0.92 (the packet's margins) x 0.408 (1500px = 8.5in): 22px ≈ 8.3pt, 19px ≈ 7.1pt. // Print size = sheet px x 0.92 (the packet's margins) x 0.408 (1500px = 8.5in): 22px ≈ 8.3pt, 19px ≈ 7.1pt.
const HEAD_COLUMNS_FULL_CSS = ` const HEAD_COLUMNS_FULL_CSS = `
.hcolk{font-size:19px;fill:#6b6553} .hcolk{font-size:19px;fill:#2f2a22;font-weight:800;letter-spacing:.1em}
.hcolt{font-size:22px;fill:#3a362c} .hcolt{font-size:22px;fill:#3a362c}
.hcolh{font-family:Georgia,'Times New Roman',serif;font-size:23px;font-weight:700;fill:#2f2a22;letter-spacing:.04em;font-variant:small-caps} .hcolh{font-family:Georgia,'Times New Roman',serif;font-size:23px;font-weight:700;fill:#2f2a22;letter-spacing:.04em;font-variant:small-caps}
.hvref{font-size:19px} .hvref{font-size:19px}
@ -827,23 +827,28 @@ function paraInto(push, text, { x, y, w, fs, lh, cls, lead }) {
// its text. A second copy of the arithmetic is how the full header's prediction once ran 53px short. // its text. A second copy of the arithmetic is how the full header's prediction once ran 53px short.
// Built like the footer's three networks: equal columns, a text measure narrower than the column, each block // Built like the footer's three networks: equal columns, a text measure narrower than the column, each block
// CENTRED on its column with its lines sharing one left edge, and short rules between the columns. // CENTRED on its column with its lines sharing one left edge, and short rules between the columns.
// 1 the facts: a small key above each value (countries, administrative areas, people groups, series) // 1 Jesus' strategy to feed the 5000
// 2 Jesus' strategy to feed the 5000 // 2 the facts: a bold key above each value (countries, administrative areas, people groups, series)
// 3 what the series is // 3 what the series is
const HC = { gut: 32, fs: 22, lh: 27, keyFs: 19, keyLh: 24, headLh: 30, gap: 8, headFs: 23 }; const HC = { gut: 32, fs: 22, lh: 27, keyFs: 19, keyLh: 24, headLh: 30, gap: 8, headFs: 23 };
// textW is calibrated for Lato. Without Lato the browser falls back to a system sans that sets
// 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;
function headColumnsBand(top, facts, approxLists) { function headColumnsBand(top, facts, approxLists) {
const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G; const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G;
const CW = (GRID_W - 2 * HC.gut) / 3; const CW = (GRID_W - 2 * HC.gut) / 3;
// The measure nearly fills the column (its inset is BAND_TEXT_PAD), so the larger type takes fewer lines. // The measure leaves room for a fallback font (see HC_FALLBACK): a block set to it stays inside its column.
const TW = (CW - 10 - BAND_TEXT_PAD / 2) * 0.96; const TW = (CW - 10 - BAND_TEXT_PAD / 2) * 0.92;
// Uppercase, letterspaced keys: measured as capitals plus the .14em tracking. // Uppercase, letterspaced keys: measured as capitals plus the .14em tracking.
const keyW = (k) => textW(k.toUpperCase(), HC.keyFs) + k.length * HC.keyFs * 0.14; 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 = [ const cols = [
facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, TW) })),
[{ head: 'Jesus’ Strategy to Feed the 5000', paras: [ [{ 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.', '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).', '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).',
] }], ] }],
facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, TW) })),
[{ head: POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?', paras: [ [{ head: POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?', paras: [
(POSTER_TEXT.sideBoxBody (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.') || '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.')
@ -854,9 +859,9 @@ function headColumnsBand(top, facts, approxLists) {
let frags = '', bottom = top; let frags = '', bottom = top;
cols.forEach((col, i) => { cols.forEach((col, i) => {
const centre = GRID_L + i * (CW + HC.gut) + CW / 2; const centre = GRID_L + i * (CW + HC.gut) + CW / 2;
if (i === 0) { if (col[0] && col[0].key !== undefined) { // the facts column: a bold key above each value
const blockW = Math.min(TW, Math.max(...col.map((r) => Math.max(keyW(r.key), ...r.lines.map((ln) => textW(ln, HC.fs)))))); const blockW = Math.min(TW, Math.max(...col.map((r) => Math.max(keyW(r.key), ...r.lines.map((ln) => textW(ln, HC.fs))))));
const x = (centre - blockW / 2).toFixed(1); const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1);
let y = top; let y = top;
col.forEach((r, k) => { col.forEach((r, k) => {
if (k) y += HC.gap; if (k) y += HC.gap;
@ -869,7 +874,7 @@ function headColumnsBand(top, facts, approxLists) {
const { head, paras } = col[0]; const { head, paras } = col[0];
const wrapped = paras.map((t) => wrapPx(t, HC.fs, TW)); const wrapped = paras.map((t) => wrapPx(t, HC.fs, TW));
const blockW = Math.min(TW, Math.max(textW(head, HC.headFs), ...wrapped.flat().map((ln) => textW(ln, HC.fs)))); const blockW = Math.min(TW, Math.max(textW(head, HC.headFs), ...wrapped.flat().map((ln) => textW(ln, HC.fs))));
const x = (centre - blockW / 2).toFixed(1); const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1);
frags += `<text x="${x}" y="${top}" class="hcolh">${esc(head)}</text>`; frags += `<text x="${x}" y="${top}" class="hcolh">${esc(head)}</text>`;
let y = top + HC.headLh; let y = top + HC.headLh;
wrapped.forEach((lines, k) => { wrapped.forEach((lines, k) => {