diff --git a/internal/check-raw-map-rtl-shaping.mjs b/internal/check-raw-map-rtl-shaping.mjs new file mode 100644 index 000000000..dc0a5092d --- /dev/null +++ b/internal/check-raw-map-rtl-shaping.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * check-raw-map-rtl-shaping — the RAW maps (built with `new gl.Map`, not useMapInstance) must + * shape Arabic, Hebrew and Persian place names. + * + * gospel's check-gospel-rtl-shaping.mjs (b9dd5545) established the method on resources-map; this + * is the same gate for the other raw maps: cities-internet/connectivity-map (both engines) and + * key-cities/cities-map (Mapbox-only; gated, so it has no leaf page and is mounted on a + * same-origin host page). Read that file's header for the full reasoning; the load-bearing parts: + * + * - WHY IT MATTERS TO EVERYONE: these maps do not localise basemap labels, so each place is drawn + * in its OWN script. Over the Middle East most labels are Arabic/Persian/Hebrew for EVERY + * viewer in every UI language. Unshaped = disconnected glyphs in the wrong order. + * - IT PANS FIRST: the shaper registers lazily and reads 'deferred' at a world view. Only after + * RTL labels are really on screen does 'loaded' mean they went through the shaper. No RTL label + * on screen is NOT MEASURED, never PASS. + * - ONE property definition per engine global does both the map hook and the poison: two + * addInitScript defineProperty calls on one global silently replace each other. + * + * WHY THE FIX HAS TO BE IN THE MAP, NOT THE ENTRY: bundle entries used to call setRTLTextPlugin at + * load time, which only worked because every leaf preloaded mapbox-gl.js. The engine is now + * injected lazily (ensureEngineScript), after the entry has run, so the call has to follow the + * engine global and precede `new gl.Map`: installRtlTextPlugin(gl) from library/maps/rtl-text.js. + * + * --poison neuters setRTLTextPlugin in the page (the real pre-fix state) and must be caught on + * every measured row. Exit: 0 pass · 1 fail · 2 not measured. + */ +import { chromium } from 'playwright' +import { serveReaped } from './_serve-reaped.mjs' +import { TREE_NAME } from './_tree-name.mjs' +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PORT = Number(process.env.RTL_RAW_PORT || 5394) +const POISON = process.argv.includes('--poison') +const TK = process.env.MAPBOX_TOKEN || JSON.parse(readFileSync(resolve(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')).token || '' +const BASE = `http://127.0.0.1:${PORT}/${TREE_NAME}` +/* Arabia: the densest concentration of rendered Arabic on the basemap. */ +const RTL_VIEW = { center: [39.2, 24.5], zoom: 5 } + +const HOST_PATH = `${TREE_NAME}/key-cities/__rtl-host.html` +const TARGETS = [ + ...['mapbox', 'maplibre'].map((provider) => ({ + name: 'cities-internet', provider, + url: `${BASE}/cities-internet/connectivity-map/index.html?provider=${provider}&lang=ar` + (provider === 'mapbox' ? `&tk=${TK}` : ''), + })), + { name: 'key-cities', provider: 'mapbox', url: `http://127.0.0.1:${PORT}/${HOST_PATH}`, host: true }, +] +const HOST_HTML = ` + + +` + +const srv = serveReaped(resolve(ROOT, 'public'), PORT) +await new Promise((r) => setTimeout(r, 2500)) +const browser = await chromium.launch({ + args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'], +}) + +const INSTALL = (poison) => { + const define = (name) => { + let v + Object.defineProperty(window, name, { + configurable: true, + get: () => v, + set: (g) => { + v = g + if (!g) return + if (poison && g.setRTLTextPlugin) g.setRTLTextPlugin = () => {} + if (g.Map && !g.__hooked) { + g.__hooked = true + const Orig = g.Map + g.Map = function (...a) { const m = new Orig(...a); window.__MAP = m; return m } + g.Map.prototype = Orig.prototype + Object.setPrototypeOf(g.Map, Orig) + } + }, + }) + } + define('mapboxgl'); define('maplibregl') +} + +const results = [] +for (const t of TARGETS) { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) + await page.addInitScript(INSTALL, POISON) + if (t.host) await page.route(/__rtl-host\.html/, (r) => r.fulfill({ contentType: 'text/html', body: HOST_HTML })) + let r + try { + await page.goto(t.url, { waitUntil: 'networkidle', timeout: 90000 }) + await page.waitForTimeout(8000) + r = await page.evaluate(async (view) => { + const m = window.__MAP + if (!m) return { err: 'the page never constructed a map' } + const sleep = (ms) => new Promise((res) => setTimeout(res, ms)) + // Wait for the style before moving: an unloaded style answers ZERO features. + for (let i = 0; i < 60 && !m.isStyleLoaded(); i++) await sleep(500) + m.jumpTo(view) + await new Promise((res) => { m.once('idle', res); setTimeout(res, 15000) }) + let names = [] + for (let i = 0; i < 30; i++) { + names = m.queryRenderedFeatures() + .filter((f) => f.layer && f.layer.type === 'symbol') + .map((f) => String((f.properties && (f.properties.name || f.properties.name_en)) || '')) + .filter(Boolean) + if (names.some((n) => /[؀-ۿݐ-ݿ֐-׿]/.test(n))) break + await sleep(1000) + } + // the engine that built THIS map, not whichever global happens to exist + const g = (window.maplibregl && m instanceof window.maplibregl.Map) ? window.maplibregl : window.mapboxgl + return { + rtl: names.filter((n) => /[؀-ۿݐ-ݿ֐-׿]/.test(n)), + symbols: names.length, + status: g && g.getRTLTextPluginStatus ? g.getRTLTextPluginStatus() : 'NO STATUS API', + } + }, RTL_VIEW) + } catch (e) { + r = { err: String(e.message).slice(0, 90) } + } + await page.close() + results.push({ ...t, ...r }) +} +await browser.close() +srv.stop() + +let bad = 0, unmeasured = 0 +for (const r of results) { + const tag = `${r.name} ${r.provider}` + if (r.err) { console.error(` ${tag}: NOT MEASURED — ${r.err}`); unmeasured++; continue } + if (!r.rtl.length) { + console.error(` ${tag}: NOT MEASURED — no RTL label rendered at the test view (${r.symbols} symbols); the gate cannot see the defect here.`) + unmeasured++; continue + } + const sample = r.rtl.slice(0, 3).join(', ') + if (r.status !== 'loaded') { + console.error(` ${tag}: FAIL — ${r.rtl.length} RTL labels on screen (${sample}) but the shaper is '${r.status}'. These labels are drawn unshaped.`) + bad++; continue + } + console.log(` ok ${tag} — ${r.rtl.length}/${r.symbols} rendered labels are RTL (${sample}), shaper loaded`) +} +const measured = results.length - unmeasured +if (POISON) { + if (unmeasured) { console.error(`RAW-RTL --poison: NOT MEASURED — ${unmeasured} row(s) had nothing to judge, so the self-test proves nothing for them.`); process.exitCode = 2 } + else if (bad === measured) { console.log(`RAW-RTL --poison: ${bad}/${measured} rows caught with the plugin neutered.`); console.log('RAW-RTL: SELF-TEST PASS — the check fails as designed.') } + else { console.error('RAW-RTL: SELF-TEST FAIL — a neutered shaper still passed.'); process.exitCode = 1 } +} else if (bad) { + console.error(`RAW-RTL: FAIL — ${bad} of ${results.length} row(s) draw RTL place names unshaped.`) + console.error(' Fix: installRtlTextPlugin(gl) from library/maps/rtl-text.js, right after the engine global') + console.error(' resolves and BEFORE new gl.Map.') + process.exitCode = 1 +} else if (unmeasured) { + console.error(`RAW-RTL: NOT MEASURED — ${unmeasured} of ${results.length} row(s) could not be judged.`); process.exitCode = 2 +} else console.log(`RAW-RTL: PASS — cities-internet (both engines) and key-cities shape RTL place names.`) diff --git a/package.json b/package.json index 599647e6c..406b1a54e 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,8 @@ "check:quoted-scripts": "node internal/check-quoted-scripts.mjs", "check:rtl-shaping": "node internal/check-gospel-rtl-shaping.mjs", "check:rtl-shaping:poison": "node internal/check-gospel-rtl-shaping.mjs --poison", + "check:rtl-raw": "node internal/check-raw-map-rtl-shaping.mjs", + "check:rtl-raw:poison": "node internal/check-raw-map-rtl-shaping.mjs --poison", "check:scrim-viewport": "node internal/check-scrim-covers-viewport.mjs", "check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test", "check:script-flags": "node internal/check-script-flags-reachable.mjs", diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue index 5fad52892..c2b6205f0 100644 --- a/plugin-designs/cities-internet/connectivity-map.vue +++ b/plugin-designs/cities-internet/connectivity-map.vue @@ -51,6 +51,7 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js' import { resolveMapProvider } from '@map/composables/useMapProvider.js' import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl } from '@map/composables/useMapInstance.js' import { resolveNamedStyle } from '@map/styles/registry.js' +import { installRtlTextPlugin } from '@map/maps/rtl-text.js' // People-group dots open the SAME detail panel as Prayer Progress (doxa-simple-map): the // shared store selection, the shared GO-pin highlight, and the shared legend wrappers // that host PeopleGroupDetail on desktop and in the phone sheet. @@ -278,6 +279,11 @@ onMounted(async () => { const gl = engineGlobal(ciProvider) || window.mapboxgl if (unmounted) return if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return } + // RTL SHAPING, before the first map exists. This raw map does not go through useMapInstance + // (which installs it), and the basemap draws each place in its OWN script: over the Middle East + // most labels are Arabic, Persian or Hebrew for EVERY viewer. An entry-time call cannot do this, + // because the engine is injected lazily, after the bundle has run. + installRtlTextPlugin(gl) try { // The dots are a second layer, not a precondition: if the list fails, the map still shades. const groupsLoaded = loadGroups().catch((e) => { console.warn('[connectivity-map] people groups:', e?.message || e) }) diff --git a/plugin-designs/key-cities/cities-map.vue b/plugin-designs/key-cities/cities-map.vue index a6e2b88cc..8fc81dcfb 100644 --- a/plugin-designs/key-cities/cities-map.vue +++ b/plugin-designs/key-cities/cities-map.vue @@ -37,6 +37,7 @@ import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { addMapAttribution } from '@map/composables/useMapAttribution' import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js' +import { installRtlTextPlugin } from '@map/maps/rtl-text.js' import { resolveAssetsBase } from '@map/utils/embedBase.js' const profileConfig = inject('profileConfig', ref(null)) @@ -137,6 +138,9 @@ onMounted(async () => { try { await ensureEngineScript('mapbox') } catch (_) {} const gl = engineGlobal('mapbox') if (!gl || !mapEl.value) { err.value = 'mapbox-gl not loaded'; return } + // RTL SHAPING before the map exists: a raw map is not covered by useMapInstance's install, and + // the Mapbox basemap draws Middle-East places in Arabic/Persian/Hebrew script for every viewer. + installRtlTextPlugin(gl) try { await load() const map = new gl.Map({