diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d466ebc3a..7659e2772 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2694,8 +2694,10 @@ "src/app/operations.js", "src/app/scene.js", "src/render/cesium/appRenderer.js", + "src/render/creditMarkup.js", "src/render/rendererOf.js", "src/render/cesium/ports.js", + "src/render/cesium/credits.js", "src/render/cesium/common.js", "src/render/cesium/camera.js", "src/render/cesium/follow.js", diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 88dbc6cec..526f9919f 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -27,8 +27,8 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; * The members a transitional renderer has today. * @typedef {Pick} + * | 'camera' | 'query' | 'input' | 'capture' | 'credits' | 'on' + * | 'resize' | 'destroy'>} * TransitionalRenderer */ @@ -115,6 +115,7 @@ export function createCesiumRendererPlugin({ query: ports.query, input: input.port, capture: ports.capture, + credits: ports.credits, on: ports.on, resize() { viewer.resize(); diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 2c565a710..8198716d4 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -337,7 +337,6 @@ const EXPECTED_MISSING = [ 'draw.clampState', 'draw.on', 'frame.setTime', - 'renderer.credits', 'renderer.effects', 'renderer.globe', 'renderer.quality', diff --git a/src/render/cesium/credits.js b/src/render/cesium/credits.js new file mode 100644 index 000000000..b84fe02e1 --- /dev/null +++ b/src/render/cesium/credits.js @@ -0,0 +1,265 @@ +/** + * @module render/cesium/credits + * @description The Cesium renderer's credits port over a Cesium credit + * display: every credit Cesium owes (its logo, static credits and each + * frame's imagery, terrain and tile credits) and every credit Core adds, as + * the contract's plain data (`src/render/contract/credits.d.ts`). + * + * Until Core renders the credit DOM, a credit Core adds is shown through + * Cesium's own display, so attribution stays on screen: `inline` credits on + * the credit line, the rest in its expandable list. Its markup is built + * here from the plain data, every value escaped. + * + * `change` fires when a Core credit is added or removed, and after a drawn + * frame whose Cesium credits differ from the last one's. + * + * Cesium keeps one static credit per markup and placement: a Core credit + * and another caller's of the same markup share one entry in the display, + * which goes when the one added first is removed. + */ +import * as Cesium from 'cesium'; +import { + LINK_SCHEMES, + LOGO_SCHEMES, + creditFromMarkup, + safeUrl, +} from '../creditMarkup.js'; +import { listenerList } from './common.js'; +import { creditDisplayContents } from './internals.js'; + +const CREDIT_KEYS = new Set(['text', 'href', 'logoUrl', 'inline']); +/** Readings kept per Cesium credit id; past this many, the cache restarts. */ +const READINGS_KEPT = 512; + +const escape = (value) => + String(value).replace( + /[&<>"']/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char], + ); + +/** + * A credit's markup for Cesium's display: the logo, then the text, linked + * when it has a web link. A link or logo of any other scheme is left out. + * @param {import('../contract/credits').Credit} credit + * @returns {string} + */ +export function creditMarkup(credit) { + const logo = credit.logoUrl && safeUrl(credit.logoUrl, LOGO_SCHEMES); + const body = [ + logo && + ``, + credit.text && escape(credit.text), + ] + .filter(Boolean) + .join(' '); + const href = credit.href && safeUrl(credit.href, LINK_SCHEMES); + return href + ? `${body}` + : `${body}`; +} + +/** The credit as given, its known fields only, in their given order. */ +function copyCredit(credit) { + if (typeof credit?.text !== 'string') + throw new TypeError('A credit needs its text'); + const copy = {}; + for (const [key, value] of Object.entries(credit)) + if (CREDIT_KEYS.has(key) && value !== undefined) copy[key] = value; + return copy; +} + +/** + * @param {{display: object|undefined, scene: object, + * report: (error: unknown) => void, requestRender: () => void}} options + * `display`: the widget's Cesium CreditDisplay; without one, only Core's + * credits are listed. `requestRender` draws the display once a Core + * credit comes or goes. + * @returns {{port: import('../contract/credits').CreditsPort, + * dispose: () => void}} + */ +export function createCesiumCredits({ display, scene, report, requestRender }) { + const listeners = listenerList(report); + /** Core's credits, in the order added: each `{credit, markup}`. */ + const added = []; + /** Core's credits as Cesium shows them, by markup: `{credit, count}`. */ + const shown = new Map(); + /** Cesium credit id → its text, link and logo. */ + const readings = new Map(); + + const reading = (credit) => { + let read = readings.get(credit.id); + if (!read) { + if (readings.size >= READINGS_KEPT) readings.clear(); + read = creditFromMarkup(credit.html); + readings.set(credit.id, read); + } + return { ...read, inline: Boolean(credit.showOnScreen) }; + }; + + // The display shows a frame's credits most frequent first, sorting them + // in place as the frame ends, after postRender: the same stable sort, + // so the order is the same before and after. + const byCount = (elements) => [...elements].sort((a, b) => b.count - a.count); + + /** The Cesium credits made for Core's, while shown. */ + const coreCredits = new Set(); + + /** + * Every credit shown now, in display order, each a Cesium credit or + * (`core`) the Cesium credit made for Core's: the logo, then on the + * credit line and then in the expanded list, the static credits in the + * order added and the last frame's own, most frequent first. A second + * credit of the same markup and placement is left out, as the display + * leaves it out. + */ + function shownCredits() { + const { statics, screen, lightbox, logo } = creditDisplayContents(display); + const out = []; + const seen = new Set(); + const take = (credit) => { + if (!credit) return; + const key = `${credit.id}:${credit.showOnScreen ? 1 : 0}`; + if (seen.has(key)) return; + seen.add(key); + out.push(credit); + }; + take(logo); + for (const [inline, elements] of [ + [true, screen], + [false, lightbox], + ]) { + for (const credit of statics) + // The display shows an ion credit as its logo. + if ( + Boolean(credit.showOnScreen) === inline && + !(logo && credit.isIon?.()) + ) + take(credit); + for (const element of byCount(elements)) + // A static credit counts Number.MAX_VALUE; those are listed above, + // and one removed since the frame is not owed any more. + if (element.count !== Number.MAX_VALUE) take(element.credit); + } + return out; + } + + /** Cesium's own credits only, for the per-frame diff. */ + const engineCredits = () => + shownCredits().filter((credit) => !coreCredits.has(credit)); + + /** + * Cesium's credits read as plain data, with Core's listed as Core gave + * them where the display shows them; any the display does not show (no + * display, or one it already had) after. + */ + function list() { + const out = []; + const listed = new Set(); + for (const credit of shownCredits()) { + if (!coreCredits.has(credit)) { + out.push(reading(credit)); + continue; + } + for (const entry of added) + if (shown.get(entry.key)?.credit === credit) { + out.push({ ...entry.credit }); + listed.add(entry); + } + } + for (const entry of added) + if (!listed.has(entry)) out.push({ ...entry.credit }); + return out; + } + const changed = () => { + if (listeners.size > 0) listeners.fire(list()); + }; + + // What Cesium owed after the last frame, as credit ids and placements. + const signature = () => + engineCredits() + .map((credit) => `${credit.id}:${credit.showOnScreen ? 1 : 0}`) + .join(','); + let lastSignature = signature(); + const afterFrame = () => { + const now = signature(); + if (now === lastSignature) return; + lastSignature = now; + changed(); + }; + const offFrame = scene.postRender.addEventListener(afterFrame); + + // Cesium shows one static credit per markup and placement, so Core's + // credits of the same markup and placement share one. + function show(markup, inline) { + const key = `${inline ? 'inline' : 'listed'}:${markup}`; + let entry = shown.get(key); + if (!entry) { + entry = { credit: new Cesium.Credit(markup, inline), count: 0 }; + shown.set(key, entry); + coreCredits.add(entry.credit); + display?.addStaticCredit(entry.credit); + } + entry.count += 1; + return key; + } + + function hide(key) { + const entry = shown.get(key); + if (!entry) return; + entry.count -= 1; + if (entry.count > 0) return; + shown.delete(key); + coreCredits.delete(entry.credit); + display?.removeStaticCredit(entry.credit); + } + + let disposed = false; + const port = { + addStatic(credit) { + const copy = copyCredit(credit); + const entry = { credit: copy, key: null }; + added.push(entry); + if (!disposed) { + entry.key = show(creditMarkup(copy), Boolean(copy.inline)); + requestRender(); + } + changed(); + return () => { + const i = added.indexOf(entry); + if (i < 0) return; + added.splice(i, 1); + if (entry.key !== null && !disposed) { + hide(entry.key); + requestRender(); + } + changed(); + }; + }, + list, + on(event, cb) { + if (event !== 'change') return () => {}; + return listeners.on(cb); + }, + }; + + return { + port, + dispose() { + disposed = true; + offFrame(); + listeners.clear(); + if (!display?.isDestroyed?.()) + for (const { credit } of shown.values()) + display?.removeStaticCredit(credit); + shown.clear(); + coreCredits.clear(); + }, + }; +} diff --git a/src/render/cesium/credits.test.mjs b/src/render/cesium/credits.test.mjs new file mode 100644 index 000000000..1eb252f93 --- /dev/null +++ b/src/render/cesium/credits.test.mjs @@ -0,0 +1,291 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createCesiumCredits, creditMarkup } from './credits.js'; + +/** A credit display as Cesium keeps it: static credits and a frame's. */ +function fakeDisplay() { + const frame = { screen: [], lightbox: [] }; + const display = { + _staticCredits: [], + _currentFrameCredits: { + screenCredits: { values: frame.screen }, + lightboxCredits: { values: frame.lightbox }, + }, + _currentCesiumCredit: undefined, + addStaticCredit(credit) { + if (!display._staticCredits.some((c) => Cesium.Credit.equals(c, credit))) + display._staticCredits.push(credit); + }, + removeStaticCredit(credit) { + const i = display._staticCredits.indexOf(credit); + if (i >= 0) display._staticCredits.splice(i, 1); + }, + /** A drawn frame: the static credits, then `extra`, as Cesium adds them. */ + draw(extra = []) { + frame.screen.length = 0; + frame.lightbox.length = 0; + const put = (credit, count) => + (credit.showOnScreen ? frame.screen : frame.lightbox).push({ + credit, + count, + }); + for (const credit of display._staticCredits) + put(credit, Number.MAX_VALUE); + for (const credit of extra) put(credit, 1); + }, + }; + return display; +} + +function setup({ display = fakeDisplay() } = {}) { + const postRender = new Set(); + const scene = { + postRender: { + addEventListener(cb) { + postRender.add(cb); + return () => postRender.delete(cb); + }, + }, + }; + const errors = []; + let renders = 0; + const part = createCesiumCredits({ + display, + scene, + report: (error) => errors.push(error), + requestRender: () => (renders += 1), + }); + return { + display, + part, + port: part.port, + errors, + renders: () => renders, + listeners: postRender, + /** One drawn frame with `extra` per-frame credits. */ + frame(extra) { + display.draw(extra); + for (const cb of [...postRender]) cb(); + }, + }; +} + +test('Cesium’s own credits are listed as plain data, the logo first', () => { + const t = setup(); + t.display._currentCesiumCredit = new Cesium.Credit( + '', + true, + ); + t.display.addStaticCredit( + new Cesium.Credit( + 'Data © Example & others', + false, + ), + ); + assert.deepEqual(t.port.list(), [ + { + text: 'Cesium ion', + href: 'https://cesium.com/', + logoUrl: '/ion-credit.png', + inline: true, + }, + { + text: 'Data © Example & others', + href: 'https://example.com/a', + inline: false, + }, + ]); +}); + +test('per-frame credits come and go with the frames, with one change each', () => { + const t = setup(); + const lists = []; + t.port.on('change', (list) => lists.push(list.map((c) => c.text))); + const tile = new Cesium.Credit('Imagery © Tiles', true); + t.frame([tile]); + t.frame([tile]); + assert.deepEqual(lists, [['Imagery © Tiles']], 'one change, not per frame'); + assert.deepEqual( + t.port.list().map((c) => c.text), + ['Imagery © Tiles'], + ); + t.frame([]); + assert.deepEqual(lists, [['Imagery © Tiles'], []]); + t.frame([]); + assert.equal(lists.length, 2); +}); + +test('a static credit added outside the port is listed at once and changes on the next frame', () => { + const t = setup(); + const lists = []; + t.port.on('change', (list) => lists.push(list.length)); + const credit = new Cesium.Credit('Layer data', false); + t.display.addStaticCredit(credit); + assert.equal(t.port.list().length, 1); + t.frame(); + t.frame(); + assert.deepEqual(lists, [1]); + t.display.removeStaticCredit(credit); + assert.equal(t.port.list().length, 0, 'not owed once removed'); + t.frame(); + assert.deepEqual(lists, [1, 0]); +}); + +test('addStatic lists the credit as given and shows it through Cesium', () => { + const t = setup(); + const lists = []; + t.port.on('change', (list) => lists.push(list)); + const credit = { + text: 'Roads ', + href: 'https://example.com/roads', + logoUrl: 'https://example.com/logo.png', + inline: true, + }; + const off = t.port.addStatic(credit); + assert.equal(JSON.stringify(t.port.list()), JSON.stringify([credit])); + assert.equal(lists.length, 1, 'change at once'); + assert.notEqual(t.port.list()[0], t.port.list()[0], 'copies'); + const [shown] = t.display._staticCredits; + assert.equal(shown.showOnScreen, true); + assert.equal( + shown.html, + ' Roads <data>', + ); + assert.equal(t.renders(), 1); + t.frame(); + assert.equal(lists.length, 1, 'its frame is not a second change'); + assert.equal(t.port.list().length, 1, 'listed once, as Core gave it'); + off(); + off(); + assert.deepEqual(t.port.list(), []); + assert.deepEqual(t.display._staticCredits, []); + assert.equal(lists.length, 2); + t.frame(); + assert.equal(lists.length, 2, 'the frame after is not a change either'); +}); + +test('credits of the same markup share one Cesium credit until the last goes', () => { + const t = setup(); + const a = t.port.addStatic({ text: 'Same' }); + const b = t.port.addStatic({ text: 'Same' }); + assert.equal(t.port.list().length, 2); + assert.equal(t.display._staticCredits.length, 1); + a(); + assert.equal(t.display._staticCredits.length, 1, 'still shown'); + b(); + assert.equal(t.display._staticCredits.length, 0); +}); + +test('credits of the same markup but another placement are shown apart', () => { + const t = setup(); + const listed = t.port.addStatic({ text: 'Same', inline: false }); + t.port.addStatic({ text: 'Same', inline: true }); + assert.equal(t.display._staticCredits.length, 2); + listed(); + assert.deepEqual( + t.display._staticCredits.map((c) => c.showOnScreen), + [true], + ); +}); + +test('a frame’s credits are listed most frequent first, as Cesium shows them', () => { + const t = setup(); + const a = new Cesium.Credit('A once', true); + const b = new Cesium.Credit('B twice', true); + const lists = []; + t.port.on('change', (list) => lists.push(list.map((c) => c.text))); + t.display.draw([a, b]); + t.display._currentFrameCredits.screenCredits.values[1].count = 2; + for (const cb of [...t.listeners]) cb(); + assert.deepEqual(lists, [['B twice', 'A once']]); + assert.deepEqual( + t.port.list().map((c) => c.text), + ['B twice', 'A once'], + ); +}); + +test('Core’s credits are listed where the display shows them', () => { + const t = setup(); + t.display.addStaticCredit(new Cesium.Credit('Engine listed', false)); + t.port.addStatic({ text: 'Core listed' }); + t.port.addStatic({ text: 'Core inline', inline: true }); + t.display.addStaticCredit(new Cesium.Credit('Engine inline', true)); + t.frame([new Cesium.Credit('Tile', true)]); + assert.deepEqual( + t.port.list().map((c) => c.text), + ['Core inline', 'Engine inline', 'Tile', 'Engine listed', 'Core listed'], + ); +}); + +test('a credit needs its text; unknown fields are dropped', () => { + const t = setup(); + assert.throws(() => t.port.addStatic({ href: 'https://example.com' })); + assert.throws(() => t.port.addStatic(null)); + t.port.addStatic({ text: 'x', extra: 1, href: undefined }); + assert.deepEqual(t.port.list(), [{ text: 'x' }]); +}); + +test('listeners follow the listener rules, and other events are none', () => { + const t = setup(); + t.port.on('change', () => { + throw new Error('listener'); + }); + const seen = []; + const off = t.port.on('change', () => seen.push(1)); + assert.equal(typeof t.port.on('other', () => {}), 'function'); + t.port.addStatic({ text: 'x' }); + assert.equal(t.errors.length, 1); + assert.deepEqual(seen, [1]); + off(); + t.port.addStatic({ text: 'y' }); + assert.deepEqual(seen, [1]); +}); + +test('without a credit display only Core’s credits are listed', () => { + const postRender = { addEventListener: () => () => {} }; + const { port } = createCesiumCredits({ + display: undefined, + scene: { postRender }, + report() {}, + requestRender() {}, + }); + const off = port.addStatic({ text: 'x' }); + assert.deepEqual(port.list(), [{ text: 'x' }]); + off(); + assert.deepEqual(port.list(), []); +}); + +test('dispose detaches from the frame and takes Core’s credits off', () => { + const t = setup(); + const seen = []; + t.port.on('change', () => seen.push(1)); + const off = t.port.addStatic({ text: 'x' }); + t.part.dispose(); + assert.equal(t.listeners.size, 0); + assert.deepEqual(t.display._staticCredits, []); + off(); + t.port.addStatic({ text: 'late' }); + assert.deepEqual(t.display._staticCredits, [], 'nothing shown after'); + assert.deepEqual(seen, [1]); + assert.equal(t.renders(), 1, 'no render asked of a disposed scene'); +}); + +test('credit markup escapes every value and links only web URLs', () => { + assert.equal( + creditMarkup({ text: 'a "b" & ' }), + 'a "b" & <c>', + ); + assert.equal( + creditMarkup({ text: 'x', href: 'javascript:alert(1)' }), + 'x', + ); + assert.equal( + creditMarkup({ text: 'x', logoUrl: 'javascript:alert(1)' }), + 'x', + ); + assert.equal( + creditMarkup({ text: 'x', href: 'https://example.com/?a=1&b="2"' }), + 'x', + ); +}); diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 15bf22588..bb189c7c0 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -29,6 +29,8 @@ export const CESIUM_CHECKS = new Set([ 'camera.viewRectangle', 'camera.fov', 'camera.trace.view and projection matrices', + 'credits', + 'credits.listenerRules', 'draw.drillPick', 'draw.ids', 'draw.models', diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index 1df6d0ea4..3802381b4 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -179,3 +179,26 @@ export function updateCameraChanged(camera) { export function billboardLoadError(billboard) { return billboard.loadError; } + +const NO_CREDITS = Object.freeze([]); + +/** + * What a credit display shows, for the credits port to report: its static + * credits, the credit elements of the frame under way or last drawn (each + * `{credit, count}`, a static one counted `Number.MAX_VALUE`) and the logo + * credit it shows first. Cesium's public API only adds and removes static + * credits; the per-frame ones (imagery, terrain and tile credits) are seen + * nowhere else. + * @param {object} display Cesium CreditDisplay. + * @returns {{statics: object[], screen: object[], lightbox: object[], + * logo: object|undefined}} + */ +export function creditDisplayContents(display) { + const frame = display?._currentFrameCredits; + return { + statics: display?._staticCredits ?? NO_CREDITS, + screen: frame?.screenCredits?.values ?? NO_CREDITS, + lightbox: frame?.lightboxCredits?.values ?? NO_CREDITS, + logo: display?._currentCesiumCredit, + }; +} diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index 6e80b4972..77f8aa18e 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -12,6 +12,7 @@ import { closeRenderLoopLatch, clusterPointCollection, commandExtents, + creditDisplayContents, defaultView, lastRenderTime, lastSeenCamera, @@ -54,6 +55,9 @@ test('every private field Core reaches is still declared by the installed Cesium [ENGINE, 'Scene/Scene.js', '_view'], [ENGINE, 'Scene/View.js', '_cameraClone'], [ENGINE, 'Scene/Scene.js', '_frameState'], + [ENGINE, 'Scene/CreditDisplay.js', '_staticCredits'], + [ENGINE, 'Scene/CreditDisplay.js', '_currentFrameCredits'], + [ENGINE, 'Scene/CreditDisplay.js', '_currentCesiumCredit'], ]; const missing = fields.filter( ([root, file, field]) => @@ -149,6 +153,27 @@ test('the helpers read and write exactly those fields', () => { const fn = () => {}; atEndOfTick({ _frameState: { afterRender } }, fn); assert.deepEqual(afterRender, [fn]); + const logo = {}; + const statics = [{}]; + const screen = [{ credit: {}, count: 1 }]; + const lightbox = []; + assert.deepEqual( + creditDisplayContents({ + _staticCredits: statics, + _currentFrameCredits: { + screenCredits: { values: screen }, + lightboxCredits: { values: lightbox }, + }, + _currentCesiumCredit: logo, + }), + { statics, screen, lightbox, logo }, + ); + assert.deepEqual(creditDisplayContents(undefined), { + statics: [], + screen: [], + lightbox: [], + logo: undefined, + }); const event = () => { const listeners = new Set(); return { diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index d95b9e7f5..392c229ba 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -2,7 +2,8 @@ * @module render/cesium/ports * @description The Cesium renderer's contract ports, built over a Cesium * widget or viewer someone else made and configured: frame phases, the - * draw sets, `setView` and the pose, picks and projection, and capture. + * draw sets, `setView` and the pose, picks and projection, credits and + * capture. * Building them puts the loop on demand (no holds yet, and no redraws for * scene time alone); nothing else in the scene changes until a call. * @@ -15,6 +16,7 @@ import * as Cesium from 'cesium'; import { heightsFor } from '../../queries/heights.js'; import { createCesiumCamera } from './camera.js'; import { listenerList, toCartesian, uncaught } from './common.js'; +import { createCesiumCredits } from './credits.js'; import { atEndOfTick, billboardLoadError, @@ -734,6 +736,13 @@ export function createCesiumPorts(widget, { capabilities }) { const cameraPart = createCesiumCamera({ scene, camera, report, widget }); const cameraPort = cameraPart.port; + const creditsPart = createCesiumCredits({ + display: widget.creditDisplay, + scene, + report, + requestRender: () => frame.requestRender('credits'), + }); + // --- Query ----------------------------------------------------------------- const screen = (x, y) => new Cesium.Cartesian2(x, y); @@ -1075,6 +1084,7 @@ export function createCesiumPorts(widget, { capabilities }) { frame, draw, camera: cameraPort, + credits: creditsPart.port, query, capture, modelSets, @@ -1091,6 +1101,7 @@ export function createCesiumPorts(widget, { capabilities }) { for (const list of Object.values(rendererListeners)) list.clear(); for (const list of Object.values(frameListeners)) list.clear(); cameraPart.dispose(); + creditsPart.dispose(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. for (const record of sets.values()) record.host = null; diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 817c77536..12c37b5d8 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -3,8 +3,9 @@ * @description The part of the Cesium renderer the reference scenes need, * through the contract's own members: a bare globe and sky, icons, points, * lines, shapes and models, `setView`, picks and projection, the frame - * phases, pointer input, settling and capture. The `/dev/conformance/` page - * draws the scenes with it to make and check the visual references. + * phases, pointer input, credits, settling and capture. The + * `/dev/conformance/` page draws the scenes with it to make and check the + * visual references. * * Its ports are `ports.js`'s, which the application's renderer shares; * this factory adds the conformance widget, its fixed settings, the input @@ -280,6 +281,7 @@ export async function createCesiumSceneRenderer({ query: ports.query, input: input.port, capture: ports.capture, + credits: ports.credits, whenSettled, resize: () => widget.resize(), on: ports.on, diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index 88aef2c54..d3c4285db 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -297,7 +297,6 @@ export function portChecks() { 'every subscription list follows the listener rules', async (h, r) => { await overAustin(h, r); - const credits = []; const lists = [ [ 'frame.on(postRender)', @@ -315,13 +314,6 @@ export function portChecks() { () => r.camera.setView({ destination: { ...AUSTIN, height: 2000 } }), ], - [ - 'credits.on(change)', - (cb) => r.credits.on('change', cb), - () => { - credits.push(r.credits.addStatic({ text: 'Conformance data' })); - }, - ], [ 'quality.signals.on(frame)', (cb) => r.quality.signals.on('frame', cb), @@ -352,7 +344,25 @@ export function portChecks() { ]); for (const [where, subscribe, fire] of lists) await listenerRules(r, where, subscribe, fire); - for (const off of credits) off(); + }, + ), + check( + 'credits.listenerRules', + 'credits.on(change) follows the listener rules', + async (h, r) => { + const credits = []; + try { + await listenerRules( + r, + 'credits.on(change)', + (cb) => r.credits.on('change', cb), + () => { + credits.push(r.credits.addStatic({ text: 'Conformance data' })); + }, + ); + } finally { + for (const off of credits) off(); + } }, ), diff --git a/src/render/creditMarkup.js b/src/render/creditMarkup.js new file mode 100644 index 000000000..70a6b9c97 --- /dev/null +++ b/src/render/creditMarkup.js @@ -0,0 +1,102 @@ +/** + * @module render/creditMarkup + * @description A credit written as HTML markup, as engines and older layer + * code carry it, read as the contract's plain credit data + * (`src/render/contract/credits.d.ts`): its text, the first link and the + * first image. Markup with more than one link keeps only the first. + * + * A plain reading, no DOM: it runs in Node and in workers. A link or logo + * of a scheme that could run script is dropped. + */ + +/** The schemes a credit may link to, and load its logo from. */ +export const LINK_SCHEMES = Object.freeze(['https:', 'http:', 'mailto:']); +export const LOGO_SCHEMES = Object.freeze(['https:', 'http:', 'data:']); + +/** + * A URL of one of `schemes`, resolved against the page, or null. + * @param {string} value + * @param {readonly string[]} schemes + * @returns {string|null} + */ +export function safeUrl(value, schemes) { + try { + const base = globalThis.location?.href ?? 'https://invalid.invalid/'; + const url = new URL(value, base); + if (!schemes.includes(url.protocol)) return null; + if (url.protocol === 'data:' && !/^data:image\//i.test(url.href)) + return null; + return url.href; + } catch { + return null; + } +} + +/** A tag's inside, quoted attribute values allowed to hold `>`. */ +const TAG_BODY = `(?:[^>"']|"[^"]*"|'[^']*')*`; +const ANCHOR = new RegExp(``, 'i'); +const IMAGE = new RegExp(``, 'i'); +const ANY_TAG = new RegExp(`<${TAG_BODY}>`, 'g'); + +const ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', + copy: '©', + reg: '®', + trade: '™', + middot: '·', + ndash: '–', + mdash: '—', +}; + +/** Character references decoded; unknown named ones are left as written. */ +function decode(text) { + return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, ref) => { + if (ref[0] !== '#') return ENTITIES[ref.toLowerCase()] ?? whole; + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; + }); +} + +/** An attribute's value in one start tag, decoded. */ +function attribute(tag, name) { + const match = new RegExp( + `\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, + 'i', + ).exec(tag); + return match ? decode(match[1] ?? match[2] ?? match[3]) : undefined; +} + +/** + * @param {string} html + * @returns {{text: string, href?: string, logoUrl?: string}} A logo with no + * text around it is named by its title or alt text. + */ +export function creditFromMarkup(html) { + const markup = String(html ?? ''); + const anchor = ANCHOR.exec(markup)?.[0]; + const image = IMAGE.exec(markup)?.[0]; + let text = decode( + markup + .replace(//g, '') + .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') + .replace(ANY_TAG, ''), + ) + .replace(/\s+/g, ' ') + .trim(); + if (!text && image) + text = attribute(image, 'title') ?? attribute(image, 'alt') ?? ''; + const credit = { text }; + const href = anchor && attribute(anchor, 'href'); + if (href && safeUrl(href, LINK_SCHEMES)) credit.href = href; + const logoUrl = image && attribute(image, 'src'); + if (logoUrl && safeUrl(logoUrl, LOGO_SCHEMES)) credit.logoUrl = logoUrl; + return credit; +} diff --git a/src/render/creditMarkup.test.mjs b/src/render/creditMarkup.test.mjs new file mode 100644 index 000000000..0440b0724 --- /dev/null +++ b/src/render/creditMarkup.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { creditFromMarkup } from './creditMarkup.js'; + +test('text, the first link and the first logo are read from markup', () => { + assert.deepEqual( + creditFromMarkup( + 'Map data © OpenStreetMap (ODbL)', + ), + { + text: 'Map data © OpenStreetMap (ODbL)', + href: 'https://www.openstreetmap.org/copyright', + }, + ); + assert.deepEqual( + creditFromMarkup( + '', + ), + { text: 'Cesium ion', href: 'https://cesium.com/', logoUrl: '/ion.png' }, + ); + assert.deepEqual(creditFromMarkup('Logo'), { + text: 'Logo', + logoUrl: 'x.png', + }); +}); + +test('character references decode; tags, comments and scripts drop', () => { + assert.deepEqual( + creditFromMarkup( + ' A &'B—&unknown; \n C', + ), + { text: "A &'B—&unknown; C" }, + ); + assert.deepEqual(creditFromMarkup('x'), { + text: 'x', + href: 'yes', + }); + assert.deepEqual( + creditFromMarkup( + 'Provider', + ), + { text: 'Provider', href: 'https://example.com/?q=>' }, + ); + assert.deepEqual( + creditFromMarkup( + 'L', + ), + { text: 'L' }, + ); + assert.deepEqual(creditFromMarkup('L'), { + text: 'L', + }); + assert.deepEqual(creditFromMarkup(''), { text: '' }); + assert.deepEqual(creditFromMarkup(undefined), { text: '' }); +});