From bb335c457159abbf4507ea58cab8da4982f66825 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 12 Oct 2026 19:35:29 +1100 Subject: [PATCH 01/48] chore(ci): run affected lib tests on PR branches The PR Flow test matrix was built from affected_apps, so a change to a library ran its specs only after merge, in the BUILD workflow's affected_units matrix. Use affected_units for the PR matrix as well. The app and lib test targets accept the same command, and Nx ignores the --configuration flag the job passes when a target defines none, which is already the case for every app. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pull-request.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index de8f315db3f..f2c1421f49c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -129,12 +129,12 @@ jobs: test: runs-on: ubuntu-latest needs: install_deps - if: ${{ fromJSON(needs.install_deps.outputs.affected_apps)[0] != null }} + if: ${{ fromJSON(needs.install_deps.outputs.affected_units)[0] != null }} strategy: matrix: - package: ${{ fromJSON(needs.install_deps.outputs.affected_apps) }} + package: ${{ fromJSON(needs.install_deps.outputs.affected_units) }} env: - jobCount: ${{ fromJSON(needs.install_deps.outputs.affected_apps).length }} + jobCount: ${{ fromJSON(needs.install_deps.outputs.affected_units).length }} configuration: ${{ needs.install_deps.outputs.configuration }} deploy_branch: ${{ needs.install_deps.outputs.deploy_branch }} short_sha: ${{ needs.install_deps.outputs.short_sha }} From f881df11ee7bade85152716ae1b4f37263839faf Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 12 Oct 2026 19:35:29 +1100 Subject: [PATCH 02/48] build(deps): add dompurify for inserted SVG markup The map editor sanitises replacement and inserted markup with DOMPurify's svg and svgFilters profiles on top of its own deny-list. Co-Authored-By: Claude Fable 5.1 --- bun.lock | 5 +++++ package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/bun.lock b/bun.lock index 23afd71b89b..5ce29461659 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", "deepmerge": "^4.3.1", + "dompurify": "^3.4.16", "dotenv": "16.4.5", "eslint": "9.39.4", "eslint-plugin-playwright": "^1.6.2", @@ -1278,6 +1279,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -1738,6 +1741,8 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="], + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], diff --git a/package.json b/package.json index 4891cf98c20..d04e17813c5 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", "deepmerge": "^4.3.1", + "dompurify": "^3.4.16", "dotenv": "16.4.5", "eslint": "9.39.4", "eslint-plugin-playwright": "^1.6.2", From 0ae789e04d31455ffc4d79b455a7dc6e8ba2c43d Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 12 Oct 2026 19:35:29 +1100 Subject: [PATCH 03/48] feat(map-editor): document core for the AI map editor New library @placeos/map-editor (libs/map-editor), framework-free so it runs in jsdom; Angular hosts wrap it later. This commit holds the document model: - MapDocument.load: strict DOMParser('image/svg+xml'); rejects parser errors, DOCTYPE and entity declarations, non-SVG roots and non-numeric viewBoxes (synthesised once from unitless width and height); sanitises on load and records what was stripped; gives every element a session anchor data-map-editor-anchor="eN"; keeps the XML prolog; serialises a clean clone with the anchors removed - canonical own-signatures and 8-character FNV-1a content hashes for the collateral gate and stale-target detection - GeometryProvider with a browser implementation (getBBox through the screen CTM, the maths of the viewer's getElementBoundsInSvgSpace) and an attribute implementation for a detached DOM, which is the spike's svg_index.py ported to TypeScript - StyleResolver, the visibility rules and the duplicate-id check lifted from Alex Sorafumo's Concierge level-map-editor-modal.component.ts as pure functions, with rule matches precomputed per tree - the deny-list sanitiser and a DOMPurify wrapper for inserted fragments (svg and svgFilters profiles, HTML parsing so camel-case names survive, re-parsed as XML) - the element index: resolved fill and stroke, labels, kind guesses, protected flags, anonymous child counts, the style rules and the spike's prompt-lines text shape - ProtectedIdSet and the merge of host-supplied sources, which marks the set incomplete rather than failing when a source errors - vitest specs over the real maps in shared/assets/maps: load and reject cases, round trips with equal signatures and stable anchors, the sanitiser keeping style, text, tspan and marker on every map. test.svg is excluded from the valid set and asserted as a rejection because it declares no SVG namespace, which an would not draw either Co-Authored-By: Claude Fable 5.1 --- libs/map-editor/README.md | 10 + libs/map-editor/project.json | 34 ++ libs/map-editor/src/index.ts | 12 + libs/map-editor/src/lib/constants.ts | 132 ++++++ libs/map-editor/src/lib/geometry.ts | 224 ++++++++++ libs/map-editor/src/lib/ids.ts | 139 +++++++ libs/map-editor/src/lib/map-document.ts | 381 ++++++++++++++++++ libs/map-editor/src/lib/map-index.ts | 278 +++++++++++++ libs/map-editor/src/lib/matrix.ts | 231 +++++++++++ libs/map-editor/src/lib/path-data.ts | 200 +++++++++ libs/map-editor/src/lib/protected-ids.ts | 106 +++++ libs/map-editor/src/lib/sanitise.ts | 243 +++++++++++ libs/map-editor/src/lib/signature.ts | 42 ++ libs/map-editor/src/lib/style-resolution.ts | 289 +++++++++++++ libs/map-editor/src/lib/value-syntax.ts | 175 ++++++++ libs/map-editor/src/test-setup.ts | 4 + libs/map-editor/src/tests/fixtures.ts | 67 +++ libs/map-editor/src/tests/geometry.spec.ts | 165 ++++++++ .../map-editor/src/tests/map-document.spec.ts | 249 ++++++++++++ libs/map-editor/src/tests/map-index.spec.ts | 109 +++++ .../src/tests/protected-ids.spec.ts | 44 ++ libs/map-editor/src/tests/sanitise.spec.ts | 207 ++++++++++ .../src/tests/style-resolution.spec.ts | 98 +++++ libs/map-editor/tsconfig.json | 24 ++ libs/map-editor/tsconfig.lib.json | 24 ++ libs/map-editor/tsconfig.spec.json | 20 + tsconfig.base.json | 1 + 27 files changed, 3508 insertions(+) create mode 100644 libs/map-editor/README.md create mode 100644 libs/map-editor/project.json create mode 100644 libs/map-editor/src/index.ts create mode 100644 libs/map-editor/src/lib/constants.ts create mode 100644 libs/map-editor/src/lib/geometry.ts create mode 100644 libs/map-editor/src/lib/ids.ts create mode 100644 libs/map-editor/src/lib/map-document.ts create mode 100644 libs/map-editor/src/lib/map-index.ts create mode 100644 libs/map-editor/src/lib/matrix.ts create mode 100644 libs/map-editor/src/lib/path-data.ts create mode 100644 libs/map-editor/src/lib/protected-ids.ts create mode 100644 libs/map-editor/src/lib/sanitise.ts create mode 100644 libs/map-editor/src/lib/signature.ts create mode 100644 libs/map-editor/src/lib/style-resolution.ts create mode 100644 libs/map-editor/src/lib/value-syntax.ts create mode 100644 libs/map-editor/src/test-setup.ts create mode 100644 libs/map-editor/src/tests/fixtures.ts create mode 100644 libs/map-editor/src/tests/geometry.spec.ts create mode 100644 libs/map-editor/src/tests/map-document.spec.ts create mode 100644 libs/map-editor/src/tests/map-index.spec.ts create mode 100644 libs/map-editor/src/tests/protected-ids.spec.ts create mode 100644 libs/map-editor/src/tests/sanitise.spec.ts create mode 100644 libs/map-editor/src/tests/style-resolution.spec.ts create mode 100644 libs/map-editor/tsconfig.json create mode 100644 libs/map-editor/tsconfig.lib.json create mode 100644 libs/map-editor/tsconfig.spec.json diff --git a/libs/map-editor/README.md b/libs/map-editor/README.md new file mode 100644 index 00000000000..4e8d2a4a9e9 --- /dev/null +++ b/libs/map-editor/README.md @@ -0,0 +1,10 @@ +# map-editor + +Document core for the PlaceOS map editor: a strictly parsed SVG map with session +anchors, an element index, a closed vocabulary of DOM operations with an applier +and a validator, snapshot history and a browser draft store. Framework-free so +it unit-tests in jsdom; Angular hosts wrap it. + +## Running unit tests + +Run `nx test map-editor` to execute the unit tests. diff --git a/libs/map-editor/project.json b/libs/map-editor/project.json new file mode 100644 index 00000000000..9bce10810ab --- /dev/null +++ b/libs/map-editor/project.json @@ -0,0 +1,34 @@ +{ + "name": "map-editor", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/map-editor/src", + "prefix": "placeos", + "tags": [], + "targets": { + "lint": {}, + "test": { + "executor": "@angular/build:unit-test", + "outputs": ["{workspaceRoot}/coverage/libs/map-editor"], + "options": { + "tsConfig": "libs/map-editor/tsconfig.spec.json", + "setupFiles": ["libs/map-editor/src/test-setup.ts"], + "isolate": true, + "watch": false, + "buildTarget": "map-editor:test-build:development" + } + }, + "test-build": { + "executor": "@angular/build:application", + "options": { + "outputPath": "dist/libs/map-editor", + "browser": "libs/map-editor/src/index.ts", + "tsConfig": "libs/map-editor/tsconfig.spec.json", + "polyfills": [] + }, + "configurations": { + "development": {} + } + } + } +} diff --git a/libs/map-editor/src/index.ts b/libs/map-editor/src/index.ts new file mode 100644 index 00000000000..c2ad62ec5c0 --- /dev/null +++ b/libs/map-editor/src/index.ts @@ -0,0 +1,12 @@ +export * from './lib/constants'; +export * from './lib/geometry'; +export * from './lib/ids'; +export * from './lib/map-document'; +export * from './lib/map-index'; +export * from './lib/matrix'; +export * from './lib/path-data'; +export * from './lib/protected-ids'; +export * from './lib/sanitise'; +export * from './lib/signature'; +export * from './lib/style-resolution'; +export * from './lib/value-syntax'; diff --git a/libs/map-editor/src/lib/constants.ts b/libs/map-editor/src/lib/constants.ts new file mode 100644 index 00000000000..b50e13e1309 --- /dev/null +++ b/libs/map-editor/src/lib/constants.ts @@ -0,0 +1,132 @@ +export const SVG_NS = 'http://www.w3.org/2000/svg'; +export const XLINK_NS = 'http://www.w3.org/1999/xlink'; + +/** Session anchor attribute; stripped from every element on serialise. */ +export const ANCHOR_ATTR = 'data-map-editor-anchor'; +/** Every attribute with this prefix is editor state, never saved. */ +export const EDITOR_ATTR_PREFIX = 'data-map-editor-'; + +export const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]*$/; +export const DEFAULT_ID_PREFIX = 'ai-'; + +/** Attributes `set_attr` may touch; `data-*` is allowed by prefix. */ +export const ATTR_ALLOW_LIST: ReadonlySet = new Set([ + 'fill', + 'stroke', + 'stroke-width', + 'stroke-dasharray', + 'fill-opacity', + 'stroke-opacity', + 'opacity', + 'class', + 'visibility', + 'rx', + 'ry', + 'font-size', + 'font-family', + 'font-weight', + 'text-anchor', +]); + +/** Properties `set_style_rule` declarations may carry. */ +export const STYLE_PROPERTY_ALLOW_LIST: ReadonlySet = ATTR_ALLOW_LIST; + +/** Attributes that are never set through `set_attr`, whatever the value. */ +export const ATTR_DENY_LIST: ReadonlySet = new Set([ + 'id', + 'transform', + 'href', + 'xlink:href', + 'style', + 'src', +]); + +export const SHAPE_KINDS = [ + 'rect', + 'circle', + 'ellipse', + 'line', + 'polygon', + 'polyline', + 'path', + 'text', + 'g', +] as const; +export type ShapeKind = (typeof SHAPE_KINDS)[number]; + +/** Geometry attributes each shape kind accepts, beyond the paint allow-list. */ +export const SHAPE_GEOMETRY_ATTRS: Readonly< + Record +> = { + rect: ['x', 'y', 'width', 'height', 'rx', 'ry'], + circle: ['cx', 'cy', 'r'], + ellipse: ['cx', 'cy', 'rx', 'ry'], + line: ['x1', 'y1', 'x2', 'y2'], + polygon: ['points'], + polyline: ['points'], + path: ['d'], + text: ['x', 'y', 'dx', 'dy'], + g: [], +}; + +export const TEXT_TAGS: ReadonlySet = new Set(['text', 'tspan']); +export const CONTAINER_TAGS: ReadonlySet = new Set(['a', 'g']); +export const LINE_TAGS: ReadonlySet = new Set(['line']); +export const REFERENCE_TAGS: ReadonlySet = new Set(['image', 'use']); + +/** Tags that never paint; compared lower-cased. */ +export const NON_RENDERED_TAGS: ReadonlySet = new Set([ + 'clippath', + 'defs', + 'desc', + 'filter', + 'lineargradient', + 'marker', + 'mask', + 'metadata', + 'pattern', + 'radialgradient', + 'script', + 'style', + 'symbol', + 'title', +]); + +/** Elements removed by the sanitiser wherever they appear; compared lower-cased. */ +export const DENIED_TAGS: ReadonlySet = new Set([ + 'script', + 'foreignobject', + 'handler', + 'iframe', + 'embed', + 'object', + 'animate', + 'animatecolor', + 'animatemotion', + 'animatetransform', + 'set', + 'discard', + 'mpath', +]); + +/** Attributes whose value is a reference and must stay inside the document. */ +export const REFERENCE_ATTRS: ReadonlySet = new Set([ + 'href', + 'xlink:href', + 'src', +]); + +export const MAX_OPS_PER_PROPOSAL = 80; +export const MAX_REPLACEMENT_MARKUP_BYTES = 16 * 1024; +export const MAX_TRANSFORM_TARGETS = 200; +export const MAX_DUPLICATE_COUNT = 40; +export const MAX_ELEMENT_GROWTH = 60; +/** Fraction of the document's byte size a proposal may add. */ +export const MAX_BYTE_GROWTH = 0.25; +/** Fraction of the viewBox size geometry may leave the canvas by. */ +export const OFF_CANVAS_TOLERANCE = 0.02; + +export const HISTORY_DEPTH = 50; +export const HISTORY_BYTE_BUDGET = 64 * 1024 * 1024; + +export const CONTENT_HASH_LENGTH = 8; diff --git a/libs/map-editor/src/lib/geometry.ts b/libs/map-editor/src/lib/geometry.ts new file mode 100644 index 00000000000..00b6dd1adba --- /dev/null +++ b/libs/map-editor/src/lib/geometry.ts @@ -0,0 +1,224 @@ +import { NON_RENDERED_TAGS } from './constants'; +import { + applyToPoint, + IDENTITY, + Matrix, + multiply, + parseTransform, + Point, +} from './matrix'; +import { pathPoints, pointsIn } from './path-data'; + +/** Axis-aligned box in root user units: x, y, width, height. */ +export type BBox = [number, number, number, number]; + +/** + * Where the editor gets geometry from. The browser provider needs the document + * attached and rendered; the attribute provider works on a detached DOM. + */ +export interface GeometryProvider { + /** Bounding box of the element in root user space, or null when unknown. */ + bboxOf(element: Element): BBox | null; + /** Matrix from the element's own space to root user space, or null when unknown. */ + ctmOf(element: Element): Matrix | null; +} + +/** A provider that knows nothing; ops fall back to element-space maths. */ +export class NoGeometryProvider implements GeometryProvider { + public bboxOf(): BBox | null { + return null; + } + + public ctmOf(): Matrix | null { + return null; + } +} + +function isGraphics(element: Element): element is SVGGraphicsElement { + return typeof (element as SVGGraphicsElement).getBBox === 'function'; +} + +function toMatrix(m: DOMMatrix): Matrix { + return { a: m.a, b: m.b, c: m.c, d: m.d, e: m.e, f: m.f }; +} + +/** + * Geometry from the rendered DOM: getBBox mapped through the element's screen + * CTM and the inverse of the root's, the viewer's getElementBoundsInSvgSpace + * maths. Returns null for anything not rendered. + */ +export class BrowserGeometryProvider implements GeometryProvider { + constructor(private readonly _root: SVGSVGElement) {} + + public bboxOf(element: Element): BBox | null { + if (!isGraphics(element)) return null; + let bbox: DOMRect; + try { + bbox = element.getBBox(); + } catch { + return null; + } + const ctm = this.ctmOf(element); + if (!ctm) return [bbox.x, bbox.y, bbox.width, bbox.height]; + const corners: Point[] = [ + [bbox.x, bbox.y], + [bbox.x + bbox.width, bbox.y], + [bbox.x, bbox.y + bbox.height], + [bbox.x + bbox.width, bbox.y + bbox.height], + ].map((p) => applyToPoint(ctm, p as Point)); + const xs = corners.map((p) => p[0]); + const ys = corners.map((p) => p[1]); + const min_x = Math.min(...xs); + const min_y = Math.min(...ys); + return [min_x, min_y, Math.max(...xs) - min_x, Math.max(...ys) - min_y]; + } + + public ctmOf(element: Element): Matrix | null { + if ( + !isGraphics(element) || + typeof element.getScreenCTM !== 'function' + ) { + return null; + } + const root_ctm = this._root.getScreenCTM?.(); + const element_ctm = element.getScreenCTM(); + if (!root_ctm || !element_ctm) return null; + return toMatrix(root_ctm.inverse().multiply(element_ctm)); + } +} + +function numberAttr(element: Element, name: string, fallback = 0): number { + const value = parseFloat(element.getAttribute(name) || ''); + return Number.isFinite(value) ? value : fallback; +} + +/** Points in the element's own space from its geometry attributes. */ +export function localPoints(element: Element): Point[] { + const tag = element.localName; + switch (tag) { + case 'rect': { + const x = numberAttr(element, 'x'); + const y = numberAttr(element, 'y'); + const w = numberAttr(element, 'width'); + const h = numberAttr(element, 'height'); + return [ + [x, y], + [x + w, y], + [x, y + h], + [x + w, y + h], + ]; + } + case 'circle': { + const cx = numberAttr(element, 'cx'); + const cy = numberAttr(element, 'cy'); + const r = numberAttr(element, 'r'); + return [ + [cx - r, cy - r], + [cx + r, cy + r], + ]; + } + case 'ellipse': { + const cx = numberAttr(element, 'cx'); + const cy = numberAttr(element, 'cy'); + const rx = numberAttr(element, 'rx'); + const ry = numberAttr(element, 'ry'); + return [ + [cx - rx, cy - ry], + [cx + rx, cy + ry], + ]; + } + case 'line': + return [ + [numberAttr(element, 'x1'), numberAttr(element, 'y1')], + [numberAttr(element, 'x2'), numberAttr(element, 'y2')], + ]; + case 'polygon': + case 'polyline': + return pointsIn(element.getAttribute('points')); + case 'path': + return pathPoints(element.getAttribute('d')); + case 'text': + case 'tspan': + return element.hasAttribute('x') || element.hasAttribute('y') + ? [[numberAttr(element, 'x'), numberAttr(element, 'y')]] + : []; + case 'use': + case 'image': { + const x = numberAttr(element, 'x'); + const y = numberAttr(element, 'y'); + const w = numberAttr(element, 'width'); + const h = numberAttr(element, 'height'); + return w || h + ? [ + [x, y], + [x + w, y + h], + ] + : [[x, y]]; + } + default: + return []; + } +} + +/** + * Geometry from attributes and parsed transforms, for a detached DOM. Path + * boxes use command end and control points; text boxes are the anchor point. + */ +export class AttributeGeometryProvider implements GeometryProvider { + public bboxOf(element: Element): BBox | null { + const ctm = this.ctmOf(element); + if (!ctm) return null; + const box = this._box(element, ctm); + return box ? [box[0], box[1], box[2] - box[0], box[3] - box[1]] : null; + } + + public ctmOf(element: Element): Matrix | null { + const chain: Element[] = []; + let current: Element | null = element; + while (current && current.localName !== 'svg') { + chain.unshift(current); + current = current.parentElement; + } + if (!current) return null; + let m: Matrix = { ...IDENTITY }; + for (const el of chain) { + m = multiply(m, parseTransform(el.getAttribute('transform'))); + } + return m; + } + + /** min x, min y, max x, max y */ + private _box( + element: Element, + ctm: Matrix, + ): [number, number, number, number] | null { + if (NON_RENDERED_TAGS.has(element.localName.toLowerCase())) return null; + let box: [number, number, number, number] | null = null; + const merge = (points: Point[]) => { + for (const [x, y] of points) { + if (!box) box = [x, y, x, y]; + else { + box[0] = Math.min(box[0], x); + box[1] = Math.min(box[1], y); + box[2] = Math.max(box[2], x); + box[3] = Math.max(box[3], y); + } + } + }; + merge(localPoints(element).map((p) => applyToPoint(ctm, p))); + for (const child of Array.from(element.children)) { + const child_ctm = multiply( + ctm, + parseTransform(child.getAttribute('transform')), + ); + const child_box = this._box(child, child_ctm); + if (child_box) { + merge([ + [child_box[0], child_box[1]], + [child_box[2], child_box[3]], + ]); + } + } + return box; + } +} diff --git a/libs/map-editor/src/lib/ids.ts b/libs/map-editor/src/lib/ids.ts new file mode 100644 index 00000000000..cf9a97b947a --- /dev/null +++ b/libs/map-editor/src/lib/ids.ts @@ -0,0 +1,139 @@ +import { ID_PATTERN, REFERENCE_ATTRS } from './constants'; + +export function isValidId(id: string): boolean { + return ID_PATTERN.test(id); +} + +export function idIssue(id: string): string | null { + if (!id) return 'id is empty'; + if (/^[^A-Za-z]/.test(id)) return `id "${id}" must start with a letter`; + if (!ID_PATTERN.test(id)) { + return `id "${id}" may only contain letters, digits, "_", "." and "-"`; + } + return null; +} + +export function idsIn(root: Element): Set { + const ids = new Set(); + if (root.hasAttribute('id')) ids.add(root.getAttribute('id') || ''); + for (const element of Array.from(root.querySelectorAll('[id]'))) { + ids.add(element.getAttribute('id') || ''); + } + ids.delete(''); + return ids; +} + +/** Ids that appear on more than one element, in document order. */ +export function duplicateIds(root: Element): string[] { + const seen = new Set(); + const duplicates: string[] = []; + const elements = [root, ...Array.from(root.querySelectorAll('[id]'))]; + for (const element of elements) { + const id = element.getAttribute('id'); + if (!id) continue; + if (seen.has(id) && !duplicates.includes(id)) duplicates.push(id); + seen.add(id); + } + return duplicates; +} + +/** `prefix-N` with the lowest N not already used in the document. */ +export function generateId( + existing: ReadonlySet, + prefix: string, +): string { + let n = 1; + while (existing.has(`${prefix}${n}`)) n++; + return `${prefix}${n}`; +} + +const PATTERN_SLOT = /\{n(?::(\d+))?\}/; + +/** + * Expand an id pattern such as `table-01.{n:03}` for a given number. A + * pattern without a slot gets the number appended. + */ +export function expandIdPattern(pattern: string, n: number): string { + const match = pattern.match(PATTERN_SLOT); + if (!match) return `${pattern}${n}`; + const width = match[1] ? parseInt(match[1], 10) : 0; + return pattern.replace(PATTERN_SLOT, `${n}`.padStart(width, '0')); +} + +/** Highest number already used by ids matching the pattern, or 0. */ +export function highestInPattern( + existing: ReadonlySet, + pattern: string, +): number { + const match = pattern.match(PATTERN_SLOT); + if (!match) return 0; + const [prefix, suffix] = pattern.split(match[0]); + let highest = 0; + for (const id of existing) { + if (!id.startsWith(prefix) || !id.endsWith(suffix)) continue; + const middle = id.slice(prefix.length, id.length - suffix.length); + if (/^\d+$/.test(middle)) + highest = Math.max(highest, parseInt(middle, 10)); + } + return highest; +} + +function referenceValues(element: Element): [string, string][] { + const values: [string, string][] = []; + for (const name of element.getAttributeNames()) { + const value = element.getAttribute(name) || ''; + if (REFERENCE_ATTRS.has(name) || value.includes('url(')) { + values.push([name, value]); + } + } + return values; +} + +function referencesId(value: string, id: string): boolean { + if (value === `#${id}`) return true; + return new RegExp(`url\\(\\s*['"]?#${escapeRegExp(id)}['"]?\\s*\\)`).test( + value, + ); +} + +export function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Elements whose attributes point at `#id`, through href or url(#id). */ +export function referencesTo(root: Element, id: string): Element[] { + const elements: Element[] = []; + for (const element of [root, ...Array.from(root.querySelectorAll('*'))]) { + if ( + referenceValues(element).some(([, value]) => + referencesId(value, id), + ) + ) { + elements.push(element); + } + } + return elements; +} + +/** Rewrite `#old` references to `#new_id`; returns the elements changed. */ +export function rewriteReferences( + root: Element, + old_id: string, + new_id: string, +): Element[] { + const changed: Element[] = []; + const pattern = new RegExp( + `(url\\(\\s*['"]?)#${escapeRegExp(old_id)}(['"]?\\s*\\))`, + 'g', + ); + for (const element of referencesTo(root, old_id)) { + for (const [name, value] of referenceValues(element)) { + let next = value; + if (value === `#${old_id}`) next = `#${new_id}`; + else next = value.replace(pattern, `$1#${new_id}$2`); + if (next !== value) element.setAttribute(name, next); + } + changed.push(element); + } + return changed; +} diff --git a/libs/map-editor/src/lib/map-document.ts b/libs/map-editor/src/lib/map-document.ts new file mode 100644 index 00000000000..052467abc43 --- /dev/null +++ b/libs/map-editor/src/lib/map-document.ts @@ -0,0 +1,381 @@ +import { ANCHOR_ATTR, SVG_NS } from './constants'; +import { AttributeGeometryProvider, GeometryProvider } from './geometry'; +import { ProtectedIdSet } from './protected-ids'; +import { SanitiseReport, sanitiseTree } from './sanitise'; +import { + canonicalSignature, + elementHash, + isEditorAttribute, +} from './signature'; +import { parseStyleRules, StyleRule } from './style-resolution'; + +export type MapDocumentErrorCode = + | 'empty' + | 'doctype' + | 'parse_error' + | 'not_svg' + | 'no_viewbox'; + +export class MapDocumentError extends Error { + constructor( + public readonly code: MapDocumentErrorCode, + message: string, + ) { + super(message); + this.name = 'MapDocumentError'; + } +} + +export interface ViewBox { + x: number; + y: number; + width: number; + height: number; +} + +export interface MapDocumentOptions { + geometry?: GeometryProvider; + protected?: ProtectedIdSet; + /** Strip the deny-list on load. Default true. */ + sanitise?: boolean; +} + +/** How an op names an element: by session anchor, by id, or both, with an optional content hash. */ +export interface Target { + a?: string | null; + id?: string | null; + h?: string | null; +} + +export type Resolution = + | { ok: true; element: Element } + | { ok: false; error: string }; + +const DOCTYPE_OR_ENTITY = /)/; +const UNITLESS_NUMBER = /^\+?(?:\d+\.?\d*|\.\d+)(?:px)?$/; + +export function parseViewBox(value: string | null): ViewBox | null { + if (!value) return null; + const parts = value.trim().split(/[\s,]+/); + if (parts.length !== 4) return null; + const numbers = parts.map((part) => + /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/.test(part) + ? Number(part) + : NaN, + ); + if (numbers.some((n) => !Number.isFinite(n))) return null; + if (numbers[2] <= 0 || numbers[3] <= 0) return null; + return { + x: numbers[0], + y: numbers[1], + width: numbers[2], + height: numbers[3], + }; +} + +export function stripEditorAttributes(root: Element): void { + for (const element of [root, ...Array.from(root.querySelectorAll('*'))]) { + for (const name of element.getAttributeNames()) { + if (isEditorAttribute(name)) element.removeAttribute(name); + } + } +} + +/** + * The level's SVG as a live DOM with session anchors on every element. + * Anchors are stripped on serialise and never reach the saved file. + */ +export class MapDocument { + public geometry: GeometryProvider; + public protected: ProtectedIdSet; + private _root: SVGSVGElement; + private _version = 0; + private _anchor_counter: number; + private _by_anchor: Map | null = null; + private _anchor_version = -1; + private _style_rules: StyleRule[] | null = null; + private _style_version = -1; + + private constructor( + root: SVGSVGElement, + public readonly view_box: ViewBox, + public readonly view_box_synthesised: boolean, + public readonly prolog: string, + public readonly sanitise_report: SanitiseReport, + anchor_counter: number, + options: MapDocumentOptions, + ) { + this._root = root; + this._anchor_counter = anchor_counter; + this.geometry = options.geometry || new AttributeGeometryProvider(); + this.protected = options.protected || ProtectedIdSet.empty(); + } + + /** + * Parse strictly: no parser error, no DOCTYPE or entity declaration, an + * `svg` root in the SVG namespace, a numeric viewBox (synthesised once + * from numeric unitless width and height when missing). + */ + public static load( + text: string, + options: MapDocumentOptions = {}, + ): MapDocument { + if (typeof text !== 'string' || !text.trim()) { + throw new MapDocumentError('empty', 'The map is empty.'); + } + if (DOCTYPE_OR_ENTITY.test(text)) { + throw new MapDocumentError( + 'doctype', + 'The map declares a DOCTYPE or entities, which are not allowed.', + ); + } + const prolog = text.match(PROLOG)?.[1] || ''; + const xml = new DOMParser().parseFromString(text, 'image/svg+xml'); + const error_node = xml.querySelector('parsererror'); + if (error_node || xml.documentElement?.localName === 'parsererror') { + const detail = + (error_node || xml.documentElement)?.textContent || ''; + throw new MapDocumentError( + 'parse_error', + `The map is not well-formed XML. ${detail.trim().slice(0, 200)}`, + ); + } + if (xml.doctype) { + throw new MapDocumentError( + 'doctype', + 'The map declares a DOCTYPE.', + ); + } + const root = xml.documentElement; + if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) { + throw new MapDocumentError( + 'not_svg', + 'The root element is not in the SVG namespace.', + ); + } + let view_box = parseViewBox(root.getAttribute('viewBox')); + let synthesised = false; + if (!view_box) { + if (root.hasAttribute('viewBox')) { + throw new MapDocumentError( + 'no_viewbox', + `The viewBox "${root.getAttribute('viewBox')}" is not four numbers.`, + ); + } + const width = root.getAttribute('width') || ''; + const height = root.getAttribute('height') || ''; + if (!UNITLESS_NUMBER.test(width) || !UNITLESS_NUMBER.test(height)) { + throw new MapDocumentError( + 'no_viewbox', + 'The map has no viewBox and no numeric width and height to derive one from.', + ); + } + view_box = { + x: 0, + y: 0, + width: parseFloat(width), + height: parseFloat(height), + }; + if (view_box.width <= 0 || view_box.height <= 0) { + throw new MapDocumentError( + 'no_viewbox', + 'The map has a zero-sized canvas.', + ); + } + root.setAttribute( + 'viewBox', + `0 0 ${view_box.width} ${view_box.height}`, + ); + synthesised = true; + } + const report: SanitiseReport = + options.sanitise === false ? { removed: [] } : sanitiseTree(root); + stripEditorAttributes(root); + let counter = 0; + for (const element of [ + root, + ...Array.from(root.querySelectorAll('*')), + ]) { + element.setAttribute(ANCHOR_ATTR, `e${++counter}`); + } + return new MapDocument( + root as unknown as SVGSVGElement, + view_box, + synthesised, + prolog, + report, + counter, + options, + ); + } + + public get root(): SVGSVGElement { + return this._root; + } + + public get version(): number { + return this._version; + } + + public get had_prolog(): boolean { + return !!this.prolog; + } + + /** Call after any change to the tree so cached lookups rebuild. */ + public markChanged(): void { + this._version++; + } + + /** Root and every descendant, in document order. */ + public elements(): Element[] { + return [this._root, ...Array.from(this._root.querySelectorAll('*'))]; + } + + public elementCount(): number { + return 1 + this._root.querySelectorAll('*').length; + } + + public anchorOf(element: Element): string { + return element.getAttribute(ANCHOR_ATTR) || ''; + } + + public byAnchor(anchor: string): Element | null { + if (this._anchor_version !== this._version || !this._by_anchor) { + this._by_anchor = new Map(); + for (const element of this.elements()) { + const anchor_value = element.getAttribute(ANCHOR_ATTR); + if (anchor_value) this._by_anchor.set(anchor_value, element); + } + this._anchor_version = this._version; + } + return this._by_anchor.get(anchor) || null; + } + + /** First element with the id, in document order. */ + public byId(id: string): Element | null { + for (const element of this.elements()) { + if (element.getAttribute('id') === id) return element; + } + return null; + } + + /** Resolve a target; anchor and id must agree when both are given. */ + public resolve(target: Target | null | undefined): Resolution { + if (!target || (!target.a && !target.id)) { + return { + ok: false, + error: 'target has neither an anchor nor an id', + }; + } + const by_anchor = target.a ? this.byAnchor(target.a) : null; + if (target.a && !by_anchor) + return { ok: false, error: `unknown anchor "${target.a}"` }; + const by_id = target.id ? this.byId(target.id) : null; + if (target.id && !by_id) + return { ok: false, error: `unknown id "${target.id}"` }; + if (by_anchor && by_id && by_anchor !== by_id) { + return { + ok: false, + error: `anchor "${target.a}" and id "${target.id}" are different elements`, + }; + } + return { ok: true, element: (by_anchor || by_id) as Element }; + } + + public nextAnchor(): string { + return `e${++this._anchor_counter}`; + } + + /** Give one element a fresh anchor, leaving its descendants alone. */ + public assignAnchor(element: Element): string { + const anchor = this.nextAnchor(); + element.setAttribute(ANCHOR_ATTR, anchor); + this.markChanged(); + return anchor; + } + + /** Give every element in the subtree a fresh anchor, replacing any it carries. */ + public assignAnchors(subtree: Element): string[] { + const anchors: string[] = []; + for (const element of [ + subtree, + ...Array.from(subtree.querySelectorAll('*')), + ]) { + const anchor = this.nextAnchor(); + element.setAttribute(ANCHOR_ATTR, anchor); + anchors.push(anchor); + } + this.markChanged(); + return anchors; + } + + public hashOf(element: Element): string { + return elementHash(element); + } + + public signatureOf(element: Element): string { + return canonicalSignature(element); + } + + /** Canonical own-signature of every element, keyed by anchor. */ + public signatures(): Map { + const map = new Map(); + for (const element of this.elements()) { + map.set(this.anchorOf(element), canonicalSignature(element)); + } + return map; + } + + public styleRules(): StyleRule[] { + if (this._style_version !== this._version || !this._style_rules) { + this._style_rules = parseStyleRules(this._root); + this._style_version = this._version; + } + return this._style_rules; + } + + /** The map without editor attributes, with the XML prolog if the source had one. */ + public serialise(): string { + const clone = this._root.cloneNode(true) as Element; + stripEditorAttributes(clone); + const markup = new XMLSerializer().serializeToString(clone); + return this.prolog ? `${this.prolog}\n${markup}` : markup; + } + + public byteSize(): number { + return new TextEncoder().encode(this.serialise()).length; + } + + /** Deep copy with the same anchors, options and anchor counter. */ + public clone(): MapDocument { + const owner = this._root.ownerDocument; + const xml = owner.implementation.createDocument(null, null, null); + const root = xml.importNode(this._root, true) as SVGSVGElement; + xml.appendChild(root); + return new MapDocument( + root, + { ...this.view_box }, + this.view_box_synthesised, + this.prolog, + { removed: [...this.sanitise_report.removed] }, + this._anchor_counter, + { geometry: this.geometry, protected: this.protected }, + ); + } + + /** Swap the live root for another (a history snapshot); anchors come with it. */ + public replaceRoot(root: Element): void { + if (root.localName !== 'svg') + throw new Error('replaceRoot needs an root'); + const parent = this._root.parentNode; + if (parent) parent.replaceChild(root, this._root); + this._root = root as SVGSVGElement; + this.markChanged(); + } + + /** Elements of a subtree, including its root, in document order. */ + public static subtree(element: Element): Element[] { + return [element, ...Array.from(element.querySelectorAll('*'))]; + } +} diff --git a/libs/map-editor/src/lib/map-index.ts b/libs/map-editor/src/lib/map-index.ts new file mode 100644 index 00000000000..eb44b82487e --- /dev/null +++ b/libs/map-editor/src/lib/map-index.ts @@ -0,0 +1,278 @@ +import { NON_RENDERED_TAGS, TEXT_TAGS } from './constants'; +import { BBox, GeometryProvider } from './geometry'; +import { duplicateIds } from './ids'; +import { MapDocument, ViewBox } from './map-document'; +import { ProtectedIdSet } from './protected-ids'; +import { ownText } from './signature'; +import { StyleResolver, StyleRule } from './style-resolution'; + +export type ElementKind = + | 'root' + | 'non-rendered' + | 'bookable' + | 'zone' + | 'layer-group' + | 'group' + | 'text' + | 'art'; + +export interface MapIndexEntry { + anchor: string; + id: string | null; + tag: string; + classes: string[]; + label: string | null; + /** Root-space x, y, width, height; null when the geometry provider cannot say. */ + bbox: BBox | null; + parent: string | null; + depth: number; + /** Paint order among rendered elements; null for non-rendered ones. */ + z: number | null; + visible: boolean; + fill: string | null; + stroke: string | null; + protected: boolean; + kind: ElementKind; + hash: string; + text: string | null; + /** Anonymous, non-group children by tag, for groups. */ + children: Record; +} + +export interface MapIndexStats { + elements: number; + ids: number; + bytes: number; +} + +export interface MapIndex { + view_box: ViewBox; + entries: MapIndexEntry[]; + by_anchor: Map; + style_rules: StyleRule[]; + duplicate_ids: string[]; + stats: MapIndexStats; +} + +export interface IndexOptions { + geometry?: GeometryProvider; + protected?: ProtectedIdSet; + /** Compute bounding boxes (the slow part). Default true. */ + bbox?: boolean; +} + +const BOOKABLE_ID = + /^(?:desk|table|room|area|park|bay|locker|space|seat|meeting)/i; +const ZONE_ID = /^zone/i; +const ZONES_GROUP = /^zones$/i; + +export function elementLabel(element: Element): string | null { + for (const child of Array.from(element.children)) { + if (child.localName === 'title') { + const title = (child.textContent || '').trim(); + if (title) return title; + } + } + const named = + element.getAttribute('data-name') || + element.getAttribute('aria-label') || + element.getAttribute('inkscape:label'); + if (named?.trim()) return named.trim(); + if (TEXT_TAGS.has(element.localName)) { + const text = (element.textContent || '').trim(); + if (text) return text; + } + const own = ownText(element); + return own || null; +} + +function isInsideNonRendered(element: Element): boolean { + let current: Element | null = element; + while (current) { + if (NON_RENDERED_TAGS.has(current.localName.toLowerCase())) return true; + current = current.parentElement; + } + return false; +} + +function guessKind( + element: Element, + is_protected: boolean, + parent_kind: ElementKind | null, +): ElementKind { + const tag = element.localName; + if (tag === 'svg' && !element.parentElement) return 'root'; + if (isInsideNonRendered(element)) return 'non-rendered'; + const id = element.getAttribute('id') || ''; + if (is_protected) return 'bookable'; + if (TEXT_TAGS.has(tag)) return 'text'; + if (ZONE_ID.test(id) || (parent_kind === 'zone' && tag !== 'g')) + return 'zone'; + if (tag === 'g' && ZONES_GROUP.test(id)) return 'zone'; + if (BOOKABLE_ID.test(id)) return 'bookable'; + if (tag === 'g' || tag === 'a') { + return id && (parent_kind === 'root' || parent_kind === 'layer-group') + ? 'layer-group' + : 'group'; + } + return 'art'; +} + +/** + * One record per element in document order, plus the parsed style rules and + * the duplicate ids the source already carries. + */ +export function buildIndex( + doc: MapDocument, + options: IndexOptions = {}, +): MapIndex { + const geometry = options.geometry || doc.geometry; + const protected_ids = options.protected || doc.protected; + const with_bbox = options.bbox !== false; + const resolver = new StyleResolver(doc.root, doc.styleRules()); + const entries: MapIndexEntry[] = []; + const by_anchor = new Map(); + const by_element = new Map(); + let z = 0; + let id_count = 0; + for (const element of doc.elements()) { + const id = element.getAttribute('id'); + if (id) id_count++; + const parent_element = element.parentElement; + const parent_entry = parent_element + ? by_element.get(parent_element) + : undefined; + const is_protected = protected_ids.has(id); + const kind = guessKind( + element, + is_protected, + parent_entry?.kind || null, + ); + const rendered = + kind !== 'non-rendered' && + !NON_RENDERED_TAGS.has(element.localName.toLowerCase()); + const tag = element.localName; + const entry: MapIndexEntry = { + anchor: doc.anchorOf(element), + id: id || null, + tag, + classes: (element.getAttribute('class') || '') + .split(/\s+/) + .filter((c) => !!c), + label: elementLabel(element), + bbox: with_bbox && rendered ? geometry.bboxOf(element) : null, + parent: parent_entry?.anchor || null, + depth: parent_entry ? parent_entry.depth + 1 : 0, + z: rendered ? z++ : null, + visible: rendered && resolver.isVisible(element), + fill: rendered + ? resolver.visibleColour(element, 'fill') || null + : null, + stroke: rendered + ? resolver.visibleColour(element, 'stroke') || null + : null, + protected: is_protected, + kind, + hash: doc.hashOf(element), + text: TEXT_TAGS.has(tag) + ? (element.textContent || '').trim() || null + : null, + children: {}, + }; + if (entry.bbox) + entry.bbox = entry.bbox.map((n) => Math.round(n)) as BBox; + if (parent_entry && !id && tag !== 'g' && rendered) { + parent_entry.children[tag] = (parent_entry.children[tag] || 0) + 1; + } + entries.push(entry); + by_anchor.set(entry.anchor, entry); + by_element.set(element, entry); + } + return { + view_box: { ...doc.view_box }, + entries, + by_anchor, + style_rules: doc.styleRules(), + duplicate_ids: duplicateIds(doc.root), + stats: { + elements: entries.length, + ids: id_count, + bytes: doc.byteSize(), + }, + }; +} + +const PROMPT_PROPERTIES = ['fill', 'stroke', 'opacity', 'stroke-width']; + +export interface PromptLinesOptions { + /** List elements without an id, text or children summary. Default false. */ + include_anonymous?: boolean; +} + +/** Compact text index for a model: one line per listed element. */ +export function promptLines( + index: MapIndex, + options: PromptLinesOptions = {}, +): string { + const { x, y, width, height } = index.view_box; + const lines = [ + `viewBox: ${x} ${y} ${width} ${height} (all coordinates in these user units, origin top-left)`, + ]; + const classes: string[] = []; + for (const rule of index.style_rules) { + const declarations = Object.entries(rule.declarations) + .filter(([name]) => PROMPT_PROPERTIES.includes(name)) + .map(([name, value]) => `${name}:${value}`) + .join(', '); + if (!declarations) continue; + for (const selector of rule.selectors) { + if (/^\.[\w-]+$/.test(selector)) + classes.push(`${selector}{${declarations}}`); + } + } + if (classes.length) lines.push(`style classes: ${classes.join('; ')}`); + if (index.duplicate_ids.length) { + lines.push( + `duplicate ids already in the file (not your doing): ${index.duplicate_ids.join(', ')}`, + ); + } + lines.push( + 'elements: anchor | id | tag | class | fill | bbox(x y w h) | parent | extra', + ); + for (const entry of index.entries) { + if (entry.kind === 'non-rendered' || entry.kind === 'root') continue; + const has_children = Object.keys(entry.children).length > 0; + const listed = + !!entry.id || + entry.tag === 'g' || + !!entry.text || + has_children || + options.include_anonymous; + if (!listed) continue; + const extra: string[] = []; + if (entry.text) extra.push(`text="${entry.text}"`); + if ( + entry.label && + entry.label !== entry.text && + entry.label !== entry.id + ) { + extra.push(`label="${entry.label}"`); + } + if (entry.protected) extra.push('protected'); + if (!entry.visible) extra.push('hidden'); + if (has_children) { + extra.push( + `anon ${Object.entries(entry.children) + .map(([tag, count]) => `${tag}x${count}`) + .join(' ')}`, + ); + } + const bbox = entry.bbox ? entry.bbox.join(' ') : '-'; + lines.push( + `${entry.anchor} | ${entry.id || '-'} | ${entry.tag} | ${ + entry.classes.join(' ') || '-' + } | ${entry.fill || '-'} | ${bbox} | ${entry.parent || 'root'} | ${extra.join(' ')}`.trimEnd(), + ); + } + return lines.join('\n'); +} diff --git a/libs/map-editor/src/lib/matrix.ts b/libs/map-editor/src/lib/matrix.ts new file mode 100644 index 00000000000..ddd01eed933 --- /dev/null +++ b/libs/map-editor/src/lib/matrix.ts @@ -0,0 +1,231 @@ +/** Affine matrix in SVG order: x' = a x + c y + e, y' = b x + d y + f. */ +export interface Matrix { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +export type Point = [number, number]; + +export const IDENTITY: Readonly = Object.freeze({ + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, +}); + +const NUMBER = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g; +const TRANSFORM_FN = + /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g; + +export function multiply(m: Matrix, n: Matrix): Matrix { + return { + a: m.a * n.a + m.c * n.b, + b: m.b * n.a + m.d * n.b, + c: m.a * n.c + m.c * n.d, + d: m.b * n.c + m.d * n.d, + e: m.a * n.e + m.c * n.f + m.e, + f: m.b * n.e + m.d * n.f + m.f, + }; +} + +export function invert(m: Matrix): Matrix | null { + const det = m.a * m.d - m.b * m.c; + if (!det || !Number.isFinite(det)) return null; + return { + a: m.d / det, + b: -m.b / det, + c: -m.c / det, + d: m.a / det, + e: (m.c * m.f - m.d * m.e) / det, + f: (m.b * m.e - m.a * m.f) / det, + }; +} + +export function applyToPoint(m: Matrix, [x, y]: Point): Point { + return [m.a * x + m.c * y + m.e, m.b * x + m.d * y + m.f]; +} + +export function isIdentity(m: Matrix, epsilon = 1e-9): boolean { + return ( + Math.abs(m.a - 1) < epsilon && + Math.abs(m.b) < epsilon && + Math.abs(m.c) < epsilon && + Math.abs(m.d - 1) < epsilon && + Math.abs(m.e) < epsilon && + Math.abs(m.f) < epsilon + ); +} + +export function isTranslation(m: Matrix, epsilon = 1e-9): boolean { + return ( + Math.abs(m.a - 1) < epsilon && + Math.abs(m.b) < epsilon && + Math.abs(m.c) < epsilon && + Math.abs(m.d - 1) < epsilon + ); +} + +export function translation(x: number, y: number): Matrix { + return { a: 1, b: 0, c: 0, d: 1, e: x, f: y }; +} + +export function scaling(x: number, y: number): Matrix { + return { a: x, b: 0, c: 0, d: y, e: 0, f: 0 }; +} + +export function rotation(degrees: number): Matrix { + const radians = (degrees * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }; +} + +export function numbersIn(value: string): number[] { + return (value.match(NUMBER) || []).map(Number); +} + +/** + * Parse an SVG `transform` attribute into one matrix. + * Unknown functions and functions with the wrong arity are skipped. + */ +export function parseTransform(value: string | null | undefined): Matrix { + let result: Matrix = { ...IDENTITY }; + if (!value) return result; + for (const match of value.matchAll(TRANSFORM_FN)) { + const name = match[1]; + const args = numbersIn(match[2]); + let m: Matrix | null = null; + switch (name) { + case 'matrix': + if (args.length === 6) { + m = { + a: args[0], + b: args[1], + c: args[2], + d: args[3], + e: args[4], + f: args[5], + }; + } + break; + case 'translate': + if (args.length) m = translation(args[0], args[1] || 0); + break; + case 'scale': + if (args.length) { + m = scaling(args[0], args.length > 1 ? args[1] : args[0]); + } + break; + case 'rotate': + if (args.length === 3) { + m = multiply( + multiply( + translation(args[1], args[2]), + rotation(args[0]), + ), + translation(-args[1], -args[2]), + ); + } else if (args.length) { + m = rotation(args[0]); + } + break; + case 'skewX': + if (args.length) { + m = { + a: 1, + b: 0, + c: Math.tan((args[0] * Math.PI) / 180), + d: 1, + e: 0, + f: 0, + }; + } + break; + case 'skewY': + if (args.length) { + m = { + a: 1, + b: Math.tan((args[0] * Math.PI) / 180), + c: 0, + d: 1, + e: 0, + f: 0, + }; + } + break; + } + if (m) result = multiply(result, m); + } + return result; +} + +function round(value: number, places: number): number { + const factor = 10 ** places; + const rounded = Math.round(value * factor) / factor; + return Object.is(rounded, -0) ? 0 : rounded; +} + +/** Shortest `transform` function that reproduces the matrix. */ +export function formatTransform(m: Matrix, places = 3): string { + if (isTranslation(m)) { + return `translate(${round(m.e, places)} ${round(m.f, places)})`; + } + return `matrix(${[m.a, m.b, m.c, m.d, m.e, m.f] + .map((v) => round(v, places)) + .join(' ')})`; +} + +export interface TransformSpec { + translate?: Point | null; + scale?: Point | null; + rotate?: number | null; + origin?: Point | null; +} + +/** + * Matrix for a transform op in one coordinate space: scale, then rotate about + * the origin, then translate. + */ +export function composeTransform(spec: TransformSpec): Matrix { + const [ox, oy] = spec.origin || [0, 0]; + let m: Matrix = { ...IDENTITY }; + if (spec.scale) m = multiply(scaling(spec.scale[0], spec.scale[1]), m); + if (spec.rotate) m = multiply(rotation(spec.rotate), m); + if (spec.scale || spec.rotate) { + m = multiply(multiply(translation(ox, oy), m), translation(-ox, -oy)); + } + if (spec.translate) { + m = multiply(translation(spec.translate[0], spec.translate[1]), m); + } + return m; +} + +/** Express a root-space transform in the space the matrix `ctm` maps from. */ +export function changeOfBasis(root_space: Matrix, ctm: Matrix): Matrix | null { + const inverse = invert(ctm); + if (!inverse) return null; + return multiply(multiply(inverse, root_space), ctm); +} + +export function transformBBox( + m: Matrix, + [x, y, w, h]: [number, number, number, number], +): [number, number, number, number] { + const corners: Point[] = [ + [x, y], + [x + w, y], + [x, y + h], + [x + w, y + h], + ].map((p) => applyToPoint(m, p as Point)); + const xs = corners.map((p) => p[0]); + const ys = corners.map((p) => p[1]); + const min_x = Math.min(...xs); + const min_y = Math.min(...ys); + return [min_x, min_y, Math.max(...xs) - min_x, Math.max(...ys) - min_y]; +} diff --git a/libs/map-editor/src/lib/path-data.ts b/libs/map-editor/src/lib/path-data.ts new file mode 100644 index 00000000000..27bde171063 --- /dev/null +++ b/libs/map-editor/src/lib/path-data.ts @@ -0,0 +1,200 @@ +import { numbersIn } from './matrix'; + +const TOKEN = + /[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g; +const ARITY: Readonly> = { + M: 2, + L: 2, + H: 1, + V: 1, + C: 6, + S: 4, + Q: 4, + T: 2, + A: 7, + Z: 0, +}; + +export interface PathCheck { + ok: boolean; + error?: string; + commands: number; +} + +/** + * Grammar check of path data: known commands, numeric arguments in whole + * groups, nothing before the first command, no stray characters. + */ +export function checkPathData(d: string | null | undefined): PathCheck { + if (!d || !d.trim()) + return { ok: false, error: 'empty path data', commands: 0 }; + const stripped = d.replace(TOKEN, '').replace(/[\s,]/g, ''); + if (stripped) { + return { + ok: false, + error: `unexpected characters "${stripped.slice(0, 12)}"`, + commands: 0, + }; + } + const tokens = d.match(TOKEN) || []; + let command = ''; + let pending: number[] = []; + let commands = 0; + let first = true; + for (const token of tokens) { + if (/^[A-Za-z]$/.test(token)) { + if (pending.length) { + return { + ok: false, + error: `command ${command} has a partial argument group`, + commands, + }; + } + if (first && token.toUpperCase() !== 'M') { + return { ok: false, error: 'path must start with M', commands }; + } + first = false; + command = token; + commands++; + continue; + } + if (!command) { + return { + ok: false, + error: 'number before the first command', + commands, + }; + } + const arity = ARITY[command.toUpperCase()]; + if (arity === 0) { + return { ok: false, error: 'Z takes no arguments', commands }; + } + pending.push(Number(token)); + if (pending.length === arity) { + pending = []; + commands++; + } + } + if (pending.length) { + return { + ok: false, + error: `command ${command} has a partial argument group`, + commands, + }; + } + return { ok: commands > 0, commands }; +} + +/** + * End points and control points of a path, absolute. Arc flags and radii are + * skipped, so an arc contributes only its end point. + */ +export function pathPoints(d: string | null | undefined): [number, number][] { + const points: [number, number][] = []; + if (!d) return points; + const tokens = d.match(TOKEN) || []; + let current: [number, number] = [0, 0]; + let start: [number, number] = [0, 0]; + let command = ''; + let index = 0; + + const take = (count: number): number[] | null => { + if (index + count > tokens.length) return null; + const values: number[] = []; + for (let i = 0; i < count; i++) { + const token = tokens[index + i]; + if (/^[A-Za-z]$/.test(token)) return null; + values.push(Number(token)); + } + index += count; + return values; + }; + + while (index < tokens.length) { + const token = tokens[index]; + if (/^[A-Za-z]$/.test(token)) { + command = token; + index++; + if (command === 'Z' || command === 'z') { + current = start; + continue; + } + } + if (!command) { + index++; + continue; + } + const relative = command === command.toLowerCase(); + const upper = command.toUpperCase(); + const abs = (x: number, y: number): [number, number] => + relative ? [current[0] + x, current[1] + y] : [x, y]; + let values: number[] | null; + switch (upper) { + case 'M': + values = take(2); + if (!values) return points; + current = abs(values[0], values[1]); + start = current; + points.push(current); + command = relative ? 'l' : 'L'; + break; + case 'L': + values = take(2); + if (!values) return points; + current = abs(values[0], values[1]); + points.push(current); + break; + case 'H': + values = take(1); + if (!values) return points; + current = [ + relative ? current[0] + values[0] : values[0], + current[1], + ]; + points.push(current); + break; + case 'V': + values = take(1); + if (!values) return points; + current = [ + current[0], + relative ? current[1] + values[0] : values[0], + ]; + points.push(current); + break; + case 'C': + case 'S': + case 'Q': + case 'T': { + const count = ARITY[upper]; + values = take(count); + if (!values) return points; + let last = current; + for (let i = 0; i < count; i += 2) { + last = abs(values[i], values[i + 1]); + points.push(last); + } + current = last; + break; + } + case 'A': + values = take(7); + if (!values) return points; + current = abs(values[5], values[6]); + points.push(current); + break; + default: + index++; + } + } + return points; +} + +export function pointsIn(value: string | null | undefined): [number, number][] { + const numbers = numbersIn(value || ''); + const points: [number, number][] = []; + for (let i = 0; i + 1 < numbers.length; i += 2) { + points.push([numbers[i], numbers[i + 1]]); + } + return points; +} diff --git a/libs/map-editor/src/lib/protected-ids.ts b/libs/map-editor/src/lib/protected-ids.ts new file mode 100644 index 00000000000..dd0f0d095e5 --- /dev/null +++ b/libs/map-editor/src/lib/protected-ids.ts @@ -0,0 +1,106 @@ +export type ProtectedIdKind = + | 'desk' + | 'room' + | 'area' + | 'locker' + | 'parking' + | 'poi' + | 'other'; + +/** A map id that a booking, area, locker, parking or point-of-interest record binds to. */ +export interface ProtectedId { + id: string; + kind: ProtectedIdKind; + label: string; +} + +/** One host-supplied source of protected ids, by name. */ +export type ProtectedIdSource = () => Promise; + +export interface ProtectedIdSourceOutcome { + source: string; + ids?: readonly ProtectedId[]; + error?: unknown; +} + +/** + * Ids the editor must not break. `complete` is false when any source failed, + * in which case the host shows "binding check incomplete" and treats the ids + * that did load as the protected set. + */ +export class ProtectedIdSet { + private readonly _ids = new Map(); + + constructor( + ids: Iterable = [], + public readonly complete = true, + public readonly failed_sources: readonly string[] = [], + ) { + for (const entry of ids) { + if (!entry.id) continue; + const existing = this._ids.get(entry.id); + if (!existing) this._ids.set(entry.id, entry); + else if (!existing.label && entry.label) + this._ids.set(entry.id, entry); + } + } + + public static empty(): ProtectedIdSet { + return new ProtectedIdSet(); + } + + public get size(): number { + return this._ids.size; + } + + public has(id: string | null | undefined): boolean { + return !!id && this._ids.has(id); + } + + public get(id: string): ProtectedId | undefined { + return this._ids.get(id); + } + + public ids(): ProtectedId[] { + return [...this._ids.values()]; + } + + public labelOf(id: string): string { + const entry = this._ids.get(id); + return entry?.label || id; + } +} + +/** Merge per-source outcomes; a failed source marks the set incomplete. */ +export function mergeProtectedIds( + outcomes: readonly ProtectedIdSourceOutcome[], +): ProtectedIdSet { + const ids: ProtectedId[] = []; + const failed: string[] = []; + for (const outcome of outcomes) { + if (outcome.error !== undefined || !outcome.ids) + failed.push(outcome.source); + else ids.push(...outcome.ids); + } + return new ProtectedIdSet(ids, failed.length === 0, failed); +} + +/** Run every source, never rejecting; failures are recorded on the set. */ +export async function collectProtectedIds( + sources: Readonly>, +): Promise { + const names = Object.keys(sources); + const settled = await Promise.allSettled( + names.map((name) => sources[name]()), + ); + return mergeProtectedIds( + settled.map((result, i) => + result.status === 'fulfilled' + ? { source: names[i], ids: result.value } + : { + source: names[i], + error: result.reason ?? new Error('failed'), + }, + ), + ); +} diff --git a/libs/map-editor/src/lib/sanitise.ts b/libs/map-editor/src/lib/sanitise.ts new file mode 100644 index 00000000000..08cd9cb3d64 --- /dev/null +++ b/libs/map-editor/src/lib/sanitise.ts @@ -0,0 +1,243 @@ +import DOMPurify from 'dompurify'; +import { + DENIED_TAGS, + EDITOR_ATTR_PREFIX, + REFERENCE_ATTRS, + SVG_NS, + XLINK_NS, +} from './constants'; + +export interface SanitiseRemoval { + kind: 'element' | 'attribute' | 'declaration'; + tag: string; + name?: string; + value?: string; + reason: string; +} + +export interface SanitiseReport { + removed: SanitiseRemoval[]; +} + +const UNSAFE_CSS_VALUE = + /expression\s*\(|behavior\s*:|javascript\s*:|url\s*\(\s*(?!['"]?#)/i; +const IMPORT_RULE = /@import\b[^;]*;?/gi; +const DECLARATION = /([\w-]+)\s*:\s*([^;{}]*)(;|(?=\}))/g; + +export function isUnsafeCssValue(value: string): boolean { + return UNSAFE_CSS_VALUE.test(value); +} + +/** Drop `@import` and declarations with external, scripted or behaviour values. */ +export function cleanStyleText(css: string): { + text: string; + removed: string[]; +} { + const removed: string[] = []; + let text = css.replace(IMPORT_RULE, (match) => { + removed.push(match.trim()); + return ''; + }); + if (UNSAFE_CSS_VALUE.test(text)) { + text = text.replace( + DECLARATION, + (match, name: string, value: string) => { + if (!isUnsafeCssValue(value)) return match; + removed.push(`${name}:${value.trim()}`); + return ''; + }, + ); + } + return { text, removed }; +} + +function isAllowedReference(tag: string, value: string): boolean { + const trimmed = value.trim(); + if (trimmed.startsWith('#')) return true; + return ( + tag === 'image' && + /^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);/i.test(trimmed) + ); +} + +/** + * Strip the deny-list from a subtree in place: scripts, foreign content, + * animation, event handlers, external references and unsafe style text. + * Editor attributes present in the source are stripped too. + */ +export function sanitiseTree(root: Element): SanitiseReport { + const removed: SanitiseRemoval[] = []; + const elements = [root, ...Array.from(root.querySelectorAll('*'))]; + for (const element of elements) { + const tag = element.localName.toLowerCase(); + if (DENIED_TAGS.has(tag)) { + removed.push({ kind: 'element', tag, reason: 'denied element' }); + element.remove(); + continue; + } + if (element.namespaceURI !== SVG_NS) { + removed.push({ + kind: 'element', + tag: element.tagName, + reason: 'element outside the SVG namespace', + }); + element.remove(); + continue; + } + for (const name of element.getAttributeNames()) { + const lower = name.toLowerCase(); + const value = element.getAttribute(name) || ''; + if (lower.startsWith('on')) { + removed.push({ + kind: 'attribute', + tag, + name, + reason: 'event handler', + }); + element.removeAttribute(name); + } else if (lower.startsWith(EDITOR_ATTR_PREFIX)) { + element.removeAttribute(name); + } else if ( + (REFERENCE_ATTRS.has(lower) || lower.endsWith(':href')) && + !isAllowedReference(tag, value) + ) { + removed.push({ + kind: 'attribute', + tag, + name, + value: value.slice(0, 80), + reason: 'reference outside the document', + }); + element.removeAttribute(name); + if (element.hasAttributeNS(XLINK_NS, 'href')) { + element.removeAttributeNS(XLINK_NS, 'href'); + } + } else if (lower === 'style' && UNSAFE_CSS_VALUE.test(value)) { + const cleaned = cleanStyleText(value); + for (const declaration of cleaned.removed) { + removed.push({ + kind: 'declaration', + tag, + name, + value: declaration, + reason: 'unsafe style value', + }); + } + if (cleaned.text.trim()) + element.setAttribute(name, cleaned.text); + else element.removeAttribute(name); + } + } + if (tag === 'style') { + const cleaned = cleanStyleText(element.textContent || ''); + if (cleaned.removed.length) { + for (const declaration of cleaned.removed) { + removed.push({ + kind: 'declaration', + tag, + value: declaration, + reason: 'unsafe style text', + }); + } + element.textContent = cleaned.text; + } + } + } + return { removed }; +} + +export interface PurifiedMarkup { + element: Element | null; + error?: string; + report: SanitiseReport; +} + +/** Elements DOMPurify adds around the input and strips again; not part of the markup. */ +const PURIFY_WRAPPER_TAGS = new Set(['html', 'head', 'body']); + +let purifier: ReturnType | null = null; + +function getPurifier(): ReturnType | null { + if (purifier) return purifier; + const host = (globalThis as { window?: unknown }).window; + if (!host) return null; + const instance = DOMPurify(host as Parameters[0]); + if (!instance.isSupported) return null; + purifier = instance; + return purifier; +} + +/** + * Sanitise markup for one SVG element: DOMPurify with the svg and svgFilters + * profiles (HTML parsing, so camel-case SVG names survive), re-parsed as XML, + * then the deny-list. The result belongs to a scratch document; import it + * before inserting. + */ +export function purifyMarkup(markup: string): PurifiedMarkup { + const report: SanitiseReport = { removed: [] }; + const purify = getPurifier(); + if (!purify) { + return { element: null, error: 'sanitiser unavailable', report }; + } + const wrapped = `${markup}`; + const body: Node = purify.sanitize(wrapped, { + USE_PROFILES: { svg: true, svgFilters: true }, + RETURN_DOM: true, + }); + for (const entry of purify.removed) { + if ('element' in entry && entry.element) { + const tag = + (entry.element as Element).localName || entry.element.nodeName; + if (PURIFY_WRAPPER_TAGS.has(tag)) continue; + report.removed.push({ + kind: 'element', + tag, + reason: 'removed by DOMPurify', + }); + } else if ('attribute' in entry && entry.attribute) { + report.removed.push({ + kind: 'attribute', + tag: (entry.from as Element).localName || '', + name: entry.attribute.name, + reason: 'removed by DOMPurify', + }); + } + } + const wrapper = Array.from(body.childNodes).find( + (node): node is Element => node.nodeType === 1, + ); + if (!wrapper || wrapper.localName !== 'svg') { + return { element: null, error: 'markup was removed entirely', report }; + } + const serialised = new XMLSerializer().serializeToString(wrapper); + const doc = new DOMParser().parseFromString(serialised, 'image/svg+xml'); + if ( + doc.querySelector('parsererror') || + doc.documentElement.localName !== 'svg' + ) { + return { element: null, error: 'markup is not well-formed', report }; + } + for (const node of Array.from(doc.documentElement.childNodes)) { + if ( + node.nodeType === 8 || + (node.nodeType === 3 && !(node.nodeValue || '').trim()) + ) { + node.remove(); + } + } + const children = Array.from(doc.documentElement.childNodes); + if (children.length !== 1 || children[0].nodeType !== 1) { + return { + element: null, + error: `markup must be exactly one element, found ${children.length}`, + report, + }; + } + const element = children[0] as Element; + const stripped = sanitiseTree(element); + report.removed.push(...stripped.removed); + if (!element.parentNode) { + return { element: null, error: 'the root element is denied', report }; + } + return { element, report }; +} diff --git a/libs/map-editor/src/lib/signature.ts b/libs/map-editor/src/lib/signature.ts new file mode 100644 index 00000000000..bed042617bb --- /dev/null +++ b/libs/map-editor/src/lib/signature.ts @@ -0,0 +1,42 @@ +import { CONTENT_HASH_LENGTH, EDITOR_ATTR_PREFIX } from './constants'; + +export function isEditorAttribute(name: string): boolean { + return name.startsWith(EDITOR_ATTR_PREFIX); +} + +/** Direct text node children, trimmed and joined. */ +export function ownText(element: Element): string { + let text = ''; + for (const node of Array.from(element.childNodes)) { + if (node.nodeType === 3 || node.nodeType === 4) + text += node.nodeValue || ''; + } + return text.trim(); +} + +/** + * Canonical own-signature of an element: tag, sorted own attributes without + * editor attributes, own text. Children are not included. + */ +export function canonicalSignature(element: Element): string { + const attrs = element + .getAttributeNames() + .filter((name) => !isEditorAttribute(name)) + .sort() + .map((name) => `${name}=${JSON.stringify(element.getAttribute(name))}`); + return `${element.tagName}|${attrs.join('|')}|${JSON.stringify(ownText(element))}`; +} + +/** FNV-1a 32-bit as 8 hex characters. */ +export function contentHash(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(CONTENT_HASH_LENGTH, '0'); +} + +export function elementHash(element: Element): string { + return contentHash(canonicalSignature(element)); +} diff --git a/libs/map-editor/src/lib/style-resolution.ts b/libs/map-editor/src/lib/style-resolution.ts new file mode 100644 index 00000000000..54e2094bd0e --- /dev/null +++ b/libs/map-editor/src/lib/style-resolution.ts @@ -0,0 +1,289 @@ +import { + CONTAINER_TAGS, + LINE_TAGS, + NON_RENDERED_TAGS, + REFERENCE_TAGS, + TEXT_TAGS, + XLINK_NS, +} from './constants'; + +export interface StyleRule { + selectors: readonly string[]; + declarations: Readonly>; +} + +const COMMENT = /\/\*[\s\S]*?\*\//g; +const RULE = /([^{}@]+)\{([^{}]*)\}/g; +const AT_STATEMENT = /@[^{};]*;/g; +const AT_BLOCK = /@[^{}]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g; + +/** Flat rules from every `', + 'not_svg', + ); + expectRejection('', 'not_svg'); + expectRejection(readMap('test'), 'not_svg'); + }); + + it('rejects a non-numeric viewBox', () => { + expectRejection(svg('', 'viewBox="0 0 100% 50"'), 'no_viewbox'); + expectRejection(svg('', 'viewBox="0 0 100"'), 'no_viewbox'); + expectRejection(svg('', 'viewBox="0 0 0 50"'), 'no_viewbox'); + }); + + it('synthesises a viewBox once from numeric unitless width and height', () => { + const doc = load('', {}, 'width="800" height="600"'); + expect(doc.view_box).toEqual({ x: 0, y: 0, width: 800, height: 600 }); + expect(doc.view_box_synthesised).toBe(true); + expect(doc.root.getAttribute('viewBox')).toBe('0 0 800 600'); + }); + + it('rejects a missing viewBox when width or height carry units', () => { + expectRejection( + svg('', 'width="100%" height="600"'), + 'no_viewbox', + ); + expectRejection( + svg('', 'width="10cm" height="6cm"'), + 'no_viewbox', + ); + expectRejection(svg('', ''), 'no_viewbox'); + }); + + it('parses viewBox values with commas and decimals', () => { + expect(parseViewBox('0,0,2507.883,1523.289')).toEqual({ + x: 0, + y: 0, + width: 2507.883, + height: 1523.289, + }); + expect(parseViewBox('a b c d')).toBeNull(); + }); + + it('keeps whether the source had an XML prolog', () => { + const with_prolog = MapDocument.load( + `\n${svg('')}`, + ); + expect(with_prolog.had_prolog).toBe(true); + expect( + with_prolog + .serialise() + .startsWith('\n'); + expect(without.had_prolog).toBe(false); + expect(without.serialise().startsWith(' { + const doc = load( + `
+ + `, + ); + const reasons = doc.sanitise_report.removed.map( + (entry) => `${entry.kind}:${entry.name || entry.tag}`, + ); + expect(reasons).toContain('element:script'); + expect(reasons).toContain('attribute:onclick'); + expect(reasons).toContain('element:foreignobject'); + expect(reasons).toContain('element:animate'); + expect(reasons).toContain('attribute:href'); + expect( + doc.root.querySelector('script, foreignObject, animate'), + ).toBeNull(); + expect(doc.root.querySelector('rect')?.hasAttribute('onclick')).toBe( + false, + ); + expect(doc.root.querySelector('use')?.hasAttribute('href')).toBe(false); + expect(doc.root.querySelector('image')?.getAttribute('href')).toMatch( + /^data:image\/png/, + ); + const css = doc.root.querySelector('style')?.textContent || ''; + expect(css).not.toContain('@import'); + expect(css).not.toContain('http://evil'); + expect(css).toContain('url(#grad)'); + }); + + it('assigns anchors in document order to every element', () => { + const doc = load( + 'x', + ); + expect(doc.anchorOf(doc.root)).toBe('e1'); + expect(anchor(doc, 'a')).toBe('e2'); + expect(anchor(doc, 'b')).toBe('e3'); + expect(anchor(doc, 'c')).toBe('e4'); + expect(anchor(doc, 'd')).toBe('e5'); + expect(doc.elementCount()).toBe(5); + }); + + it('resolves targets by anchor, id or both and rejects disagreement', () => { + const doc = load(''); + expect(doc.resolve({ a: 'e2' }).ok).toBe(true); + expect(doc.resolve({ id: 'b' }).ok).toBe(true); + expect(doc.resolve({ a: 'e2', id: 'a' }).ok).toBe(true); + const mismatch = doc.resolve({ a: 'e2', id: 'b' }); + expect(mismatch.ok).toBe(false); + expect(doc.resolve({ a: 'e99' }).ok).toBe(false); + expect(doc.resolve({ id: 'nope' }).ok).toBe(false); + expect(doc.resolve({}).ok).toBe(false); + expect(doc.resolve(null).ok).toBe(false); + }); + + it('hashes the canonical own-signature and ignores editor attributes', () => { + const doc = load(''); + const rect = doc.byId('a') as Element; + const hash = doc.hashOf(rect); + expect(hash).toMatch(/^[0-9a-f]{8}$/); + rect.setAttribute('data-map-editor-highlight', 'true'); + expect(doc.hashOf(rect)).toBe(hash); + rect.setAttribute('fill', 'blue'); + expect(doc.hashOf(rect)).not.toBe(hash); + }); + + it('serialises without editor attributes and keeps namespaces', () => { + const doc = MapDocument.load(OFFICE); + const markup = doc.serialise(); + expect(markup).not.toContain(ANCHOR_ATTR); + expect(markup).toContain('xmlns="http://www.w3.org/2000/svg"'); + expect(markup).toContain('xlink:href="#table-01.001"'); + expect(markup).toContain(' element.hasAttribute(ANCHOR_ATTR)), + ).toBe(true); + }); + + it('clones with the same anchors and an independent tree', () => { + const doc = MapDocument.load(OFFICE); + const copy = doc.clone(); + expect(copy.elementCount()).toBe(doc.elementCount()); + expect(anchor(copy, 'table-01.002')).toBe(anchor(doc, 'table-01.002')); + (copy.byId('table-01.002') as Element).setAttribute('fill', 'red'); + expect(doc.byId('table-01.002')?.getAttribute('fill')).toBeNull(); + expect(copy.nextAnchor()).toBe(doc.nextAnchor()); + }); + + it('swaps the root in place and re-indexes anchors', () => { + const doc = MapDocument.load(OFFICE); + const snapshot = doc.root.cloneNode(true) as Element; + (doc.byId('table-01.001') as Element).remove(); + expect(doc.byId('table-01.001')).toBeNull(); + doc.replaceRoot(snapshot); + expect(doc.byId('table-01.001')).not.toBeNull(); + expect(doc.byAnchor(anchor(doc, 'table-01.001'))).toBe( + doc.byId('table-01.001'), + ); + expect(doc.root.ownerDocument.documentElement).toBe(doc.root); + }); + + describe.each(SAMPLE_MAPS)('sample map %s', (name) => { + it('loads, round-trips with equal signatures and stable anchors, and strips nothing', () => { + const doc = loadMap(name); + expect(doc.sanitise_report.removed).toEqual([]); + expect(doc.elementCount()).toBeGreaterThan(20); + const before = doc.signatures(); + const reloaded = MapDocument.load(doc.serialise()); + expect(reloaded.elementCount()).toBe(doc.elementCount()); + const after = reloaded.signatures(); + let differences = 0; + for (const [key, signature] of before) { + if (after.get(key) !== signature) differences++; + } + expect(differences).toBe(0); + const duplicates = duplicateIds(doc.root); + for (const element of doc.elements()) { + const id = element.getAttribute('id'); + if (!id || duplicates.includes(id)) continue; + expect(reloaded.anchorOf(reloaded.byId(id) as Element)).toBe( + doc.anchorOf(element), + ); + } + expect(reloaded.had_prolog).toBe(doc.had_prolog); + expect(reloaded.view_box).toEqual(doc.view_box); + }); + + it('serialises within a few bytes of the source', () => { + const doc = loadMap(name); + const source = readMap(name); + expect( + Math.abs(doc.serialise().length - source.length), + ).toBeLessThan(200); + }); + }); +}); diff --git a/libs/map-editor/src/tests/map-index.spec.ts b/libs/map-editor/src/tests/map-index.spec.ts new file mode 100644 index 00000000000..e2a52f1399c --- /dev/null +++ b/libs/map-editor/src/tests/map-index.spec.ts @@ -0,0 +1,109 @@ +import { AttributeGeometryProvider } from '../lib/geometry'; +import { MapDocument } from '../lib/map-document'; +import { buildIndex, promptLines } from '../lib/map-index'; +import { ProtectedIdSet } from '../lib/protected-ids'; +import { anchor, loadMap, OFFICE, SAMPLE_MAPS } from './fixtures'; + +const geometry = new AttributeGeometryProvider(); + +describe('buildIndex', () => { + it('describes every element with anchors, parents, boxes, styles and kinds', () => { + const protected_ids = new ProtectedIdSet([ + { id: 'table-01.001', kind: 'desk', label: 'Desk 1' }, + ]); + const doc = MapDocument.load(OFFICE, { + geometry, + protected: protected_ids, + }); + const index = buildIndex(doc); + expect(index.entries.length).toBe(doc.elementCount()); + expect(index.entries[0].kind).toBe('root'); + const desk = index.by_anchor.get(anchor(doc, 'table-01.001'))!; + expect(desk).toMatchObject({ + id: 'table-01.001', + tag: 'rect', + classes: ['desk'], + protected: true, + kind: 'bookable', + depth: 2, + visible: true, + }); + expect(desk.bbox).toEqual([100, 200, 30, 20]); + expect(desk.parent).toBe(anchor(doc, 'desks')); + expect(desk.fill).toBe('#dde'); + expect(desk.stroke).toBe('#446'); + expect(desk.hash).toMatch(/^[0-9a-f]{8}$/); + expect(index.by_anchor.get(anchor(doc, 'table-01.002'))?.kind).toBe( + 'bookable', + ); + expect(index.by_anchor.get(anchor(doc, 'base'))?.kind).toBe( + 'layer-group', + ); + expect(index.by_anchor.get(anchor(doc, 'zone-01.A'))?.kind).toBe( + 'zone', + ); + expect(index.by_anchor.get(anchor(doc, 'label-1'))?.kind).toBe('text'); + expect(index.by_anchor.get(anchor(doc, 'label-1'))?.text).toBe( + 'Meeting 1.01(8 seats)', + ); + expect(index.by_anchor.get(anchor(doc, 'grad'))?.kind).toBe( + 'non-rendered', + ); + expect(index.by_anchor.get(anchor(doc, 'grad'))?.z).toBeNull(); + expect(index.by_anchor.get(anchor(doc, 'base'))?.children).toEqual({ + path: 1, + }); + expect(index.style_rules.length).toBe(3); + expect(index.stats.ids).toBe(21); + }); + + it('writes the prompt lines in the spike shape', () => { + const doc = MapDocument.load(OFFICE, { geometry }); + const text = promptLines(buildIndex(doc)); + const lines = text.split('\n'); + expect(lines[0]).toBe( + 'viewBox: 0 0 1000 500 (all coordinates in these user units, origin top-left)', + ); + expect(lines[1]).toContain('.desk{fill:#dde, stroke:#446}'); + expect(text).toContain( + '| table-01.001 | rect | desk | #dde | 100 200 30 20 |', + ); + expect(text).toContain('anon pathx1'); + expect(text).not.toContain('| grad |'); + expect(text).toContain('text="Meeting 1.01(8 seats)"'); + }); + + describe.each(SAMPLE_MAPS)('sample map %s', (name) => { + it('indexes every element with valid parents and boxes for id shapes', () => { + const doc = loadMap(name, { geometry }); + const started = Date.now(); + const index = buildIndex(doc); + const elapsed = Date.now() - started; + expect(index.entries.length).toBe(doc.elementCount()); + for (const entry of index.entries) { + if (entry.parent) + expect(index.by_anchor.has(entry.parent)).toBe(true); + if ( + entry.id && + entry.kind !== 'non-rendered' && + entry.tag !== 'g' + ) + expect(entry.bbox).not.toBeNull(); + } + expect(index.stats.elements).toBe(doc.elementCount()); + expect(elapsed).toBeLessThan(10_000); + const prompt = promptLines(index); + expect(prompt.split('\n').length).toBeGreaterThan(3); + expect(prompt.length).toBeLessThan(40_000); + }); + }); + + it('reports the duplicate id level_2 ships', () => { + const doc = loadMap('level_2', { geometry }); + const index = buildIndex(doc, { bbox: false }); + expect(index.duplicate_ids).toEqual(['table-02.013']); + expect(promptLines(index)).toContain( + 'duplicate ids already in the file (not your doing): table-02.013', + ); + }); +}); diff --git a/libs/map-editor/src/tests/protected-ids.spec.ts b/libs/map-editor/src/tests/protected-ids.spec.ts new file mode 100644 index 00000000000..0261fc96baa --- /dev/null +++ b/libs/map-editor/src/tests/protected-ids.spec.ts @@ -0,0 +1,44 @@ +import { + collectProtectedIds, + mergeProtectedIds, + ProtectedIdSet, +} from '../lib/protected-ids'; + +describe('protected ids', () => { + it('merges sources and marks the set incomplete when one fails', async () => { + const set = await collectProtectedIds({ + desks: async () => [ + { id: 'table-01.001', kind: 'desk', label: 'Desk 1' }, + ], + rooms: async () => { + throw new Error('offline'); + }, + areas: async () => [ + { id: 'area-1.01-status', kind: 'area', label: 'Meeting 1.01' }, + { id: 'table-01.001', kind: 'desk', label: '' }, + ], + }); + expect(set.complete).toBe(false); + expect(set.failed_sources).toEqual(['rooms']); + expect(set.size).toBe(2); + expect(set.has('table-01.001')).toBe(true); + expect(set.has('nope')).toBe(false); + expect(set.has(null)).toBe(false); + expect(set.labelOf('table-01.001')).toBe('Desk 1'); + expect(set.labelOf('unknown')).toBe('unknown'); + expect(set.ids().map((entry) => entry.id)).toEqual([ + 'table-01.001', + 'area-1.01-status', + ]); + }); + + it('is complete when every source answers', () => { + const set = mergeProtectedIds([ + { source: 'a', ids: [{ id: 'x', kind: 'other', label: 'X' }] }, + { source: 'b', ids: [] }, + ]); + expect(set.complete).toBe(true); + expect(set.failed_sources).toEqual([]); + expect(ProtectedIdSet.empty().size).toBe(0); + }); +}); diff --git a/libs/map-editor/src/tests/sanitise.spec.ts b/libs/map-editor/src/tests/sanitise.spec.ts new file mode 100644 index 00000000000..ae2b308f06d --- /dev/null +++ b/libs/map-editor/src/tests/sanitise.spec.ts @@ -0,0 +1,207 @@ +import { cleanStyleText, purifyMarkup, sanitiseTree } from '../lib/sanitise'; +import { loadMap, readMap, SAMPLE_MAPS, svg } from './fixtures'; + +const DIRTY = ` + + Hi there + + + + + +
html
+ + + + + + + + +