An entire book of the Bible on one poster, in the languages of the unreached.
+ Text is fetched at render time from public-domain sources; no Bibles are stored.
+
+
+
+
+
+
+
diff --git a/plugin-designs/bible-posters/index.js b/plugin-designs/bible-posters/index.js
new file mode 100644
index 000000000..7d74642b4
--- /dev/null
+++ b/plugin-designs/bible-posters/index.js
@@ -0,0 +1,31 @@
+/**
+ * plugin-designs/bible-posters/index.js
+ *
+ * Bundle entry for the Bible Posters generator (Posterverse → Experimental).
+ * Folder name = bundle name = tag. One flat profile: bible-posters.vue.
+ * Builds to public/dx-maps/bible-posters/bible-posters.js.
+ *
+ * This plugin has NO map — it is a text/poster generator — so it registers no colour strategies and
+ * needs no Mapbox token; it honours only the reader's LOCALE (interface language) through the provided
+ * i18n instance, exactly like every other plugin.
+ *
+ *
+ *
+ */
+import { defineMapElement } from '@map/elements/defineMapElement.js'
+
+const profileModules = import.meta.glob('./*.vue')
+
+const BiblePostersElement = defineMapElement({
+ profileModules,
+ configureApp: (app, { pinia, i18n }) => {
+ // Install i18n AND hand the instance to ProfileLoader by reference; the element root cannot call
+ // useI18n() itself, so this is how a host sets the plugin's interface language.
+ },
+})
+
+if (typeof customElements !== 'undefined' && !customElements.get('bible-posters')) {
+ customElements.define('bible-posters', BiblePostersElement)
+}
+
+export default BiblePostersElement
diff --git a/plugin-designs/bible-posters/src/biblePoster.js b/plugin-designs/bible-posters/src/biblePoster.js
new file mode 100644
index 000000000..32afc7af0
--- /dev/null
+++ b/plugin-designs/bible-posters/src/biblePoster.js
@@ -0,0 +1,155 @@
+/**
+ * biblePoster.js — the Bible-book poster layout engine (pure: plan maths + an HTML string, no Vue).
+ *
+ * Modeled on wall-of-people-groups/src/blessPoster.js (the wall's engine): a `fit()` search for the
+ * largest readable type size, an HTML document for the shared in-place print frame, and a screen model
+ * that mirrors the print exactly ("what the wall shows is what prints"). The CONTENT differs — a book
+ * is flowing text, not a card grid — so the body is CSS multi-column justified text rather than a grid.
+ *
+ * The Driver's layout (§4): a bold BOOK-NAME header, then MULTIPLE COLUMNS about the width of a phone
+ * screen, ~7-10 words per line, JUSTIFIED, the whole book visible at once. RTL scripts flow right-to-left.
+ *
+ * ⚠ This is a MODEL of the layout (character-advance estimates), not a measurement of the rendered page.
+ * A headless plan proves the arithmetic; only a browser (rega) proves the page. Every number the model
+ * uses is named here so a rendered measurement can correct the constants.
+ */
+
+const PT = 1 / 72 // one point in inches
+export const MIN_READABLE_PT = 6 // a book printed below this is grey noise on a poster
+export const MAX_PT = 16
+export const PX_IN = 96 // screen world: CSS px per inch (matches blessPoster.screenPoster)
+
+// Layout targets (the Driver's words, made numeric — correct against a rendered measurement).
+const WORDS_PER_LINE = 8 // "~7-10 words per line"
+const AVG_WORD_CHARS = 6 // 5 letters + a space, averaged
+const CHARS_PER_LINE = WORDS_PER_LINE * AVG_WORD_CHARS // 48
+const CHAR_EM = 0.5 // average glyph advance as a fraction of the em (justified serif)
+const LINE_LH = 1.32 // body line-height
+const COL_GAP_IN = 0.28 // gap between columns
+const MARGIN_IN = 0.5 // poster margin (all sides)
+const HEAD_FRAC = 0.10 // header band as a fraction of poster height (min 0.6in)
+const CREDIT_IN = 0.32 // credit-line band height
+const CONTENT_SLACK = 1.15 // justification + word-break slack over raw character count
+
+/** Column width (inches) that yields ~WORDS_PER_LINE words per line at this type size. */
+function colWidthIn(pt) { return CHARS_PER_LINE * CHAR_EM * pt * PT }
+
+/**
+ * Plan a book onto a poster of widthIn × heightIn. Returns the chosen columns + type size and whether
+ * the whole book fits at a readable size.
+ * @param {{widthIn:number,heightIn:number}} size
+ * @param {{charCount:number,verseCount:number,blocks:Array}} book
+ */
+export function planBiblePoster(size, book) {
+ const W = size.widthIn - 2 * MARGIN_IN
+ const headH = Math.max(0.6, size.heightIn * HEAD_FRAC)
+ const H = size.heightIn - 2 * MARGIN_IN - headH - CREDIT_IN
+ const headingCount = (book.blocks || []).filter((b) => b.kind === 'heading').length
+ // effective characters the body must hold: text + verse-number glyphs + a line's worth per heading
+ const effective = Math.ceil(book.charCount * CONTENT_SLACK + book.verseCount * 3 + headingCount * CHARS_PER_LINE)
+
+ const capacityAt = (pt) => {
+ const colW = colWidthIn(pt)
+ const cols = Math.max(1, Math.floor((W + COL_GAP_IN) / (colW + COL_GAP_IN)))
+ const lineIn = pt * LINE_LH * PT
+ const linesPerCol = Math.max(0, Math.floor(H / lineIn))
+ return { pt, colW, cols, linesPerCol, capacity: cols * linesPerCol * CHARS_PER_LINE }
+ }
+
+ // Largest type size (0.5pt steps) whose capacity holds the whole book.
+ let chosen = null
+ for (let pt = MAX_PT; pt >= MIN_READABLE_PT - 0.001; pt -= 0.5) {
+ const c = capacityAt(pt)
+ if (c.capacity >= effective) { chosen = c; break }
+ }
+ const fits = !!chosen
+ const plan = chosen || capacityAt(MIN_READABLE_PT) // if nothing fits, report the floor's numbers
+ return {
+ fits, effective,
+ widthIn: size.widthIn, heightIn: size.heightIn,
+ marginIn: MARGIN_IN, headHIn: headH, creditHIn: CREDIT_IN,
+ contentWIn: W, contentHIn: H,
+ cols: plan.cols, colWIn: plan.colW, colGapIn: COL_GAP_IN,
+ fontPt: plan.pt, lineHeight: LINE_LH, linesPerCol: plan.linesPerCol,
+ capacity: plan.capacity,
+ }
+}
+
+/** Given a book and a set of candidate sizes, the smallest that fits (for the "which size" hint). */
+export function fitLadder(sizes, book) {
+ return sizes.map((s) => {
+ const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book)
+ return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt, cols: p.cols }
+ })
+}
+
+const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
+
+/** The body HTML: headings and verses in reading order; verse numbers are bidi-isolated superscripts. */
+function bodyHtml(book) {
+ let html = ''
+ let lastChapter = 0
+ for (const b of book.blocks) {
+ if (b.kind === 'heading') { html += '
'
+}
+
+/** The screen model (WYSIWYG): the same poster, scaled to CSS px. Returns { css, html, worldW, worldH }. */
+export function screenBiblePoster(book, size, texts = {}) {
+ const dir = texts.dir || book.dir || 'ltr'
+ const plan = planBiblePoster(size, book)
+ const css = posterCss(plan, dir, PX_IN)
+ const html = '
' + esc(book.name) + '
'
+ + '
' + bodyHtml(book) + '
'
+ + '
' + esc(texts.credit || '') + '
'
+ return { css, html, dir, plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
+}
diff --git a/plugin-designs/bible-posters/src/bibleSource.js b/plugin-designs/bible-posters/src/bibleSource.js
new file mode 100644
index 000000000..dbfcd71de
--- /dev/null
+++ b/plugin-designs/bible-posters/src/bibleSource.js
@@ -0,0 +1,105 @@
+/**
+ * bibleSource.js — the RENDER-TIME text client for the Free Use Bible API (bible.helloao.org).
+ *
+ * Fetches a whole book on demand and returns a normalized structure the renderer can lay out.
+ * NOTHING is written to the repo or public/: whole Bibles are never stored (the licence gate and the
+ * §4 spec). A small in-memory (per-bundle, per-session) cache avoids re-fetching a book the reader
+ * just looked at; it is discarded on reload.
+ *
+ * The API (verified 2026-09-30):
+ * {base}/{translationId}/books.json → { books: [{ id, commonName, numberOfChapters, ... }] }
+ * {base}/{translationId}/{BOOK}/{chapter}.json → { chapter: { content: [ {type:'verse',number,content:[...]}
+ * | {type:'heading',content:[...]} ] } }
+ * CORS is open (Access-Control-Allow-Origin: *) and no key is required.
+ */
+
+export const API_BASE = 'https://bible.helloao.org/api'
+
+const booksCache = new Map() // translationId -> [{id, name, chapters, order}]
+const bookCache = new Map() // `${translationId}/${bookId}` -> normalized book
+
+async function getJson(url, signal) {
+ const res = await fetch(url, { signal, headers: { accept: 'application/json' } })
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url)
+ return res.json()
+}
+
+/** The translation's book table: id → localized name + chapter count. Used to build the picker and to
+ * know which of the 12 packet books this translation actually has (OT books need an OT translation). */
+export async function fetchBooks(translationId, { signal } = {}) {
+ if (booksCache.has(translationId)) return booksCache.get(translationId)
+ const d = await getJson(`${API_BASE}/${translationId}/books.json`, signal)
+ const raw = Array.isArray(d) ? d : (d.books || [])
+ const books = raw.map((b) => ({
+ id: b.id,
+ name: b.commonName || b.name || b.id,
+ chapters: b.numberOfChapters || 0,
+ order: b.order || 0,
+ }))
+ booksCache.set(translationId, books)
+ return books
+}
+
+/** One verse/heading item → plain text. Verse `content` is an array of strings and, occasionally,
+ * objects (poetry lines, notes). We keep the readable text and drop note apparatus. */
+function itemText(item) {
+ const parts = Array.isArray(item.content) ? item.content : [item.content]
+ return parts
+ .map((p) => {
+ if (typeof p === 'string') return p
+ if (p && typeof p === 'object') return p.text || (Array.isArray(p.content) ? p.content.filter((x) => typeof x === 'string').join(' ') : '')
+ return ''
+ })
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+/**
+ * Fetch an ENTIRE book: every chapter, flattened to an ordered list of blocks.
+ * Returns { bookId, name, dir, chapters: N, blocks: [{kind:'heading'|'verse', chapter, number?, text}],
+ * verseCount, charCount }. `onProgress(done, total)` fires per chapter.
+ */
+export async function fetchBook(translationId, bookId, { signal, onProgress, dir = 'ltr', concurrency = 4 } = {}) {
+ const key = `${translationId}/${bookId}`
+ if (bookCache.has(key)) { onProgress?.(1, 1); return bookCache.get(key) }
+
+ const books = await fetchBooks(translationId, { signal })
+ const meta = books.find((b) => b.id === bookId)
+ if (!meta) throw new Error(`${translationId} has no book ${bookId}`)
+ const total = meta.chapters
+ const chapterBlocks = new Array(total) // keep chapter order regardless of fetch order
+ let done = 0
+
+ async function one(ch) {
+ const d = await getJson(`${API_BASE}/${translationId}/${bookId}/${ch}.json`, signal)
+ const content = (d.chapter || d).content || []
+ const blocks = []
+ for (const item of content) {
+ if (item.type === 'heading') { const t = itemText(item); if (t) blocks.push({ kind: 'heading', chapter: ch, text: t }) }
+ else if (item.type === 'verse') { const t = itemText(item); if (t) blocks.push({ kind: 'verse', chapter: ch, number: item.number, text: t }) }
+ }
+ chapterBlocks[ch - 1] = blocks
+ done += 1
+ onProgress?.(done, total)
+ }
+
+ // bounded-concurrency pool over chapters 1..total
+ const queue = []
+ for (let ch = 1; ch <= total; ch++) queue.push(ch)
+ const workers = new Array(Math.min(concurrency, total)).fill(0).map(async () => {
+ while (queue.length) { const ch = queue.shift(); await one(ch) }
+ })
+ await Promise.all(workers)
+
+ const blocks = chapterBlocks.flat().filter(Boolean)
+ let verseCount = 0, charCount = 0
+ for (const b of blocks) { if (b.kind === 'verse') verseCount++; charCount += b.text.length }
+ const book = { bookId, name: meta.name, dir, chapters: total, blocks, verseCount, charCount }
+ bookCache.set(key, book)
+ return book
+}
+
+/** Test seam: clear the session caches. */
+export function _clearCache() { booksCache.clear(); bookCache.clear() }
diff --git a/plugin-designs/bible-posters/src/books.js b/plugin-designs/bible-posters/src/books.js
new file mode 100644
index 000000000..935a5a62c
--- /dev/null
+++ b/plugin-designs/bible-posters/src/books.js
@@ -0,0 +1,39 @@
+/**
+ * books.js — the 12 books the UPG-100 prayer packet cites, in packet-citation order.
+ *
+ * The Bible Posters generator shows an ENTIRE BOOK on one poster. These 12 are the starting set
+ * (DRIVER-SWARMS-POSTERVERSE-BIBLE-1855 §4): every book referenced by the en packet in
+ * library/scripts/posters/packet-strings.json. The verse list is the index; the poster offers the book.
+ *
+ * `id` — the USFM/OSIS book id the Free Use Bible API (bible.helloao.org) uses in its URLs.
+ * `en` — an English fallback label for the UI picker BEFORE the fetched translation is known;
+ * the POSTER HEADER itself uses the fetched translation's own `commonName` (in the text
+ * language), so a reader sees "يوحنا" / "Johannes", never the English name.
+ * `testament` — 'OT' books (Genesis, Joshua) exist only in a translation that HAS an Old Testament,
+ * which narrows the offered language set; the other 10 are New Testament and fetch from
+ * any NT-only translation too.
+ * `chaptersHint` — the chapter count for the WEB text (eng_web), used only to size a progress bar and a
+ * fit pre-estimate; the real count comes from the translation's own books.json at render.
+ */
+export const PACKET_BOOKS = [
+ { id: 'ACT', en: 'Acts', testament: 'NT', chaptersHint: 28 },
+ { id: 'MAT', en: 'Matthew', testament: 'NT', chaptersHint: 28 },
+ { id: 'JHN', en: 'John', testament: 'NT', chaptersHint: 21 },
+ { id: 'MRK', en: 'Mark', testament: 'NT', chaptersHint: 16 },
+ { id: 'LUK', en: 'Luke', testament: 'NT', chaptersHint: 24 },
+ { id: 'HEB', en: 'Hebrews', testament: 'NT', chaptersHint: 13 },
+ { id: 'GEN', en: 'Genesis', testament: 'OT', chaptersHint: 50 },
+ { id: '1JN', en: '1 John', testament: 'NT', chaptersHint: 5 },
+ { id: 'COL', en: 'Colossians', testament: 'NT', chaptersHint: 4 },
+ { id: '1CO', en: '1 Corinthians', testament: 'NT', chaptersHint: 16 },
+ { id: '1PE', en: '1 Peter', testament: 'NT', chaptersHint: 5 },
+ { id: 'JOS', en: 'Joshua', testament: 'OT', chaptersHint: 24 },
+]
+
+export const BOOK_BY_ID = Object.fromEntries(PACKET_BOOKS.map((b) => [b.id, b]))
+
+/** Books this translation can serve: OT books need a translation whose books.json includes them. */
+export function booksForTranslation(availableBookIds) {
+ const have = new Set(availableBookIds || [])
+ return PACKET_BOOKS.filter((b) => have.has(b.id))
+}
diff --git a/plugin-designs/bible-posters/src/sizes.js b/plugin-designs/bible-posters/src/sizes.js
new file mode 100644
index 000000000..f3235fb06
--- /dev/null
+++ b/plugin-designs/bible-posters/src/sizes.js
@@ -0,0 +1,28 @@
+/**
+ * sizes.js — the poster size ladder for Bible Posters.
+ *
+ * ⚠ TEMPORARY, DO NOT LET IT DIVERGE. studio OWNS the shared size table + picker
+ * (library/constants/posterSizes.js) and the approved SIZE KEYS
+ * (_qa-cycle/03-deployment-cycle/tasks/DRIVER-BATCH-AND-BIG-POSTERS-1735 "SIZE KEYS"):
+ * paper · 36x36 · 36x60 · 66x66 · 60x96, portrait only. As of development ce930476 the shared table
+ * still holds the OLD US/ISO presets, so this mirrors the APPROVED keys locally to unblock the build.
+ * REPLACE this with `import { ... } from '@map/constants/posterSizes.js'` (and the shared picker) as
+ * soon as studio lands the new ladder — ask studio via dx-shepherd; never edit the shared file here.
+ *
+ * A book is laid out on the FINISHED poster (width × height, portrait). `sheets` records the panel plan
+ * for the large sizes (glued print); the multi-panel PRINT path reuses library panelSheets.js and is a
+ * milestone-2 follow-up — the fit MODEL uses the finished dimensions.
+ */
+// Labels are locale-neutral DIMENSIONS (in/ft are unit symbols, like cm) so no English word leaks into a
+// non-English UI. When studio's shared table lands its localized names (poster.size., e.g. "Standard
+// paper"), switch the picker to those; ask studio via dx-shepherd, never edit the shared file here.
+export const BIBLE_POSTER_SIZES = [
+ { id: 'paper', key: 'paper', label: '8.5 × 11 in', widthIn: 8.5, heightIn: 11, sheets: 1 },
+ { id: '36x36', key: '36x36', label: '3 × 3 ft', widthIn: 36, heightIn: 36, sheets: 1 },
+ { id: '36x60', key: '36x60', label: '3 × 5 ft', widthIn: 36, heightIn: 60, sheets: 1 },
+ { id: '66x66', key: '66x66', label: '5.5 × 5.5 ft', widthIn: 66, heightIn: 66, sheets: 2 },
+ { id: '60x96', key: '60x96', label: '5 × 8 ft', widthIn: 60, heightIn: 96, sheets: 3 },
+]
+
+export const SIZE_BY_ID = Object.fromEntries(BIBLE_POSTER_SIZES.map((s) => [s.id, s]))
+export const DEFAULT_SIZE_ID = '36x60'
diff --git a/plugin-designs/bible-posters/src/translations.js b/plugin-designs/bible-posters/src/translations.js
new file mode 100644
index 000000000..8220b33e1
--- /dev/null
+++ b/plugin-designs/bible-posters/src/translations.js
@@ -0,0 +1,64 @@
+/**
+ * translations.js — the LICENCE-VETTED text sources for the Bible Posters generator.
+ *
+ * ⚠ LICENSING GATE (bible-poster-plugin's first task; see
+ * _qa-cycle/01-ideation-research/BIBLE-POSTER-LICENSING.md). A full BOOK is displayed IN FULL on a
+ * printed, white-label giveaway poster, so ONLY texts whose licence permits that may ship. This v1
+ * list is STRICT PUBLIC-DOMAIN ONLY — the exact translations the UPG-100 packet already ships as
+ * public domain, plus the WEB seed dx-shepherd named. It is safe under EITHER Driver answer to the
+ * open question (PD-only vs. a licensed/broader set), so it is what builds now.
+ *
+ * The CC-BY / CC-BY-SA expansion (the IRV family — Hindi, Tamil, Telugu, Punjabi — and the wider
+ * UPG languages) is DEFERRED pending the Driver's ruling; do not add a non-public-domain text here
+ * without dx-shepherd's go-ahead and a per-translation licence check (eBible details + Door43),
+ * never the transport's blanket "no restrictions" claim nor eBible's Redistributable flag alone.
+ *
+ * Transport: the Free Use Bible API (bible.helloao.org) — CORS, no key, per-chapter JSON, fetched at
+ * RENDER TIME. No whole Bibles are stored in the repo or public/. Each translation carries a
+ * `licenseUrl` back to its eBible record; `credit` is the poster's printed credit line.
+ *
+ * `iso639_3` is the text language, so the picker can offer the languages of the top unreached people
+ * groups (the Gospel Resources map data keys on iso639_3) once the CC-BY-SA set is approved.
+ */
+
+/** @typedef {Object} Translation
+ * @property {string} id Free Use Bible API translation id (its URL segment)
+ * @property {string} iso639_3 text language code
+ * @property {string} language English language name (UI grouping)
+ * @property {string} name the translation's English name
+ * @property {'ltr'|'rtl'} dir text direction
+ * @property {string} licence the licence class (v1: all 'public-domain')
+ * @property {string} credit the poster credit line
+ * @property {string} licenseUrl the licence record of source
+ */
+
+/** v1 STRICT PUBLIC-DOMAIN seed — the packet's 6 + WEB. Every entry has all 66 books (OT+NT). */
+export const TRANSLATIONS = [
+ { id: 'eng_web', iso639_3: 'eng', language: 'English', endonym: 'English', name: 'World English Bible',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'World English Bible · public domain · ebible.org (via bible.helloao.org)',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=eng-web' },
+ { id: 'spa_r09', iso639_3: 'spa', language: 'Spanish', endonym: 'Español', name: 'Reina Valera 1909',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Reina Valera 1909 · dominio público · ebible.org (via bible.helloao.org)',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=spaRV1909' },
+ { id: 'fra_ost', iso639_3: 'fra', language: 'French', endonym: 'Français', name: 'French Ostervald Bible',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Bible Ostervald · domaine public · ebible.org (via bible.helloao.org)',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=fra_fob' },
+ { id: 'deu_l12', iso639_3: 'deu', language: 'German', endonym: 'Deutsch', name: 'German Luther Bible 1912',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Lutherbibel 1912 · gemeinfrei · ebible.org (via bible.helloao.org)',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=deu1912' },
+ { id: 'rus_syn', iso639_3: 'rus', language: 'Russian', endonym: 'Русский', name: 'Russian Synodal Bible',
+ dir: 'ltr', licence: 'public-domain',
+ credit: 'Синодальный перевод · общественное достояние · ebible.org (via bible.helloao.org)',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=russyn' },
+ { id: 'arb_vdv', iso639_3: 'arb', language: 'Arabic', endonym: 'العربية', name: 'Arabic Van Dyck Bible',
+ dir: 'rtl', licence: 'public-domain',
+ credit: 'فان دايك · ملك عام · ebible.org (via bible.helloao.org)',
+ licenseUrl: 'https://ebible.org/Scriptures/details.php?id=arb-vd' },
+]
+
+export const TRANSLATION_BY_ID = Object.fromEntries(TRANSLATIONS.map((t) => [t.id, t]))
+export const DEFAULT_TRANSLATION_ID = 'eng_web'