clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

feat: normalize renderer SVG semantics

Dami committed 1dfcd6a
commit
1dfcd6aefd6cf205f402e95efa962ab144206b69
parent
0a5caaaea7a814bc15e2f9a5d835725b2a1aa5d4
author
Dami <dami.etoile@gmail.com>
5 files changed, +436 -71
Mapps/editor/test/svg-contract.test.js155+++++++++++++-
Mdocs/svg-normalization.md17+-
Mrenderers/catalog/test/catalog.test.ts13+-
Mshared/svg/index.d.ts2+-
Mshared/svg/index.js320++++++++++++++++++++++++++----
M apps/editor/test/svg-contract.test.js +140 -15
@@ -41,7 +41,7 @@ test('produces deterministic, idempotent raw canonical SVG', () => {
4141 4242 assert.equal(first, repeated)4343 assert.equal(first, normalizedAgain)44- assert.match(first, /data-dz-normalizer="svg-normalizer-1"/)44+ assert.match(first, /data-dz-normalizer="svg-normalizer-2"/)4545 assert.match(first, /data-dz-profile="safe-raw-1"/)4646 assert.match(first, /data-dz-palette="renderer"/)4747 assert.match(first, /data-dz-conformance="raw"/)
@@ -55,6 +55,16 @@ test('does not duplicate root metadata when canonical output crosses another con
5555 assert.equal((twice.match(/<desc>/g) ?? []).length, 1)5656 })5757 58+test('recomputes stale semantic annotations when upgrading the normalizer build', () => {59+ const stale = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" data-dz-schema="1" data-dz-normalizer="svg-normalizer-1" data-dz-profile="graphviz-15-semantic-1" data-dz-materializer="svg-materializer-1"><style data-dz-owned="materializer">stale</style><rect data-dz-owned="materializer" data-dz-role="canvas" width="100" height="50"></rect><rect data-dz-owned="normalizer" data-dz-role="object-surface" data-dz-fill="surface-1" width="10" height="10"></rect><g class="node"><ellipse fill="none" stroke="black" stroke-width="0" cx="50" cy="25" rx="40" ry="20" data-dz-fill="surface-1"></ellipse><rect x="20" y="10" width="60" height="30" fill="white" stroke="black" data-renderer-id="kept" data-dz-fill="surface-3"></rect></g></svg>'60+ const upgraded = canonicalizeSvg(stale, metadata, 'dbml', 'dbml@1.0.31+graphviz@15.1.1')61+62+ assert.match(upgraded, /data-dz-normalizer="svg-normalizer-2"/)63+ assert.doesNotMatch(upgraded, /data-dz-owned=/)64+ assert.doesNotMatch(upgraded, /<ellipse[^>]*data-dz-/)65+ assert.match(upgraded, /data-renderer-id="kept" data-dz-fill="surface-1" data-dz-stroke="line"/)66+})67+5868 test('keeps legacy editor presentation outside the canonical render', () => {5969 const canonical = canonicalizeSvg(source, metadata, 'pikchr')6070 const displayed = materializePresentation(canonical, { background: '#ffffff', padding: 8, frame: true })
@@ -73,7 +83,7 @@ test('exposes structured normalization failures to both callers', () => {
7383 )7484 })7585 76-const semanticCanonical = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" width="10" height="10" data-dz-schema="1" data-dz-normalizer="svg-normalizer-1" data-dz-profile="fixture-semantic-1" data-dz-palette="renderer" data-dz-engine="fixture" data-dz-appearance="raw" data-dz-conformance="semantic" data-dz-bounds="0 0 10 10" data-dz-appearances="auto-transparent light-transparent dark-transparent auto-framed light-framed dark-framed"><rect x="1" y="1" width="8" height="8" fill="white" stroke="black" data-dz-fill="surface-1" data-dz-stroke="line"></rect><text x="5" y="6" fill="black" data-dz-fill="ink">A</text></svg>'86+const semanticCanonical = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" width="10" height="10" data-dz-schema="1" data-dz-normalizer="svg-normalizer-2" data-dz-profile="fixture-semantic-1" data-dz-palette="renderer" data-dz-engine="fixture" data-dz-appearance="raw" data-dz-conformance="semantic" data-dz-bounds="0 0 10 10" data-dz-appearances="auto-transparent light-transparent dark-transparent auto-framed light-framed dark-framed"><rect x="1" y="1" width="8" height="8" fill="white" stroke="black" data-dz-fill="surface-1" data-dz-stroke="line"></rect><text x="5" y="6" fill="black" data-dz-fill="ink">A</text></svg>'7787 7888 test('materializes explicit and automatic palettes without changing diagram geometry', () => {7989 const light = materializeSvg(semanticCanonical, 'light-transparent')
@@ -127,7 +137,7 @@ test('applies the pinned GraphViz semantic profile without recoloring authored p
127137 const canonical = sanitizeAndDecorateSvg(graphviz, metadata, presentation, 'graphviz', 'graphviz@15.1.1')128138 const dark = materializeSvg(canonical, 'dark-transparent')129139 130- assert.match(canonical, /data-dz-profile="graphviz-15-semantic-1"/)140+ assert.match(canonical, /data-dz-profile="graphviz-15-semantic-2"/)131141 assert.match(canonical, /data-dz-conformance="semantic"/)132142 assert.match(canonical, /data-dz-role="canvas"/)133143 assert.match(canonical, /data-dz-fill="surface-1"/)
@@ -140,22 +150,22 @@ test('applies the pinned GraphViz semantic profile without recoloring authored p
140150 test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {141151 const fixtures = [142152 {143- engine: 'd2', version: 'd2@0.7.1/dagre-wasm-1', profile: 'd2-0.7-semantic-1',153+ engine: 'd2', version: 'd2@0.7.1/dagre-wasm-1', profile: 'd2-0.7-semantic-2',144154 source: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect x="0" y="0" width="20" height="20" fill="#FFFFFF"></rect><rect class="shape fill-B6 stroke-B1" x="2" y="2" width="16" height="16" fill="#f7f8fe" stroke="#0d32b2"></rect><text class="fill-N1" fill="#0a0f25">A</text></svg>',145155 roles: ['data-dz-role="canvas"', 'data-dz-fill="surface-1"', 'data-dz-stroke="accent-1"', 'data-dz-fill="ink"'],146156 },147157 {148- engine: 'plantuml', version: 'plantuml@1.2026.6/edge-wasm-1', profile: 'plantuml-2026-semantic-1',158+ engine: 'plantuml', version: 'plantuml@1.2026.6/edge-wasm-1', profile: 'plantuml-2026-semantic-2',149159 source: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect x="2" y="2" width="16" height="16" fill="#E2E2F0" stroke="#181818"></rect><text fill="#000000">A</text></svg>',150160 roles: ['data-dz-fill="surface-1"', 'data-dz-stroke="line"', 'data-dz-fill="ink"'],151161 },152162 {153- engine: 'svgbob', version: 'svgbob@0.7.6/edge-wasm-1', profile: 'svgbob-0.7-semantic-1',163+ engine: 'svgbob', version: 'svgbob@0.7.6/edge-wasm-1', profile: 'svgbob-0.7-semantic-2',154164 source: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><style>.solid{stroke:black}.nofill{fill:white}</style><rect class="backdrop" x="0" y="0" width="20" height="20" fill="white"></rect><rect class="solid nofill" x="2" y="2" width="16" height="16"></rect><text>A</text></svg>',155165 roles: ['data-dz-role="canvas"', 'data-dz-stroke="line"', 'data-dz-fill="surface-1"', 'data-dz-fill="ink"'],156166 },157167 {158- engine: 'blockdiag', version: 'blockdiag@3.4.2/python-worker-1', profile: 'neutral-svg-semantic-1',168+ engine: 'blockdiag', version: 'blockdiag@3.4.2/python-worker-1', profile: 'neutral-svg-semantic-2',159169 source: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect x="2" y="2" width="16" height="16" fill="rgb(255,255,255)" stroke="rgb(0,0,0)"></rect><text fill="rgb(0,0,0)">A</text></svg>',160170 roles: ['data-dz-fill="surface-1"', 'data-dz-stroke="line"', 'data-dz-fill="ink"'],161171 },
@@ -169,16 +179,131 @@ test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {
169179 }170180 })171181 172-test('keeps authored SVG paint intact while offering an outer frame', () => {173- const source = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="hotpink"></rect></svg>'182+test('adapts neutral authored paint while preserving non-neutral paint and transparent support', () => {183+ const source = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="white"></rect><path d="M1 1L9 9" stroke="hotpink"></path><text x="2" y="6" fill="black">A</text></svg>'174184 const canonical = canonicalizeSvg(source, metadata, 'tikz', '@planktimerr/tikzjax@1.0.63/client-unit-1')175185 const framed = materializeSvg(canonical, 'auto-framed')186+ const transparent = materializeSvg(canonical, 'dark-transparent')176187 177- assert.match(canonical, /data-dz-conformance="presentation-only"/)178- assert.match(canonical, /data-dz-appearances="auto-framed light-framed dark-framed"/)179- assert.match(framed, /fill="hotpink"/)180- assert.throws(181- () => materializeSvg(canonical, 'dark-transparent'),182- error => error instanceof SvgNormalizationError && error.code === 'unsupported_appearance',188+ assert.match(canonical, /data-dz-profile="authored-neutral-semantic-1"/)189+ assert.match(canonical, /data-dz-conformance="adaptive"/)190+ assert.match(canonical, /data-dz-role="canvas"/)191+ assert.match(canonical, /stroke="hotpink"/)192+ assert.doesNotMatch(canonical, /stroke="hotpink"[^>]*data-dz-/)193+ assert.match(canonical, /data-dz-fill="ink"/)194+ assert.match(framed, /data-dz-role="frame"/)195+ assert.match(transparent, /data-dz-appearance="dark-transparent"/)196+})197+198+test('keeps GraphViz canvases and arrowheads coherent without consuming WireViz wire colors', () => {199+ const graphviz = canonicalizeSvg(200+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><g class="graph"><polygon fill="white" stroke="none" points="-4,-4 44,-4 44,24 -4,24"></polygon><g class="edge"><path fill="none" stroke="black" d="M2 10L30 10"></path><polygon fill="black" stroke="black" points="30,10 25,7 25,13"></polygon></g></g></svg>',201+ metadata, 'graphviz', 'graphviz@15.1.1',202+ )203+ assert.match(graphviz, /data-dz-role="canvas"/)204+ assert.match(graphviz, /<polygon fill="black" stroke="black"[^>]*data-dz-stroke="line"[^>]*data-dz-fill="line"/)205+206+ const wireviz = canonicalizeSvg(207+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 30"><g class="graph"><polygon fill="white" stroke="none" points="0,0 100,0 100,30 0,30"></polygon><g class="node"><rect x="2" y="2" width="96" height="26" fill="white" stroke="black"></rect><path d="M2 10L98 10" fill="none" stroke="#ef4444"></path><path d="M2 14L98 14" fill="none" stroke="white"></path></g></g></svg>',208+ metadata, 'wireviz', '0.3.2',209+ )210+ assert.match(wireviz, /stroke="#ef4444"/)211+ assert.doesNotMatch(wireviz, /stroke="#ef4444"[^>]*data-dz-/)212+ assert.match(wireviz, /stroke="white"/)213+ assert.doesNotMatch(wireviz, /stroke="white"[^>]*data-dz-/)214+})215+216+test('maps DBML table paint without turning invisible bounds into oval surfaces', () => {217+ const canonical = canonicalizeSvg(218+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80"><g class="graph"><g class="node"><ellipse fill="none" stroke="black" stroke-width="0" cx="60" cy="40" rx="58" ry="38"></ellipse><polygon fill="#1d71b8" stroke="none" points="10,10 110,10 110,35 10,35"></polygon><text x="20" y="28" fill="#ffffff">users</text><polygon fill="#e7e2dd" stroke="none" points="10,35 110,35 110,70 10,70"></polygon><text x="20" y="58" fill="#29235c">id integer</text></g><g class="edge"><path fill="none" stroke="#29235c" d="M1 1L8 8"></path><polygon fill="#29235c" stroke="#29235c" points="8,8 4,7 7,4"></polygon></g></g></svg>',219+ metadata, 'dbml', 'dbml@1.0.31+graphviz@15.1.1',220+ )221+ assert.match(canonical, /fill="#1d71b8"[^>]*data-dz-fill="accent-1"/)222+ assert.match(canonical, /fill="#ffffff"[^>]*data-dz-fill="on-accent"/)223+ assert.match(canonical, /fill="#e7e2dd"[^>]*data-dz-fill="surface-1"/)224+ assert.match(canonical, /fill="#29235c"[^>]*data-dz-fill="ink"/)225+ assert.doesNotMatch(canonical, /<ellipse[^>]*data-dz-/)226+})227+228+test('recognizes nested D2 and WaveDrom canvases while leaving masks renderer-defined', () => {229+ const d2 = canonicalizeSvg(230+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><svg viewBox="-10 -10 40 40"><rect x="-10" y="-10" width="40" height="40" fill="#FFFFFF" class="fill-N7" stroke-width="0"></rect><mask id="m"><rect x="-10" y="-10" width="40" height="40" fill="white"></rect><rect x="1" y="1" width="8" height="8" fill="black"></rect></mask><rect class="shape fill-B6" x="2" y="2" width="16" height="16" fill="#f7f8fe"></rect></svg></svg>',231+ metadata, 'd2', 'd2@0.7.1/dagre-wasm-1',183232 )233+ assert.equal((d2.match(/data-dz-role="canvas"/g) ?? []).length, 1)234+ assert.doesNotMatch(d2.match(/<mask[\s\S]*?<\/mask>/)?.[0] ?? '', /data-dz-(?:fill|stroke|role)/)235+236+ const wavedrom = canonicalizeSvg(237+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><rect width="40" height="20" style="stroke:none;fill:white"></rect><text x="5" y="12" class="info">clock</text><path class="s7" d="M0 0L20 0L20 20L0 20Z"></path></svg>',238+ metadata, 'wavedrom', 'wavedrom@3.6.2',239+ )240+ assert.match(wavedrom, /data-dz-role="canvas"/)241+ assert.match(wavedrom, /class="info"[^>]*data-dz-fill="ink-muted"/)242+ assert.match(wavedrom, /class="s7"[^>]*data-dz-fill="surface-1"/)243+})244+245+test('normalizes CSS-driven Mermaid and BPMN neutral structures', () => {246+ const mermaid = canonicalizeSvg(247+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><defs><marker><path class="arrowMarkerPath" d="M0 0L5 2L0 4Z"></path></marker></defs><path class="flowchart-link" d="M20 20L80 20"></path><g class="node default"><rect class="basic label-container" x="2" y="2" width="36" height="24"></rect><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Idea</span></div></foreignObject></g></svg>',248+ metadata, 'mermaid', 'mermaid@11.17.0',249+ )250+ assert.match(mermaid, /class="arrowMarkerPath"[^>]*data-dz-fill="line"[^>]*data-dz-stroke="line"/)251+ assert.match(mermaid, /class="flowchart-link"[^>]*data-dz-stroke="line"/)252+ assert.match(mermaid, /class="basic label-container"[^>]*data-dz-fill="surface-1"[^>]*data-dz-stroke="line"/)253+ assert.match(mermaid, /class="nodeLabel"[^>]*data-dz-fill="ink"/)254+255+ const bpmn = canonicalizeSvg(256+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><rect x="2" y="2" width="70" height="30" style="stroke:rgb(34,36,42);stroke-width:2px;fill:white"></rect><text class="djs-label" x="10" y="20" style="fill:rgb(34,36,42)"><tspan x="10" y="20">Task</tspan></text><rect class="djs-hit" x="0" y="0" width="100" height="40" style="fill:none;stroke-opacity:0;stroke:white"></rect></svg>',257+ metadata, 'bpmn', 'bpmn-js@18.25.1',258+ )259+ assert.match(bpmn, /data-dz-fill="surface-1"/)260+ assert.match(bpmn, /data-dz-stroke="line"/)261+ assert.equal((bpmn.match(/data-dz-fill="ink"/g) ?? []).length, 2)262+ assert.doesNotMatch(bpmn.match(/<rect class="djs-hit"[^>]*>/)?.[0] ?? '', /data-dz-/)263+})264+265+test('creates addressable surfaces for closed Pikchr and line-built Svgbob boxes', () => {266+ const pikchr = canonicalizeSvg(267+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><path d="M1 1L19 1L19 19L1 19Z" style="fill:none;stroke:rgb(0,0,0)"></path><text x="5" y="12" fill="black">A</text></svg>',268+ metadata, 'pikchr', 'pikchr@85e65b9686/edge-wasm-1',269+ )270+ assert.match(pikchr, /<path[^>]*data-dz-fill="surface-1"/)271+272+ const svgbob = canonicalizeSvg(273+ '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30" class="svgbob"><g><line x1="4" y1="4" x2="24" y2="4" class="solid"></line><line x1="4" y1="4" x2="4" y2="24" class="solid"></line><line x1="24" y1="4" x2="24" y2="24" class="solid"></line><line x1="4" y1="24" x2="24" y2="24" class="solid"></line></g></svg>',274+ metadata, 'svgbob', 'svgbob@0.7.6/edge-wasm-1',275+ )276+ assert.match(svgbob, /data-dz-owned="normalizer" data-dz-role="object-surface" data-dz-fill="surface-1"/)277+ assert.equal((svgbob.match(/data-dz-role="object-surface"/g) ?? []).length, 1)278+ assert.equal(canonicalizeSvg(svgbob, metadata, 'svgbob', 'svgbob@0.7.6/edge-wasm-1'), svgbob)279+})280+281+test('resolves text against normalized light surfaces and C4 accents', () => {282+ const blockdiag = canonicalizeSvg(283+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><rect x="5" y="5" width="90" height="30" fill="rgb(219,234,254)" stroke="rgb(0,0,0)"></rect><text x="20" y="25" fill="rgb(0,0,0)">Agent</text></svg>',284+ metadata, 'blockdiag', 'blockdiag@3.4.2/python-worker-1',285+ )286+ assert.match(blockdiag, /data-dz-fill="surface-2"/)287+ assert.match(blockdiag, /<text[^>]*data-dz-fill="ink"/)288+289+ const c4 = canonicalizeSvg(290+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><g><rect x="5" y="5" width="90" height="40" fill="#08427B" stroke="#181818"></rect><text x="20" y="30" fill="#000000">Person</text></g></svg>',291+ metadata, 'c4plantuml', 'plantuml@1.2026.6/edge-wasm-1',292+ )293+ assert.match(c4, /data-dz-fill="accent-1"/)294+ assert.match(c4, /<text[^>]*data-dz-fill="on-accent"/)295+})296+297+test('transparent appearances survive adversarial light and dark substrates without a renderer canvas', () => {298+ const fixtures = [299+ canonicalizeSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect width="20" height="20" fill="white"></rect><path d="M2 2L18 18" stroke="black"></path></svg>', metadata, 'excalidraw', '@excalidraw/excalidraw@0.18.1'),300+ canonicalizeSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect class="backdrop" width="20" height="20" fill="white"></rect><rect class="solid nofill" x="2" y="2" width="16" height="16"></rect><text x="5" y="12">A</text></svg>', metadata, 'ditaa', 'ditaa-ascii-via-svgbob@0.7.6/edge-wasm-1'),301+ ]302+ for (const canonical of fixtures) {303+ for (const appearance of ['light-transparent', 'dark-transparent']) {304+ const output = materializeSvg(canonical, appearance)305+ assert.match(output, /\[data-dz-role="canvas"\]:not\(\[data-dz-owned="materializer"\]\)\{display:none!important\}/)306+ assert.doesNotMatch(output, /<(?:rect|path|polygon)[^>]*data-dz-owned="materializer"[^>]*data-dz-role="canvas"/)307+ }308+ }184309 })
M docs/svg-normalization.md +9 -8
@@ -134,7 +134,7 @@ Schema 1 uses the following root attributes:
134134 ```xml135135 <svg136136 data-dz-schema="1"137- data-dz-normalizer="svg-normalizer-1"137+ data-dz-normalizer="svg-normalizer-2"138138 data-dz-engine="example"139139 data-dz-profile="semantic-markup-2"140140 data-dz-palette="renderer"
@@ -187,13 +187,14 @@ The current catalog uses these profile families:
187187 188188 | Profile | Conformance | Engines | Supported appearances |189189 | --- | --- | --- | --- |190-| `graphviz-15-semantic-1` | Semantic | Graphviz, DBML, ERD, WireViz | All |191-| `d2-0.7-semantic-1` | Semantic | D2 | All |192-| `plantuml-2026-semantic-1` | Semantic | PlantUML, C4 PlantUML, Structurizr | All |193-| `svgbob-0.7-semantic-1` | Semantic | Svgbob, Ditaa | All |194-| `neutral-svg-semantic-1` | Adaptive | BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, RackDiag, Bytefield, Mermaid, BPMN, Nomnoml, Pikchr, Symbolator, UMLet | All |195-| `structured-svg-semantic-1` | Adaptive | GoAT, Vega, Vega-Lite, WaveDrom | All |196-| `authored-svg-presentation-1` | Presentation only | Diagrams.net, Excalidraw, TikZ | Raw and framed |190+| `graphviz-15-semantic-2` | Semantic | Graphviz, DBML, ERD, WireViz | All |191+| `d2-0.7-semantic-2` | Semantic | D2 | All |192+| `plantuml-2026-semantic-2` | Semantic | PlantUML, C4 PlantUML, Structurizr | All |193+| `svgbob-0.7-semantic-2` | Semantic | Svgbob, Ditaa | All |194+| `neutral-svg-semantic-2` | Adaptive | BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, RackDiag, Bytefield, Mermaid, BPMN, Nomnoml, Pikchr, Symbolator, UMLet | All |195+| `structured-svg-semantic-2` | Adaptive | GoAT, Vega, Vega-Lite, WaveDrom | All |196+| `authored-neutral-semantic-1` | Adaptive | Excalidraw, TikZ | All |197+| `authored-svg-presentation-1` | Presentation only | Diagrams.net | Raw and framed |197198 198199 Semantic profiles rely on pinned renderer structures and stable class or group199200 meaning. Adaptive profiles recognize neutral paint and selected structural
M renderers/catalog/test/catalog.test.ts +10 -3
M shared/svg/index.d.ts +1 -1
M shared/svg/index.js +276 -44
@@ -9,7 +9,7 @@ const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]
99 const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\/(?:x-)?font-woff2?);base64,[a-z0-9+/=\s]+$/i1010 1111 export const SVG_SCHEMA = '1'12-export const NORMALIZER_BUILD = 'svg-normalizer-1'12+export const NORMALIZER_BUILD = 'svg-normalizer-2'1313 export const MATERIALIZER_BUILD = 'svg-materializer-1'1414 export const PALETTE_BUILD = 'diagramzip-palette-1'1515 export const RAW_PROFILE = 'safe-raw-1'
@@ -45,32 +45,32 @@ function capability(profile, conformance, limitations, appearances = APPEARANCES
4545 }4646 4747 const GRAPHVIZ_NORMALIZATION = capability(48- 'graphviz-15-semantic-1',48+ 'graphviz-15-semantic-2',4949 'semantic',5050 ['Authored non-neutral colors are preserved and may not adapt between palettes.'],5151 )5252 const D2_NORMALIZATION = capability(53- 'd2-0.7-semantic-1',53+ 'd2-0.7-semantic-2',5454 'semantic',5555 ['Authored D2 colors are preserved; the pinned neutral and primary theme roles adapt.'],5656 )5757 const PLANTUML_NORMALIZATION = capability(58- 'plantuml-2026-semantic-1',58+ 'plantuml-2026-semantic-2',5959 'semantic',6060 ['Pinned neutral PlantUML paint adapts; explicit themes and authored colors remain renderer-defined.'],6161 )6262 const SVGBOB_NORMALIZATION = capability(63- 'svgbob-0.7-semantic-1',63+ 'svgbob-0.7-semantic-2',6464 'semantic',6565 ['Line art, labels, and ordinary surfaces adapt; source-specific non-neutral paint is preserved.'],6666 )6767 const NEUTRAL_SVG_NORMALIZATION = capability(68- 'neutral-svg-semantic-1',68+ 'neutral-svg-semantic-2',6969 'adaptive',7070 ['Neutral paint adapts; authored non-neutral fills, shadows, and data colors are preserved.'],7171 )7272 const DETAILED_SVG_NORMALIZATION = capability(73- 'structured-svg-semantic-1',73+ 'structured-svg-semantic-2',7474 'adaptive',7575 ['Stable structural roles adapt; authored categorical and semantic colors are preserved.'],7676 )
@@ -80,6 +80,11 @@ const PRESENTATION_NORMALIZATION = capability(
8080 ['The outer canvas and frame adapt. Paint inside the authored SVG remains unchanged.'],8181 FRAMED_APPEARANCES,8282 )83+const AUTHORED_NEUTRAL_NORMALIZATION = capability(84+ 'authored-neutral-semantic-1',85+ 'adaptive',86+ ['Neutral canvas, ink, and line paint adapts; authored non-neutral paint remains renderer-defined.'],87+)8388 8489 const PROFILE_BY_ENGINE = Object.freeze({8590 graphviz: GRAPHVIZ_NORMALIZATION,
@@ -110,8 +115,8 @@ const PROFILE_BY_ENGINE = Object.freeze({
110115 vegalite: DETAILED_SVG_NORMALIZATION,111116 wavedrom: DETAILED_SVG_NORMALIZATION,112117 diagramsnet: PRESENTATION_NORMALIZATION,113- excalidraw: PRESENTATION_NORMALIZATION,114- tikz: PRESENTATION_NORMALIZATION,118+ excalidraw: AUTHORED_NEUTRAL_NORMALIZATION,119+ tikz: AUTHORED_NEUTRAL_NORMALIZATION,115120 })116121 117122 const VERSION_PATTERNS = Object.freeze({
@@ -271,12 +276,31 @@ function rectCoversBounds(element, bounds) {
271276 && y + height >= boundY + boundHeight - tolerance272277 }273278 279+function polygonCoversBounds(element, bounds) {280+ const numbers = (element.attributes.get('points') ?? '').match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []281+ if (numbers.length < 8 || numbers.length % 2 !== 0 || numbers.some(value => !Number.isFinite(value))) return false282+ const xs = numbers.filter((_, index) => index % 2 === 0)283+ const ys = numbers.filter((_, index) => index % 2 === 1)284+ const [boundX, boundY, boundWidth, boundHeight] = bounds285+ const tolerance = 0.01286+ return Math.min(...xs) <= boundX + tolerance287+ && Math.max(...xs) >= boundX + boundWidth - tolerance288+ && Math.min(...ys) <= boundY + tolerance289+ && Math.max(...ys) >= boundY + boundHeight - tolerance290+}291+274292 function rendererBackdrop(element, bounds, engine) {293+ if (element.attributes.get('data-dz-role') === 'canvas') return true275294 const name = localName(element)276295 const classes = new Set((element.attributes.get('class') ?? '').toLowerCase().split(/\s+/))277296 if (name === 'rect' && classes.has('backdrop')) return true278297 if (name === 'rect' && bounds !== null && whiteFill(element) && rectCoversBounds(element, bounds)) return true279- return engine === 'graphviz' && name === 'polygon' && whiteFill(element) && (element.attributes.get('stroke') ?? '').toLowerCase() === 'none'298+ return engine === 'graphviz'299+ && name === 'polygon'300+ && bounds !== null301+ && whiteFill(element)302+ && (element.attributes.get('stroke') ?? '').toLowerCase() === 'none'303+ && polygonCoversBounds(element, bounds)280304 }281305 282306 function removeRendererBackdrops(element, inheritedBounds, engine) {
@@ -294,38 +318,52 @@ function neutralPaint(element, property, accepted) {
294318 return accepted.has(value)295319 }296320 297-function applyGraphvizProfile(root) {298- const visit = (node, context = '') => {321+function applyGraphvizProfile(root, engine) {322+ const neutralInk = new Set(['', 'black', '#000', '#000000', 'rgb(0,0,0)'])323+ const graphvizLine = new Set([...neutralInk, ...(engine === 'dbml' ? ['#29235c'] : [])])324+ const visit = (node, context = '', inheritedBounds = presentationBounds(root)) => {299325 if (node.type === 'text') return300326 const classes = new Set((node.attributes.get('class') ?? '').toLowerCase().split(/\s+/).filter(Boolean))301- const nextContext = classes.has('node') ? 'node' : classes.has('edge') ? 'edge' : classes.has('cluster') ? 'cluster' : context327+ const nextContext = classes.has('node') ? 'node' : classes.has('edge') ? 'edge' : classes.has('cluster') ? 'cluster' : classes.has('graph') ? 'graph' : context302328 const name = localName(node)303- if (rendererBackdrop(node, presentationBounds(root), 'graphviz')) node.attributes.set('data-dz-role', 'canvas')304- if (name === 'text' && neutralPaint(node, 'fill', new Set(['', 'black', '#000', '#000000', 'rgb(0,0,0)']))) {329+ const bounds = name === 'svg' ? presentationBounds(node) ?? inheritedBounds : inheritedBounds330+ const backdrop = rendererBackdrop(node, bounds, 'graphviz')331+ || (nextContext === 'graph' && name === 'polygon' && whiteFill(node) && ownPaint(node, 'stroke') === 'none')332+ const fill = ownPaint(node, 'fill')333+ const stroke = ownPaint(node, 'stroke')334+ if (backdrop) node.attributes.set('data-dz-role', 'canvas')335+ if (name === 'text' && (neutralInk.has(fill) || (engine === 'dbml' && fill === '#29235c'))) {305336 node.attributes.set('data-dz-fill', nextContext === 'edge' ? 'ink-muted' : 'ink')306337 }307- if (nextContext === 'node' && ['ellipse', 'polygon', 'path', 'rect'].includes(name)) {308- if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-1')309- if (neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line')338+ if (name === 'text' && engine === 'dbml' && WHITE_PAINT.has(fill)) node.attributes.set('data-dz-fill', 'on-accent')339+ if (nextContext === 'node' && ['ellipse', 'polygon', 'path', 'rect'].includes(name) && !backdrop && !zeroStroke(node)) {340+ if ((WHITE_PAINT.has(fill) && !(engine === 'wireviz' && stroke === 'none'))341+ || (engine !== 'dbml' && !(engine === 'wireviz' && name === 'path') && transparentPaint(fill))) {342+ node.attributes.set('data-dz-fill', 'surface-1')343+ }344+ if (engine === 'dbml' && fill === '#e7e2dd') node.attributes.set('data-dz-fill', 'surface-1')345+ if (engine === 'dbml' && fill === '#1d71b8') node.attributes.set('data-dz-fill', 'accent-1')346+ if (graphvizLine.has(stroke)) node.attributes.set('data-dz-stroke', 'line')310347 }311348 if (nextContext === 'cluster' && ['polygon', 'path', 'rect'].includes(name)) {312349 if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-2')313- if (neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line-muted')350+ if (graphvizLine.has(stroke)) node.attributes.set('data-dz-stroke', 'line-muted')314351 }315352 if (nextContext === 'edge') {316- if (['path', 'polyline', 'line'].includes(name) && neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line')317- if (['polygon', 'path'].includes(name) && neutralPaint(node, 'fill', new Set(['black', '#000', '#000000']))) node.attributes.set('data-dz-fill', 'line')353+ if (['path', 'polyline', 'line', 'polygon'].includes(name) && graphvizLine.has(stroke)) node.attributes.set('data-dz-stroke', 'line')354+ if (['polygon', 'path'].includes(name) && graphvizLine.has(fill)) node.attributes.set('data-dz-fill', 'line')318355 }319- for (const child of node.children) visit(child, nextContext)356+ for (const child of node.children) visit(child, nextContext, bounds)320357 }321358 visit(root)322359 }323360 324-const BLACK_PAINT = new Set(['black', '#000', '#000000', '#111', '#111111', '#111827', '#181818', '#33322e', 'rgb(0,0,0)'])361+const BLACK_PAINT = new Set(['black', '#000', '#000000', '#111', '#111111', '#111827', '#181818', '#22242a', '#333', '#333333', '#33322e', 'rgb(0,0,0)', 'rgb(34,36,42)'])325362 const WHITE_PAINT = new Set(['white', '#fff', '#ffffff', '#e2e2f0', '#e8e8e8', '#eee8d5', '#f1f1f1', '#f7f8fe', 'rgb(255,255,255)'])326363 const MUTED_PAINT = new Set(['gray', 'grey', '#666', '#666666', '#7f7f7f', '#888', '#888888', '#aaa', '#aaaaaa'])327364 const SHAPE_ELEMENTS = new Set(['circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect'])328365 const TEXT_ELEMENTS = new Set(['text', 'tspan', 'div', 'span', 'p', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'li'])366+const NON_PAINT_CONTEXTS = new Set(['clippath', 'filter', 'mask', 'pattern'])329367 330368 function normalizedPaint(value) {331369 return (value ?? '').replaceAll(' ', '').toLowerCase()
@@ -335,6 +373,15 @@ function ownPaint(element, property) {
335373 return normalizedPaint(element.attributes.get(property) ?? styleValue(element, property) ?? '')336374 }337375 376+function transparentPaint(value) {377+ return value === '' || value === 'none' || value === 'transparent' || value === '#00000000' || value === 'rgba(0,0,0,0)'378+}379+380+function zeroStroke(element) {381+ const value = element.attributes.get('stroke-width') ?? styleValue(element, 'stroke-width')382+ return value !== null && Number.parseFloat(value) === 0383+}384+338385 function classNames(element) {339386 return new Set((element.attributes.get('class') ?? '').split(/\s+/).filter(Boolean))340387 }
@@ -345,25 +392,30 @@ function filteredShadow(element) {
345392 }346393 347394 function applyNeutralSvgProfile(root, engine, extraSurfacePaint = []) {348- const bounds = presentationBounds(root)349395 const surfaces = new Set([...WHITE_PAINT, ...extraSurfacePaint.map(normalizedPaint)])350- const visit = (node, inheritedFill = '', inheritedStroke = '') => {396+ const visit = (node, inheritedFill = '', inheritedStroke = '', inheritedBounds = presentationBounds(root), skipPaint = false) => {351397 if (node.type === 'text') return352398 const name = localName(node)399+ const nextSkipPaint = skipPaint || NON_PAINT_CONTEXTS.has(name)400+ const bounds = name === 'svg' ? presentationBounds(node) ?? inheritedBounds : inheritedBounds353401 const fill = ownPaint(node, 'fill') || inheritedFill354402 const stroke = ownPaint(node, 'stroke') || inheritedStroke355- const backdrop = rendererBackdrop(node, bounds, engine)403+ const classes = classNames(node)404+ const backdrop = !nextSkipPaint && (rendererBackdrop(node, bounds, engine) || (engine === 'd2' && name === 'rect' && classes.has('fill-N7')))356405 if (backdrop) node.attributes.set('data-dz-role', 'canvas')357- if (TEXT_ELEMENTS.has(name)) {358- if (BLACK_PAINT.has(fill) || fill === '') node.attributes.set('data-dz-fill', 'ink')406+ if (!nextSkipPaint && TEXT_ELEMENTS.has(name)) {407+ if (BLACK_PAINT.has(fill) || transparentPaint(fill)) node.attributes.set('data-dz-fill', 'ink')359408 else if (MUTED_PAINT.has(fill)) node.attributes.set('data-dz-fill', 'ink-muted')360- } else if (SHAPE_ELEMENTS.has(name) && !backdrop) {409+ } else if (!nextSkipPaint && SHAPE_ELEMENTS.has(name) && !backdrop) {361410 if (surfaces.has(fill)) node.attributes.set('data-dz-fill', 'surface-1')362411 else if (BLACK_PAINT.has(fill) && !filteredShadow(node)) node.attributes.set('data-dz-fill', 'line')363412 if (BLACK_PAINT.has(stroke)) node.attributes.set('data-dz-stroke', 'line')364413 else if (MUTED_PAINT.has(stroke)) node.attributes.set('data-dz-stroke', 'line-muted')414+ if (engine === 'pikchr' && name === 'path' && transparentPaint(fill) && /(?:z|Z)\s*$/.test(node.attributes.get('d') ?? '')) {415+ node.attributes.set('data-dz-fill', 'surface-1')416+ }365417 }366- for (const child of node.children) visit(child, fill, stroke)418+ for (const child of node.children) visit(child, fill, stroke, bounds, nextSkipPaint)367419 }368420 visit(root)369421 }
@@ -384,24 +436,81 @@ function applyD2Profile(root) {
384436 visit(root)385437 }386438 439+function lineEndpoints(node) {440+ if (node.type === 'text' || localName(node) !== 'line') return null441+ const values = ['x1', 'y1', 'x2', 'y2'].map(name => numericCoordinate(node.attributes.get(name)))442+ return values.some(value => value === null) ? null : values443+}444+445+function addSvgbobObjectSurfaces(root) {446+ const visit = node => {447+ if (node.type === 'text') return448+ if (node.children.some(child => child.type !== 'text' && child.attributes.get('data-dz-owned') === 'normalizer')) return449+ const lines = node.children.map(lineEndpoints).filter(Boolean)450+ const vertical = lines.filter(([x1, y1, x2, y2]) => x1 === x2 && y1 !== y2)451+ const horizontal = lines.filter(([x1, y1, x2, y2]) => y1 === y2 && x1 !== x2)452+ const key = values => values.join(',')453+ const horizontalKeys = new Set(horizontal.map(([x1, y1, x2]) => key([Math.min(x1, x2), y1, Math.max(x1, x2)])))454+ const additions = []455+ for (let first = 0; first < vertical.length; first++) {456+ const [leftX, firstY1, , firstY2] = vertical[first]457+ const top = Math.min(firstY1, firstY2)458+ const bottom = Math.max(firstY1, firstY2)459+ for (let second = first + 1; second < vertical.length; second++) {460+ const [rightX, secondY1, , secondY2] = vertical[second]461+ if (Math.min(secondY1, secondY2) !== top || Math.max(secondY1, secondY2) !== bottom || leftX === rightX) continue462+ const minX = Math.min(leftX, rightX)463+ const maxX = Math.max(leftX, rightX)464+ if (!horizontalKeys.has(key([minX, top, maxX])) || !horizontalKeys.has(key([minX, bottom, maxX]))) continue465+ additions.push(element('rect', [466+ ['x', String(minX)], ['y', String(top)], ['width', String(maxX - minX)], ['height', String(bottom - top)],467+ ['fill', 'none'], ['data-dz-owned', 'normalizer'], ['data-dz-role', 'object-surface'], ['data-dz-fill', 'surface-1'],468+ ]))469+ }470+ }471+ if (additions.length > 0) node.children.unshift(...additions)472+ for (const child of node.children) visit(child)473+ }474+ visit(root)475+}476+387477 function applySvgbobProfile(root) {388478 applyNeutralSvgProfile(root, 'svgbob')389- const visit = node => {479+ addSvgbobObjectSurfaces(root)480+ const visit = (node, marker = '') => {390481 if (node.type === 'text') return391482 const classes = classNames(node)392483 const name = localName(node)484+ const nextMarker = name === 'marker' ? node.attributes.get('id') ?? '' : marker393485 if (classes.has('backdrop')) node.attributes.set('data-dz-role', 'canvas')394486 if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')395487 if (classes.has('solid')) node.attributes.set('data-dz-stroke', 'line')396488 if (classes.has('nofill') || classes.has('bg_filled')) node.attributes.set('data-dz-fill', 'surface-1')397- if (classes.has('filled')) node.attributes.set('data-dz-fill', 'line')398- for (const child of node.children) visit(child)489+ if (classes.has('filled') || ['arrow', 'diamond', 'circle'].includes(nextMarker)) node.attributes.set('data-dz-fill', 'line')490+ if (classes.has('filled') || classes.has('bg_filled') || nextMarker !== '') node.attributes.set('data-dz-stroke', 'line')491+ for (const child of node.children) visit(child, nextMarker)399492 }400493 visit(root)401494 }402495 403-function applyPlantumlProfile(root) {496+function applyPlantumlProfile(root, engine) {404497 applyNeutralSvgProfile(root, 'plantuml', ['#e2e2f0', '#f1f1f1'])498+ if (engine !== 'c4plantuml') return499+ const c4Accent = new Set(['#08427b', '#1168bd'])500+ const visit = node => {501+ if (node.type === 'text') return502+ const name = localName(node)503+ const fill = ownPaint(node, 'fill')504+ if (SHAPE_ELEMENTS.has(name) && c4Accent.has(fill)) node.attributes.set('data-dz-fill', 'accent-1')505+ for (const child of node.children) visit(child)506+ const ownsAccent = node.children.some(child => child.type !== 'text' && child.attributes.get('data-dz-fill') === 'accent-1')507+ if (ownsAccent) {508+ for (const child of node.children) {509+ if (child.type !== 'text' && TEXT_ELEMENTS.has(localName(child))) child.attributes.set('data-dz-fill', 'on-accent')510+ }511+ }512+ }513+ visit(root)405514 }406515 407516 function applyGoatProfile(root) {
@@ -412,8 +521,14 @@ function applyGoatProfile(root) {
412521 const name = localName(node)413522 if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')414523 if (classes.has('path')) node.attributes.set('data-dz-stroke', 'line')415- if (classes.has('arrowhead') || classes.has('filled')) node.attributes.set('data-dz-fill', 'line')416- if (classes.has('hollow')) node.attributes.set('data-dz-fill', 'surface-1')524+ if (classes.has('arrowhead') || classes.has('filled')) {525+ node.attributes.set('data-dz-fill', 'line')526+ node.attributes.set('data-dz-stroke', 'line')527+ }528+ if (classes.has('hollow')) {529+ node.attributes.set('data-dz-fill', 'surface-1')530+ node.attributes.set('data-dz-stroke', 'line')531+ }417532 for (const child of node.children) visit(child)418533 }419534 visit(root)
@@ -439,6 +554,7 @@ function applyVegaProfile(root) {
439554 }440555 441556 function applyWavedromProfile(root) {557+ applyNeutralSvgProfile(root, 'wavedrom')442558 const accentClasses = new Map([443559 ['s8', 'accent-3'], ['s9', 'accent-3'], ['s10', 'accent-1'], ['s11', 'accent-1'],444560 ['s12', 'accent-2'], ['s13', 'accent-2'], ['s14', 'accent-3'], ['s15', 'accent-1'],
@@ -447,8 +563,9 @@ function applyWavedromProfile(root) {
447563 if (node.type === 'text') return448564 const classes = classNames(node)449565 const name = localName(node)450- if (TEXT_ELEMENTS.has(name) && ![...classes].some(value => ['muted', 'warning', 'error', 'info', 'success'].includes(value))) {451- node.attributes.set('data-dz-fill', 'ink')566+ if (TEXT_ELEMENTS.has(name)) {567+ if ([...classes].some(value => ['muted', 'info'].includes(value))) node.attributes.set('data-dz-fill', 'ink-muted')568+ else if (![...classes].some(value => ['warning', 'error', 'success'].includes(value))) node.attributes.set('data-dz-fill', 'ink')452569 }453570 if ([...classes].some(value => ['s1', 's2', 's3', 's4'].includes(value))) node.attributes.set('data-dz-stroke', 'line')454571 if (classes.has('s5') || classes.has('s7')) node.attributes.set('data-dz-fill', 'surface-1')
@@ -460,17 +577,110 @@ function applyWavedromProfile(root) {
460577 visit(root)461578 }462579 580+function applyNeutralRendererDetails(root, engine) {581+ const visit = node => {582+ if (node.type === 'text') return583+ const name = localName(node)584+ const classes = classNames(node)585+ const fill = ownPaint(node, 'fill')586+ if (engine === 'mermaid') {587+ if (classes.has('label-container')) {588+ node.attributes.set('data-dz-fill', 'surface-1')589+ node.attributes.set('data-dz-stroke', 'line')590+ }591+ if (classes.has('flowchart-link')) node.attributes.set('data-dz-stroke', 'line')592+ if (classes.has('arrowMarkerPath')) {593+ node.attributes.set('data-dz-fill', 'line')594+ node.attributes.set('data-dz-stroke', 'line')595+ }596+ }597+ if (engine === 'blockdiag' && SHAPE_ELEMENTS.has(name) && (fill === '#dbeafe' || fill === 'rgb(219,234,254)')) {598+ node.attributes.set('data-dz-fill', 'surface-2')599+ }600+ if (engine === 'umlet' && classes.has('solid')) {601+ node.attributes.set('data-dz-fill', 'line')602+ node.attributes.set('data-dz-stroke', 'line')603+ }604+ for (const child of node.children) visit(child)605+ }606+ visit(root)607+}608+609+function elementPaintBounds(node) {610+ if (node.type === 'text') return null611+ const name = localName(node)612+ if (name === 'rect') {613+ const x = numericCoordinate(node.attributes.get('x') ?? '0')614+ const y = numericCoordinate(node.attributes.get('y') ?? '0')615+ const width = numericCoordinate(node.attributes.get('width'))616+ const height = numericCoordinate(node.attributes.get('height'))617+ return [x, y, width, height].some(value => value === null) ? null : [x, y, x + width, y + height]618+ }619+ if (name === 'circle' || name === 'ellipse') {620+ const cx = numericCoordinate(node.attributes.get('cx'))621+ const cy = numericCoordinate(node.attributes.get('cy'))622+ const rx = numericCoordinate(node.attributes.get(name === 'circle' ? 'r' : 'rx'))623+ const ry = numericCoordinate(node.attributes.get(name === 'circle' ? 'r' : 'ry'))624+ return [cx, cy, rx, ry].some(value => value === null) ? null : [cx - rx, cy - ry, cx + rx, cy + ry]625+ }626+ if (name === 'polygon') {627+ const values = (node.attributes.get('points') ?? '').match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []628+ if (values.length < 6 || values.length % 2 !== 0) return null629+ const xs = values.filter((_, index) => index % 2 === 0)630+ const ys = values.filter((_, index) => index % 2 === 1)631+ return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]632+ }633+ return null634+}635+636+function resolveSiblingTextContrast(root) {637+ const visit = node => {638+ if (node.type === 'text') return639+ const surfaces = []640+ for (const child of node.children) {641+ if (child.type === 'text') continue642+ const bounds = elementPaintBounds(child)643+ const role = child.attributes.get('data-dz-fill') ?? ''644+ const structuralRole = child.attributes.get('data-dz-role') ?? ''645+ const fill = ownPaint(child, 'fill')646+ if (bounds !== null && structuralRole !== 'canvas' && !filteredShadow(child)647+ && (role.startsWith('surface-') || role.startsWith('accent-') || (!transparentPaint(fill) && !BLACK_PAINT.has(fill)))) {648+ surfaces.push({ bounds, role, fill })649+ }650+ if (TEXT_ELEMENTS.has(localName(child))) {651+ const x = numericCoordinate(child.attributes.get('x'))652+ const y = numericCoordinate(child.attributes.get('y'))653+ const owner = x === null || y === null ? null : surfaces.findLast(surface => {654+ const [left, top, right, bottom] = surface.bounds655+ return x >= left && x <= right && y >= top && y <= bottom656+ })657+ if (owner?.role.startsWith('accent-')) child.attributes.set('data-dz-fill', 'on-accent')658+ else if (owner?.role.startsWith('surface-')) child.attributes.set('data-dz-fill', 'ink')659+ else if (owner !== null && owner !== undefined) child.attributes.delete('data-dz-fill')660+ }661+ }662+ for (const child of node.children) visit(child)663+ }664+ visit(root)665+}666+463667 function applyProfile(root, capability, engine) {464- if (capability.profile === 'graphviz-15-semantic-1') applyGraphvizProfile(root)465- else if (capability.profile === 'd2-0.7-semantic-1') applyD2Profile(root)466- else if (capability.profile === 'plantuml-2026-semantic-1') applyPlantumlProfile(root)467- else if (capability.profile === 'svgbob-0.7-semantic-1') applySvgbobProfile(root)468- else if (capability.profile === 'neutral-svg-semantic-1') applyNeutralSvgProfile(root, engine)469- else if (capability.profile === 'structured-svg-semantic-1') {668+ if (capability.profile === 'graphviz-15-semantic-2') applyGraphvizProfile(root, engine)669+ else if (capability.profile === 'd2-0.7-semantic-2') applyD2Profile(root)670+ else if (capability.profile === 'plantuml-2026-semantic-2') applyPlantumlProfile(root, engine)671+ else if (capability.profile === 'svgbob-0.7-semantic-2') applySvgbobProfile(root)672+ else if (capability.profile === 'neutral-svg-semantic-2') {673+ const surfacePaint = engine === 'mermaid' ? ['#ececff', '#f9f9f9'] : []674+ applyNeutralSvgProfile(root, engine, surfacePaint)675+ applyNeutralRendererDetails(root, engine)676+ }677+ else if (capability.profile === 'structured-svg-semantic-2') {470678 if (engine === 'goat') applyGoatProfile(root)471679 else if (engine === 'vega' || engine === 'vegalite') applyVegaProfile(root)472680 else if (engine === 'wavedrom') applyWavedromProfile(root)473681 }682+ else if (capability.profile === 'authored-neutral-semantic-1') applyNeutralSvgProfile(root, engine)683+ if (capability.conformance !== 'presentation-only') resolveSiblingTextContrast(root)474684 }475685 476686 function applyRootBackgroundStyle(root, background) {
@@ -499,6 +709,20 @@ function addMetadata(root, metadata) {
499709 root.children.unshift(...additions)500710 }501711 712+function removeStaleNormalizerOutput(root) {713+ const visit = node => {714+ if (node.type === 'text') return715+ node.attributes.delete('data-dz-role')716+ node.attributes.delete('data-dz-fill')717+ node.attributes.delete('data-dz-stroke')718+ node.children = node.children.filter(child => child.type === 'text'719+ || !['materializer', 'normalizer'].includes(child.attributes.get('data-dz-owned')))720+ for (const child of node.children) visit(child)721+ }722+ visit(root)723+ root.attributes.delete('data-dz-materializer')724+}725+502726 function applyLegacyPresentation(root, presentation, engine) {503727 const additions = []504728 let frame = null
@@ -600,6 +824,12 @@ export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, r
600824 601825 export function canonicalizeSvg(source, metadata, engine, rendererVersion = '') {602826 const root = parseSafeSvg(source)827+ const previousNormalizer = root.attributes.get('data-dz-normalizer')828+ if (previousNormalizer !== undefined && previousNormalizer !== NORMALIZER_BUILD) removeStaleNormalizerOutput(root)829+ else if (root.attributes.has('data-dz-materializer')) {830+ removeMaterializerOutput(root)831+ root.attributes.delete('data-dz-materializer')832+ }603833 const capability = normalizationFor(engine, rendererVersion)604834 applyProfile(root, capability, engine)605835 root.attributes.set('data-dz-schema', SVG_SCHEMA)
@@ -609,8 +839,10 @@ export function canonicalizeSvg(source, metadata, engine, rendererVersion = '')
609839 root.attributes.set('data-dz-engine', engine)610840 root.attributes.set('data-dz-appearance', 'raw')611841 root.attributes.set('data-dz-conformance', capability.conformance)842+ root.attributes.delete('data-dz-appearances')612843 const appearances = capability.appearances.filter(appearance => appearance !== 'raw')613844 if (appearances.length > 0) root.attributes.set('data-dz-appearances', appearances.join(' '))845+ root.attributes.delete('data-dz-bounds')614846 const bounds = presentationBounds(root)615847 if (bounds !== null) root.attributes.set('data-dz-bounds', bounds.join(' '))616848 addMetadata(root, metadata)