Name the map's countries on page 1 in the packet's language

The map sheets are generated once, in English, so in an Arabic or
Chinese packet page 1's map labelled every country in Latin capitals,
and its privacy line was English.

The labels are now replaced as the sheet is lifted into the packet.
They are recognised by the generator's own rule rather than a list:
generate.mjs draws each label as esc(ISO3_NAME[iso3].toUpperCase())
from the zone data's ISO3_NAME table, so inverting that table under
that transform names the country behind every label it can draw. The
inverse is unambiguous (162 labels, no two countries sharing one), so a
label is known exactly or left as drawn. The map's privacy line is word
for word the packet's privacy_note, so it takes this language's.

A width guard: the generator placed every label and then dropped any
that would collide, measuring the English name, so a translation is
fitted into that same box - estimated wider, it shrinks in proportion
and cannot reach a neighbour the English name did not. On the Sudan
poster, six Arabic names shrink (the longest, Saudi Arabia, from 15px
to 10px); none of the narrower Chinese ones do. Capitals' tracking is
dropped for scripts without capitals, where it breaks Arabic joining.

English is untouched: its relabeller is null, and the lifted sheet is
byte-identical with and without it. No sheet or poster is regenerated -
the change is in the lift, so the tracked 3x4 posters page 1 now uses
are relabelled as they are.

Both print paths supply the relabeller: the UPG 100 profile from its
own ISO3_NAME and the library's country-name resolver, the offline
generator from the same table and the injected ISO-3 to ISO-2 table.

check-map-labels (gates:pre), on the tracked Sudan poster: English
byte-identical; in ar and zh no known English name left, halo matching
fill, privacy localized; the guard fires for Arabic, not Chinese, and
grows nothing; Arabic tracking off, Spanish kept; an unknown label left
alone. Mutation-tested five ways, each caught.
This commit is contained in:
Doxa Maps 2026-09-29 01:07:36 +00:00
parent 6af71bce45
commit 87c59e3a1f
6 changed files with 209 additions and 8 deletions

View file

@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* check-map-labels — the map on page 1 names its countries in the packet's language.
*
* Map sheets are generated once, in English; the packet relabels their country names (and the map's
* privacy line) at print time (library/packet/mapLabels.js + packet-render.js liftMap). Checked on a
* real tracked poster, the artwork page 1 actually lifts:
*
* (1) ENGLISH IS UNTOUCHED — lifting with and without the relabeller is byte-identical in English,
* and the relabeller for English is null.
* (2) KNOWN NAMES ARE ALL REPLACED — in ar and zh no known English label survives, every halo still
* matches its fill, and the privacy line takes the language's own sentence.
* (3) THE WIDTH GUARD FITS, NEVER GROWS — no label is ever set larger than the sheet's size, the guard
* FIRES for a translation wider than its English (ar), and does NOT for shorter ones (zh).
* (4) TRACKING OFF WHERE IT BREAKS SCRIPT — Arabic labels drop letter-spacing; Spanish capitals keep it.
* (5) UNKNOWN LABELS ARE LEFT AS DRAWN — never guessed.
*
* Exit 0 pass · 1 fail · 75 NOT MEASURED.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const POSTER = join(REPO, 'data/posters/sudan.html')
if (!existsSync(POSTER)) {
console.error('MAP-LABELS: NOT MEASURED (exit 75) — no tracked poster at data/posters/sudan.html to lift')
process.exit(75)
}
const { createPacketRenderer } = await import(join(REPO, 'library/packet/packet-render.js'))
const { makeCountryLabelFor } = await import(join(REPO, 'library/packet/mapLabels.js'))
const { ISO3_NAME } = await import(join(REPO, 'plugin-designs/my-upg-100-list/data/territory-counts.js'))
const strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
const a2 = JSON.parse(readFileSync(join(REPO, 'library/data/iso3-to-alpha2.json'), 'utf8')).map
const countryName = (iso3, lc) => { const t = a2[iso3]; try { return t ? new Intl.DisplayNames([lc], { type: 'region' }).of(t) : '' } catch { return '' } }
// A label the generator cannot draw, planted to prove unknowns are left alone.
const poster = readFileSync(POSTER, 'utf8').replace('</svg>', '<text x="1" y="1" text-anchor="middle" class="ctry-name">ATLANTIS</text></svg>')
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const KNOWN = new Set(Object.values(ISO3_NAME).map((n) => esc(String(n).toUpperCase())))
const labels = (html) => [...html.matchAll(/<text([^>]*class="(ctry-name(?:-halo|-own)?)"[^>]*)>([^<]*)<\/text>/g)]
.map((m) => ({ attrs: m[1], cls: m[2], text: m[3], px: Number((m[1].match(/font-size:([\d.]+)px/) || [])[1]) || null }))
const base = Number((poster.match(/\.ctry-name\{[^}]*font-size:([\d.]+)px/) || [])[1]) || 15
const fails = [], notes = []
const ok = (c, msg, note) => { if (!c) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
const lift = (lc) => createPacketRenderer({ strings, lang: lc }).liftMap(poster, { labelFor: makeCountryLabelFor({ iso3Name: ISO3_NAME, countryName, lang: lc }) })
// (1)
{
const R = createPacketRenderer({ strings, lang: 'en' })
ok(makeCountryLabelFor({ iso3Name: ISO3_NAME, countryName, lang: 'en' }) === null, 'the English relabeller is not null')
ok(R.liftMap(poster) === lift('en'), 'lifting in English CHANGED the sheet', 'English: the lifted sheet is byte-identical with and without the relabeller')
}
const out = { ar: lift('ar'), zh: lift('zh'), es: lift('es') }
// (2)
for (const lc of ['ar', 'zh']) {
const L = labels(out[lc])
const left = L.filter((l) => KNOWN.has(l.text)).map((l) => l.text)
const halos = L.filter((l) => l.cls === 'ctry-name-halo'), fills = L.filter((l) => l.cls !== 'ctry-name-halo')
ok(!left.length, `${lc}: English country names survived: ${[...new Set(left)].join(', ')}`)
ok(halos.length && halos.every((h, i) => fills[i] && fills[i].text === h.text), `${lc}: a halo no longer matches its fill — the outline and the name would disagree`)
ok(out[lc].includes(esc(strings[lc].privacy_note)), `${lc}: the map's privacy line was not localized`)
if (!left.length) notes.push(` ok ${lc}: ${halos.length} country labels relabelled, halo = fill, privacy line localized`)
}
// (3)
{
const all = [...labels(out.ar), ...labels(out.zh), ...labels(out.es)]
ok(all.every((l) => l.px === null || l.px <= base + 1e-9), 'a relabelled name was set LARGER than the sheet\'s label size')
const arShrunk = labels(out.ar).filter((l) => l.px !== null && l.px < base).length
const zhShrunk = labels(out.zh).filter((l) => l.px !== null && l.px < base).length
ok(arShrunk > 0, 'the width guard never fired in Arabic, where the Saudi Arabia label is far wider than its English')
ok(zhShrunk === 0, `the width guard shrank ${zhShrunk} Chinese label(s) that are narrower than their English`,
`width guard: shrinks ${arShrunk} Arabic labels to fit their English boxes, none of the narrower Chinese, and grows nothing`)
}
// (4)
{
const arFill = labels(out.ar).filter((l) => l.cls !== 'ctry-name-halo' && !KNOWN.has(l.text) && l.text !== 'ATLANTIS')
const esFill = labels(out.es).filter((l) => l.cls !== 'ctry-name-halo' && l.text !== 'ATLANTIS')
ok(arFill.length && arFill.every((l) => /letter-spacing:0/.test(l.attrs)), 'Arabic labels keep the capitals\' tracking, which breaks letter joining')
ok(esFill.every((l) => !/letter-spacing:0/.test(l.attrs)), 'Spanish capitals lost their tracking',
'tracking is dropped for Arabic and kept for Spanish capitals')
}
// (5)
ok(labels(out.ar).filter((l) => l.text === 'ATLANTIS').length === 1, 'an unknown label was altered or removed', 'an unknown label is left exactly as drawn')
for (const n of notes) console.log(n)
if (fails.length) {
console.error('MAP-LABELS: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('MAP-LABELS: PASS — the map names its countries in the packet\'s language, fits each name to its English box, and leaves English untouched.')

View file

@ -0,0 +1,39 @@
/**
* mapLabels — name the countries on a generated map sheet in the packet's language, at print time.
*
* The map sheets (posters, and the map-only packet page) are generated once, in English: every country
* on the map is labelled with its English name in capitals. In an Arabic or Chinese packet those labels
* printed in Latin capitals. Regenerating a sheet per language would multiply the artwork by eleven; so
* instead the label is recognised on the way into the packet and replaced.
*
* ★ RECOGNISED BY THE GENERATOR'S OWN RULE, NOT A HAND-MADE LIST. generate.mjs writes each label as
* esc(ISO3_NAME[iso3].toUpperCase()), from the ISO3_NAME table in the zone data. Inverting that same
* table under that same transform gives, for every label the generator can draw, the country it names.
* The inverse is unambiguous (no two countries share a label), so a label is either known exactly or
* left alone — never guessed.
*
* Pure and zero-import: the caller injects the table, the country-name resolver its runtime can load,
* and the language, so the browser print and the offline generator run the same code.
*
* @param {{iso3Name: Object<string,string>, countryName: (iso3:string, lang:string)=>string, lang: string}} o
* @returns {null | ((labelText:string) => (string|null))}
* null for English — the sheets are already English, and returning no relabeller is what keeps an
* English packet's output byte-identical. Otherwise a function from a label as it appears in the
* sheet to the localized name, or null for a label it does not know (which is then left as drawn).
*/
export function makeCountryLabelFor({ iso3Name, countryName, lang } = {}) {
const lc = String(lang || 'en').split('-')[0].toLowerCase()
if (lc === 'en' || !iso3Name || typeof countryName !== 'function') return null
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const byLabel = new Map()
for (const [iso3, name] of Object.entries(iso3Name)) byLabel.set(esc(String(name).toUpperCase()), iso3)
return (labelText) => {
const iso3 = byLabel.get(String(labelText))
if (!iso3) return null
const name = countryName(iso3, lc)
if (!name || name === iso3) return null
// Capitals as the sheet draws them, in the language's own casing rules; scripts without case
// (Arabic, Devanagari, Chinese) come back unchanged.
return esc(name.toLocaleUpperCase(lc))
}
}

View file

@ -346,7 +346,50 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const SHEET_INSET = 34; // generate.mjs SHEET_EDGE (26) + 8: where a sheet's label columns begin const SHEET_INSET = 34; // generate.mjs SHEET_EDGE (26) + 8: where a sheet's label columns begin
const MAP_TOP = 256; // below mapPage's title block (with the series line it ends near y=238) const MAP_TOP = 256; // below mapPage's title block (with the series line it ends near y=238)
const MAP_BOTTOM = H - M; // above the fine print const MAP_BOTTOM = H - M; // above the fine print
function liftMap(posterHtml) { // ── THE MAP'S OWN LABELS, IN THE PACKET'S LANGUAGE ──────────────────────────
// A map sheet is generated once, in English. In a translated packet its country names are replaced
// on the way in (labelFor: see mapLabels.js), and the map's privacy line, which is word for word this
// catalogue's privacy_note, takes this language's version.
// ★ A WIDTH GUARD, because the generator placed every label and then removed any that would collide,
// measuring the ENGLISH name. A translation is fitted into that same box: estimated wider, it shrinks
// in proportion, so it can never reach a neighbour the English name did not. Tracking (0.1em on the
// capitals) is dropped for scripts without capitals — it breaks Arabic joining.
// Estimates are relative (translation against the English it replaces, same font), which is all a
// fit needs; per-script advances are deliberately generous.
const advance = (ch, tracked) => {
if (/\s/.test(ch)) return 0.3;
if (/[\u3000-\u303f\u3400-\u9fff\uff00-\uffef]/.test(ch)) return 1.0; // CJK
if (/[\u0600-\u06ff]/.test(ch)) return 0.55; // Arabic
if (/[\u0900-\u097f]/.test(ch)) return 0.65; // Devanagari
return 0.64 + (tracked ? 0.1 : 0); // Latin/Cyrillic capitals
};
const cased = (str) => /[A-ZÀ-ÞА-ЯЁ]/.test(str);
const estimate = (str) => [...String(str).replace(/&[a-z#0-9]+;/g, 'x')].reduce((a, ch) => a + advance(ch, cased(str)), 0);
function relabelSheet(svg, css, labelFor) {
const base = Number((css.match(/\.ctry-name\{[^}]*font-size:([\d.]+)px/) || [])[1]) || 15;
const fit = (en, tr, px) => {
const k = Math.min(1, estimate(en) / Math.max(estimate(tr), 1e-6));
const style = [];
if (k < 1) style.push(`font-size:${(px * k).toFixed(1)}px`);
if (!cased(tr)) style.push('letter-spacing:0');
return style.join(';');
};
const withStyle = (attrs, style) => (!style ? attrs
: /\sstyle="/.test(attrs) ? attrs.replace(/\sstyle="([^"]*)"/, (q, v) => ` style="${v};${style}"`)
: `${attrs} style="${style}"`);
let out = svg.replace(/<text([^>]*class="ctry-name(?:-halo|-own)?"[^>]*)>([^<]*)<\/text>/g, (whole, attrs, text) => {
const tr = labelFor(text);
return tr ? `<text${withStyle(attrs, fit(text, tr, base))}>${tr}</text>` : whole;
});
const enPrivacy = esc(EN.privacy_note || ''), privacy = esc(t('privacy_note') || '');
if (enPrivacy && privacy && privacy !== enPrivacy) {
out = out.replace(/<text([^>]*class="attrib(?:-halo)?"[^>]*)>([^<]*)<\/text>/g, (whole, attrs, text) => (text === enPrivacy
? `<text${withStyle(attrs, fit(text, privacy, Number((css.match(/\.attrib\{[^}]*font-size:([\d.]+)px/) || [])[1]) || 11))}>${privacy}</text>` : whole));
}
return out;
}
function liftMap(posterHtml, { labelFor = null } = {}) {
const html = String(posterHtml || ''); const html = String(posterHtml || '');
const m = html.match(/<svg\b[^>]*>[\s\S]*?<\/svg>/); const m = html.match(/<svg\b[^>]*>[\s\S]*?<\/svg>/);
if (!m) return ''; if (!m) return '';
@ -369,7 +412,9 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
} }
// The root's own width/height would pin it at authored size; the wrapper sets the size instead, // The root's own width/height would pin it at authored size; the wrapper sets the size instead,
// and the viewBox (default preserveAspectRatio, meet) scales the drawing to it. // and the viewBox (default preserveAspectRatio, meet) scales the drawing to it.
const svg = m[0].replace(root, root.replace(/\s(width|height)="[^"]*"/g, '').replace(/^<svg\b/, '<svg width="100%" height="100%"')); const sized = m[0].replace(root, root.replace(/\s(width|height)="[^"]*"/g, '').replace(/^<svg\b/, '<svg width="100%" height="100%"'));
// No relabeller (an English packet) leaves the sheet exactly as drawn.
const svg = typeof labelFor === 'function' ? relabelSheet(sized, html, labelFor) : sized;
return `<style>${mapCss(html)}</style>` return `<style>${mapCss(html)}</style>`
+ `<div class="map-sheet" data-map-kind="${mapOnly ? 'map-only' : 'poster'}" data-map-scale="${s.toFixed(4)}"` + `<div class="map-sheet" data-map-kind="${mapOnly ? 'map-only' : 'poster'}" data-map-scale="${s.toFixed(4)}"`
+ ` style="position:absolute;left:${x.toFixed(1)}px;top:${y.toFixed(1)}px;` + ` style="position:absolute;left:${x.toFixed(1)}px;top:${y.toFixed(1)}px;`

View file

@ -66,6 +66,15 @@ const DATA = join(REPO, SERIES.dataDir);
// which is the path zone-title.js was built for. A data set without zone-title.js keeps its labels. // which is the path zone-title.js was built for. A data set without zone-title.js keeps its labels.
const ALPHA2 = JSON.parse(readFileSync(join(REPO, 'library/data/iso3-to-alpha2.json'), 'utf8')).map; const ALPHA2 = JSON.parse(readFileSync(join(REPO, 'library/data/iso3-to-alpha2.json'), 'utf8')).map;
const ZONE_TITLE = await import(join(DATA, 'zone-title.js')).then((m) => m.zoneTitle).catch(() => null); const ZONE_TITLE = await import(join(DATA, 'zone-title.js')).then((m) => m.zoneTitle).catch(() => null);
// The map's country names in the packet's language (mapLabels.js), from the zone data's ISO3_NAME — the
// table generate.mjs labels the map from — with the same injected ISO-3 table for Intl. null in English.
const { makeCountryLabelFor } = await import('../../packet/mapLabels.js');
const ISO3_NAME_TABLE = await import(join(DATA, 'territory-counts.js')).then((m) => m.ISO3_NAME).catch(() => null);
const LABEL_FOR = makeCountryLabelFor({
iso3Name: ISO3_NAME_TABLE,
countryName: (iso3, lc) => { const two = ALPHA2[iso3]; try { return two ? new Intl.DisplayNames([lc], { type: 'region' }).of(two) : '' } catch { return '' } },
lang: LANG,
});
const OUT = join(__dirname, 'out', 'packets'); const OUT = join(__dirname, 'out', 'packets');
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
@ -548,7 +557,7 @@ for (const z of zones) {
process.exit(1); process.exit(1);
} }
// ONE lift for every path (packet-render.js liftMap); this used to be one of three copies. // ONE lift for every path (packet-render.js liftMap); this used to be one of three copies.
mapSvg = R.liftMap(poster); mapSvg = R.liftMap(poster, { labelFor: LABEL_FOR });
// The sheet EXISTS but yields nothing liftable — no <svg>, or one too small to be a map sheet: // The sheet EXISTS but yields nothing liftable — no <svg>, or one too small to be a map sheet:
// a truncated or failed generate run. Same outcome as a missing file (page 1 says the map is // a truncated or failed generate run. Same outcome as a missing file (page 1 says the map is
// missing), and it is reported the same way rather than left as the one unexplained branch. // missing), and it is reported the same way rather than left as the one unexplained branch.

View file

@ -214,6 +214,7 @@
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs", "check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs", "check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
"check:evangelical-share": "node internal/check-evangelical-share.mjs", "check:evangelical-share": "node internal/check-evangelical-share.mjs",
"check:map-labels": "node internal/check-map-labels.mjs",
"check:packets-all": "node internal/check-packets-all.mjs", "check:packets-all": "node internal/check-packets-all.mjs",
"check:parse": "node internal/inline-parse-check.mjs", "check:parse": "node internal/inline-parse-check.mjs",
"check:pinned-umd": "node internal/check-pinned-umd.mjs", "check:pinned-umd": "node internal/check-pinned-umd.mjs",
@ -311,7 +312,7 @@
"fix:sri": "node internal/fix-sri.mjs", "fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs", "gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share", "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs", "library-index": "node internal/generate-library-index.mjs",
@ -487,6 +488,7 @@
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.", "check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.", "check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",
"check:evangelical-share": "the packet's % evangelical page states THIS list's share: summed over every DOXA group the per-group table reproduces christian-share.json exactly; different lists give different figures; a list with no published levels prints a dash, never 0% (Number(null) is 0); the coverage clause appears only when partial.", "check:evangelical-share": "the packet's % evangelical page states THIS list's share: summed over every DOXA group the per-group table reproduces christian-share.json exactly; different lists give different figures; a list with no published levels prints a dash, never 0% (Number(null) is 0); the coverage clause appears only when partial.",
"check:map-labels": "the packet's page-1 map names its countries in the packet's language, on a real tracked poster: English is byte-identical with and without the relabeller; in ar and zh no known English country name survives, halos match fills and the privacy line is localized; the width guard shrinks wider translations and grows nothing; Arabic drops letter tracking; an unknown label is left as drawn.",
"check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.", "check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.", "check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
"check:pinned-umd": "library/utils/loadPinnedUmd.js (d3 for PPLR and the Wall) loads a pinned UMD with SRI and never touches the host page's global of the same name: a host's own d3 gains no keys, a failed load leaves it in place. Headless.", "check:pinned-umd": "library/utils/loadPinnedUmd.js (d3 for PPLR and the Wall) loads a pinned UMD with SRI and never touches the host page's global of the same name: a host's own d3 gains no keys, a failed load leaves it in place. Headless.",

View file

@ -1431,13 +1431,26 @@ async function packetRecords(zone) {
// poster. Absent is fine — page 1 then says the map is missing rather than printing blank. // poster. Absent is fine — page 1 then says the map is missing rather than printing blank.
// ⚠ res.ok IS CHECKED. The old body read .text() on any response, so a 404 page was searched for an // ⚠ res.ok IS CHECKED. The old body read .text() on any response, so a 404 page was searched for an
// <svg> as if it were a poster. // <svg> as if it were a poster.
async function packetMapSvg(zoneId, R) { // The map's country names in the packet's language (mapLabels.js), built from this profile's own
// ISO3_NAME table — the one generate.mjs labels the map from — so every label it can draw is known.
// null in English, which leaves the sheet exactly as drawn.
async function packetLabelFor() {
try {
const [{ makeCountryLabelFor }, { countryName }] = await Promise.all([
import('@map/packet/mapLabels.js'),
import('@map/utils/countryName.js'),
]);
return makeCountryLabelFor({ iso3Name: ISO3_NAME, countryName, lang: packetLang() });
} catch { return null; } // the map keeps its English labels rather than failing the print
}
async function packetMapSvg(zoneId, R, labelFor = null) {
const base = resolveAssetsBase(profileConfig.value); const base = resolveAssetsBase(profileConfig.value);
for (const file of R.mapSheetFiles(zoneId)) { for (const file of R.mapSheetFiles(zoneId)) {
try { try {
const res = await fetch(`${base}/posters/${file}`, { cache: 'force-cache' }); const res = await fetch(`${base}/posters/${file}`, { cache: 'force-cache' });
// A 200 that is not a map sheet (an SPA fallback page) lifts to '' — keep looking. // A 200 that is not a map sheet (an SPA fallback page) lifts to '' — keep looking.
const lifted = res.ok ? R.liftMap(await res.text()) : ''; const lifted = res.ok ? R.liftMap(await res.text(), { labelFor }) : '';
if (lifted) return lifted; if (lifted) return lifted;
} catch { /* try the next file */ } } catch { /* try the next file */ }
} }
@ -1477,7 +1490,7 @@ async function printPrayerPacket(zoneId, variant = 'full') {
const titled = await localizedZone(zone); const titled = await localizedZone(zone);
await packetPrints.printPacket(titled, { await packetPrints.printPacket(titled, {
variant, variant,
mapSvg: await packetMapSvg(zoneId, R), mapSvg: await packetMapSvg(zoneId, R, await packetLabelFor()),
funnel: await packetFunnel(), funnel: await packetFunnel(),
}); });
} }
@ -1800,7 +1813,7 @@ async function makeSelectionPacket() {
]); ]);
const R = createPacketRenderer({ strings, lang: packetLang() }); const R = createPacketRenderer({ strings, lang: packetLang() });
const lists = selectedLists(); const lists = selectedLists();
const mapSvg = lists.length === 1 ? await packetMapSvg(lists[0].id, R) : ''; const mapSvg = lists.length === 1 ? await packetMapSvg(lists[0].id, R, await packetLabelFor()) : '';
// ⚠ "1 OF 27 LISTS" IS FALSE FOR A CUSTOM SELECTION — it is not one of the catalogue's lists at // ⚠ "1 OF 27 LISTS" IS FALSE FOR A CUSTOM SELECTION — it is not one of the catalogue's lists at
// all. The NARROWING is still true and still the point, and "N of the 2,107" stays exactly true // all. The NARROWING is still true and still the point, and "N of the 2,107" stays exactly true
// because N is the selection's own row count. So a multi-list selection keeps the funnel steps // because N is the selection's own row count. So a multi-list selection keeps the funnel steps