Add Bible Posters plugin: a whole book of the Bible on one poster

A new experimental generator (Posterverse) that renders an ENTIRE BOOK of the Bible
in justified, phone-width columns with a bold book-name header, in the languages of
the unreached people groups. Text is fetched at render time from public-domain
translations via the Free Use Bible API; no Bibles are stored in the repo or public/.

v1 offers strict public-domain texts only, with a per-poster credit line: the World
English Bible plus the prayer packet's Reina-Valera 1909, Ostervald, Luther 1912,
Synodal and Van Dyck (Arabic, right-to-left). Book, text-language and size pickers;
the generator reports which size fits a book at a readable type size, and standard
paper is honestly shown as too small for a whole book. Interface strings are
localized in all 11 locales. Starts from the 12 books the UPG-100 packet cites.
This commit is contained in:
Doxa Maps 2026-09-30 22:55:25 +00:00
parent 42bc198898
commit 5c44b3cdcd
20 changed files with 949 additions and 0 deletions

View file

@ -0,0 +1,27 @@
{
"title": "ملصقات الكتاب المقدس",
"book": "السفر",
"language": "اللغة",
"size": "الحجم",
"print": "طباعة",
"close": "إغلاق",
"loading": "جارٍ تحميل السفر…",
"fetchFailed": "تعذّر تحميل هذا النص. حاول مرة أخرى.",
"fitsAt": "يتّسع بحجم {pt} نقطة في {cols} أعمدة.",
"tooSmall": "أصغر من أن يُقرأ بهذا الحجم.",
"trySize": "جرّب {size}",
"books": {
"ACT": "أعمال الرسل",
"MAT": "متى",
"JHN": "يوحنا",
"MRK": "مرقس",
"LUK": "لوقا",
"HEB": "العبرانيين",
"GEN": "التكوين",
"1JN": "يوحنا الأولى",
"COL": "كولوسي",
"1CO": "كورنثوس الأولى",
"1PE": "بطرس الأولى",
"JOS": "يشوع"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Bibel-Poster",
"book": "Buch",
"language": "Sprache",
"size": "Größe",
"print": "Drucken",
"close": "Schließen",
"loading": "Buch wird geladen…",
"fetchFailed": "Dieser Text konnte nicht geladen werden. Bitte erneut versuchen.",
"fitsAt": "Passt bei {pt} pt in {cols} Spalten.",
"tooSmall": "Zu klein, um in dieser Größe lesbar zu sein.",
"trySize": "versuchen Sie {size}",
"books": {
"ACT": "Apostelgeschichte",
"MAT": "Matthäus",
"JHN": "Johannes",
"MRK": "Markus",
"LUK": "Lukas",
"HEB": "Hebräer",
"GEN": "1. Mose",
"1JN": "1. Johannes",
"COL": "Kolosser",
"1CO": "1. Korinther",
"1PE": "1. Petrus",
"JOS": "Josua"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Bible Posters",
"book": "Book",
"language": "Language",
"size": "Size",
"print": "Print",
"close": "Close",
"loading": "Loading the book…",
"fetchFailed": "Could not load this text. Please try again.",
"fitsAt": "Fits at {pt} pt in {cols} columns.",
"tooSmall": "Too small to read at this size.",
"trySize": "try {size}",
"books": {
"ACT": "Acts",
"MAT": "Matthew",
"JHN": "John",
"MRK": "Mark",
"LUK": "Luke",
"HEB": "Hebrews",
"GEN": "Genesis",
"1JN": "1 John",
"COL": "Colossians",
"1CO": "1 Corinthians",
"1PE": "1 Peter",
"JOS": "Joshua"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Carteles bíblicos",
"book": "Libro",
"language": "Idioma",
"size": "Tamaño",
"print": "Imprimir",
"close": "Cerrar",
"loading": "Cargando el libro…",
"fetchFailed": "No se pudo cargar este texto. Inténtalo de nuevo.",
"fitsAt": "Cabe a {pt} pt en {cols} columnas.",
"tooSmall": "Demasiado pequeño para leerse en este tamaño.",
"trySize": "prueba {size}",
"books": {
"ACT": "Hechos",
"MAT": "Mateo",
"JHN": "Juan",
"MRK": "Marcos",
"LUK": "Lucas",
"HEB": "Hebreos",
"GEN": "Génesis",
"1JN": "1 Juan",
"COL": "Colosenses",
"1CO": "1 Corintios",
"1PE": "1 Pedro",
"JOS": "Josué"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Affiches bibliques",
"book": "Livre",
"language": "Langue",
"size": "Taille",
"print": "Imprimer",
"close": "Fermer",
"loading": "Chargement du livre…",
"fetchFailed": "Impossible de charger ce texte. Veuillez réessayer.",
"fitsAt": "Tient à {pt} pt sur {cols} colonnes.",
"tooSmall": "Trop petit pour être lu à cette taille.",
"trySize": "essayez {size}",
"books": {
"ACT": "Actes",
"MAT": "Matthieu",
"JHN": "Jean",
"MRK": "Marc",
"LUK": "Luc",
"HEB": "Hébreux",
"GEN": "Genèse",
"1JN": "1 Jean",
"COL": "Colossiens",
"1CO": "1 Corinthiens",
"1PE": "1 Pierre",
"JOS": "Josué"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "बाइबल पोस्टर",
"book": "पुस्तक",
"language": "भाषा",
"size": "आकार",
"print": "प्रिंट करें",
"close": "बंद करें",
"loading": "पुस्तक लोड हो रही है…",
"fetchFailed": "यह पाठ लोड नहीं हो सका। कृपया पुनः प्रयास करें।",
"fitsAt": "{pt} pt पर {cols} स्तंभों में समाता है।",
"tooSmall": "इस आकार में पढ़ने के लिए बहुत छोटा।",
"trySize": "{size} आज़माएँ",
"books": {
"ACT": "प्रेरितों के काम",
"MAT": "मत्ती",
"JHN": "यूहन्ना",
"MRK": "मरकुस",
"LUK": "लूका",
"HEB": "इब्रानियों",
"GEN": "उत्पत्ति",
"1JN": "1 यूहन्ना",
"COL": "कुलुस्सियों",
"1CO": "1 कुरिन्थियों",
"1PE": "1 पतरस",
"JOS": "यहोशू"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Poster biblici",
"book": "Libro",
"language": "Lingua",
"size": "Dimensione",
"print": "Stampa",
"close": "Chiudi",
"loading": "Caricamento del libro…",
"fetchFailed": "Impossibile caricare questo testo. Riprova.",
"fitsAt": "Sta a {pt} pt su {cols} colonne.",
"tooSmall": "Troppo piccolo da leggere a questa dimensione.",
"trySize": "prova {size}",
"books": {
"ACT": "Atti",
"MAT": "Matteo",
"JHN": "Giovanni",
"MRK": "Marco",
"LUK": "Luca",
"HEB": "Ebrei",
"GEN": "Genesi",
"1JN": "1 Giovanni",
"COL": "Colossesi",
"1CO": "1 Corinzi",
"1PE": "1 Pietro",
"JOS": "Giosuè"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Cartazes bíblicos",
"book": "Livro",
"language": "Idioma",
"size": "Tamanho",
"print": "Imprimir",
"close": "Fechar",
"loading": "Carregando o livro…",
"fetchFailed": "Não foi possível carregar este texto. Tente novamente.",
"fitsAt": "Cabe a {pt} pt em {cols} colunas.",
"tooSmall": "Pequeno demais para ler neste tamanho.",
"trySize": "tente {size}",
"books": {
"ACT": "Atos",
"MAT": "Mateus",
"JHN": "João",
"MRK": "Marcos",
"LUK": "Lucas",
"HEB": "Hebreus",
"GEN": "Gênesis",
"1JN": "1 João",
"COL": "Colossenses",
"1CO": "1 Coríntios",
"1PE": "1 Pedro",
"JOS": "Josué"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Postere biblice",
"book": "Cartea",
"language": "Limba",
"size": "Dimensiune",
"print": "Printează",
"close": "Închide",
"loading": "Se încarcă cartea…",
"fetchFailed": "Textul nu a putut fi încărcat. Încearcă din nou.",
"fitsAt": "Încape la {pt} pt pe {cols} coloane.",
"tooSmall": "Prea mic pentru a fi citit la această dimensiune.",
"trySize": "încearcă {size}",
"books": {
"ACT": "Faptele Apostolilor",
"MAT": "Matei",
"JHN": "Ioan",
"MRK": "Marcu",
"LUK": "Luca",
"HEB": "Evrei",
"GEN": "Geneza",
"1JN": "1 Ioan",
"COL": "Coloseni",
"1CO": "1 Corinteni",
"1PE": "1 Petru",
"JOS": "Iosua"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Библейские плакаты",
"book": "Книга",
"language": "Язык",
"size": "Размер",
"print": "Печать",
"close": "Закрыть",
"loading": "Загрузка книги…",
"fetchFailed": "Не удалось загрузить текст. Попробуйте ещё раз.",
"fitsAt": "Помещается при {pt} pt в {cols} колонках.",
"tooSmall": "Слишком мелко для чтения при этом размере.",
"trySize": "попробуйте {size}",
"books": {
"ACT": "Деяния",
"MAT": "Матфея",
"JHN": "Иоанна",
"MRK": "Марка",
"LUK": "Луки",
"HEB": "Евреям",
"GEN": "Бытие",
"1JN": "1 Иоанна",
"COL": "Колоссянам",
"1CO": "1 Коринфянам",
"1PE": "1 Петра",
"JOS": "Иисус Навин"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "圣经海报",
"book": "书卷",
"language": "语言",
"size": "尺寸",
"print": "打印",
"close": "关闭",
"loading": "正在加载书卷…",
"fetchFailed": "无法加载此文本,请重试。",
"fitsAt": "以 {pt} 磅、{cols} 栏排布。",
"tooSmall": "此尺寸下字太小,无法阅读。",
"trySize": "请尝试 {size}",
"books": {
"ACT": "使徒行传",
"MAT": "马太福音",
"JHN": "约翰福音",
"MRK": "马可福音",
"LUK": "路加福音",
"HEB": "希伯来书",
"GEN": "创世记",
"1JN": "约翰一书",
"COL": "歌罗西书",
"1CO": "哥林多前书",
"1PE": "彼得前书",
"JOS": "约书亚记"
}
}

View file

@ -0,0 +1,7 @@
{
"stage": "experimental",
"type": "single",
"label": "Bible Posters",
"targets": ["web"],
"note": "A generator that shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups. Text is fetched at render time from the Free Use Bible API (public-domain / openly-licensed only); no Bibles are stored in the repo. Lives in the Posterverse Experimental shelf."
}

View file

@ -0,0 +1,193 @@
<script setup>
/**
* bible-posters.vue — the Bible Posters generator (Posterverse → Experimental).
*
* Shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups.
* Text is fetched at RENDER TIME from the Free Use Bible API (bible.helloao.org); no Bibles are stored.
* v1 offers STRICT PUBLIC-DOMAIN translations only (the licence gate); the layout engine + credit line
* are in src/. Print goes through the shared in-place printer (works for a human click and an agent).
*
* @profile bible-posters
* @bundle bible-posters
* @element bible-posters
*/
import { inject, ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { printDocument } from '@map/composables/printInPlace.js'
import { PACKET_BOOKS, booksForTranslation } from './src/books.js'
import { TRANSLATIONS, TRANSLATION_BY_ID, DEFAULT_TRANSLATION_ID } from './src/translations.js'
import { BIBLE_POSTER_SIZES, SIZE_BY_ID, DEFAULT_SIZE_ID } from './src/sizes.js'
import { fetchBooks, fetchBook } from './src/bibleSource.js'
import { planBiblePoster, buildBiblePosterHtml, screenBiblePoster, MIN_READABLE_PT } from './src/biblePoster.js'
const { t } = useI18n()
const profileConfig = inject('profileConfig', null)
const cfg = (typeof profileConfig === 'object' && profileConfig) || {}
const translationId = ref(cfg.translation || DEFAULT_TRANSLATION_ID)
const bookId = ref(cfg.book || 'JHN')
const sizeId = ref(cfg.size || DEFAULT_SIZE_ID)
const availableBookIds = ref(PACKET_BOOKS.map((b) => b.id)) // narrows per translation (OT books need OT)
const book = ref(null) // the fetched, normalized book
const loading = ref(false)
const progress = reactive({ done: 0, total: 0 })
const errorKey = ref('')
let inflight = null
const translation = computed(() => TRANSLATION_BY_ID[translationId.value] || TRANSLATIONS[0])
const size = computed(() => SIZE_BY_ID[sizeId.value] || BIBLE_POSTER_SIZES[0])
const offeredBooks = computed(() => booksForTranslation(availableBookIds.value))
// The layout plan + the fit hint. `book` may be null while loading; guard.
const plan = computed(() => (book.value ? planBiblePoster(size.value, book.value) : null))
const screen = computed(() => {
if (!book.value) return null
return screenBiblePoster(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir })
})
// Which of the ladder's sizes fit this book, for the "which size" guidance.
const ladder = computed(() => {
if (!book.value) return []
return BIBLE_POSTER_SIZES.map((s) => {
const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book.value)
return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt }
})
})
const smallestFitting = computed(() => ladder.value.find((l) => l.fits) || null)
async function loadBooksList() {
try {
const list = await fetchBooks(translationId.value)
availableBookIds.value = list.map((b) => b.id)
if (!availableBookIds.value.includes(bookId.value)) {
const firstNt = offeredBooks.value.find((b) => b.testament === 'NT') || offeredBooks.value[0]
if (firstNt) bookId.value = firstNt.id
}
} catch { /* keep the full list; the book fetch reports a real failure */ }
}
async function loadBook() {
const controller = new AbortController()
if (inflight) inflight.abort()
inflight = controller
loading.value = true; errorKey.value = ''; progress.done = 0; progress.total = 0
try {
const b = await fetchBook(translationId.value, bookId.value, {
signal: controller.signal,
dir: translation.value.dir,
onProgress: (done, total) => { progress.done = done; progress.total = total },
})
if (controller.signal.aborted) return
book.value = b
} catch (e) {
if (!controller.signal.aborted) { errorKey.value = 'biblePosters.fetchFailed'; book.value = null }
} finally {
if (inflight === controller) { inflight = null; loading.value = false }
}
}
function print() {
if (!book.value) return
const html = buildBiblePosterHtml(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir })
printDocument(html, { labels: { print: t('biblePosters.print'), close: t('biblePosters.close') } })
}
watch(translationId, async () => { await loadBooksList(); await loadBook() })
watch(bookId, loadBook)
onMounted(async () => { await loadBooksList(); await loadBook() })
onBeforeUnmount(() => { if (inflight) inflight.abort() })
// The preview scales the poster's world (widthIn×96 px) to fit the preview box.
const previewBox = ref(null)
const previewScale = ref(1)
function fitPreview() {
const el = previewBox.value, s = screen.value
if (!el || !s) return
const pad = 24
const sx = (el.clientWidth - pad) / s.worldW
const sy = (el.clientHeight - pad) / s.worldH
previewScale.value = Math.max(0.02, Math.min(sx, sy))
}
watch([screen, previewBox], fitPreview, { flush: 'post' })
let ro = null
onMounted(() => { ro = new ResizeObserver(fitPreview); if (previewBox.value) ro.observe(previewBox.value) })
onBeforeUnmount(() => { if (ro) ro.disconnect() })
</script>
<template>
<div class="bpx">
<header class="bpx-bar">
<div class="bpx-title">{{ t('biblePosters.title') }}</div>
<div class="bpx-controls">
<label class="bpx-field">
<span>{{ t('biblePosters.book') }}</span>
<select v-model="bookId">
<option v-for="b in offeredBooks" :key="b.id" :value="b.id">{{ t('biblePosters.books.' + b.id) }}</option>
</select>
</label>
<label class="bpx-field">
<span>{{ t('biblePosters.language') }}</span>
<select v-model="translationId">
<option v-for="tr in TRANSLATIONS" :key="tr.id" :value="tr.id">{{ tr.endonym }}</option>
</select>
</label>
<label class="bpx-field">
<span>{{ t('biblePosters.size') }}</span>
<select v-model="sizeId">
<option v-for="s in BIBLE_POSTER_SIZES" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</label>
<button type="button" class="bpx-print" :disabled="!book || loading" @click="print">
{{ t('biblePosters.print') }}
</button>
</div>
</header>
<p class="bpx-hint" v-if="plan">
<span v-if="plan.fits">{{ t('biblePosters.fitsAt', { pt: plan.fontPt, cols: plan.cols }) }}</span>
<span v-else class="bpx-warn">
{{ t('biblePosters.tooSmall') }}
<template v-if="smallestFitting"> — {{ t('biblePosters.trySize', { size: smallestFitting.label }) }}</template>
</span>
</p>
<div class="bpx-stage" ref="previewBox">
<div v-if="loading" class="bpx-status">
{{ t('biblePosters.loading') }}<span v-if="progress.total"> {{ progress.done }} / {{ progress.total }}</span>
</div>
<div v-else-if="errorKey" class="bpx-status bpx-warn">{{ t(errorKey) }}</div>
<template v-else-if="screen">
<div class="bpx-scaler" :style="{ width: screen.worldW + 'px', height: screen.worldH + 'px', transform: 'scale(' + previewScale + ')' }">
<component :is="'style'">{{ screen.css }}</component>
<div class="bp" v-html="screen.html"></div>
</div>
</template>
</div>
<p class="bpx-credit" v-if="book">{{ translation.credit }}</p>
</div>
</template>
<style>
.bpx { display: flex; flex-direction: column; height: 100%; min-height: 0; background: #f3f3f1; color: #3b463d;
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; }
.bpx-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; justify-content: space-between;
padding: 12px 16px; background: #fff; border-bottom: 1px solid #d1d4cd; }
.bpx-title { font-size: 18px; font-weight: 700; }
.bpx-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.bpx-field { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #6d766e; }
.bpx-field select { min-height: 36px; padding: 0 10px; border: 1px solid #d1d4cd; border-radius: 8px;
background: #fff; color: #3b463d; font: inherit; }
.bpx-print { min-height: 38px; padding: 0 18px; border: 0; border-radius: 8px; background: #3b463d; color: #fff;
font: inherit; font-weight: 600; cursor: pointer; }
.bpx-print:disabled { background: #b7bdb4; cursor: default; }
.bpx-hint { margin: 0; padding: 8px 16px; font-size: 13px; color: #3b463d; }
.bpx-warn { color: #9a3b2f; }
.bpx-stage { flex: 1 1 0; min-height: 0; position: relative; overflow: hidden; display: flex;
align-items: center; justify-content: center; padding: 12px; }
.bpx-scaler { transform-origin: center center; box-shadow: 0 6px 24px rgba(0,0,0,.18); background: #fff; }
.bpx-status { font-size: 14px; color: #6d766e; }
.bpx-credit { margin: 0; padding: 8px 16px; font-size: 11px; color: #6d766e; text-align: center;
border-top: 1px solid #d1d4cd; background: #fff; }
</style>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · bible-posters staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #f3f3f1; color: #3b463d; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px 20px; }
h1 { font-size: 20px; font-weight: 700; }
.sub { color: #6d766e; font-size: 13px; margin: 6px 0 20px; }
.frame { height: 80vh; border: 1px solid #d1d4cd; border-radius: 12px; overflow: hidden; background: #fff; }
bible-posters { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>bible-posters · staging</h1>
<p class="sub">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.</p>
<div class="frame">
<bible-posters profile-config='{"profile":"bible-posters"}'></bible-posters>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -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.
*
* <script src="/dx-maps/bible-posters/bible-posters.js"></script>
* <bible-posters profile-config='{"profile":"bible-posters","locale":"ar"}'></bible-posters>
*/
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

View file

@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 += '<h2 class="bh">' + esc(b.text) + '</h2>'; continue }
if (b.chapter !== lastChapter) { html += '<span class="cn">' + esc(b.chapter) + '</span>'; lastChapter = b.chapter }
html += '<span class="v"><sup class="vn" dir="ltr">' + esc(b.number) + '</sup>' + esc(b.text) + ' </span>'
}
return html
}
/** CSS shared by print and screen. Built by concatenation (no backticks inside — AGENT-BRIEF house rule
* for shadow CSS; kept here for consistency and safe embedding). `unit` scales the poster to px on screen. */
function posterCss(plan, dir, unit) {
const u = (inches) => (inches * unit).toFixed(2) + (unit === 1 ? 'in' : 'px')
const isRtl = dir === 'rtl'
return ''
+ '*{box-sizing:border-box}'
+ '.bp{position:relative;width:' + u(plan.widthIn) + ';height:' + u(plan.heightIn) + ';'
+ 'padding:' + u(plan.marginIn) + ';background:#fff;color:#1c1c1c;'
+ 'font-family:Georgia,"Times New Roman",serif;direction:' + (isRtl ? 'rtl' : 'ltr') + ';'
+ '-webkit-print-color-adjust:exact;print-color-adjust:exact;overflow:hidden}'
+ '.bp-head{height:' + u(plan.headHIn) + ';display:flex;flex-direction:column;align-items:center;'
+ 'justify-content:center;text-align:center;border-bottom:' + u(0.02) + ' solid #1c1c1c}'
+ '.bp-title{margin:0;font-weight:700;line-height:1.02;font-size:' + u(plan.headHIn * 0.5) + '}'
+ '.bp-body{height:' + u(plan.contentHIn) + ';margin-top:' + u(0.12) + ';'
+ 'column-count:' + plan.cols + ';column-gap:' + u(plan.colGapIn) + ';column-fill:auto;'
+ 'font-size:' + (unit === 1 ? plan.fontPt + 'pt' : (plan.fontPt * unit / 72).toFixed(2) + 'px') + ';'
+ 'line-height:' + plan.lineHeight + ';text-align:justify;hyphens:auto;-webkit-hyphens:auto;'
+ 'text-justify:inter-word;overflow:hidden}'
+ '.bp-body .bh{font-weight:700;font-size:1.02em;margin:0.5em 0 0.15em;break-after:avoid;'
+ 'font-family:-apple-system,"Segoe UI",Arial,sans-serif}'
+ '.bp-body .cn{font-weight:700;font-size:1.5em;line-height:1;float:' + (isRtl ? 'right' : 'left') + ';'
+ 'margin:' + (isRtl ? '0 0 0 0.12em' : '0 0.12em 0 0') + ';color:#92552a}'
+ '.bp-body .vn{font-size:0.62em;font-weight:700;color:#92552a;vertical-align:super;'
+ 'unicode-bidi:isolate;margin:0 0.12em}'
+ '.bp-body .v{}'
+ '.bp-credit{position:absolute;left:' + u(plan.marginIn) + ';right:' + u(plan.marginIn) + ';'
+ 'bottom:' + u(plan.marginIn * 0.4) + ';height:' + u(plan.creditHIn) + ';display:flex;align-items:center;'
+ 'justify-content:center;text-align:center;font-family:-apple-system,"Segoe UI",Arial,sans-serif;'
+ 'font-size:' + u(0.11) + ';color:#6d766e}'
}
/** The print document: one page, the whole book. `texts` supplies the credit line and optional dir/lang. */
export function buildBiblePosterHtml(book, size, texts = {}) {
const dir = texts.dir || book.dir || 'ltr'
const plan = planBiblePoster(size, book)
const css = '@page{size:' + size.widthIn + 'in ' + size.heightIn + 'in;margin:0}html,body{margin:0;padding:0}'
+ posterCss(plan, dir, 1)
const inner = '<div class="bp-head"><h1 class="bp-title" dir="auto">' + esc(book.name) + '</h1></div>'
+ '<div class="bp-body" dir="auto">' + bodyHtml(book) + '</div>'
+ '<div class="bp-credit">' + esc(texts.credit || '') + '</div>'
return '<!doctype html><html' + (dir === 'rtl' ? ' dir="rtl"' : '') + '><head><meta charset="utf-8">'
+ '<title>' + esc(book.name) + '</title><style>' + css + '</style></head>'
+ '<body><div class="bp">' + inner + '</div></body></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 = '<div class="bp-head"><h1 class="bp-title" dir="auto">' + esc(book.name) + '</h1></div>'
+ '<div class="bp-body" dir="auto">' + bodyHtml(book) + '</div>'
+ '<div class="bp-credit">' + esc(texts.credit || '') + '</div>'
return { css, html, dir, plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
}

View file

@ -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() }

View file

@ -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))
}

View file

@ -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.<key>, 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'

View file

@ -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'