Poster Design: Styles draws the prayer packet and every card stock

The seven templates that showed 'preview unavailable' for the packet and
the cards now draw from the shared renderers with the Nepal sample:
- each card stock is the deck the wall prints (createCardRenderer with
  that stock, buildDeck), its first card the thumbnail;
- each packet variant is the packet renderer's page 1, its map page,
  built from the sample's map-only sheet as the wall's packet builds it.

src/pageThumb.js turns a document's first page into an image: it keeps
that page, re-serialises it as XHTML inside an SVG foreignObject and
returns it as an SVG image, which a plain <img> draws. Photos are inlined
only from the page's own origin; the people-group photo host sends no CORS
headers, so a card's photo frame stays empty in the thumbnail (the printed
deck still carries the photo), and no refused fetch reaches the console.

A card stock's print button prints its whole deck. A packet card is not
offered for printing here, since only its page 1 is built.
This commit is contained in:
Doxa Maps 2026-10-01 00:25:09 +00:00
parent 7f4f7cd7bf
commit 2d00df7323
3 changed files with 141 additions and 20 deletions

View file

@ -23,6 +23,8 @@ import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import countryData from '@map/data/country-posters.json'
import { makeCountrySheet } from '@map/packet/countrySheet.js'
import { loadPacketStrings } from '@map/packet/packetStrings.js'
import { firstPageImage } from './src/pageThumb.js'
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
import FullDetailsDialog from './src/FullDetailsDialog.vue'
@ -64,33 +66,73 @@ const countrySheet = (format, id = SAMPLE_COUNTRY_ID) => {
const c = countryById(id)
return c ? sheetFor(c, format) : Promise.resolve('')
}
const ctx = { countrySheet }
// The sample's people groups and the packet's words, each fetched once for the whole gallery.
const lang = () => getRuntimeLocale() || detectLocale() || 'en'
let rowsP = null
const sampleRows = () => (rowsP ||= (sample ? recordsFor({ type: 'country', values: [sample.iso3] }) : Promise.resolve([]))
.then((r) => r || []).catch(() => { rowsP = null; return [] }))
let stringsP = null
const packetStrings = () => (stringsP ||= loadPacketStrings(assetsBase.value))
// A card deck exactly as the wall prints one (usePosterPrints printCardDeck: createCardRenderer(...).buildDeck):
// the whole deck is the document, its first card the thumbnail.
async function cardDeck(stockId) {
const [rows, strings, { createCardRenderer }] = await Promise.all([sampleRows(), packetStrings(), import('@map/packet/card-render.js')])
if (!rows.length) return null
const R = createCardRenderer({ strings, lang: lang(), stock: stockId })
return { html: R.buildDeck(sample, rows), page: '.card', width: R.W, height: R.H }
}
// A packet's PAGE 1, its map page, from the same renderer and the same map sheet the wall's packet uses
// (the sample's map-only 'packet' sheet, lifted by the renderer). Only page 1 is built: it is what the
// thumbnail shows, and it needs nothing the later pages need (descriptions, the evangelical share).
async function packetDocument(variant) {
const [rows, strings, { createPacketRenderer }, sheetHtml] = await Promise.all([
sampleRows(), packetStrings(), import('@map/packet/packet-render.js'), countrySheet('packet'),
])
if (!rows.length) return null
const R = createPacketRenderer({ strings, lang: lang() })
const mapSvg = sheetHtml ? R.liftMap(sheetHtml) : ''
const doc = R.buildPacket(sample, rows, new Map(), mapSvg, null, null, variant, null)
return { html: doc, page: '.sheet', width: R.W, height: R.H, firstPageOnly: true }
}
const ctx = { countrySheet, cardDeck, packetDocument }
// The printed document's size, measured when it is drawn: what "file size" means in the details.
// render() answers a poster's HTML (a map sheet) or a document { html, page, width, height } (a deck, a packet).
const fileBytes = ref({})
async function renderDocument(name) {
const c = byName.get(name)
if (!c || !c.render) return ''
const html = await c.render(ctx)
if (html) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
return html || ''
if (!c || !c.render) return null
const out = await c.render(ctx)
const html = typeof out === 'string' ? out : out && out.html
if (!html) return null
if (!(out && out.firstPageOnly)) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
return out
}
// previewSrc: the thumbnail, rasterised from the drawn document at a quarter scale (a 1500px sheet
// becomes ~375px, the card's size). A render that comes out blank is refused, so the card shows
// "preview unavailable" rather than a black rectangle.
// previewSrc: the thumbnail. A map sheet is rasterised at a quarter scale (a 1500px sheet becomes ~375px,
// the card's size), and a render that comes out blank is refused, so the card shows "preview unavailable"
// rather than a black rectangle. A document's first page is drawn as it prints (src/pageThumb.js).
async function previewSrc(item) {
const html = await renderDocument(item.id)
if (!html) return null
const out = await renderDocument(item.id)
if (!out) return null
if (typeof out === 'object') return firstPageImage(out.html, out)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(html, { scale: 0.25 })
const blob = await rasterPoster(out, { scale: 0.25 })
if (!(await looksRendered(blob))) return null
return URL.createObjectURL(blob)
}
// The card's print button prints THIS configuration: the format the wall asks for is ignored on
// purpose, because the card already names its size.
const sheet = (item) => renderDocument(item.id)
// The card's print button prints THIS configuration: the format the wall asks for is ignored on purpose,
// because the card already names its size. A card deck prints whole, as the wall prints it. A packet is
// built here only as far as its page 1, so it is NOT offered for printing from this card: printing a
// packet missing its later pages would misrepresent the template.
async function sheet(item) {
const out = await renderDocument(item.id)
if (!out || (typeof out === 'object' && out.firstPageOnly)) return ''
return typeof out === 'string' ? out : out.html
}
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
const open = ref(null)

View file

@ -0,0 +1,77 @@
/**
* pageThumb.js: the FIRST PAGE of a printable HTML document (a card deck, a prayer packet) as an image
* a poster card can show.
*
* The poster rasteriser (library/packet/poster-raster.js) lifts an <svg> out of a poster; a deck or a
* packet is HTML pages instead, so it has nothing to lift. This takes the document the reader would
* print, keeps its first page, and wraps that page in an SVG <foreignObject>, which a browser draws
* through a plain <img>. Nothing is re-laid-out here: the page, its stylesheet and its size all come
* from the shared renderer.
*
* Two things an SVG shown through <img> does differently, both handled here:
* - it is an ISOLATED document that loads nothing, so a photo on the page is fetched now and inlined as
* a data: URL, but ONLY from this page's own origin: a fetch needs CORS, and the people-group photo host
* sends none, so a cross-origin photo is left out WITHOUT trying (trying only fills the console with
* refusals). Its frame stays, empty, and the printed document still carries the photo;
* - its markup must be XML, so the page is re-serialised with XMLSerializer (void elements closed,
* entities resolved) rather than pasted as HTML.
*
* A pilot in this plugin; if a second surface needs a document thumbnail it belongs in library/.
*/
const MAX_PHOTOS = 8
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timed out')), ms)
promise.then((v) => { clearTimeout(timer); resolve(v) }, (e) => { clearTimeout(timer); reject(e) })
})
}
async function asDataUrl(src, timeoutMs) {
const res = await withTimeout(fetch(src, { mode: 'cors', cache: 'force-cache' }), timeoutMs)
if (!res.ok) throw new Error(`photo ${res.status}`)
const blob = await res.blob()
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(r.error)
r.readAsDataURL(blob)
})
}
/**
* @param {string} html the whole printable document, exactly as the renderer builds it
* @param {object} o
* page CSS selector of one page (".card", ".sheet"); the first match is kept
* width the page's width in CSS px (the renderer's W)
* height the page's height in CSS px (the renderer's H)
* timeoutMs per photo
* @returns {Promise<string|null>} an image URL (image/svg+xml), or null when the document has no page
*/
export async function firstPageImage(html, { page, width, height, timeoutMs = 8000 } = {}) {
if (!html || !page || !width || !height) return null
const doc = new DOMParser().parseFromString(html, 'text/html')
const first = doc.body && doc.body.querySelector(page)
if (!first) return null
// Keep only the body child that holds the first page.
let top = first
while (top.parentElement && top.parentElement !== doc.body) top = top.parentElement
for (const n of [...doc.body.childNodes]) if (n !== top) n.remove()
const sameOrigin = (src) => { try { return new URL(src, document.baseURI).origin === location.origin } catch (_) { return false } }
const photos = [...top.querySelectorAll('img[src]')].filter((img) => !/^data:/i.test(img.getAttribute('src')))
let fetched = 0
for (const img of photos) {
const src = img.getAttribute('src')
if (fetched < MAX_PHOTOS && sameOrigin(src)) {
fetched++
try { img.setAttribute('src', await asDataUrl(src, timeoutMs)); continue } catch (_) { /* left out below */ }
}
img.removeAttribute('src')
}
const xhtml = new XMLSerializer().serializeToString(doc.documentElement)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
+ `<foreignObject x="0" y="0" width="${width}" height="${height}">${xhtml}</foreignObject></svg>`
return URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }))
}

View file

@ -7,8 +7,10 @@
* its name and is marked legacy.
*
* Each entry says where its renderer lives in library/ and, when the gallery can draw it, how:
* render(ctx) returns the template's HTML document drawn from the LIBRARY with the sample data, so a
* library change shows up in the gallery the next time it opens. Entries the gallery cannot draw yet
* render(ctx) draws the template from the LIBRARY with the sample data, so a library change shows up in
* the gallery the next time it opens. It returns either a poster's HTML (a map sheet: an <svg> the poster
* rasteriser lifts) or a printable DOCUMENT as { html, page, width, height } (a deck, a packet: HTML pages,
* whose first page becomes the thumbnail, src/pageThumb.js). Entries the gallery cannot draw yet
* say so (render: null) and the card shows the wall's "preview unavailable" placeholder; the details
* dialog states which part is missing, rather than a picture of something else.
*
@ -74,8 +76,8 @@ export function listConfigurations() {
renderer: 'library/packet/packet-render.js buildPacket (VARIANTS)',
settings: { variant },
sample: SAMPLE_COUNTRY_ID,
render: null,
missing: 'posterDesign.missing.packet',
// Page 1 of the packet (its map page) as the thumbnail; ctx.packetDocument builds it.
render: (ctx) => ctx.packetDocument(variant),
})),
...Object.entries(STOCK).map(([id, s]) => ({
name: `cards.${id}`,
@ -86,8 +88,8 @@ export function listConfigurations() {
renderer: 'library/packet/card-render.js createCardRenderer (STOCK)',
settings: { stock: id },
sample: SAMPLE_COUNTRY_ID,
render: null,
missing: 'posterDesign.missing.cards',
// The first card of the sample's deck; ctx.cardDeck builds the whole deck the wall would print.
render: (ctx) => ctx.cardDeck(id),
})),
{
name: 'wall.bless-panels',