From 042d2ec10f02cb69500cad3ba821b46b9e7fd406 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 00:55:43 +0000 Subject: [PATCH 01/15] UPG100 packet: the set captions count every group of the list A group with no record in a set figure's data (no near-neighbour data, for example) was in none of that figure's numbers, so the captions added up to less than the list: Laos printed 94 of its 95 groups. Each caption now names the groups without data ("1 without data", in all 11 languages), so its parts add up to the list. check:packet-variants asserts the sum both ways. --- internal/check-packet-variants.mjs | 16 ++++++++++++++++ library/packet/packet-render.js | 13 +++++++++---- library/scripts/posters/packet-strings.json | 11 +++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/check-packet-variants.mjs b/internal/check-packet-variants.mjs index a18be11b6..25040a0f7 100644 --- a/internal/check-packet-variants.mjs +++ b/internal/check-packet-variants.mjs @@ -217,6 +217,22 @@ ok(!/[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't ok(/10/.test(caps.join(' ')), 'the bounded tally does not state its unknown count — an un-inked category reads as zero in print') } +// ★ EVERY GROUP OF THE LIST IS COUNTED (2026-10-03): a group with no record in a figure's data is named in +// that figure's caption ("15 without data"), so each caption's parts add up to the list. Both ways: the +// fixture's figures lack records (45 groups; 30 bounded, 24 centered), and complete data prints no such part. +{ + const sum = (cap) => [...cap.matchAll(/\d[\d,]*/g)].reduce((a, m) => a + Number(m[0].replace(/,/g, '')), 0) + const capsOf = (html) => [...html.matchAll(/class="set-cap">([^<]*) m[1]) + const nd = String(strings.en.sets_no_data).replace('{n}', '').trim() + const short = capsOf(full) + ok(short.length === 2 && short.every((c) => sum(c) === 45 && c.includes(nd)), + `each set caption should add up to the list's 45 groups, naming the ones ${nd}: ${short.join(' | ')}`) + const whole = capsOf(R.buildPacket(zone, groups, new Map(), '', funnel, christian, 'full', { + bounded: groups.map((g, i) => ({ id: g.slug, reached: i % 2 === 0 })), centered: groups.map((g, i) => ({ id: g.slug, ring: (i % 10) + 1 })) })) + ok(whole.length === 2 && whole.every((c) => sum(c) === 45 && !c.includes(nd)), + `with a record for every group, each caption should add up to 45 and name none ${nd}: ${whole.join(' | ')}`) +} + // ── THE RING LEGEND: THE AGREED VOCABULARY, AND ONLY THE RINGS THAT HOLD SOMEONE ──────────────── // ★ The names are COMPOSED from band x culture rather than a flat 1..10 table, because "same // people" (provisional, pending a product decision) appears in rings 1, 4 AND 7 — three cells that a diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index 89f12d3e0..ac4f70533 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -1632,13 +1632,18 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de + ``; } - function setsPage(zone, sets) { + function setsPage(zone, sets, listSize) { // '' WITHOUT DATA, exactly like funnelPage and christianPage — a page that cannot state true // figures must not appear at all. This is what keeps FULL valid before a host supplies sets. if (!sets || (!Array.isArray(sets.bounded) && !Array.isArray(sets.centered))) return ''; const bounded = Array.isArray(sets.bounded) ? sets.bounded : []; const centered = Array.isArray(sets.centered) ? sets.centered : []; if (!bounded.length && !centered.length) return ''; + // ★ EVERY GROUP OF THE LIST IS COUNTED (2026-10-03). A group with no record in a figure's data (no + // near-neighbour data, say) is in none of its figures, so a caption used to add up to less than the + // list: Laos printed "88 placed · 0 at the centre · 6 unplaced", 94 of its 95. The groups missing from + // a figure's data are now named in its caption ("1 without data"), so its parts add up to the list. + const noData = (n) => (Number.isFinite(listSize) && listSize > n ? ` · ${fill(t('sets_no_data'), { n: num(listSize - n) })}` : ''); const panels = []; let legend = ''; @@ -1661,7 +1666,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de ${cross(L.cx, L.cy, 44)}

${esc(fill(t('sets_bounded_tally'), - { inside: num(T.inside), outside: num(T.outside), unknown: num(T.unknown) }))}

+ { inside: num(T.inside), outside: num(T.outside), unknown: num(T.unknown) }) + noData(bounded.length))}

` }); } @@ -1713,7 +1718,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de ${bands.join('')}${dots}${nums.join('')}${cross(0, 0, 40)}

${esc(fill(t('sets_centered_tally'), - { placed: num(bf.placed.length), centre: num(T.centre), unplaced: num(T.unplaced + bf.unplaced.length) }))}

+ { placed: num(bf.placed.length), centre: num(T.centre), unplaced: num(T.unplaced + bf.unplaced.length) }) + noData(centered.length))}

` }); const rows = ringLegend(T); // The legend first says what a ring IS, in plain words, then lists the rings that hold someone. @@ -1880,7 +1885,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // packet lists the SAME groups as a worksheet (LIST_STYLE); the full packet as profiles. ...(LIST_STYLE[variant] ? LIST_STYLE[variant](zone, groups, photos) : listPages(zone, groups, photos)), // BACK MATTER: after the profiles (brief A1). Full-detail only. - keep('sets') && setsPage(zone, sets), + keep('sets') && setsPage(zone, sets, Array.isArray(groups) ? groups.length : NaN), keep('wheel') && wheelPage(), keep('points') && pointsPage(), ].filter(Boolean); diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json index dfafecbf2..915547300 100644 --- a/library/scripts/posters/packet-strings.json +++ b/library/scripts/posters/packet-strings.json @@ -263,6 +263,7 @@ "sets_bounded_title": "Reached, and not", "sets_centered_title": "Distance from a neighbour", "sets_bounded_tally": "{inside} inside · {outside} outside · {unknown} unknown", + "sets_no_data": "{n} without data", "sets_centered_tally": "{placed} placed · {centre} at the centre · {unplaced} unplaced", "ring_band_dialect": "Same dialect", "ring_band_language": "Same language", @@ -608,6 +609,7 @@ "sets_bounded_title": "Alcanzados y no alcanzados", "sets_centered_title": "Distancia a un vecino", "sets_bounded_tally": "{inside} dentro · {outside} fuera · {unknown} desconocido", + "sets_no_data": "{n} sin datos", "sets_centered_tally": "{placed} ubicados · {centre} en el centro · {unplaced} sin ubicar", "ring_band_dialect": "Mismo dialecto", "ring_band_language": "Misma lengua", @@ -939,6 +941,7 @@ "sets_bounded_title": "Atteints, ou non", "sets_centered_title": "Distance d’un voisin", "sets_bounded_tally": "{inside} à l’intérieur · {outside} à l’extérieur · {unknown} inconnu", + "sets_no_data": "{n} sans données", "sets_centered_tally": "{placed} placés · {centre} au centre · {unplaced} non placés", "ring_band_dialect": "Même dialecte", "ring_band_language": "Même langue", @@ -1267,6 +1270,7 @@ "sets_bounded_title": "Alcançados e não alcançados", "sets_centered_title": "Distância de um vizinho", "sets_bounded_tally": "{inside} dentro · {outside} fora · {unknown} desconhecido", + "sets_no_data": "{n} sem dados", "sets_centered_tally": "{placed} posicionados · {centre} no centro · {unplaced} sem posição", "ring_band_dialect": "Mesmo dialeto", "ring_band_language": "Mesma língua", @@ -1598,6 +1602,7 @@ "sets_bounded_title": "Erreicht und nicht erreicht", "sets_centered_title": "Abstand zu einem Nachbarn", "sets_bounded_tally": "{inside} innen · {outside} außen · {unknown} unbekannt", + "sets_no_data": "{n} ohne Daten", "sets_centered_tally": "{placed} platziert · {centre} in der Mitte · {unplaced} ohne Platz", "ring_band_dialect": "Gleicher Dialekt", "ring_band_language": "Gleiche Sprache", @@ -1926,6 +1931,7 @@ "sets_bounded_title": "Raggiunti e non raggiunti", "sets_centered_title": "Distanza da un vicino", "sets_bounded_tally": "{inside} dentro · {outside} fuori · {unknown} sconosciuto", + "sets_no_data": "{n} senza dati", "sets_centered_tally": "{placed} collocati · {centre} al centro · {unplaced} non collocati", "ring_band_dialect": "Stesso dialetto", "ring_band_language": "Stessa lingua", @@ -2254,6 +2260,7 @@ "sets_bounded_title": "Atinse și neatinse", "sets_centered_title": "Distanța față de un vecin", "sets_bounded_tally": "{inside} înăuntru · {outside} în afară · {unknown} necunoscut", + "sets_no_data": "{n} fără date", "sets_centered_tally": "{placed} plasate · {centre} în centru · {unplaced} neplasate", "ring_band_dialect": "Același dialect", "ring_band_language": "Aceeași limbă", @@ -2583,6 +2590,7 @@ "sets_bounded_title": "Достигнутые и нет", "sets_centered_title": "Расстояние до соседа", "sets_bounded_tally": "{inside} внутри · {outside} снаружи · {unknown} неизвестно", + "sets_no_data": "{n} без данных", "sets_centered_tally": "{placed} размещено · {centre} в центре · {unplaced} без места", "ring_band_dialect": "Тот же диалект", "ring_band_language": "Тот же язык", @@ -2921,6 +2929,7 @@ "sets_bounded_title": "وصلتهم البشارة ومن لم تصلهم", "sets_centered_title": "المسافة إلى جار", "sets_bounded_tally": "{inside} في الداخل · {outside} في الخارج · {unknown} غير معروف", + "sets_no_data": "{n} بلا بيانات", "sets_centered_tally": "{placed} موضوعون · {centre} في المركز · {unplaced} بلا موضع", "ring_band_dialect": "اللهجة نفسها", "ring_band_language": "اللغة نفسها", @@ -3249,6 +3258,7 @@ "sets_bounded_title": "पहुँचे और नहीं पहुँचे", "sets_centered_title": "पड़ोसी से दूरी", "sets_bounded_tally": "{inside} भीतर · {outside} बाहर · {unknown} अज्ञात", + "sets_no_data": "{n} बिना आँकड़ों के", "sets_centered_tally": "{placed} स्थित · {centre} केंद्र में · {unplaced} अस्थित", "ring_band_dialect": "वही बोली", "ring_band_language": "वही भाषा", @@ -3575,6 +3585,7 @@ "sets_bounded_title": "已触及与未触及", "sets_centered_title": "与邻近族群的距离", "sets_bounded_tally": "{inside} 圈内 · {outside} 圈外 · {unknown} 未知", + "sets_no_data": "{n} 无数据", "sets_centered_tally": "{placed} 已定位 · {centre} 在中心 · {unplaced} 未定位", "ring_band_dialect": "同一方言", "ring_band_language": "同一语言", From 446b0bfc8f16620af4a6d9c53424707d19f7771c Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 05:15:12 +0000 Subject: [PATCH 02/15] UPG100 packet: Full detail page 1 rows follow the explanation Page 1 of the Full detail list reserved a fixed block for its explanation and placed the rows below the reserve, so every language shorter than the tallest left a white gap above the head row (0.36 to 1.30 in). The explanation (at most the old reserve) and the rows now share one column: the head row sits right under the explanation and the spare room falls to the page's foot. The page still holds the same number of rows; the rows stay absolutely placed, only their origin moves. --- library/packet/packet-render.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index ac4f70533..428db099c 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -376,7 +376,11 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de .pray-head{position:absolute;inset-inline-end:${M}px;display:grid;grid-template-columns:repeat(${PRAY_COLS.length},${PRAY_CELL}px); height:${PRAY_HEAD_H - 6}px;align-items:end;font:700 15px/1.2 Georgia,serif;color:#6f6a5c;text-align:center} .pray-head span{text-wrap:balance;padding:0 0 5px;border-bottom:2px solid #3b463d} - .fd-top{position:absolute;left:${M}px;top:${M + HEAD}px;width:${CW.toFixed(0)}px;height:${FD_TOP_H}px;overflow:hidden} + /* Page 1 of the Full detail list: the explanation, then the head row and the rows right under it (listPages). */ + .fd-first{position:absolute;left:${M}px;top:${M + HEAD}px;width:${CW.toFixed(0)}px} + .fd-top{max-height:${FD_TOP_H}px;overflow:hidden} + .fd-rows{position:relative} + .fd-rows .pray-head{inset-inline-end:0} .hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170} /* ⚠ box-sizing:border-box IS LOAD-BEARING HERE, NOT HOUSEKEEPING. Without it the row's height is the CONTENT box and the padding + border are added OUTSIDE it, so the row renders taller @@ -1191,7 +1195,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de PRAY_COLS.map((key) => `${esc(t(key))}`).join('')}`; for (let start = 0, page = 0; start < groups.length; page += 1) { const per = page === 0 ? FD_FIRST : PER_PAGE; - const rowsTop = page === 0 ? FD_ROWS_TOP : M + HEAD; + // ★ PAGE 1'S ROWS FOLLOW THE EXPLANATION (2026-10-03). The explanation reserved a fixed FD_TOP_H and the + // rows sat below the reserve, so every language shorter than the tallest left a gap above the head row + // (0.36-1.30 in, measured in all 11). Now the explanation (at most FD_TOP_H) and the rows share one + // column (.fd-first), and the rows are placed inside .fd-rows, right under it. The page still holds + // FD_FIRST rows, sized for the full reserve, so a shorter explanation leaves the room at the page's foot. + // The rows stay absolutely placed (see below), only their origin moves: no row can reflow to a page. + const rowsTop = page === 0 ? PRAY_HEAD_H : M + HEAD; + const left = page === 0 ? 0 : M; const slice = groups.slice(start, start + per); // ★ ONE FULL-WIDTH ROW PER GROUP. The column index is gone: `top` advances by the row, and the // three cells sit inside it. Absolute positioning is kept rather than switching to flow layout @@ -1208,7 +1219,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de const img = src ? `` : `
${esc(t('no_photo'))}
`; - return `
${img}
${i + 1}. ${esc(g.name)}
@@ -1217,15 +1228,17 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
${PRAY_COLS.map(() => '').join('')}
`; }).join(''); - const first = page === 0 - ? `
${explainTop()}${explainBand(groups.length, + const rowsH = PRAY_HEAD_H + slice.length * (CH + GUT) - GUT; + const body = page === 0 + ? `
${explainTop()}${explainBand(groups.length, { w: PRAY_COLS.length * PRAY_CELL, cols: `repeat(${PRAY_COLS.length},${PRAY_CELL}px)`, gap: 0, cell: 'xb-cell xb-cell-fd' })}
` - : ''; + + `
${prayHead(0)}${cards}
` + : `${prayHead(rowsTop - PRAY_HEAD_H)}${cards}`; pages.push(sheet(`
${esc(zone.label || zone.id)}
${esc(fill(t('list_header'), { from: num(start + 1), to: num(Math.min(start + per, groups.length)), count: num(groups.length) }))}
- ${first}${prayHead(rowsTop - PRAY_HEAD_H)}${cards}`)); + ${body}`)); start += per; } return pages; From f69a10c8e07c624e4125536c83e005c9c59489df Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 05:42:42 +0000 Subject: [PATCH 03/15] UPG100 poster: new header words, mirrored columns, UPG100 spelled as one term - The Jesus column and the "What is a UPG100 List?" column take their new wording (series.json); "informed intercession." prints bold, a **...** run in the head-columns band. - The two prose columns take the full measure, so each sits the same distance from its rule and they mirror each other round the centred facts column. - The Poster Design label of the UPG100 zone posters spells UPG100 and matches Poster Design's own card title, in all 11 languages. --- library/i18n/locales/ar/upgMap.json | 2 +- library/i18n/locales/de/upgMap.json | 2 +- library/i18n/locales/en/upgMap.json | 2 +- library/i18n/locales/es/upgMap.json | 2 +- library/i18n/locales/fr/upgMap.json | 2 +- library/i18n/locales/hi/upgMap.json | 2 +- library/i18n/locales/it/upgMap.json | 2 +- library/i18n/locales/pt/upgMap.json | 2 +- library/i18n/locales/ro/upgMap.json | 2 +- library/i18n/locales/ru/upgMap.json | 2 +- library/i18n/locales/zh/upgMap.json | 2 +- library/packet/poster-render.js | 32 +++++++++++++++++++++++++---- library/scripts/posters/series.json | 6 +++--- 13 files changed, 42 insertions(+), 18 deletions(-) diff --git a/library/i18n/locales/ar/upgMap.json b/library/i18n/locales/ar/upgMap.json index bd77e9a42..53d106ba5 100644 --- a/library/i18n/locales/ar/upgMap.json +++ b/library/i18n/locales/ar/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "ملصقات UPG100", "wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ", "peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة", - "posterDesignTerritory": "ملصق منطقة UPG-100" + "posterDesignTerritory": "ملصق منطقة UPG100" } diff --git a/library/i18n/locales/de/upgMap.json b/library/i18n/locales/de/upgMap.json index c247491fe..4240cd3d1 100644 --- a/library/i18n/locales/de/upgMap.json +++ b/library/i18n/locales/de/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "UPG100-Poster", "wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ", "peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal", - "posterDesignTerritory": "UPG-100-Gebietsplakat" + "posterDesignTerritory": "UPG100-Gebietsplakat" } diff --git a/library/i18n/locales/en/upgMap.json b/library/i18n/locales/en/upgMap.json index 916e5e21e..7364a6190 100644 --- a/library/i18n/locales/en/upgMap.json +++ b/library/i18n/locales/en/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "UPG100 Posters", "wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ", "peopleStillLoading": "the people groups are still loading, try again in a moment", - "posterDesignTerritory": "UPG-100 territory poster" + "posterDesignTerritory": "UPG100 zone poster" } diff --git a/library/i18n/locales/es/upgMap.json b/library/i18n/locales/es/upgMap.json index da700b62f..794d3f36f 100644 --- a/library/i18n/locales/es/upgMap.json +++ b/library/i18n/locales/es/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "Carteles UPG100", "wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ", "peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento", - "posterDesignTerritory": "Cartel de territorio UPG-100" + "posterDesignTerritory": "Cartel de zona UPG100" } diff --git a/library/i18n/locales/fr/upgMap.json b/library/i18n/locales/fr/upgMap.json index 06bd814ca..937900fa5 100644 --- a/library/i18n/locales/fr/upgMap.json +++ b/library/i18n/locales/fr/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "Affiches UPG100", "wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ", "peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant", - "posterDesignTerritory": "Affiche de territoire UPG-100" + "posterDesignTerritory": "Affiche de zone UPG100" } diff --git a/library/i18n/locales/hi/upgMap.json b/library/i18n/locales/hi/upgMap.json index d96462b7c..4eb1cfafd 100644 --- a/library/i18n/locales/hi/upgMap.json +++ b/library/i18n/locales/hi/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "UPG100 पोस्टर", "wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ", "peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें", - "posterDesignTerritory": "UPG-100 क्षेत्र पोस्टर" + "posterDesignTerritory": "UPG100 क्षेत्र का पोस्टर" } diff --git a/library/i18n/locales/it/upgMap.json b/library/i18n/locales/it/upgMap.json index c9f8d025e..6c9a47d53 100644 --- a/library/i18n/locales/it/upgMap.json +++ b/library/i18n/locales/it/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "Poster UPG100", "wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ", "peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento", - "posterDesignTerritory": "Manifesto di territorio UPG-100" + "posterDesignTerritory": "Poster di zona UPG100" } diff --git a/library/i18n/locales/pt/upgMap.json b/library/i18n/locales/pt/upgMap.json index 9cffd9585..7a1a7b2cf 100644 --- a/library/i18n/locales/pt/upgMap.json +++ b/library/i18n/locales/pt/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "Cartazes UPG100", "wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ", "peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante", - "posterDesignTerritory": "Cartaz de território UPG-100" + "posterDesignTerritory": "Cartaz de zona UPG100" } diff --git a/library/i18n/locales/ro/upgMap.json b/library/i18n/locales/ro/upgMap.json index 331a40a1e..056cdb3ad 100644 --- a/library/i18n/locales/ro/upgMap.json +++ b/library/i18n/locales/ro/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "Afișe UPG100", "wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ", "peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă", - "posterDesignTerritory": "Afiș de teritoriu UPG-100" + "posterDesignTerritory": "Poster de zonă UPG100" } diff --git a/library/i18n/locales/ru/upgMap.json b/library/i18n/locales/ru/upgMap.json index 3e91bde32..ffaa2788c 100644 --- a/library/i18n/locales/ru/upgMap.json +++ b/library/i18n/locales/ru/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "Плакаты UPG100", "wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ", "peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение", - "posterDesignTerritory": "Плакат территории UPG-100" + "posterDesignTerritory": "Плакат зоны UPG100" } diff --git a/library/i18n/locales/zh/upgMap.json b/library/i18n/locales/zh/upgMap.json index 326f50cdb..ce9f431d5 100644 --- a/library/i18n/locales/zh/upgMap.json +++ b/library/i18n/locales/zh/upgMap.json @@ -20,5 +20,5 @@ "wallHeading": "UPG100 海报", "wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ", "peopleStillLoading": "族群仍在加载中,请稍后再试", - "posterDesignTerritory": "UPG-100 区域海报" + "posterDesignTerritory": "UPG100 区域海报(单张)" } diff --git a/library/packet/poster-render.js b/library/packet/poster-render.js index 7b0871815..25b7a95a7 100644 --- a/library/packet/poster-render.js +++ b/library/packet/poster-render.js @@ -290,6 +290,7 @@ const HEAD_COLUMNS_CSS = ` const HEAD_COLUMNS_FULL_CSS = ` .hcolk{font-size:19px;fill:#2f2a22;font-weight:800;letter-spacing:.1em} .hcolt{font-size:22px;fill:#3a362c} + .hcolb{font-weight:700;fill:#2f2a22} .hcolh{font-family:Georgia,'Times New Roman',serif;font-size:23px;font-weight:700;fill:#2f2a22;letter-spacing:.04em;font-variant:small-caps} .hvref{font-size:19px} .hverse{font-size:19.5px} @@ -851,6 +852,27 @@ const strategyParas = () => (POSTER_TEXT.strategyBody ? [].concat(POSTER_TEXT.st // series.json (poster.sideBoxMore: one paragraph, or a list; [] prints none), as UPG100 does to keep its band even. const whatIsMore = () => (POSTER_TEXT.sideBoxMore !== undefined ? [].concat(POSTER_TEXT.sideBoxMore) : [WHAT_IS_MORE]); const WHAT_IS_MORE = 'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.'; +// A paragraph's lines as SVG text content, its **...** runs set bold (.hcolb); the markers are not printed. +// The wrap measures the plain text: a short bold run is a few px wider, inside the measure's room. +function boldLines(text, width) { + let plain = '', bold = [], on = false; + for (const part of String(text).replace(/\s+/g, ' ').trim().split('**')) { + for (let i = 0; i < part.length; i++) { plain += part[i]; bold.push(on); } // per UTF-16 unit, as plain[p] reads + on = !on; + } + let p = 0; + return wrapPx(plain, HC.fs, width).map((ln) => { + while (plain[p] === ' ') p++; + let out = '', run = '', runBold = null; + const flush = () => { if (run) out += runBold ? `${esc(run)}` : esc(run); run = ''; }; + for (let k = 0; k < ln.length; k++, p++) { + if (bold[p] !== runBold) { flush(); runBold = bold[p]; } + run += plain[p]; + } + flush(); + return out; + }); +} function headColumnsBand(top, facts, approxLists) { const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G; const CW = (GRID_W - 2 * HC.gut) / 3; @@ -885,14 +907,16 @@ function headColumnsBand(top, facts, approxLists) { bottom = Math.max(bottom, y); } else { const { head, paras } = col[0]; - const wrapped = paras.map((t) => wrapPx(t, HC.fs, TW)); - const blockW = Math.min(TW, Math.max(textW(head, HC.headFs), ...wrapped.flat().map((ln) => textW(ln, HC.fs)))); + // ★ MIRRORED (2026-10-03): both prose columns take the full measure, so each sits the same distance from + // its rule and the two outer columns mirror each other round the centred facts column. (Each used to be + // centred on its own widest line, so the two could sit at different distances from their rules.) + const blockW = TW; const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1); frags += `${esc(head)}`; let y = top + HC.headLh; - wrapped.forEach((lines, k) => { + paras.forEach((text, k) => { if (k) y += HC.gap; - for (const ln of lines) { frags += `${esc(ln)}`; y += HC.lh; } + for (const ln of boldLines(text, TW)) { frags += `${ln}`; y += HC.lh; } }); bottom = Math.max(bottom, y); } diff --git a/library/scripts/posters/series.json b/library/scripts/posters/series.json index 21c598936..cbdb05c85 100644 --- a/library/scripts/posters/series.json +++ b/library/scripts/posters/series.json @@ -29,7 +29,7 @@ "titleQualifier": "the coloured words before the region name, e.g. 'Unengaged of' (:2427)", "seriesName": "the name in quotes on the Series line and in the side box (:1877, :1907, :2115)", "sideBoxHeading": "the right-hand explainer's heading (:2097)", - "sideBoxBody": "that explainer's first paragraph (:2115)", + "sideBoxBody": "that explainer's first paragraph (:2115); in the head-columns band **...** prints a bold run", "strategyBody": "the Jesus column's text: one paragraph, or a list of them; absent = poster-render's STRATEGY_DEFAULT", "sideBoxMore": "the explainer's further paragraphs: one, or a list; [] prints none; absent = poster-render's WHAT_IS_MORE" }, @@ -68,8 +68,8 @@ "titleQualifier": "Unengaged of", "seriesName": "UPG100 List", "sideBoxHeading": "What is a UPG100 List?", - "sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.", - "strategyBody": "Jesus fed the 5,000 by multiplying a small offering. He had the crowd sit in groups of hundreds and fifties (Mark 6:40). So we pray by name, a few groups at a time.", + "sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists. Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise **informed intercession.**", + "strategyBody": "2000 People Groups? Jesus’ disciples were faced with a similar problem, “How can we feed so many with only five loaves and two fish?” Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).", "sideBoxMore": [], "headColumns": true }, From 6f3a45f44f761d7a1e27c7769320d6b0d3971c2d Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 3 Oct 2026 05:47:39 +0000 Subject: [PATCH 04/15] UPG100: the header Share menu opens beside its button, at the maps' width The composite's header now declares itself the Share menu's area. The Share button's fallback looked for its area during render, before layout, and settled on its own narrow parent, so the menu clamped to its 200px floor and opened at the far left of the header (at 1440 and 390, en and ar). With the header declared, the menu opens beside the button at the maps' width, mirrored in right-to-left pages. --- plugin-designs/my-upg-100-list/upg-100-mfe.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugin-designs/my-upg-100-list/upg-100-mfe.vue b/plugin-designs/my-upg-100-list/upg-100-mfe.vue index 2471d658d..48a954944 100644 --- a/plugin-designs/my-upg-100-list/upg-100-mfe.vue +++ b/plugin-designs/my-upg-100-list/upg-100-mfe.vue @@ -392,7 +392,13 @@ useShadowStyles(PANE_GRID_STYLES + `