From 87c59e3a1f92bc60404432d63583d62ac49d66c4 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 01:07:36 +0000 Subject: [PATCH] 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. --- internal/check-map-labels.mjs | 93 +++++++++++++++++++ library/packet/mapLabels.js | 39 ++++++++ library/packet/packet-render.js | 49 +++++++++- library/scripts/posters/packet.mjs | 11 ++- package.json | 4 +- .../my-upg-100-list/upg-100-list.vue | 21 ++++- 6 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 internal/check-map-labels.mjs create mode 100644 library/packet/mapLabels.js 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(/]*>[\s\S]*?<\/svg>/); 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, // and the viewBox (default preserveAspectRatio, meet) scales the drawing to it. - const svg = m[0].replace(root, root.replace(/\s(width|height)="[^"]*"/g, '').replace(/^${mapCss(html)}` + `
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); for (const file of R.mapSheetFiles(zoneId)) { try { 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. - const lifted = res.ok ? R.liftMap(await res.text()) : ''; + const lifted = res.ok ? R.liftMap(await res.text(), { labelFor }) : ''; if (lifted) return lifted; } catch { /* try the next file */ } } @@ -1477,7 +1490,7 @@ async function printPrayerPacket(zoneId, variant = 'full') { const titled = await localizedZone(zone); await packetPrints.printPacket(titled, { variant, - mapSvg: await packetMapSvg(zoneId, R), + mapSvg: await packetMapSvg(zoneId, R, await packetLabelFor()), funnel: await packetFunnel(), }); } @@ -1800,7 +1813,7 @@ async function makeSelectionPacket() { ]); const R = createPacketRenderer({ strings, lang: packetLang() }); 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 // 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