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