doxa-maps/plugin-designs/wall-of-people-groups/src/blessPoster.js

136 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* blessPoster.js — the large printed poster engine, first used for "Bless the nations in Jesus' name":
* every people group (photo, name and a blessing verse) on ONE poster, sized for a print shop.
*
* THE DEFAULT IS 5 ft WIDE × 8 ft TALL, printed as THREE 5 × 3 ft sheets stacked top to bottom:
* · every sheet has a 6-inch BORDER on its top, left and right;
* · the top two sheets' BOTTOM edge is FLAT (trimmed at the shop) and is glued onto the 6-inch top
* border of the sheet below, which is a blank GLUE band (marked "glue here");
* · the bottom sheet also has a 6-inch bottom border, so the finished poster has a 6-inch border all
* the way round. 36 + 30 + 30 = 96 in = 8 ft tall, 60 in = 5 ft wide.
* Content never straddles a glue line: rows are fitted to each sheet's band.
*
* Other presets reuse the same engine (a single sheet with a plain border). The card size and every
* text size are COMPUTED from the preset and the number of groups, and planPoster() reports the verse
* point size, so a preset can be judged by whether its verses are readable.
*
* Pure: plan maths + an HTML string for the shared in-place print frame (library printInPlace). No Vue.
*/
import { blessingFor, POSTER } from './blessings.js'
// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border.
export const PRESETS = {
'5x8ft': { label: '5 × 8 ft', sheetW: 60, sheetH: 36, sheets: 3, border: 6 },
'3x5ft': { label: '3 × 5 ft', sheetW: 36, sheetH: 60, sheets: 1, border: 2 },
'24x36in': { label: '24 × 36 in', sheetW: 24, sheetH: 36, sheets: 1, border: 1 },
'18x24in': { label: '18 × 24 in', sheetW: 18, sheetH: 24, sheets: 1, border: 0.75 },
}
export const DEFAULT_PRESET = '5x8ft'
export const MIN_READABLE_PT = 6 // verse size below which a preset is flagged "verses not readable"
const CARD_ASPECT = 0.8 // card width / height: a portrait card (name, photo, verse)
const PT = 1 / 72 // one point in inches
/**
* The plan for n groups on a preset: per-sheet content bands, the card grid, and the text sizes.
* Banner + subtitle sit at the top of sheet 1's band; the footer at the bottom of the last sheet's.
*/
export function planPoster(presetId, n) {
const P = PRESETS[presetId] || PRESETS[DEFAULT_PRESET]
const b = P.border
const W = P.sheetW - 2 * b
const bannerH = Math.max(1.6, W * 0.11), footerH = Math.max(0.7, W * 0.045)
// Each sheet's content band, in the sheet's own coordinates: y0..y1.
const bands = []
for (let s = 0; s < P.sheets; s++) {
const last = s === P.sheets - 1
let y0 = b // sheet 1: outer border; the others: the glue band
let y1 = P.sheetH - (last ? b : 0) // flat bottom edge, except the last sheet's border
const top = s === 0 ? bannerH : 0, bottom = last ? footerH : 0
bands.push({ s, y0: y0 + top, y1: y1 - bottom, bannerY: s === 0 ? y0 : null, footerY: last ? y1 - footerH : null })
}
// The largest card (fewest columns) whose grid holds n when rows are fitted to each band.
let best = null
for (let cols = 4; cols <= 400 && !best; cols++) {
const cw = W / cols, ch = cw / CARD_ASPECT
const rowsPer = bands.map((bd) => Math.max(0, Math.floor((bd.y1 - bd.y0) / ch)))
if (cols * rowsPer.reduce((a, r) => a + r, 0) >= n) best = { cols, cw, ch, rowsPer }
}
const { cols, cw, ch, rowsPer } = best
// Place cards band by band, row by row.
const cards = []
let i = 0
for (let s = 0; s < bands.length && i < n; s++) {
for (let r = 0; r < rowsPer[s] && i < n; r++) {
for (let c = 0; c < cols && i < n; c++, i++) cards.push({ i, s, x: b + c * cw, y: bands[s].y0 + r * ch, w: cw, h: ch })
}
}
// Text sizes from the card: the verse gets ~4 lines of ~26 characters across the card width.
const versePt = (cw / 13.5) / PT // character width ≈ 0.5 em, 26 chars per line
return {
preset: P, presetId: PRESETS[presetId] ? presetId : DEFAULT_PRESET, n, cols, rowsPer, cw, ch, bands, cards,
bannerH, footerH, contentW: W,
posterW: P.sheetW, posterH: P.sheetH * P.sheets - (P.sheets - 1) * b, // after gluing: 60 × 96 for 5x8ft
text: { versePt: +versePt.toFixed(1), namePt: +(versePt * 1.25).toFixed(1), readable: versePt >= MIN_READABLE_PT },
}
}
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
/**
* The print document: one page per sheet. groups: [{ name, imageUrl }]. texts: { banner, subtitle,
* subBanner: {text, ref}, footer: {text, ref}, glueHere } (the wall passes translated UI strings).
*/
export function buildBlessPosterHtml(groups, presetId = DEFAULT_PRESET, texts = {}) {
const plan = planPoster(presetId, groups.length)
const P = plan.preset, b = P.border
const banner = texts.banner || POSTER.banner
const subtitle = texts.subtitle || ''
const sub = texts.subBanner || POSTER.subBanner
const footer = texts.footer || POSTER.footer
const glueHere = texts.glueHere || 'glue here'
const f = (v) => v.toFixed(3) + 'in'
const vPt = plan.text.versePt, nPt = plan.text.namePt
const card = (c) => {
const g = groups[c.i], v = blessingFor(c.i)
const img = g.imageUrl ? `<img src="${esc(g.imageUrl)}" alt="">` : '<div class="noimg"></div>'
// Below MIN_READABLE_PT the verse would print as grey noise: that size shows name + photo only.
const verse = plan.text.readable ? `<div class="vs">${esc(v.text)}</div><div class="rf">${esc(v.ref)}</div>` : ''
return `<div class="card" style="left:${f(c.x)};top:${f(c.y)};width:${f(c.w)};height:${f(c.h)}">`
+ `<div class="nm">${esc(g.name || '')}</div>${img}${verse}</div>`
}
const pages = plan.bands.map((bd) => {
const s = bd.s, last = s === plan.bands.length - 1
const glue = s > 0 ? `<div class="glue" style="top:0;height:${f(b)}">${esc(glueHere)} ▲ ${s} / ${P.sheets}</div>` : ''
const head = bd.bannerY != null
? `<header class="ban" style="top:${f(bd.bannerY)};height:${f(plan.bannerH)}"><h1>${esc(banner)}</h1>`
+ (subtitle ? `<p class="subt">${esc(subtitle)}</p>` : '')
+ `<p class="sb">${esc(sub.text)} <span>${esc(sub.ref)}</span></p></header>` : ''
const foot = bd.footerY != null
? `<footer class="ftr" style="top:${f(bd.footerY)};height:${f(plan.footerH)}"><p>${esc(footer.text)} <span>${esc(footer.ref)}</span></p></footer>` : ''
const trim = !last && P.sheets > 1 ? '<div class="trim"></div>' : ''
return `<section class="sheet">${glue}${head}${plan.cards.filter((c) => c.s === s).map(card).join('')}${foot}${trim}`
+ `<div class="tag">${s + 1} / ${P.sheets}</div></section>`
})
const W = plan.contentW
const css = `@page{size:${P.sheetW}in ${P.sheetH}in;margin:0}
*{box-sizing:border-box}html,body{margin:0;padding:0}
body{font-family:Georgia,'Times New Roman',serif;color:#1c1c1c;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.sheet{position:relative;width:${P.sheetW}in;height:${P.sheetH}in;overflow:hidden;page-break-after:always;break-after:page}
.ban{position:absolute;left:${f(b)};width:${f(W)};text-align:center;display:flex;flex-direction:column;justify-content:center}
.ban h1{margin:0;font-size:${f(plan.bannerH * 0.42)};line-height:1.05;font-weight:700}
.ban .subt{margin:${f(plan.bannerH * 0.04)} auto 0;max-width:80%;font:${f(plan.bannerH * 0.075)}/1.3 -apple-system,'Segoe UI',Arial,sans-serif;color:#333}
.ban .sb{margin:${f(plan.bannerH * 0.04)} 0 0;font-size:${f(plan.bannerH * 0.09)};font-style:italic;color:#444}
.ban .sb span,.ftr span{font-style:normal;font-size:.7em;color:#777}
.card{position:absolute;padding:${f(plan.cw * 0.035)};display:flex;flex-direction:column;align-items:center;text-align:center;overflow:hidden}
.card .nm{font:700 ${nPt}pt/1.1 -apple-system,'Segoe UI',Arial,sans-serif;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:none}
.card img,.card .noimg{width:100%;height:${plan.text.readable ? 52 : 78}%;object-fit:cover;margin:${f(plan.cw * 0.02)} 0;background:#eee;flex:none}
.card .vs{font-size:${vPt}pt;line-height:1.15;font-style:italic}
.card .rf{font-size:${(vPt * 0.8).toFixed(1)}pt;color:#777;margin-top:1pt}
.ftr{position:absolute;left:${f(b)};width:${f(W)};display:flex;align-items:center;justify-content:center;text-align:center}
.ftr p{margin:0;font-size:${f(plan.footerH * 0.32)};font-style:italic}
.glue{position:absolute;left:0;width:100%;border-bottom:.02in dashed #b00;display:flex;align-items:center;justify-content:center;font:700 ${f(Math.min(0.5, b * 0.08))} sans-serif;color:#b00;background:rgba(176,0,0,.035);letter-spacing:.05em;text-transform:uppercase}
.trim{position:absolute;left:0;bottom:0;width:100%;border-bottom:.02in solid #999}
.tag{position:absolute;right:${f(Math.max(0.15, b * 0.1))};top:${f(Math.max(0.1, b * 0.08))};font:600 ${f(Math.max(0.12, b * 0.035))} sans-serif;color:#aaa}`
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(banner)}</title><style>${css}</style></head><body>${pages.join('')}</body></html>`
}