diff --git a/plugin-designs/wall-of-people-groups/src/blessPoster.js b/plugin-designs/wall-of-people-groups/src/blessPoster.js index 2d9cec333..913f49633 100644 --- a/plugin-designs/wall-of-people-groups/src/blessPoster.js +++ b/plugin-designs/wall-of-people-groups/src/blessPoster.js @@ -2,7 +2,8 @@ * 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: + * THREE SIZES (the brief): 3 × 4 ft, the DEFAULT, and 2 × 3 ft are one sheet each; 5 × 8 ft is the large one, + * 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"); @@ -10,25 +11,27 @@ * 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. + * The single-sheet sizes reuse the same engine (one sheet with a plain border). The card size and every + * text size are COMPUTED from the preset and the number of groups. The brief asks that the scripture stay + * readable at every size: where a verse under every card would print below MIN_READABLE_PT (3 × 4 and + * 2 × 3 ft, for ~2,100 groups), the size uses the STRIP layout, cards of name + photo with ONE verse per + * row on a strip under it at STRIP_PT, instead of dropping the verses. * * Pure: plan maths + an HTML string for the shared in-place print frame (library printInPlace), and the * SAME layout for the screen (screenPoster: what the wall shows is what prints). No Vue. */ import { blessingFor, POSTER, THEMES } from './blessings.js' -// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border. +// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border. The brief's +// three: 3 × 4 ft (the default, a living-room wall), 2 × 3 ft (small), 5 × 8 ft (three glued panels). 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 }, - '3x3ft': { label: '3 × 3 ft', sheetW: 36, sheetH: 36, 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 }, + '3x4ft': { label: '3 × 4 ft', sheetW: 36, sheetH: 48, sheets: 1, border: 2 }, + '2x3ft': { label: '2 × 3 ft', sheetW: 24, sheetH: 36, sheets: 1, border: 1 }, + '5x8ft': { label: '5 × 8 ft', sheetW: 60, sheetH: 36, sheets: 3, border: 6 }, } -export const DEFAULT_PRESET = '5x8ft' -export const MIN_READABLE_PT = 6 // verse size below which a preset is flagged "verses not readable" +export const DEFAULT_PRESET = '3x4ft' +export const MIN_READABLE_PT = 6 // verse size below which a verse under every card is not readable +export const STRIP_PT = 9 // the one-verse strip under each row, on the sizes that use it const CARD_ASPECT = 0.8 // card width / height: a portrait card (name, photo, verse) const PT = 1 / 72 // one point in inches @@ -44,7 +47,7 @@ export function planPoster(presetId, n) { // Top of sheet 1: the title + DOXA line, then the prayer-themes table. Bottom of the last sheet: the // priestly blessing, the journey from Abraham to the church and the closing line. All scale with the width. const headH = W * 0.075, tableH = W * 0.17 - const bannerH = headH + tableH, footerH = W * 0.2 + const bannerH = headH + tableH, footerH = footerHeight(W) // Each sheet's content band, in the sheet's own coordinates: y0..y1. const bands = [] for (let s = 0; s < P.sheets; s++) { @@ -54,32 +57,62 @@ export function planPoster(presetId, n) { 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 }) + // The largest card (fewest columns) whose grid holds n when rows are fitted to each band; `strip` is the + // height of the verse strip under each row (0 = the card layout, a verse on every card). + const fit = (strip) => { + for (let cols = 4; cols <= 400; cols++) { + const cw = W / cols, ch = cw / CARD_ASPECT + const rowsPer = bands.map((bd) => Math.max(0, Math.floor((bd.y1 - bd.y0) / (ch + strip)))) + if (cols * rowsPer.reduce((a, r) => a + r, 0) >= n) return { cols, cw, ch, rowsPer, strip } } } // Text sizes from the card: the verse gets ~4 lines of ~26 characters across the card width. - const versePt = (cw / 12.5) / PT // character width ≈ 0.5 em, ~24 chars per line + const versePtOf = (cw) => (cw / 12.5) / PT // character width ≈ 0.5 em, ~24 chars per line + let grid = fit(0) + const stripLayout = versePtOf(grid.cw) < MIN_READABLE_PT + if (stripLayout) grid = fit((STRIP_PT * 1.35 + 4) * PT) // one line at STRIP_PT, plus air + const { cols, cw, ch, rowsPer, strip } = grid + // Place cards band by band, row by row; in the strip layout each row's strip sits under it. + const cards = [], strips = [] + let i = 0, row = 0 + for (let s = 0; s < bands.length && i < n; s++) { + for (let r = 0; r < rowsPer[s] && i < n; r++, row++) { + const y = bands[s].y0 + r * (ch + strip) + for (let c = 0; c < cols && i < n; c++, i++) cards.push({ i, s, x: b + c * cw, y, w: cw, h: ch }) + if (strip) strips.push({ row, s, x: b, y: y + ch, w: W, h: strip }) + } + } + const versePt = versePtOf(cw) return { preset: P, presetId: PRESETS[presetId] ? presetId : DEFAULT_PRESET, n, cols, rowsPer, cw, ch, bands, cards, + layout: stripLayout ? 'strip' : 'cards', strips, bannerH, footerH, headH, tableH, 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 }, + text: { versePt: +versePt.toFixed(1), namePt: +(versePt * 1.25).toFixed(1), readable: !stripLayout, stripPt: STRIP_PT }, } } +// The footer's height at content width W (inches), FROM ITS TEXT: the priestly blessing (the small +// instruction, three large lines, the small promise), the journey's seven columns (as tall as its longest +// passage wraps), and the closing line. Characters are counted at ≈ 0.5 em each, with 12% slack for +// word breaks; the footer is never shallower than a fifth of the width. The same font sizes as the CSS. +// The journey's passages are the long text: never below 6 pt (2 × 3 ft), ~12 pt on the 5 × 8 ft poster, which +// keeps the 5 × 8's verse under every card at 6 pt (a larger footer would shrink the cards below it). +export const FOOTER_FONTS = (W) => ({ small: W * 0.0065, line: W * 0.012, ref: W * 0.0075, name: W * 0.0062, station: Math.max(6 / 72, W * 0.0034), close: W * 0.0085 }) +function footerHeight(W) { + const F = FOOTER_FONTS(W), gap = W * 0.006 + const lines = (text, width, fs) => Math.max(1, Math.ceil((String(text).length * 0.5 * fs * 1.12) / width)) + const B = POSTER.blessing + const blessH = [...B.intro, ...B.outro].reduce((a, t) => a + lines(t, W * 0.8, F.small) * F.small * 1.35, 0) + + B.lines.reduce((a, t) => a + lines(t, W, F.line) * F.line * 1.25, 0) + F.ref * 1.6 + const colW = (W - 6 * gap) / 7 - W * 0.008 + const stationH = Math.max(...POSTER.journey.map((v) => lines(v.text, colW, F.station) * F.station * 1.3)) + F.name * 1.6 + F.ref * 1.6 + W * 0.006 + // the closing line's English sizes its row (its translations run within a few words of it); CLOSING_EN is + // declared below, which is fine: this runs when a poster is planned, long after the module has loaded + const closeH = lines(CLOSING_EN, W * 0.8, F.close) * F.close * 1.35 + return Math.max(W * 0.2, (blessH + stationH + closeH + W * 0.03) * 1.05) +} + // The closing line under the footer (agent-written); translated via texts.labels.closing when available. const CLOSING_EN = 'Jesus Christ is the cornerstone, and we are living stones. These people groups are the reward of his suffering. Take up our priestly calling: bless them in his name, and join the global family in prayer.' // The journey's station names (agent-written); translated via texts.labels.journey_ when available. @@ -162,7 +195,7 @@ function posterParts(groups, presetId, texts, withCards = true) { const upright = /^(zh|ja|ko)/.test(String(texts.lang || '')) // CJK sidebar labels stand upright const glueHere = texts.glueHere || 'glue here' const f = (v) => v.toFixed(3) + 'in' - const W = plan.contentW + const W = plan.contentW, FF = FOOTER_FONTS(W) // the footer's sizes: the same numbers footerHeight() plans with const vPt = plan.text.versePt, rPt = cardText(plan, '').refPt // The rotated sidebar labels of the chart are sized from their text: the longest word must fit the row's // height (a CJK label stands upright, one em a character; other scripts ≈ 0.72 em a capital with the @@ -208,12 +241,17 @@ function posterParts(groups, presetId, texts, withCards = true) { + `
${table}
` : '' const foot = bd.footerY != null ? `` : '' const trim = !last && P.sheets > 1 ? '
' : '' - return { cards: withCards ? plan.cards.filter((c) => c.s === s).map(card).join('') : '', chrome: `${glue}${head}${foot}${trim}
${s + 1} / ${P.sheets}
` } + // STRIP layout: one blessing verse per row, on a strip under the row (the same rotation as the cards'). + const strips = plan.strips.filter((t) => t.s === s).map((t) => { const v = blessingFor(t.row) + return `
${esc(v.text)}${esc(v.ref)}
` }).join('') + return { cards: withCards ? plan.cards.filter((c) => c.s === s).map(card).join('') : '', chrome: `${glue}${head}${strips}${foot}${trim}
${s + 1} / ${P.sheets}
` } }) const css = `@page{size:${P.sheetW}in ${P.sheetH}in;margin:0} *{box-sizing:border-box}html,body{margin:0;padding:0} @@ -234,15 +272,19 @@ body,.poster{font-family:Georgia,'Times New Roman',serif;color:#1c1c1c;-webkit-p .td .tv{margin:0;font-size:${f(W * 0.0078)};line-height:1.3;font-style:italic} .td .tr{margin:${f(W * 0.002)} 0 0;font-size:${f(W * 0.0062)};color:#666} .ftr{position:absolute;left:${f(b)};width:${f(W)};display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;border-top:.03in solid #1c1c1c} -.bless p{margin:0;font-size:${f(W * 0.0145)};line-height:1.25;font-weight:600} -.bless .br{font-size:${f(W * 0.0075)};font-weight:400;color:#666;margin-top:${f(W * 0.002)}} +.bless p{margin:0;font-size:${f(FF.line)};line-height:1.25;font-weight:600} +.bless p.bi{max-width:80%;margin:${f(W * 0.001)} auto;font-size:${f(FF.small)};line-height:1.35;font-weight:400;font-style:italic;color:#444} +.bless .br{font-size:${f(FF.ref)};font-weight:400;color:#666;margin-top:${f(W * 0.002)}} .jny{display:grid;grid-template-columns:repeat(7,1fr);gap:${f(W * 0.006)};margin-top:${f(W * 0.01)};width:100%;align-items:stretch} .st{display:flex;flex-direction:column;justify-content:center;padding:${f(W * 0.003)} ${f(W * 0.004)};border-top:.02in solid #c9b58a} -.st .sn{margin:0 0 ${f(W * 0.002)};font:700 ${f(W * 0.0062)} Georgia,serif;letter-spacing:.05em;text-transform:uppercase;color:#6b4f1d} -.st .sv{margin:0;font-size:${f(W * 0.0056)};font-style:italic;line-height:1.3} -.st .sr{margin:${f(W * 0.002)} 0 0;font-size:${f(W * 0.0048)};color:#666} -.st.ctr{background:#faf8f2;border:.03in solid #1c1c1c}.st.ctr .sv{font-size:${f(W * 0.0064)};font-weight:600}.st.ctr .sn{color:#1c1c1c} -.close{margin:${f(W * 0.008)} auto 0;max-width:80%;font:700 ${f(W * 0.0085)}/1.35 -apple-system,'Segoe UI',Arial,sans-serif} +.st .sn{margin:0 0 ${f(W * 0.002)};font:700 ${f(FF.name)} Georgia,serif;letter-spacing:.05em;text-transform:uppercase;color:#6b4f1d} +.st .sv{margin:0;font-size:${f(FF.station)};font-style:italic;line-height:1.3} +.st .sr{margin:${f(W * 0.002)} 0 0;font-size:${f(Math.max(6 / 72, W * 0.0048))};color:#666} +.st.ctr{background:#faf8f2;border:.03in solid #1c1c1c}.st.ctr .sv{font-weight:600}.st.ctr .sn{color:#1c1c1c} +.close{margin:${f(W * 0.008)} auto 0;max-width:80%;font:700 ${f(FF.close)}/1.35 -apple-system,'Segoe UI',Arial,sans-serif} +.vstrip{position:absolute;display:flex;align-items:center;justify-content:center;gap:.6em;font-size:${STRIP_PT}pt;font-style:italic; + color:#2d2d2d;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-top:.01in solid #e6e6e6} +.vstrip .vref{font-style:normal;color:#777;font-size:.85em} .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-weight:700;line-height:1.1;font-family:-apple-system,'Segoe UI',Arial,sans-serif;width:100%;white-space:nowrap;overflow:hidden;flex:none} .card .nm.w2{white-space:normal;overflow-wrap:anywhere;hyphens:auto;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} diff --git a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue index f0fd54006..0b6cfb15f 100644 --- a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue +++ b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue @@ -68,7 +68,7 @@ export default { data() { return { mode: 'bless', // 'bless' (default) | 'social' - posterPreset: DEFAULT_PRESET, // blessPoster.js PRESETS: 5×8 ft (3 sheets) by default + posterPreset: DEFAULT_PRESET, // blessPoster.js PRESETS: 3×4 ft by default; 2×3 ft; 5×8 ft on three glued sheets // Each label is isolated left to right (U+2066 … U+2069): "5 × 8 ft · 3×" would otherwise be reordered // by an RTL page into "×ft · 3 8 × 5" (an