Bible Posters: language-tagged columns, atomic book fetch, cleaner credits

Address studio's 5B nits (non-blocking, folded in before the first render):
- Tag each translation with a BCP-47 code and set lang on the poster (html + body)
  so hyphens:auto works in narrow justified columns — removes rivers on Latin text.
- Fetch a whole book atomically: a single chapter failure now aborts the sibling
  requests and surfaces the error, instead of leaving a half book.
- Drop the English word "via" from the per-poster credit lines (a bare separator).
- Correct the entry comment: defineMapElement installs i18n; the hook only adds provides.
This commit is contained in:
Doxa Maps 2026-09-30 23:07:10 +00:00
parent 5c44b3cdcd
commit cdf0df268d
5 changed files with 42 additions and 31 deletions

View file

@ -43,7 +43,7 @@ const offeredBooks = computed(() => booksForTranslation(availableBookIds.value))
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 })
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.
@ -89,7 +89,7 @@ async function loadBook() {
function print() {
if (!book.value) return
const html = buildBiblePosterHtml(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir })
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') } })
}
@ -160,7 +160,7 @@ onBeforeUnmount(() => { if (ro) ro.disconnect() })
<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 class="bp" :lang="screen.lang || null" :dir="screen.dir" v-html="screen.html"></div>
</div>
</template>
</div>

View file

@ -19,8 +19,9 @@ 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.
// 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.
},
})

View file

@ -132,13 +132,14 @@ function posterCss(plan, dir, unit) {
/** 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' + (dir === 'rtl' ? ' dir="rtl"' : '') + '><head><meta charset="utf-8">'
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>'
}
@ -151,5 +152,5 @@ export function screenBiblePoster(book, size, texts = {}) {
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 }
return { css, html, dir, lang: texts.lang || '', plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
}

View file

@ -72,26 +72,35 @@ export async function fetchBook(translationId, bookId, { signal, onProgress, dir
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) {
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)
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) { const ch = queue.shift(); await one(ch) }
while (queue.length && !internal.signal.aborted) { const ch = queue.shift(); await one(ch) }
})
await Promise.all(workers)
try { await Promise.all(workers) }
finally { if (signal) signal.removeEventListener('abort', onExt) }
const blocks = chapterBlocks.flat().filter(Boolean)
let verseCount = 0, charCount = 0

View file

@ -34,29 +34,29 @@
/** 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',
{ 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 (via bible.helloao.org)',
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', language: 'Spanish', endonym: 'Español', name: 'Reina Valera 1909',
{ 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 (via bible.helloao.org)',
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', language: 'French', endonym: 'Français', name: 'French Ostervald Bible',
{ 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 (via bible.helloao.org)',
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', language: 'German', endonym: 'Deutsch', name: 'German Luther Bible 1912',
{ 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 (via bible.helloao.org)',
credit: 'Lutherbibel 1912 · gemeinfrei · ebible.org · 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',
{ id: 'rus_syn', iso639_3: 'rus', bcp47: 'ru', language: 'Russian', endonym: 'Русский', name: 'Russian Synodal Bible',
dir: 'ltr', licence: 'public-domain',
credit: 'Синодальный перевод · общественное достояние · ebible.org (via bible.helloao.org)',
credit: 'Синодальный перевод · общественное достояние · ebible.org · 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',
{ id: 'arb_vdv', iso639_3: 'arb', bcp47: 'ar', language: 'Arabic', endonym: 'العربية', name: 'Arabic Van Dyck Bible',
dir: 'rtl', licence: 'public-domain',
credit: 'فان دايك · ملك عام · ebible.org (via bible.helloao.org)',
credit: 'فان دايك · ملك عام · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=arb-vd' },
]