Compare commits

...

2 commits

4 changed files with 140 additions and 55 deletions

View file

@ -0,0 +1,48 @@
// _life-grid-read.mjs — read a packet's life-in-weeks grid BACK INTO BOXES from its SVG (packet-render.js lifeGrid).
//
// The grid is one <svg class="lc-grid"> of pattern tiles, not 5,200 elements, so a gate can no longer count boxes. It
// reads the geometry instead: every <rect data-cells="row col rows cols"> in a <g class="lc-dec" data-row0="R"> must
// sit exactly where its cells are (x = col × pitch, width = cols × pitch − gap, …), the BASE tiles must cover every box
// exactly once, and the SHADED tiles must sit on base boxes. Returns the boxes in reading order, so a gate asserts the
// same things it asserted on the DOM: 100 × 52 = 5,200 boxes, and the shaded ones are exactly the first N.
// Pure string parsing: check-packet-variants passes the renderer's HTML, check-packet-render the served DOM's markup
// (whose serialiser writes <rect …></rect>, handled below).
export function readLifeGrid(markup) {
const s = String(markup || '')
const head = s.match(/<svg class="lc-grid"[^>]*>/)
if (!head) return { error: 'no <svg class="lc-grid"> in the frame' }
const attr = (k) => Number((head[0].match(new RegExp(`data-grid-${k}="([\\d.]+)"`)) || [])[1])
const cols = attr('cols'), rows = attr('rows'), declared = attr('shaded'), P = attr('pitch'), GAP = attr('gap')
if (!(cols > 0 && rows > 0 && P > 0 && GAP >= 0)) return { error: 'the grid does not declare its cols / rows / pitch / gap' }
const state = new Array(cols * rows).fill(null) // null = not drawn, 'base', 'on'
const errors = []
const near = (a, b) => Math.abs(a - b) < 0.01
for (const g of s.matchAll(/<g class="lc-dec" data-row0="(\d+)"[^>]*>([\s\S]*?)<\/g>/g)) {
const row0 = Number(g[1])
const rects = /<rect data-cells="(\d+) (\d+) (\d+) (\d+)" x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)" fill="url\(#(lc[bs])-[a-z]+\)"\s*(?:\/>|><\/rect>)/g
for (const m of g[2].matchAll(rects)) {
const [r, c, nr, nc, x, y, w, h] = m.slice(1, 9).map(Number)
const base = m[9] === 'lcb'
if (!near(x, c * P) || !near(y, r * P) || !near(w, nc * P - GAP) || !near(h, nr * P - GAP)) {
errors.push(`the rect for cells ${r} ${c} ${nr} ${nc} (decade row ${row0}) is not drawn where those cells are`)
}
for (let i = 0; i < nr; i++) for (let j = 0; j < nc; j++) {
const row = row0 + r + i, col = c + j
if (row >= rows || col >= cols) { errors.push(`a rect reaches outside the ${cols} x ${rows} grid`); continue }
const k = row * cols + col
if (base) { if (state[k] !== null) errors.push(`box ${k} is drawn twice`); state[k] = 'base' }
else { if (state[k] !== 'base') errors.push(`box ${k} is shaded with no box under it`); state[k] = 'on' }
}
}
}
const undrawn = state.filter((v) => v === null).length
const shaded = state.filter((v) => v === 'on').length
const topFill = state.slice(0, shaded).every((v) => v === 'on')
return { cols, rows, total: cols * rows, declared, shaded, topFill, undrawn, errors: [...new Set(errors)].slice(0, 5) }
}
/** The one verdict both gates use: `g` is exactly a cols x rows grid, every box drawn once, the first `shaded` shaded. */
export function lifeGridIs(g, { cols, rows, shaded }) {
return !!g && !g.error && g.cols === cols && g.rows === rows && g.undrawn === 0 && g.errors.length === 0
&& g.shaded === shaded && g.declared === shaded && g.topFill
}

View file

@ -31,6 +31,7 @@ import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { requirePathInThisCheckout } from './_serves-this-tree.mjs'
import { readLifeGrid } from './_life-grid-read.mjs'
// ★ --expect-funnel TURNS AN OPTIONAL PAGE INTO A DECLARED REQUIREMENT, and it exists because of a
// real near-miss (regions-plugin, 2026-09-26, reported unprompted). A merge resolved with
@ -250,28 +251,13 @@ const r = await page.evaluate(() => {
// stays exact whether or not the caller supplied set data.
setsSheets: document.querySelectorAll('.sheet-sets').length,
christianSheets: document.querySelectorAll('.sheet-christian').length,
lifeCells: document.querySelectorAll('.lc-frame .lc-c').length,
// ★ THE GRIDS ARE ONE SVG EACH now (pattern tiles, not 5,200 elements): their markup goes back to Node and is read
// into boxes by internal/_life-grid-read.mjs, the same reader check-packet-variants uses. lifeCells / lifeShaded /
// lifeTopFill / jesusTopFill are derived from it just after this evaluate, under the same names.
lifeFrameHtml: (document.querySelector('.sheet-life .lc-frame') || {}).outerHTML || null,
christianFrameHtml: (document.querySelector('.sheet-christian .lc-frame') || {}).outerHTML || null,
prayBoxes: document.querySelectorAll('.pray-box').length,
funnelTip: document.querySelectorAll('.funnel-tip').length,
lifeShaded: document.querySelectorAll('.lc-frame .lc-reached').length,
// ★ FILLED FROM THE TOP, in reading order (the packet's design), measured on the DOM order of the cells:
// the shaded boxes must be EXACTLY the first N. This used to require a SCATTER, the opposite design;
// a count alone still cannot tell a top fill from a scatter or a fill from the bottom.
lifeTopFill: (() => {
const grid = document.querySelector('.sheet-christian .lc-frame')
if (!grid) return null
const cells = [...grid.querySelectorAll('.lc-c')]
const n = cells.filter((c) => c.classList.contains('lc-reached')).length
return cells.slice(0, n).every((c) => c.classList.contains('lc-reached'))
})(),
// The life page: Jesus' 33 years, the first 33 x 52 = 1,716 boxes and no others.
jesusTopFill: (() => {
const grid = document.querySelector('.sheet-life .lc-frame')
if (!grid) return null
const cells = [...grid.querySelectorAll('.lc-c')]
const on = cells.filter((c) => c.classList.contains('lc-on')).length
return { on, top: cells.slice(0, on).every((c) => c.classList.contains('lc-on')) }
})(),
funnelEq: document.querySelectorAll('.funnel-eq').length,
funnelSteps: document.querySelectorAll('.funnel-mine').length,
// ★ MEASURED SO THE GATE CAN REPORT WHAT IT ASSERTED. rega: "the gate prints no funnel line, even
@ -446,6 +432,18 @@ const r = await page.evaluate(() => {
}).filter((nums) => nums.length === 3),
}
})
// The two life-in-weeks grids, read back into boxes (see lifeFrameHtml above). A grid that does not read as the
// full 5,200 boxes reports its DRAWN count, so the per-grid check below fails by name rather than passing on a total.
{
const read = (h) => (h ? readLifeGrid(h) : null)
const life = read(r.lifeFrameHtml), chr = read(r.christianFrameHtml)
const drawn = (g) => (!g || g.error ? 0 : g.errors.length ? g.total - g.undrawn - 1 : g.total - g.undrawn)
r.lifeCells = drawn(life) + drawn(chr)
r.lifeShaded = chr && !chr.error ? chr.shaded : 0
r.lifeTopFill = chr && !chr.error ? chr.topFill : null
r.jesusTopFill = life && !life.error ? { on: life.shaded, top: life.topFill } : null
delete r.lifeFrameHtml; delete r.christianFrameHtml
}
const pdf = await page.pdf({ preferCSSPageSize: true })
await browser.close()

View file

@ -13,6 +13,7 @@
* Exit 0 pass · 1 fail.
*/
import { resolve, dirname } from 'node:path'
import { readLifeGrid, lifeGridIs } from './_life-grid-read.mjs'
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
@ -125,16 +126,21 @@ ok(count(light, /sheet-sets-(bounded|centered)/g) === 0, 'lightweight still carr
const a = html.indexOf('<div class="lc-frame', i)
return a < 0 ? '' : html.slice(a, html.indexOf('<div class="lc-legend">', a))
}
const cells = (h) => [...h.matchAll(/<i class="lc-c( lc-on| lc-reached)?"><\/i>/g)].map((m) => (m[1] || '').trim())
const life = cells(frame(full, 'sheet-life'))
ok(life.length === 5200, `the life calendar should hold 100 x 52 = 5,200 boxes, got ${life.length}`)
const gold = life.filter((c) => c === 'lc-on').length
ok(gold === 33 * 52 && life.slice(0, gold).every((c) => c === 'lc-on'),
`the life calendar should shade Jesus' 33 years as its FIRST 1,716 boxes — got ${gold}, from the top: ${life.slice(0, gold).every((c) => c === 'lc-on')}`)
const ev = cells(frame(full, 'sheet-christian'))
const shaded = ev.filter((c) => c === 'lc-reached').length
ok(ev.length === 5200 && shaded === Math.round(0.032 * 5200) && ev.slice(0, shaded).every((c) => c === 'lc-reached'),
`the %-evangelical grid should shade 3.2% = ${Math.round(0.032 * 5200)} boxes FROM THE TOP, got ${shaded} of ${ev.length}`)
// ★ THE GRID IS ONE SVG NOW (pattern tiles, not 5,200 elements), read BACK INTO BOXES by its geometry
// (internal/_life-grid-read.mjs): every rect must sit where the boxes it claims are, the base must draw every box
// exactly once, and the shaded tiles must be exactly the first N in reading order. Same assertions as the DOM had.
const life = readLifeGrid(frame(full, 'sheet-life'))
ok(lifeGridIs(life, { cols: 52, rows: 100, shaded: 33 * 52 }),
`the life calendar should be 100 x 52 = 5,200 boxes with Jesus' 33 years as its FIRST 1,716: read ${JSON.stringify({ cols: life.cols, rows: life.rows, shaded: life.shaded, undrawn: life.undrawn, topFill: life.topFill, errors: life.errors, error: life.error })}`)
const ev = readLifeGrid(frame(full, 'sheet-christian'))
ok(lifeGridIs(ev, { cols: 65, rows: 80, shaded: Math.round(0.032 * 5200) }),
`the %-evangelical grid should be 5,200 boxes (65 x 80) shading 3.2% = ${Math.round(0.032 * 5200)} FROM THE TOP: read ${JSON.stringify({ cols: ev.cols, rows: ev.rows, shaded: ev.shaded, undrawn: ev.undrawn, topFill: ev.topFill, errors: ev.errors, error: ev.error })}`)
// NEGATIVE CONTROLS, from the real grid: a decade missing (90 rows drawn) and a wrong column count must be REFUSED.
const lifeMarkup = frame(full, 'sheet-life')
const noDecade = readLifeGrid(lifeMarkup.replace(/<g class="lc-dec" data-row0="90"[\s\S]*?<\/g>/, ''))
const wrongCols = readLifeGrid(lifeMarkup.replace('data-grid-cols="52"', 'data-grid-cols="51"'))
ok(!lifeGridIs(noDecade, { cols: 52, rows: 100, shaded: 33 * 52 }) && !lifeGridIs(wrongCols, { cols: 52, rows: 100, shaded: 33 * 52 }),
'NEGATIVE CONTROL: a grid missing a decade, or declaring 51 columns, was accepted, so this check cannot fail')
}
// ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no

View file

@ -896,38 +896,71 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// ★ axes:false DRAWS THE SAME BOXES WITH NO WEEK OR YEAR LABELS, 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.
// ★ ONE SVG, NOT 5,200 BOXES (2026-09-30). The grid was 5,200 HTML boxes a page, and a print preview paints
// those slowest of the whole packet (measured at phone-like CPU: the two grid sheets were ~3.4 s of a 14 s
// print). Each kind of box is now ONE <pattern> tile and each region ONE <rect> filled with it, so a grid is a
// few dozen elements, vector, sharp at any print size. It keeps the CSS grid's geometry exactly (11px boxes,
// 3px gaps, ten-year decades under a rule; 14.5px boxes on the people page) and its reading order: shaded from
// the first box, and in a right-to-left document the boxes are MIRRORED (the labels are placed, not mirrored).
// ★ READABLE BACK INTO BOXES: every rect sits in CELL units of its decade (x = column × pitch) and names its
// cells in data-cells="row col rows cols", and the <svg> carries data-grid-cols/-rows/-shaded/-pitch, so a gate
// checks the geometry against the boxes it claims (check-packet-variants, check-packet-render).
function lifeGrid({ shaded = 0, shadeClass = 'lc-on', axes = true } = {}) {
const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS;
const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0)));
// Week labels sit in the column of the week they name (column 1 is the year labels).
const WEEK_MARKS = [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50];
const head = axes ? `<div class="lc-wk">${WEEK_MARKS.map((w) => `<span style="grid-column:${w + 1}">${w}</span>`).join('')}</div>` : '';
// ★ WITHOUT AXES THE GRID REFLOWS TO FILL THE PAGE. 52 columns exist only because a year has 52
// weeks; a page whose box is a share of PEOPLE has no such constraint, and at 52 x 11px the grid
// used about half the sheet's width. The same 5,200 boxes are laid out 65 x 80 (both divide 5,200,
// so the rectangle is whole) in 14.5px squares. MEASURED in a browser: 80 x 65 across the full width
// left 478px of blank sheet below the legend (72% of the height used), because a wide grid is short;
// 65 x 80 spends that height on bigger boxes and still leaves room for a longer lede in other languages.
if (!axes) {
const cells = []
for (let i = 0; i < TOTAL; i++) cells.push(`<i class="lc-c${i < n ? ' ' + shadeClass : ''}"></i>`)
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-frame lc-plain"><div class="lc-all">${cells.join('')}</div></div>` };
// With no axes the same 5,200 boxes reflow to 65 x 80 (both divide 5,200) in 14.5px squares, filling the page.
const COLS = axes ? WEEKS : 65, ROWS = axes ? YEARS : 80;
const BOX = axes ? 11 : 14.5, GAP = 3, P = BOX + GAP;
const LABEL = axes ? 30 + GAP : 0; // the year column and its gap
const HEAD = axes ? 20 : 0; // the week labels: a 16px line + 4px
const DEC = axes ? 10 : ROWS; // rows per decade (the whole grid when there is no axis)
const SEP = axes ? 13 : 0; // between decades: 6px, a 1px rule, 6px
const gridW = COLS * P - GAP, decH = DEC * P - GAP, decades = ROWS / DEC;
const Wd = LABEL + gridW, Hd = HEAD + decades * decH + (decades - 1) * SEP;
const rtl = RTL.has(lang);
const kind = axes ? 'life' : 'people';
const FILLS = { base: ['#fff', '#8f8a78'], 'lc-on': ['#c9a13b', '#a8832a'], 'lc-reached': ['#2f6d4f', '#2f6d4f'] };
const tile = (id, [fill, stroke]) => `<pattern id="${id}" width="${P}" height="${P}" patternUnits="userSpaceOnUse">`
+ `<rect x=".5" y=".5" width="${BOX - 1}" height="${BOX - 1}" fill="${fill}" stroke="${stroke}" stroke-width="1"/></pattern>`;
const baseId = `lcb-${kind}`, onId = `lcs-${kind}`;
const region = (r, c, nr, nc, id) => `<rect data-cells="${r} ${c} ${nr} ${nc}" x="${c * P}" y="${r * P}" width="${nc * P - GAP}" height="${nr * P - GAP}" fill="url(#${id})"/>`;
const full = Math.floor(n / COLS), rem = n % COLS;
const groups = [];
for (let d = 0; d < decades; d++) {
const r0 = d * DEC, top = HEAD + d * (decH + SEP);
const parts = [region(0, 0, DEC, COLS, baseId)];
const fr = Math.min(DEC, Math.max(0, full - r0)); // this decade's fully shaded rows
if (fr > 0) parts.push(region(0, 0, fr, COLS, onId));
if (rem > 0 && full >= r0 && full < r0 + DEC) parts.push(region(full - r0, 0, 1, rem, onId));
groups.push(`<g class="lc-dec" data-row0="${r0}" transform="translate(${LABEL} ${top})">${parts.join('')}</g>`);
}
const decades = [];
for (let d = 0; d < YEARS / 10; d++) {
const rows = [];
for (let y = d * 10; y < d * 10 + 10; y++) {
const year = y + 1;
if (axes) rows.push(`<b class="lc-y">${year === 1 || year % 5 === 0 ? num(year) : ''}</b>`);
for (let w = 0; w < WEEKS; w++) {
rows.push(`<i class="lc-c${y * WEEKS + w < n ? ' ' + shadeClass : ''}"></i>`);
}
// Decade rules span the grid's full width, labels included; symmetric, so they are not mirrored.
const rules = [];
for (let d = 1; d < decades; d++) {
const y = HEAD + d * (decH + SEP) - SEP / 2;
rules.push(`<line x1="0" x2="${Wd}" y1="${y}" y2="${y}" stroke="#d8d3c4" stroke-width="1"/>`);
}
// Labels are placed on the reading side, never mirrored (text must read forwards).
const labels = [];
if (axes) {
const WEEK_MARKS = [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50];
for (const w of WEEK_MARKS) {
const x = LABEL + (w - 1) * P + BOX / 2;
labels.push(`<text x="${rtl ? Wd - x : x}" y="8" text-anchor="middle" dominant-baseline="central" font-family="Georgia,serif" font-size="10" fill="#6f6a5c">${w}</text>`);
}
for (let y = 0; y < YEARS; y++) {
const year = y + 1;
if (!(year === 1 || year % 5 === 0)) continue;
const d = Math.floor(y / DEC), cy = HEAD + d * (decH + SEP) + (y - d * DEC) * P + BOX / 2;
labels.push(`<text x="${rtl ? Wd - 24 : 24}" y="${cy}" text-anchor="${rtl ? 'start' : 'end'}" dominant-baseline="central" font-family="Georgia,serif" font-size="10" fill="#8a8577">${num(year)}</text>`);
}
decades.push(`<div class="lc-dec">${rows.join('')}</div>`);
}
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-frame${axes ? '' : ' lc-plain'}">${head}${decades.join('')}</div>` };
const cells = `<g class="lc-cells"${rtl ? ` transform="translate(${Wd} 0) scale(-1 1)"` : ''}>${groups.join('')}</g>`;
const svg = `<svg class="lc-grid" xmlns="http://www.w3.org/2000/svg" width="${Wd}" height="${Hd}" viewBox="0 0 ${Wd} ${Hd}"`
+ ` data-grid-cols="${COLS}" data-grid-rows="${ROWS}" data-grid-shaded="${n}" data-grid-pitch="${P}" data-grid-gap="${GAP}" data-grid-shade="${shadeClass}"`
+ ` style="display:block"><defs>${tile(baseId, FILLS.base)}${tile(onId, FILLS[shadeClass] || FILLS['lc-on'])}</defs>${rules.join('')}${cells}${labels.join('')}</svg>`;
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-frame${axes ? '' : ' lc-plain'}">${svg}</div>` };
}
// Page 2 — a human life, unshaded except for the weeks already lived if the caller supplies an age.
// No age is supplied today: the packet is printed for a reader we do not know, so shading their life
// would be a guess presented as a fact. The empty grid is the honest version and still lands the