diff --git a/plugin-designs/poster-design/poster-styles.vue b/plugin-designs/poster-design/poster-styles.vue index 307f6528d..d1552bb19 100644 --- a/plugin-designs/poster-design/poster-styles.vue +++ b/plugin-designs/poster-design/poster-styles.vue @@ -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) diff --git a/plugin-designs/poster-design/src/pageThumb.js b/plugin-designs/poster-design/src/pageThumb.js new file mode 100644 index 000000000..429d20969 --- /dev/null +++ b/plugin-designs/poster-design/src/pageThumb.js @@ -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 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 , which a browser draws + * through a plain . 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 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} 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 = `` + + `${xhtml}` + return URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })) +} diff --git a/plugin-designs/poster-design/src/templates.js b/plugin-designs/poster-design/src/templates.js index 9454bfa78..d29fd613d 100644 --- a/plugin-designs/poster-design/src/templates.js +++ b/plugin-designs/poster-design/src/templates.js @@ -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 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',