136 lines
8.9 KiB
JavaScript
136 lines
8.9 KiB
JavaScript
/**
|
||
* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`
|
||
}
|