diff --git a/internal/check-map-labels.mjs b/internal/check-map-labels.mjs
new file mode 100644
index 000000000..3b7d3c2c9
--- /dev/null
+++ b/internal/check-map-labels.mjs
@@ -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('', 'ATLANTIS')
+
+const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
+const KNOWN = new Set(Object.values(ISO3_NAME).map((n) => esc(String(n).toUpperCase())))
+const labels = (html) => [...html.matchAll(/]*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.')
diff --git a/library/packet/mapLabels.js b/library/packet/mapLabels.js
new file mode 100644
index 000000000..bb4803f78
--- /dev/null
+++ b/library/packet/mapLabels.js
@@ -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, 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, '&').replace(//g, '>').replace(/"/g, '"')
+ 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))
+ }
+}
diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js
index 70803f603..560838d27 100644
--- a/library/packet/packet-render.js
+++ b/library/packet/packet-render.js
@@ -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 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
- 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(/]*class="ctry-name(?:-halo|-own)?"[^>]*)>([^<]*)<\/text>/g, (whole, attrs, text) => {
+ const tr = labelFor(text);
+ return tr ? `${tr}` : whole;
+ });
+ const enPrivacy = esc(EN.privacy_note || ''), privacy = esc(t('privacy_note') || '');
+ if (enPrivacy && privacy && privacy !== enPrivacy) {
+ out = out.replace(/]*class="attrib(?:-halo)?"[^>]*)>([^<]*)<\/text>/g, (whole, attrs, text) => (text === enPrivacy
+ ? `${privacy}` : whole));
+ }
+ return out;
+ }
+
+ function liftMap(posterHtml, { labelFor = null } = {}) {
const html = String(posterHtml || '');
const m = html.match(/