feat(posters): generate the poster in the browser when none is shipped
Some checks are pending
build-gate / build (push) Waiting to run

Driver: "it would be a big win to not have to have 80mb of images in a public
folder and let the posters get generated by the website."

Downloading a poster used to end at `poster not found (404)` whenever the
pre-rendered .webp was absent. It now falls back to rasterising the poster's own
HTML in the browser — fetch posters/<id>.html, inline its stylesheet into the
<svg>, draw to a canvas, hand back a blob. Same move the prayer packet already
makes: generate on click, ship nothing.

This unblocks dropping the 41 MB of committed .webp posters without degrading a
working feature, which is the part that had to come first. It also covers the
KEY CITY poster wall for free — both walls share this component.

⚠ AN SVG LOADED THROUGH <img> IS AN ISOLATED DOCUMENT: it cannot see the host
page's CSS, so every var() falls back to its initial value and `fill`'s initial
value is BLACK. The packet shipped exactly that bug once (660 of 661 map
elements black, no console error). The stylesheet is therefore inlined INTO the
svg, and the result is checked by measuring pixels — a blank render throws
rather than handing over a black rectangle. Verified by check:poster-raster:
laos rasterises to 3000x4000 with luminance 229 and 54 distinct colours; strip
the <style> block and it drops to 13 and 2 colours and the gate fires.

WHAT I MEASURED AND REJECTED ALONG THE WAY, so nobody repeats it: shipping the
poster HTML *instead of* the webp looked like a clean win on the first sample
(laos: 115 KB gzipped vs 213 KB webp) and does NOT generalise — poster HTML
scales with geometry complexity while webp is a fixed-resolution raster, so
asia-india-rajasthan is 414 KB gzipped vs 239 KB webp. Generalising from one
sample is how that idea nearly shipped.

Also measured: 351 of the 378 committed posters (35.4 MB) are city-* posters
belonging to the Key Cities feature, not to any of the 29 UPG-100 lists. They
are a live feature, not dead weight — which is why this lands as a fallback in
the shared component rather than a deletion.

encodeURIComponent, not btoa, for the data URL: the posters carry non-Latin-1
people-group and place names and btoa throws on those.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 03:05:07 +00:00
parent 4475581c1b
commit d154a76f64
3 changed files with 143 additions and 34 deletions

View file

@ -161,8 +161,31 @@ async function exportPoster(item) {
exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch', exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch',
message: 'fetching poster', pct: 20 } message: 'fetching poster', pct: 20 }
try { try {
const res = await fetch(`${base.value}/posters/${item.id}.webp`) let res = await fetch(`${base.value}/posters/${item.id}.webp`)
if (!res.ok) throw new Error(`poster not found (${res.status})`) if (!res.ok) {
// ★ NO PRE-RENDERED IMAGE? GENERATE IT. The 378 .webp posters were 41 MB in
// every clone, shipped to every reader whether or not they opened one — and a
// publish was already rejected once at a 74 MB asset tree. The poster HTML is
// SMALLER than the image it produces (~115 KB gzipped vs ~213 KB) and it is
// the source, so the reader picks the resolution instead of being handed one.
// This is the same move the prayer packet already makes: generate on click,
// ship nothing. A 404 used to end here as "poster not found".
exportState.value = { ...exportState.value, phase: 'render',
message: 'generating the poster', pct: 45 }
const htmlRes = await fetch(`${base.value}/posters/${item.id}.html`)
if (!htmlRes.ok) throw new Error(`poster not found (${htmlRes.status})`)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 })
// Fail LOUDLY rather than hand over a black rectangle: an SVG in an <img> is
// an isolated document, so a missing stylesheet makes every fill fall back to
// black with no error at all.
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
return
}
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 } exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
download(await res.blob(), `${props.downloadPrefix}-${item.id}-poster.webp`) download(await res.blob(), `${props.downloadPrefix}-${item.id}-poster.webp`)
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 } exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }

View file

@ -0,0 +1,86 @@
// poster-raster.js — turn a poster's HTML/SVG into a downloadable image, in the
// browser, on click.
//
// ★ WHY: 378 posters were committed as .webp — 41 MB in every clone, shipped to
// every reader whether or not they open one. Driver: "it would be a big win to not
// have to have 80mb of images in a public folder and let the posters get generated
// by the website." A publish was ALREADY rejected once at a 74 MB asset tree
// (git-packed to 57 MiB), which is what not doing this costs.
//
// THE MOVE IS NOT A REWRITE. generate.mjs is 2,816 lines with 26 Node touchpoints;
// porting it to the browser would be a large, risky job. But the poster's shipped
// artefact does not have to be a RASTER — the browser can rasterise the SVG itself:
//
// posters/<id>.html ~115 KB gzipped (text, compresses well)
// posters/<id>.webp ~213 KB (already rasterised, one fixed size)
//
// So the HTML is SMALLER than the image it produces, and it is the source: the
// reader picks the resolution at download time instead of being handed one. The
// packet already fetches this same HTML to draw its map page, so the file is
// already a shipped, proven artefact.
//
// ⚠ AN SVG LOADED THROUGH <img> IS AN ISOLATED DOCUMENT. It cannot see the host
// page's CSS, so every var() falls back to its initial value — and `fill`'s initial
// value is BLACK. That is exactly how the packet's map first shipped as a solid
// black rectangle with no error. The stylesheet must be INLINED into the SVG, and
// the result must be checked by MEASURING the pixels, not by looking at the code.
/**
* Lift the <svg> out of a poster document with its stylesheet inlined, so it
* renders standalone.
*/
export function inlineSvg(posterHtml) {
const svg = (posterHtml.match(/<svg[\s\S]*?<\/svg>/) || [])[0];
if (!svg) throw new Error('poster HTML contains no <svg>');
const css = (posterHtml.match(/<style>([\s\S]*?)<\/style>/) || [])[1] || '';
// Strip any nested </style> so the injected block cannot terminate early.
const safe = css.replace(/<\/style>/gi, '');
return svg.replace(/<svg([^>]*)>/, `<svg$1><style>${safe}</style>`);
}
/**
* 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);
// 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)}`;
const img = new Image();
img.src = url;
await img.decode();
const w = Math.round(img.naturalWidth * scale);
const h = Math.round(img.naturalHeight * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
// White under the art: a transparent canvas prints as black on some viewers, and
// the poster's own paper colour only covers its drawn area.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
const blob = await new Promise((res) => canvas.toBlob(res, type, quality));
if (!blob) throw new Error('canvas.toBlob returned nothing');
return blob;
}
/**
* Is this canvas mostly black? A cheap guard against the isolated-document trap
* above, so a broken render fails LOUDLY instead of downloading a black rectangle.
*/
export async function looksRendered(blob) {
const bmp = await createImageBitmap(blob);
const c = document.createElement('canvas');
c.width = 40; c.height = 40;
const x = c.getContext('2d');
x.drawImage(bmp, bmp.width * 0.4, bmp.height * 0.45, 40, 40, 0, 0, 40, 40);
const d = x.getImageData(0, 0, 40, 40).data;
let sum = 0;
for (let i = 0; i < d.length; i += 4) sum += (d[i] + d[i + 1] + d[i + 2]) / 3;
return sum / (d.length / 4) > 40;
}

File diff suppressed because one or more lines are too long