diff --git a/internal/check-poster-attribution.mjs b/internal/check-poster-attribution.mjs new file mode 100644 index 000000000..24a1c9125 --- /dev/null +++ b/internal/check-poster-attribution.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * check-poster-attribution — the poster footer credits whoever actually served the tiles. + * + * The footer is a LICENCE statement, so it has to be true rather than merely present. + * posterDefaults ships '© Mapbox © OpenStreetMap'; under the fleet's MapLibre default Mapbox serves + * nothing, OpenFreeMap serves everything, and any poster carrying the default credited a company + * with no involvement while omitting the three parties whose licences apply. + * + * ★ THE FIXTURE IS THE REAL THING, and it is the reason this check exists in this shape. Fetched + * 2026-09-28: tiles.openfreemap.org/styles/liberty declares two sources and NEITHER has an + * `attribution` field, so reading the style alone returns nothing. The credit lives in the TileJSON + * at tiles.openfreemap.org/planet, as HTML with anchors and a © entity, which the engine merges + * into the SOURCE object after it loads. A check written against a tidy invented style would have + * passed while the real map printed the wrong credit. + * + * Headless, no network, no browser, no dependencies. Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { attributionTextFromMap, attributionToText } = await import(resolve(ROOT, 'library/maps/map-attribution.js')) + +const fails = [] +const eq = (what, got, want) => { if (got !== want) fails.push(`${what}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`) } +const ok = (what, cond, detail) => { if (!cond) fails.push(what + (detail ? `\n ${detail}` : '')) } + +// The exact string tiles.openfreemap.org/planet returns. +const OFM_TILEJSON_HTML = + 'OpenFreeMap ' + + '© OpenMapTiles ' + + 'Data from OpenStreetMap' +const OFM_TEXT = 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap' + +const fakeMap = ({ sources, live }) => ({ + getStyle: () => ({ sources }), + getSource: (id) => (live && Object.prototype.hasOwnProperty.call(live, id) ? live[id] : undefined), +}) + +// 1. the real shape: style declares nothing, the loaded source carries the TileJSON credit +{ + const map = fakeMap({ + sources: { ne2_shaded: { type: 'raster' }, openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' } }, + live: { ne2_shaded: {}, openmaptiles: { attribution: OFM_TILEJSON_HTML } }, + }) + eq('the OpenFreeMap credit is read from the LOADED source, not the style', attributionTextFromMap(map), OFM_TEXT) + ok('and it no longer says Mapbox', !attributionTextFromMap(map).includes('Mapbox')) +} + +// 2. a Mapbox map still credits Mapbox — this must not become "always OpenFreeMap" +{ + const map = fakeMap({ + sources: { composite: { type: 'vector' } }, + live: { composite: { attribution: '© Mapbox © OpenStreetMap' } }, + }) + eq('a Mapbox-served map credits Mapbox', attributionTextFromMap(map), '© Mapbox © OpenStreetMap') +} + +// 3. a style that DOES declare attribution is honoured (the source is preferred, not required) +{ + const map = fakeMap({ sources: { s: { type: 'raster', attribution: 'Tiles by Someone' } }, live: {} }) + eq('a style-declared attribution is used when there is no live source', attributionTextFromMap(map), 'Tiles by Someone') +} +{ + const map = fakeMap({ + sources: { s: { type: 'raster', attribution: 'the stale request' } }, + live: { s: { attribution: 'what actually came back' } }, + }) + eq('the LOADED source wins over the style declaration', attributionTextFromMap(map), 'what actually came back') +} + +// 4. repeated credits collapse; several distinct ones all survive +{ + const map = fakeMap({ + sources: { a: {}, b: {}, c: {} }, + live: { a: { attribution: '© OpenStreetMap' }, b: { attribution: '© OpenStreetMap' }, c: { attribution: 'geoBoundaries' } }, + }) + eq('the same credit twice is printed once', attributionTextFromMap(map), '© OpenStreetMap geoBoundaries') +} + +// 5. it must never invent, and never throw — an empty answer hands the caller back its fallback +{ + eq('no map -> empty', attributionTextFromMap(null), '') + eq('a map with no getStyle -> empty', attributionTextFromMap({}), '') + eq('sources with no attribution -> empty', attributionTextFromMap(fakeMap({ sources: { a: {} }, live: { a: {} } })), '') + const throwing = { getStyle: () => { throw new Error('style not loaded') } } + eq('getStyle throwing before load -> empty, not a crash', attributionTextFromMap(throwing), '') + const badSource = { getStyle: () => ({ sources: { a: {} } }), getSource: () => { throw new Error('nope') } } + eq('getSource throwing -> empty, not a crash', attributionTextFromMap(badSource), '') +} + +// 6. flattening: a footer is text, and entities must not print raw +{ + eq('anchors are removed', attributionToText('© OpenStreetMap'), '© OpenStreetMap') + eq('© becomes ©', attributionToText('© OpenMapTiles'), '© OpenMapTiles') + eq('whitespace collapses', attributionToText(' a \n b '), 'a b') + ok('no markup survives', !/[<>]/.test(attributionToText(OFM_TILEJSON_HTML))) + ok('no undecoded entity survives', !/&[a-z]+;/i.test(attributionToText(OFM_TILEJSON_HTML))) +} + +// 7. the renderer must actually consult it, and must keep the spec's value as a fallback. +{ + const SRC = readFileSync(resolve(ROOT, 'library/composables/poster/useMapPoster.js'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + ok('the renderer imports the helper', /import\s*\{[^}]*attributionTextFromMap[^}]*\}\s*from/.test(SRC)) + ok('and calls it with the live source map', /attributionTextFromMap\s*\(\s*sourceMap\s*\)/.test(SRC)) + // The spread `...(spec.slots?.footer || {})` contains a brace, so a [^}] class stops short of the + // property it is looking for and reports the opposite of the truth. + ok('the derived credit reaches the footer slot', /footer\s*:\s*\{[\s\S]{0,160}?attribution\s*:\s*credit/.test(SRC)) + ok('an empty credit falls back to the spec rather than printing nothing', + /credit\s*\?[\s\S]{0,240}?:\s*spec/.test(SRC), + 'a licence footer may never be blank') +} + +if (fails.length) { + console.error('POSTER-ATTRIBUTION: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('POSTER-ATTRIBUTION: PASS — the credit is read from the LOADED source (where the TileJSON lands) rather than the style or a constant, HTML and entities are flattened for print, duplicates collapse, a Mapbox map still says Mapbox, and an unreadable map falls back instead of printing a blank licence line.') diff --git a/library/composables/poster/useMapPoster.js b/library/composables/poster/useMapPoster.js index 792d69b51..f4f7e0977 100644 --- a/library/composables/poster/useMapPoster.js +++ b/library/composables/poster/useMapPoster.js @@ -34,6 +34,7 @@ import { ref, readonly } from 'vue'; import { equalHeightLatBands } from '../../maps/mercator-slice.js'; import { computeLayout } from './usePosterLayout.js'; +import { attributionTextFromMap } from '../../maps/map-attribution.js'; import { engineFor } from '../useMapProvider.js'; const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware @@ -126,7 +127,17 @@ export function useMapPoster(getSourceMap) { : await _renderTileStitch(sourceMap, px, signal); _setProgress('compose', 0, 1, 'compositing overlay'); - const composed = await _composeOverlay(baseRaster, spec, px); + // ★ THE FOOTER CREDITS WHOEVER ACTUALLY SERVED THE TILES. posterDefaults ships + // '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that + // served nothing and omits the three parties whose licences do apply. Read from the map rather + // than from a constant; the spec's value stays as the fallback, because an EMPTY attribution is + // the one outcome a licence never allows. The caller's spec is copied, not mutated — it belongs + // to whoever passed it in. + const credit = attributionTextFromMap(sourceMap); + const specForOverlay = credit + ? { ...spec, slots: { ...spec.slots, footer: { ...(spec.slots?.footer || {}), attribution: credit } } } + : spec; + const composed = await _composeOverlay(baseRaster, specForOverlay, px); _setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`); const blob = (format === 'pdf') diff --git a/library/constants/posterDefaults.js b/library/constants/posterDefaults.js index bad8faeff..78319c04c 100644 --- a/library/constants/posterDefaults.js +++ b/library/constants/posterDefaults.js @@ -71,6 +71,11 @@ export const posterDefaults = { fontSizePt: 10, color: '#222', // Caller may set left/right text; attribution is enforced by FooterSlot. + // ★ A FALLBACK OF LAST RESORT, NOT THE ANSWER. useMapPoster reads the credit from the map's + // LOADED sources (library/maps/map-attribution.js) and overrides this, because under the + // fleet's MapLibre default Mapbox serves nothing and this line would credit the wrong party. + // It stays non-empty only so a map that cannot be read still prints something: a licence + // footer may never be blank. attribution: '© Mapbox © OpenStreetMap', backgroundColor: 'rgba(255,255,255,0.92)', }, diff --git a/library/maps/map-attribution.js b/library/maps/map-attribution.js new file mode 100644 index 000000000..01e521373 --- /dev/null +++ b/library/maps/map-attribution.js @@ -0,0 +1,54 @@ +/** + * map-attribution — the credit the tiles on THIS map actually ask for, as plain text. + * + * ★ WHY THIS IS READ AND NOT DECLARED. The poster footer defaulted to '© Mapbox © OpenStreetMap', + * which is a licence statement, so it must be true. Under the fleet's MapLibre default Mapbox serves + * nothing and OpenFreeMap serves everything, and any poster carrying that default credited a company + * with no involvement while omitting the three parties whose licences actually apply. + * + * ★ AND IT CANNOT BE READ FROM THE STYLE. Checked against the live style 2026-09-28: + * tiles.openfreemap.org/styles/liberty declares TWO sources and NEITHER carries an `attribution` + * field. The credit lives in the TileJSON the vector source points at + * (tiles.openfreemap.org/planet), which the engine fetches and merges into the SOURCE OBJECT once it + * loads. So `map.getStyle().sources[id].attribution` is null and `map.getSource(id).attribution` is + * the real answer — the style is the request, the source is what came back. This reads both and + * prefers the live source, because a style that DOES declare one should still be honoured. + * + * The TileJSON value is HTML with anchors and entities; a poster footer is text, so it is flattened + * rather than escaped-and-printed. Returns '' when nothing can be read, and the CALLER keeps its own + * fallback — printing an empty attribution is the one outcome that is never acceptable. + */ + +const ENTITIES = { '©': '©', '&': '&', ' ': ' ', '<': '<', '>': '>', '"': '"', ''': "'" }; + +/** Flatten one attribution value (HTML or plain) to trimmed, single-spaced text. */ +export function attributionToText(value) { + if (!value) return ''; + return String(value) + .replace(/<[^>]*>/g, ' ') // anchors carry the link, not the credit + .replace(/&[a-z]+;|&#\d+;/gi, (e) => ENTITIES[e.toLowerCase()] ?? ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Every distinct credit the map's sources ask for, joined into one line. + * + * @param {Object} map a mapboxgl/maplibregl Map instance + * @returns {string} plain text, or '' when the map cannot be read + */ +export function attributionTextFromMap(map) { + if (!map || typeof map.getStyle !== 'function') return ''; + let style; + try { style = map.getStyle(); } catch (_) { return ''; } // getStyle throws before the style loads + const sources = (style && style.sources) || {}; + const seen = []; + for (const id of Object.keys(sources)) { + // The live source first: it is the one the TileJSON was merged into. + let live = null; + try { live = typeof map.getSource === 'function' ? map.getSource(id) : null; } catch (_) { live = null; } + const text = attributionToText((live && live.attribution) || sources[id].attribution); + if (text && !seen.includes(text)) seen.push(text); + } + return seen.join(' '); +} diff --git a/package.json b/package.json index 0c91ad29a..ee8451e22 100644 --- a/package.json +++ b/package.json @@ -296,7 +296,7 @@ "fix:sri": "node internal/fix-sri.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", - "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: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", + "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: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", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs",