Bible push: merge 0a4e53993
This commit is contained in:
commit
c0eafc4e10
21 changed files with 967 additions and 0 deletions
|
|
@ -415,6 +415,7 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
|
|||
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
|
||||
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
|
||||
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
|
||||
| `connect-src` (Bible Posters) | `https://bible.helloao.org` | the public-domain Bible text, fetched per book at render time (no text is bundled) |
|
||||
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
|
||||
|
||||
---
|
||||
|
|
|
|||
27
library/i18n/locales/ar/biblePosters.json
Normal file
27
library/i18n/locales/ar/biblePosters.json
Normal 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": "يشوع"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/de/biblePosters.json
Normal file
27
library/i18n/locales/de/biblePosters.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/en/biblePosters.json
Normal file
27
library/i18n/locales/en/biblePosters.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/es/biblePosters.json
Normal file
27
library/i18n/locales/es/biblePosters.json
Normal 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é"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/fr/biblePosters.json
Normal file
27
library/i18n/locales/fr/biblePosters.json
Normal 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é"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/hi/biblePosters.json
Normal file
27
library/i18n/locales/hi/biblePosters.json
Normal 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": "यहोशू"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/it/biblePosters.json
Normal file
27
library/i18n/locales/it/biblePosters.json
Normal 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è"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/pt/biblePosters.json
Normal file
27
library/i18n/locales/pt/biblePosters.json
Normal 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é"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/ro/biblePosters.json
Normal file
27
library/i18n/locales/ro/biblePosters.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/ru/biblePosters.json
Normal file
27
library/i18n/locales/ru/biblePosters.json
Normal 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": "Иисус Навин"
|
||||
}
|
||||
}
|
||||
27
library/i18n/locales/zh/biblePosters.json
Normal file
27
library/i18n/locales/zh/biblePosters.json
Normal 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": "约书亚记"
|
||||
}
|
||||
}
|
||||
7
plugin-designs/bible-posters/bible-posters.meta.json
Normal file
7
plugin-designs/bible-posters/bible-posters.meta.json
Normal 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."
|
||||
}
|
||||
193
plugin-designs/bible-posters/bible-posters.vue
Normal file
193
plugin-designs/bible-posters/bible-posters.vue
Normal 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, lang: translation.value.bcp47 })
|
||||
})
|
||||
|
||||
// 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, lang: translation.value.bcp47 })
|
||||
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" :lang="screen.lang || null" :dir="screen.dir" 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>
|
||||
30
plugin-designs/bible-posters/index.html
Normal file
30
plugin-designs/bible-posters/index.html
Normal 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>
|
||||
32
plugin-designs/bible-posters/index.js
Normal file
32
plugin-designs/bible-posters/index.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* 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 }) => {
|
||||
// defineMapElement already installs Pinia + i18n and provides the i18n instance to ProfileLoader
|
||||
// (that is how a host sets the interface language). This hook is only for extra app-level provides;
|
||||
// Bible Posters needs none.
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('bible-posters')) {
|
||||
customElements.define('bible-posters', BiblePostersElement)
|
||||
}
|
||||
|
||||
export default BiblePostersElement
|
||||
156
plugin-designs/bible-posters/src/biblePoster.js
Normal file
156
plugin-designs/bible-posters/src/biblePoster.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* 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 approved layout: 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 check 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 approved design, 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 += '<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 lang = texts.lang || '' // a BCP-47 tag so hyphens:auto works and the page declares its language
|
||||
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' + (lang ? ' lang="' + esc(lang) + '"' : '') + (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, lang: texts.lang || '', plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
|
||||
}
|
||||
114
plugin-designs/bible-posters/src/bibleSource.js
Normal file
114
plugin-designs/bible-posters/src/bibleSource.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* 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
|
||||
|
||||
// One book is one atomic fetch: a chapter failure ABORTS the siblings (never a half book), while still
|
||||
// honouring the caller's own signal. An internal controller chains to it so we can abort from inside.
|
||||
const internal = new AbortController()
|
||||
const onExt = () => internal.abort()
|
||||
if (signal) { if (signal.aborted) internal.abort(); else signal.addEventListener('abort', onExt, { once: true }) }
|
||||
|
||||
async function one(ch) {
|
||||
try {
|
||||
const d = await getJson(`${API_BASE}/${translationId}/${bookId}/${ch}.json`, internal.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)
|
||||
} catch (e) { internal.abort(); throw e } // stop the other workers on the first failure
|
||||
}
|
||||
|
||||
// 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 && !internal.signal.aborted) { const ch = queue.shift(); await one(ch) }
|
||||
})
|
||||
try { await Promise.all(workers) }
|
||||
finally { if (signal) signal.removeEventListener('abort', onExt) }
|
||||
|
||||
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() }
|
||||
39
plugin-designs/bible-posters/src/books.js
Normal file
39
plugin-designs/bible-posters/src/books.js
Normal 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
|
||||
* (the approved starting set): 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))
|
||||
}
|
||||
35
plugin-designs/bible-posters/src/sizes.js
Normal file
35
plugin-designs/bible-posters/src/sizes.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* sizes.js — the poster size ladder for Bible Posters, taken from the SHARED table.
|
||||
*
|
||||
* Consumes the centrally-owned ladder (library/constants/posterSizes.js, imported as
|
||||
* '@map/constants/posterSizes.js'); this plugin only SELECTS its subset of keys and keeps its own
|
||||
* locale-neutral dimension labels until the shared picker (with localized names) lands. The size table
|
||||
* is never edited here.
|
||||
*
|
||||
* A book is laid out on the FINISHED poster (width × height, portrait). `sheets` is the shared entry's
|
||||
* panel count; the multi-panel PRINT path (reusing library panelSheets.js) is a later milestone — the
|
||||
* fit MODEL uses the finished dimensions.
|
||||
*/
|
||||
import { POSTER_LADDER, resolvePosterSize } from '@map/constants/posterSizes.js'
|
||||
|
||||
// The 5 ladder keys this plugin offers (portrait; a whole book needs the large ones). 'paper' carries
|
||||
// Letter inches in the shared table; sizing it to A4 for an A4 reader (via paperFor) is a later refinement.
|
||||
const BIBLE_KEYS = ['paper', '36x36', '36x60', '66x66', '60x96']
|
||||
|
||||
// Locale-neutral DIMENSION labels (in/ft are unit symbols, like cm) so no English word leaks into a
|
||||
// non-English UI. Swap for the shared picker's localized names when it lands.
|
||||
const LABELS = {
|
||||
paper: '8.5 × 11 in',
|
||||
'36x36': '3 × 3 ft',
|
||||
'36x60': '3 × 5 ft',
|
||||
'66x66': '5.5 × 5.5 ft',
|
||||
'60x96': '5 × 8 ft',
|
||||
}
|
||||
|
||||
export const BIBLE_POSTER_SIZES = BIBLE_KEYS.map((k) => {
|
||||
const e = resolvePosterSize(k) || POSTER_LADDER[k]
|
||||
return { id: k, key: k, label: LABELS[k], widthIn: e.inches.w, heightIn: e.inches.h, sheets: e.sheets.count }
|
||||
})
|
||||
|
||||
export const SIZE_BY_ID = Object.fromEntries(BIBLE_POSTER_SIZES.map((s) => [s.id, s]))
|
||||
export const DEFAULT_SIZE_ID = '36x60'
|
||||
63
plugin-designs/bible-posters/src/translations.js
Normal file
63
plugin-designs/bible-posters/src/translations.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* translations.js — the LICENCE-VETTED text sources for the Bible Posters generator.
|
||||
*
|
||||
* ⚠ LICENSING GATE (this plugin's first task). 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. It is safe under EITHER answer to the open licensing 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 open licensing decision; do not add a non-public-domain text
|
||||
* here without explicit sign-off 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', bcp47: 'en', language: 'English', endonym: 'English', name: 'World English Bible',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'World English Bible · public domain · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=eng-web' },
|
||||
{ id: 'spa_r09', iso639_3: 'spa', bcp47: 'es', language: 'Spanish', endonym: 'Español', name: 'Reina Valera 1909',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Reina Valera 1909 · dominio público · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=spaRV1909' },
|
||||
{ id: 'fra_ost', iso639_3: 'fra', bcp47: 'fr', language: 'French', endonym: 'Français', name: 'French Ostervald Bible',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Bible Ostervald · domaine public · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=fra_fob' },
|
||||
{ id: 'deu_l12', iso639_3: 'deu', bcp47: 'de', language: 'German', endonym: 'Deutsch', name: 'German Luther Bible 1912',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Lutherbibel 1912 · gemeinfrei · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=deu1912' },
|
||||
{ id: 'rus_syn', iso639_3: 'rus', bcp47: 'ru', language: 'Russian', endonym: 'Русский', name: 'Russian Synodal Bible',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Синодальный перевод · общественное достояние · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=russyn' },
|
||||
{ id: 'arb_vdv', iso639_3: 'arb', bcp47: 'ar', language: 'Arabic', endonym: 'العربية', name: 'Arabic Van Dyck Bible',
|
||||
dir: 'rtl', licence: 'public-domain',
|
||||
credit: 'فان دايك · ملك عام · ebible.org · 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'
|
||||
Loading…
Reference in a new issue