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:
parent
7f4f7cd7bf
commit
2d00df7323
3 changed files with 141 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
77
plugin-designs/poster-design/src/pageThumb.js
Normal file
77
plugin-designs/poster-design/src/pageThumb.js
Normal 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' }))
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue