Packet page 1 reads on paper: the full UPG-100 sheet's header band and footer at ~8pt with darker ink, the verse on two lines

Only the full layout (HEAD_COLUMNS, the packet's page 1, printed on letter at 92%): the column text 14 → 22px
(~8.3pt), the fact keys and the verse reference 19px (~7pt), the column headings 23px, the footer's three blocks
13.5 → 22px with their lines re-measured; the Matthew 9:36 verse is set on two balanced lines at 19.5px. The band's
measure fills its column (0.96), a country list longer than twelve prints ten and '+N' (the Deaf bloc names 66),
and the full footer band is 400px. All 28 zones render; each map is 858px (it was 1,206). The series-wide rules
are unchanged, so the SPARSE posters are byte-identical.
This commit is contained in:
Doxa Maps 2026-10-02 22:49:49 +00:00
parent 360e361614
commit 2e0422f6fa

View file

@ -282,6 +282,19 @@ const HEAD_COLUMNS_CSS = `
.hcolk{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12.5px;fill:#8a8470;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
.hcolt{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:14px;fill:#5f5849;letter-spacing:.01em}
.hsrc{font-size:13.5px}`;
// ★ READABLE ON PAPER (2026-10-02): the full sheet is the packet's page 1, printed on letter at 92% (its margins),
// where 14px column text came out near 5pt in light grey. The header band and the footer's three blocks are set
// at 22 / 21px with darker ink: about 8pt on the printed page. Only the full layout (HEAD_COLUMNS) gets this; the
// 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.
const HEAD_COLUMNS_FULL_CSS = `
.hcolk{font-size:19px;fill:#6b6553}
.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}
.hvref{font-size:19px}
.hverse{font-size:19.5px}
.hsrc{font-size:22px;fill:#4a4636}
.hsrc-em{fill:#2f2a22}`;
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;
@ -357,6 +370,8 @@ const POSTER_TEXT = SERIES_DEF.poster || {};
const HEAD_COLUMNS = POSTER_TEXT.headColumns === true && !SPARSE && !MAP_ONLY;
// The series-wide part (the footer prose at its measured size) applies to its SPARSE sheets too.
const HEAD_COLUMNS_ON_SERIES = POSTER_TEXT.headColumns === true && !MAP_ONLY;
// The full sheet's footer band at the larger type (21px blocks): measured on the 28 zones, see HEAD_COLUMNS_FULL_CSS.
const FULL_FOOTER = 400;
const SERIES_NAME = process.env.SERIES_NAME
|| (process.env.CITIES === '1' ? 'Key City Maps'
: POSTER_TEXT.seriesName
@ -414,7 +429,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 = MAP_ONLY ? FMT.footer : SPARSE ? 316 : 280;
const FOOTER = MAP_ONLY ? FMT.footer : SPARSE ? 316 : HEAD_COLUMNS ? FULL_FOOTER : 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
@ -815,11 +830,12 @@ function paraInto(push, text, { x, y, w, fs, lh, cls, lead }) {
// 1 the facts: a small key above each value (countries, administrative areas, people groups, series)
// 2 Jesus' strategy to feed the 5000
// 3 what the series is
const HC = { gut: 40, fs: 14, lh: 18.5, keyFs: 12.5, keyLh: 17, headLh: 22, gap: 7 };
const HC = { gut: 32, fs: 22, lh: 27, keyFs: 19, keyLh: 24, headLh: 30, gap: 8, headFs: 23 };
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;
const TW = (CW - 10 - BAND_TEXT_PAD / 2) * 0.86;
// The measure nearly fills the column (its inset is BAND_TEXT_PAD), so the larger type takes fewer lines.
const TW = (CW - 10 - BAND_TEXT_PAD / 2) * 0.96;
// 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 cols = [
@ -852,9 +868,9 @@ function headColumnsBand(top, facts, approxLists) {
} else {
const { head, paras } = col[0];
const wrapped = paras.map((t) => wrapPx(t, HC.fs, TW));
const blockW = Math.min(TW, Math.max(textW(head, 15), ...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);
frags += `<text x="${x}" y="${top}" class="hvh">${esc(head)}</text>`;
frags += `<text x="${x}" y="${top}" class="hcolh">${esc(head)}</text>`;
let y = top + HC.headLh;
wrapped.forEach((lines, k) => {
if (k) y += HC.gap;
@ -876,7 +892,9 @@ function headFacts(zone, pins) {
...(Array.isArray(zone.adm0) ? zone.adm0.map((c) => ISO3_NAME[c] || c) : []),
].filter(Boolean))].sort((a, b) => a.localeCompare(b, 'en-US'));
return [
['Countries', ctry.join(' \u00b7 ') || '\u2014'],
// A list of countries longer than a column can carry (the Deaf bloc names 66) prints its first ten and the
// count of the rest, so one sheet's facts do not take its map's height.
['Countries', (ctry.length > 12 ? `${ctry.slice(0, 10).join(' \u00b7 ')} \u00b7 +${ctry.length - 10}` : ctry.join(' \u00b7 ')) || '\u2014'],
['Administrative Areas', provincesOf(zone).join(' \u00b7 ') || '\u2014'],
['People groups', `${pins.length} UPG`],
['Series', `${SERIES_NAME} \u00b7 map ${SERIES.index} of ${SERIES.totalLists}`],
@ -884,7 +902,15 @@ function headFacts(zone, pins) {
}
// The band's first baseline: the title flow (136), the scripture block (16 + 25 + 12), and the 34 the
// renderer leaves before its columns. The bottom adds the renderer's 4 + 14.
const HC_TOP = 136 + 16 + 25 + 12 + 34;
// The verse's second line (HEAD_COLUMNS sets it on two) and the split, by words, nearest the middle of the text.
const HC_VERSE_LH = 26;
function VERSE_LINES(v) {
const w = v.split(' '), half = v.length / 2;
let best = 1, d = Infinity, run = 0;
for (let i = 0; i < w.length - 1; i++) { run += w[i].length + 1; if (Math.abs(run - half) < d) { d = Math.abs(run - half); best = i + 1 } }
return [w.slice(0, best).join(' '), w.slice(best).join(' ')];
}
const HC_TOP = 136 + 16 + 25 + HC_VERSE_LH + 12 + 34;
async function renderPoster(zone, pins, zoneFC) {
// Per-zone override for the badge, so a zone with unusually crowded or sparse
@ -2117,10 +2143,20 @@ async function renderPoster(zone, pins, zoneFC) {
headY += 16;
headBlock += `<line x1="${VRULE_L}" y1="${headY}" x2="${VRULE_R}" y2="${headY}" class="hrule"/>`;
headY += 25;
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">`
+ `\u201cBut when he saw the multitudes, he was moved with compassion for them because they were harassed and scattered, like sheep without a shepherd.\u201d`
+ ` <tspan class="hvref">MATTHEW 9:36</tspan>`
+ `</text>`;
const VERSE = `\u201cBut when he saw the multitudes, he was moved with compassion for them because they were harassed and scattered, like sheep without a shepherd.\u201d`;
if (HEAD_COLUMNS) {
// At the full sheet's print size the verse is set on TWO balanced lines (one line at a readable size would
// run past the band); the reference ends the second line.
const [l1, l2] = VERSE_LINES(VERSE);
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">${esc(l1)}</text>`;
headY += HC_VERSE_LH;
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">${esc(l2)} <tspan class="hvref">MATTHEW 9:36</tspan></text>`;
} else {
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">`
+ VERSE
+ ` <tspan class="hvref">MATTHEW 9:36</tspan>`
+ `</text>`;
}
headY += 12;
headBlock += `<line x1="${VRULE_L}" y1="${headY}" x2="${VRULE_R}" y2="${headY}" class="hrule"/>`;
@ -2254,7 +2290,7 @@ async function renderPoster(zone, pins, zoneFC) {
const GRID_W = PW - 2 * BAND_MARGIN_G;
// The type is slightly larger now that there is more room to work with.
// 12.5 -> 13.5 with leading kept proportional (~1.30).
const FS = 13.5, LH = 17.5;
const FS = HEAD_COLUMNS ? 22 : 13.5, LH = HEAD_COLUMNS ? 28 : 17.5;
// The two vision columns sit BELOW the ruled scripture, so they read as its
// supporting detail, not as headline copy. Slimmer type + tighter leading than
// the sheet's body size: less height for the same words, and the contrast in
@ -2375,7 +2411,7 @@ async function renderPoster(zone, pins, zoneFC) {
// networks answer, so it carries more weight than an ordinary column heading,
// and sizing it here does not move every other heading on the sheet.
footBlock += `<text x="${CX}" y="${fy}" text-anchor="middle" class="hcta">Start with Prayer!</text>`;
fy += SPARSE ? 25 : 22; // clears the larger .hcta line
fy += SPARSE ? 25 : HEAD_COLUMNS ? 38 : 22; // clears the larger .hcta line (and, full, the larger blocks below)
const B2_GUT = 40;
const B2_CW = (GRID_W - 2 * B2_GUT) / 3;
@ -2930,7 +2966,7 @@ async function renderPoster(zone, pins, zoneFC) {
page. Collapse the flow height to the scaled height. */
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
}
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}${HEAD_COLUMNS_ON_SERIES ? HEAD_COLUMNS_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}${HEAD_COLUMNS_ON_SERIES ? HEAD_COLUMNS_CSS + '\n' : ''}${HEAD_COLUMNS ? HEAD_COLUMNS_FULL_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
return sides ? { html, title, octiOK, crossings, diagFrac, sideLanes: sides.stats } : { html, title, octiOK, crossings, diagFrac };
}