Large side-lane sheets: photos print in the sheet's own document, and a raster never draws a broken image

Measured on the 60x96 print: every photo beside a name rasterised as the browser's broken-image glyph. An SVG
rasterised through <img> is an isolated document that fetches no external resource, and none of the people-group
photo hosts (media.joshuaproject.net, joshuaproject.net, images.peoplegroups.org and its resizer,
peoplegroupspublic.blob.core.windows.net, s3.doxa.life) sends CORS, so a photo cannot be inlined as data: in the
browser either.
- poster-raster.js: rasterPoster drops every <image> whose href is not data: (the basemap tiles are data: and stay),
  so a download shows the paper, never the glyph. hasExternalImages() says when a sheet carries such photos.
- PosterWall: printing a generated sheet that carries photos prints THE SHEET ITSELF through printDocument, where
  the photos load, on its own @page (60in 96in), instead of a raster shrunk onto letter. Same HUD steps.
- check:side-lanes: photo arms (0 external images reach the raster; the wall prints such a sheet as a document),
  each with a poison in its self-test.
- check:side-lane-photo-pixels: the pixel side, a browser gate (a data: photo draws red; 99/99 external slots stay
  flat; without the strip 0/99 do). Declared on-demand until its first browser-seat run.
This commit is contained in:
Doxa Maps 2026-10-01 09:45:33 +00:00
parent 9775eb7494
commit 2f163e04c1
6 changed files with 163 additions and 6 deletions

View file

@ -10,6 +10,8 @@
"check:coverage-consistent": "the full run includes clause L, the age of the oldest link verification (45 days), which depends on the CALENDAR: chained, it would turn gates red on a date (first 2026-11-08) with no code change. Run it with the link re-verification; every other clause runs in gates:pre as check:coverage-consistent:chained",
"check:snippets-draw": "AWAITING ITS FIRST BROWSER RUN: the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded.",
"check:grid-merge-defaults": "WAITS FOR ITS CODE: it asserts the defaults of three grid props (filtersToggle, backdrop, backdropFloor) that are not on development, because the grid merge (b1e839a33) has not landed. Chain it in gates:pre in the same landing as that merge.",
"check:snippets-draw:mapbox": "AWAITING ITS FIRST BROWSER RUN: the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded. Needs MAP_TOKEN."
"check:snippets-draw:mapbox": "AWAITING ITS FIRST BROWSER RUN: the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded. Needs MAP_TOKEN.",
"check:side-lane-photo-pixels": "a BROWSER gate (browser seat only; elsewhere NOT MEASURED, 75). Its first slot run must come before it joins the browser suite (verify-browser-all lists only gates already run there); until then its source twin, check:side-lanes' photo arms, runs in gates:pre",
"check:side-lane-photo-pixels:self-test": "the poison run of check:side-lane-photo-pixels (a raster without stripExternalImages must draw the glyph); same browser-seat reason"
}
}

View file

@ -573,7 +573,11 @@ async function exportPoster(item) {
const format = props.posterAction === 'print' ? 'letter' : (item.sheet === '3x3' ? '3x3' : '3x4')
const html = await Promise.resolve().then(() => props.sheet(item, format)).catch(() => '')
if (html) {
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const { rasterPoster, looksRendered, hasExternalImages } = await import('@map/packet/poster-raster.js')
// ★ A SHEET WITH PHOTOS PRINTS AS ITS OWN DOCUMENT (the large side-lane sheets): a raster cannot carry a
// photo from its own host (poster-raster.js says why), and the sheet's own @page is its true size
// (60in 96in), where an image of it would be shrunk onto letter. A download still rasterises, without them.
if (props.posterAction === 'print' && hasExternalImages(html)) { await printSheetDocument(html); return }
const blob = await rasterPoster(html, { scale: 2 })
if (await looksRendered(blob)) { await deliverPoster(blob, item); return }
}
@ -802,6 +806,20 @@ function printPacketAs(item, variantId) {
// ONE delivery point for the finished poster, so 'download' and 'print' cannot
// drift apart or be applied to only one of the two paths (shipped .webp vs
// browser-rasterised). Both callers in exportPoster() route through here.
// Print a generated sheet as the document it is (photos load, the page is its own size). Same HUD steps as
// deliverPoster's print, including the phone's Print button when the tap has expired.
async function printSheetDocument(html) {
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
const { printDocument } = await import('@map/composables/printInPlace.js')
const how = await printDocument(html, { onNeedsTap: (printNow, cancel) => {
exportState.value = { ...exportState.value, phase: 'ready', message: hud('wall.hud.readyToPrint', 'ready to print'), pct: 100,
action: () => { exportState.value = null; printNow() }, cancel }
} })
if (how === 'needs-tap') return
exportState.value = { ...exportState.value, phase: 'done', message: hud('wall.hud.printOpened', 'print dialog opened'), pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
}
async function deliverPoster(blob, item) {
if (props.posterAction === 'print') {
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }

View file

@ -38,13 +38,24 @@ export function inlineSvg(posterHtml) {
return svg.replace(/<svg([^>]*)>/, `<svg$1><style>${safe}</style>`);
}
/**
* ⚠ AN EXTERNAL IMAGE CAN NEVER DRAW IN THAT ISOLATED DOCUMENT. An SVG loaded through <img> fetches no external
* resource by spec, and no people-group photo host sends CORS headers, so a photo cannot be inlined as data: in
* the browser either. Left in, each one rasterises as the browser's broken-image glyph (measured on the large
* side-lane sheet's photos). So the raster drops every <image> whose href is not data: (the basemap tiles, which
* ARE data:, stay); a sheet that carries photos PRINTS AS A DOCUMENT instead, where they load (hasExternalImages).
*/
const EXTERNAL_IMAGE = /<image\b[^>]*?\bhref="(?!data:)[^"]*"[^>]*?\/>/g;
export function hasExternalImages(posterHtml) { return /<image\b[^>]*?\bhref="(?!data:)/.test(posterHtml || ''); }
export function stripExternalImages(svg) { return svg.replace(EXTERNAL_IMAGE, ''); }
/**
* Rasterise a poster to a Blob in the browser.
* @param {string} posterHtml the poster document (posters/<id>.html)
* @param {object} opts scale (default 2), type ('image/webp'|'image/png'), quality
*/
export async function rasterPoster(posterHtml, { scale = 2, type = 'image/webp', quality = 0.85 } = {}) {
const svg = inlineSvg(posterHtml);
const svg = stripExternalImages(inlineSvg(posterHtml));
// encodeURIComponent, not btoa: the posters carry non-Latin-1 characters (people
// group and place names), and btoa throws on those.
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;

View file

@ -413,7 +413,9 @@
"check:panel-sheets": "node internal/check-panel-sheets.mjs",
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs",
"check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs",
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test"
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
"check:side-lane-photo-pixels": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs",
"check:side-lane-photo-pixels:self-test": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs --self-test"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
@ -677,7 +679,9 @@
"check:panel-sheets": "the shared panel geometry (library/components/poster/panelSheets.js) for posters printed on several sheets: 60x96 = 3 × 60×36 stacked and 66x66 = 2 × 36×66 side by side, 6 in joins, and a single sheet; the sheets tile the finished poster, glue slices tile and bleed slices repeat exactly the join, a bleed join prints a guide not a solid band, a screen preview always shows the red band, and the Bless 5x8ft takes its bands and marks from the module. Two-sided (4 planted defects). Headless.",
"check:poster-wall-host-seams": "PosterWall's two host seams, and that with neither set every wall behaves as before. headerAction: default null is the Show-on-map TOGGLE (aria-pressed, one window event per click, is-last, no emit); { labelKey, ariaKey } is a plain button that emits header-action with the item, no window event, no aria-pressed, and reads the label alone for an item with no count. previewSrc: default null is the stored previews/<id>.webp and no IntersectionObserver; set, a card renders only once in view of the wall's own scroller, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Image errors: a default wall retries the tree's own copy before the placeholder; a generated preview goes straight to it. Section heads: a section with people reads \"<n> people groups · <items>\" exactly as before; one with none reads its items alone. sectionItemCountKey lets a wall count one section in its own words ({ key, n }); unnamed sections keep the default. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 17 mutants each FAIL.",
"check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.",
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail."
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
"check:side-lane-photo-pixels": "BROWSER gate (browser seat; elsewhere 75 NOT MEASURED): the 60x96 side-lane sheet rasterised by poster-raster.js, measured in pixels at every photo slot. A data: photo draws; every photo on its own host stays flat, never the broken-image glyph an external <image> rasterises as.",
"check:side-lane-photo-pixels:self-test": "the same raster WITHOUT stripExternalImages must leave the external photo slots NOT flat (the glyph), else the gate proves nothing."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",

View file

@ -0,0 +1,75 @@
#!/usr/bin/env node
// check:side-lane-photo-pixels — a BROWSER gate (browser seat only: internal/browser-slot.sh; elsewhere exit 75,
// NOT MEASURED). The 60x96 side-lane sheet, rendered by the real poster-render.js, rasterised by the real
// poster-raster.js, measured in PIXELS at every photo slot:
// - a data: photo (a local red fixture, so it is offline) DRAWS: its slot is red;
// - every photo on its own host stays FLAT (the paper shows through): never the broken-image glyph, which is
// what an external <image> rasterises as inside an SVG loaded as an image.
// --self-test: the same raster WITHOUT stripExternalImages must leave external slots NOT flat (the glyph).
// Its source-level twin, always measured, is check:side-lanes' photo arms.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import zlib from 'node:zlib'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const { chromium } = await import(join(ROOT, 'internal/lib/playwright-guarded.mjs'))
const J = (p) => JSON.parse(readFileSync(join(ROOT, p), 'utf8'))
// a 4x4 red PNG, built here (no file)
function png(w, h, rgb) {
const crcT = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0 })
const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0 }
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]) }
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2
const raw = Buffer.concat(Array.from({ length: h }, () => Buffer.concat([Buffer.from([0]), Buffer.concat(Array.from({ length: w }, () => Buffer.from(rgb)))])))
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])
}
const RED = 'data:image/png;base64,' + png(4, 4, [230, 20, 20]).toString('base64')
const { createPosterGenerator } = await import(join(ROOT, 'library/packet/poster-render.js'))
let seed = 7; const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
const pins = Array.from({ length: 300 }, (_, i) => ({ name: `Group ${i}`, lat: 8 + rnd() * 27, lng: 68 + rnd() * 28, slug: `g${i}`, country_code: 'IND', population: 1000 + i,
image_url: i % 3 === 0 ? `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${10120 + i}.jpg` : 'https://s3.doxa.life/no-photo-images/asia.jpg' }))
const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson')
const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } }
const gen = createPosterGenerator({ env: { FORMAT: '60x96', SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: J('library/scripts/posters/series.json').country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } })
let { html } = await gen.renderPoster(zone, pins, { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') })
html = html.replace(/(<image href=")https:[^"]+(")/, `$1${RED}$2`) // the FIRST photo becomes the local fixture
const slots = [...html.matchAll(/<image href="([^"]+)" x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)].map((m) => ({ data: m[1].startsWith('data:'), x: +m[2], y: +m[3], s: +m[4] }))
const vb = html.match(/<svg[^>]*viewBox="0 0 ([\d.]+) ([\d.]+)"/); const W = vb ? +vb[1] : 2500
const cur = readFileSync(join(ROOT, 'library/packet/poster-raster.js'), 'utf8')
const poison = cur.replace('stripExternalImages(inlineSvg(posterHtml))', 'inlineSvg(posterHtml)')
if (poison === cur) throw new Error('poison anchor moved')
console.log(`sheet: ${slots.length} photo slots (${slots.filter((s) => s.data).length} data: fixture, ${slots.filter((s) => !s.data).length} external), viewBox width ${W}`)
const selfTest = process.argv.includes('--self-test')
const b = await chromium.launch()
const page = await b.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 })
let verdict = 0
try {
await page.setContent('<!doctype html><html><body></body></html>')
for (const [label, src] of selfTest ? [['POISON (no strip)', poison]] : [['CURRENT', cur]]) {
const r = await page.evaluate(async ({ src, html, slots, W }) => {
const mod = await import(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })))
const blob = await mod.rasterPoster(html, { scale: 0.5, type: 'image/png' })
const bmp = await createImageBitmap(blob); const c = document.createElement('canvas'); c.width = bmp.width; c.height = bmp.height
const ctx = c.getContext('2d'); ctx.drawImage(bmp, 0, 0); const k = bmp.width / W
const stat = (s) => { const x = Math.round((s.x + s.s * 0.2) * k), y = Math.round((s.y + s.s * 0.2) * k), n = Math.max(2, Math.round(s.s * 0.6 * k))
const d = ctx.getImageData(x, y, n, n).data; let r = 0, g = 0, b = 0, v = 0; const px = d.length / 4
for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2] } r /= px; g /= px; b /= px
for (let i = 0; i < d.length; i += 4) v += Math.abs(d[i] - r) + Math.abs(d[i + 1] - g) + Math.abs(d[i + 2] - b)
return { r: Math.round(r), g: Math.round(g), b: Math.round(b), varr: +(v / px).toFixed(1) } }
const bg = stat({ x: 2, y: 2, s: 30 })
const fix = slots.filter((s) => s.data).map(stat)
const ext = slots.filter((s) => !s.data).map(stat)
const near = (a) => a.varr <= 6 // flat: nothing drawn in the slot (paper shows through)
return { size: [bmp.width, bmp.height], bg, fixture: fix, extBackground: ext.filter(near).length, extTotal: ext.length, extSample: ext.filter((a) => !near(a)).slice(0, 3) }
}, { src, html, slots, W })
const redOk = r.fixture.length && r.fixture.every((a) => a.r > 180 && a.g < 80 && a.b < 80)
const flatAll = r.extBackground === r.extTotal && r.extTotal > 0
if (selfTest) verdict = r.extBackground === 0 ? 0 : 1
else verdict = redOk && flatAll ? 0 : 1
console.log(`${label}: raster ${r.size.join('x')}, background rgb(${r.bg.r},${r.bg.g},${r.bg.b}); fixture slot ${JSON.stringify(r.fixture)} -> ${redOk ? 'DRAWS (red)' : 'NOT drawn'}; external slots FLAT (nothing drawn): ${r.extBackground}/${r.extTotal}${r.extSample.length ? ' non-background e.g. ' + JSON.stringify(r.extSample) : ''}`)
}
} finally { await b.close() }
if (selfTest) console.log(verdict ? '✗ MISSED: external slots stayed flat without the strip' : '✓ caught: without the strip every external slot draws (the glyph)')
else console.log(verdict ? 'FAIL check:side-lane-photo-pixels' : 'PASS check:side-lane-photo-pixels — the data: photo draws; every external photo slot is flat (no broken-image glyph)')
process.exitCode = verdict

View file

@ -7,7 +7,12 @@
//
// Asserts per format: every name drawn exactly once; leader crossings 0 (the renderer's own count, re-measured here
// from the SVG); no overflow rows; photos = the pins with a real photo (60x96/66x66) or none (36x60).
// --self-test: a renderer copy WITHOUT the lane clamp must FAIL the crossing arm (else the arm proves nothing).
// PHOTOS ARE NEVER RASTERISED: a sheet whose photos sit on their own hosts must (a) reach the raster with NO external
// <image> (poster-raster.js strips them: inside an SVG loaded as an image they can only draw the broken-image glyph,
// and no photo host sends CORS), and (b) PRINT as its own document from PosterWall, where the photos load. The pixel
// side of this (a data: photo draws, an external slot stays flat) is check:side-lane-photo-pixels, a browser gate.
// --self-test: a renderer copy WITHOUT the lane clamp must FAIL the crossing arm, a raster that keeps external
// images must FAIL arm (a), and a PosterWall print that rasterises them must FAIL arm (b) (else the arms prove nothing).
// Exit 0 pass, 1 fail.
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
@ -77,6 +82,33 @@ function judge(format, m, pins, wantPhotos) {
return fails
}
// (a) + (b), on the 60x96 sheet's HTML. rasterSrc / wallSrc are the source texts, so a poison can swap them.
async function photoArms(html, rasterSrc, wallSrc) {
const fails = []
const m = await import('data:text/javascript;base64,' + Buffer.from(rasterSrc).toString('base64'))
const ext = (t) => (t.match(/<image\b[^>]*?\bhref="(?!data:)/g) || []).length
if (!ext(html)) fails.push('photos: the 60x96 sheet carries no external photo, so these arms measure nothing')
if (typeof m.hasExternalImages !== 'function' || !m.hasExternalImages(html)) fails.push('photos: poster-raster.js hasExternalImages() does not see the sheet\'s photos')
// what rasterPoster hands to new Image(): its own first line, inlineSvg + whatever it applies
const body = (rasterSrc.match(/export async function rasterPoster[\s\S]*?const url = /) || [''])[0]
const prep = (body.match(/const svg = ([^;]+);/) || [])[1] || ''
const svg = prep ? new Function('inlineSvg', 'stripExternalImages', 'posterHtml', `return ${prep}`)(m.inlineSvg, m.stripExternalImages, html) : ''
if (!prep) fails.push('photos: could not find what rasterPoster rasterises (const svg = ...)')
else if (ext(svg)) fails.push(`photos: rasterPoster would rasterise ${ext(svg)} external <image> (each one prints the broken-image glyph)`)
// (b) the wall's generated-sheet branch prints a sheet with external images as a document, before any raster
const branch = (wallSrc.match(/if \(props\.sheet\) \{[\s\S]*?rasterPoster\(html/) || [''])[0]
if (!/posterAction === 'print' && hasExternalImages\(html\)\) \{ await printSheetDocument\(html\); return \}/.test(branch)) fails.push('photos: PosterWall prints a sheet with photos through the raster (they cannot draw there)')
if (!/async function printSheetDocument\(html\)[\s\S]{0,400}printDocument\(html,/.test(wallSrc)) fails.push('photos: printSheetDocument does not hand the sheet itself to printDocument')
return fails
}
const RASTER = join(ROOT, 'library/packet/poster-raster.js'), WALL = join(ROOT, 'library/components/poster/PosterWall.vue')
async function sheetHtml(createPosterGenerator, pins) {
const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson')
const gen = createPosterGenerator({ env: { FORMAT: '60x96', SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: J('library/scripts/posters/series.json').country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } })
const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } }
return (await gen.renderPoster(zone, pins, { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') })).html || ''
}
const selfTest = process.argv.includes('--self-test')
const src = readFileSync(RENDER, 'utf8')
const pins = synthetic()
@ -92,6 +124,18 @@ if (selfTest) {
console.log(f.some((x) => /crossings/.test(x)) ? `✓ caught: no lane clamp -> ${m.crossings} crossings` : '✗ MISSED: the crossing arm stayed green without the lane clamp')
process.exitCode = f.some((x) => /crossings/.test(x)) ? 0 : 1
}
const { createPosterGenerator } = await import(RENDER)
const html = await sheetHtml(createPosterGenerator, pins)
const rs = readFileSync(RASTER, 'utf8'), ws = readFileSync(WALL, 'utf8')
for (const [name, r, w, rx] of [
['a raster that keeps external images', rs.replace('stripExternalImages(inlineSvg(posterHtml))', 'inlineSvg(posterHtml)'), ws, /rasterise \d+ external/],
['a wall print that rasterises a sheet with photos', rs, ws.replace("if (props.posterAction === 'print' && hasExternalImages(html)) { await printSheetDocument(html); return }", ''), /through the raster/],
]) {
if (r === rs && w === ws) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
const f = await photoArms(html, r, w)
const ok = f.some((x) => rx.test(x)); console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}`)
if (!ok) process.exitCode = 1
}
} else {
const { createPosterGenerator } = await import(RENDER)
const fails = [], lines = []
@ -100,6 +144,9 @@ if (selfTest) {
fails.push(...judge(format, m, pins, photos))
lines.push(`${format}: ${m.names.length} names, ${m.leaders} leaders, ${m.crossings} crossings, ${m.photos} photos, ${m.stats?.namePt} pt`)
}
const html = await sheetHtml(createPosterGenerator, pins)
const pf = await photoArms(html, readFileSync(RASTER, 'utf8'), readFileSync(WALL, 'utf8'))
fails.push(...pf); if (!pf.length) lines.push(`photos: ${(html.match(/<image\b[^>]*?\bhref="(?!data:)/g) || []).length} external, 0 reach the raster, the wall prints the sheet as a document`)
if (fails.length) { fails.forEach((f) => console.log('FAIL ' + f)); process.exitCode = 1 }
else console.log(`PASS check:side-lanes — synthetic ${pins.length} groups (stacked + clustered); ${lines.join(' | ')}`)
}