clone

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

normalize SVG appearance across renderers

Dami committed 5909228
commit
5909228a5a8a54b9038a9853915d92647b0c0bbd
parent
0fe5a564f260e752003bf40a4c7f64571e40bf68
author
Dami <dami.etoile@gmail.com>
52 files changed, +607 -125
Mdiagramzip-api/src/model.ts1+
Mdiagramzip-api/src/validation.ts9+-
Mdiagramzip-api/test/aliases.test.ts2+-
Mdiagramzip-client-units/diagramsnet/unit/_headers1+
Mdiagramzip-client-units/tikz/unit/_headers1+
Mdiagramzip-docs/docs/style/presentation.md29++-
Mdiagramzip-docs/docs/style/types/actdiag.md2+-
Mdiagramzip-docs/docs/style/types/blockdiag.md2+-
Mdiagramzip-docs/docs/style/types/bpmn.md2+-
Mdiagramzip-docs/docs/style/types/bytefield.md2+-
Mdiagramzip-docs/docs/style/types/c4plantuml.md2+-
Mdiagramzip-docs/docs/style/types/d2.md2+-
Mdiagramzip-docs/docs/style/types/dbml.md2+-
Mdiagramzip-docs/docs/style/types/diagramsnet.md2+-
Mdiagramzip-docs/docs/style/types/ditaa.md2+-
Mdiagramzip-docs/docs/style/types/erd.md2+-
Mdiagramzip-docs/docs/style/types/excalidraw.md2+-
Mdiagramzip-docs/docs/style/types/goat.md2+-
Mdiagramzip-docs/docs/style/types/graphviz.md2+-
Mdiagramzip-docs/docs/style/types/mermaid.md2+-
Mdiagramzip-docs/docs/style/types/nomnoml.md2+-
Mdiagramzip-docs/docs/style/types/nwdiag.md2+-
Mdiagramzip-docs/docs/style/types/packetdiag.md2+-
Mdiagramzip-docs/docs/style/types/pikchr.md2+-
Mdiagramzip-docs/docs/style/types/plantuml.md2+-
Mdiagramzip-docs/docs/style/types/rackdiag.md2+-
Mdiagramzip-docs/docs/style/types/seqdiag.md2+-
Mdiagramzip-docs/docs/style/types/structurizr.md2+-
Mdiagramzip-docs/docs/style/types/svgbob.md2+-
Mdiagramzip-docs/docs/style/types/symbolator.md2+-
Mdiagramzip-docs/docs/style/types/tikz.md2+-
Mdiagramzip-docs/docs/style/types/umlet.md2+-
Mdiagramzip-docs/docs/style/types/vega.md2+-
Mdiagramzip-docs/docs/style/types/vegalite.md2+-
Mdiagramzip-docs/docs/style/types/wavedrom.md2+-
Mdiagramzip-docs/docs/style/types/wireviz.md2+-
Mdiagramzip-docs/scripts/check-rendered-examples.mjs15+
Mdiagramzip-docs/scripts/generate-docs.mjs2+-
Mdiagramzip-docs/src/components/DiagramExample/index.js22+-
Mdiagramzip-docs/static/llms-full.txt60+++---
Mdiagramzip-render/test/catalog.test.ts19+-
Mdiagramzip-svg/index.js318++++++++++++++++++++++++++++--
Mdiagramzip/PERSISTENCE.md7+-
Mdiagramzip/src/client-svg.js2+
Mdiagramzip/src/main.js45++++-
Mdiagramzip/src/preview.js20++-
Mdiagramzip/src/state.js8+-
Mdiagramzip/src/style.css3+
Mdiagramzip/test/encryption.test.js2+-
Mdiagramzip/test/state.test.js16+-
Mdiagramzip/test/svg-contract.test.js65++++++
Mdocs/svg-normalization.md25++-
M diagramzip-api/src/model.ts +1 -0
M diagramzip-api/src/validation.ts +6 -3
@@ -73,8 +73,8 @@ function normalizeDiagram(value: unknown): Diagram {
7373 if (!isRecord(value.presentation)) {7474 throw new RequestError(400, 'invalid_request', 'diagram.presentation must be an object.')7575 }76- assertOnlyKeys(value.presentation, ['background', 'padding', 'frame'], 'diagram.presentation')77- const { background, padding, frame } = value.presentation76+ assertOnlyKeys(value.presentation, ['background', 'padding', 'frame', 'appearance'], 'diagram.presentation')77+ const { background, padding, frame, appearance = 'raw' } = value.presentation7878 if (typeof background !== 'string' || !backgroundPattern.test(background)) {7979 throw new RequestError(400, 'invalid_request', 'diagram.presentation.background is invalid.')8080 }
@@ -84,12 +84,15 @@ function normalizeDiagram(value: unknown): Diagram {
8484 if (typeof frame !== 'boolean') {8585 throw new RequestError(400, 'invalid_request', 'diagram.presentation.frame is invalid.')8686 }87+ if (typeof appearance !== 'string' || !['raw', 'auto-transparent', 'light-transparent', 'dark-transparent', 'auto-framed', 'light-framed', 'dark-framed'].includes(appearance)) {88+ throw new RequestError(400, 'invalid_request', 'diagram.presentation.appearance is invalid.')89+ }8790 8891 return {8992 type: value.type,9093 source: value.source,9194 options,92- presentation: { background, padding: padding as number, frame },95+ presentation: { background, padding: padding as number, frame, appearance: appearance as Diagram['presentation']['appearance'] },9396 }9497 }9598
M diagramzip-api/test/aliases.test.ts +1 -1
M diagramzip-client-units/diagramsnet/unit/_headers +1 -0
M diagramzip-client-units/tikz/unit/_headers +1 -0
M diagramzip-docs/docs/style/presentation.md +21 -8
@@ -1,17 +1,31 @@
11 ---22 id: presentation33 title: General presentation settings4-description: Set the canvas background, padding, and frame for a diagram.zip diagram.4+description: Choose a shared appearance or keep the renderer canvas, padding, and frame.55 sidebar_position: 166 ---77 88 # General presentation settings99 10-These settings apply to every rendered diagram. Set them after you create valid source.10+Open **Details**, then use **Appearance** to choose how diagram.zip presents the SVG.11+12+## Appearance13+14+**Renderer default** keeps the safe renderer output. It also enables the canvas, padding, and frame controls.15+16+The shared appearances apply one Diagram.zip palette to supported renderer output:17+18+- **Match device** includes light and dark palettes in one SVG. The SVG selects a palette with `prefers-color-scheme`.19+- **Light** always uses the light palette.20+- **Dark** always uses the dark palette.21+- **Transparent** does not add an outer canvas.22+- **Framed** adds a matching canvas, standard padding, and a border.23+24+The editor disables appearances that the current renderer profile cannot support. Diagrams.net, Excalidraw, and TikZ currently support framed appearances only. Their authored paint remains unchanged inside the shared frame.1125 1226 ## Background1327 14-Set the canvas background to a color that supports your diagram. Use a light background for most documents. Check the contrast of labels and lines after you change it.28+Choose **Renderer default** before you set the background. Use a light background for most documents. Check the contrast of labels and lines after you change it.1529 1630 ## Padding1731
@@ -31,10 +45,9 @@ For example, [Mermaid styling](/style/types/mermaid/) uses Mermaid configuration
3145 3246 ## A safe order3347 34-1. Set the canvas background.35-2. Set padding.36-3. Choose the frame state.37-4. Apply type-specific styles.38-5. Check the preview at its target size.48+1. Choose a shared appearance when the renderer supports it.49+2. Use **Renderer default** when you need custom canvas controls.50+3. Apply type-specific styles.51+4. Check the preview at its target size.3952 4053 A **Renderer options** section appears only when the renderer adds options for that type.
M diagramzip-docs/docs/style/types/actdiag.md +1 -1
M diagramzip-docs/docs/style/types/blockdiag.md +1 -1
M diagramzip-docs/docs/style/types/bpmn.md +1 -1
M diagramzip-docs/docs/style/types/bytefield.md +1 -1
M diagramzip-docs/docs/style/types/c4plantuml.md +1 -1
M diagramzip-docs/docs/style/types/d2.md +1 -1
M diagramzip-docs/docs/style/types/dbml.md +1 -1
M diagramzip-docs/docs/style/types/diagramsnet.md +1 -1
M diagramzip-docs/docs/style/types/ditaa.md +1 -1
M diagramzip-docs/docs/style/types/erd.md +1 -1
M diagramzip-docs/docs/style/types/excalidraw.md +1 -1
M diagramzip-docs/docs/style/types/goat.md +1 -1
M diagramzip-docs/docs/style/types/graphviz.md +1 -1
M diagramzip-docs/docs/style/types/mermaid.md +1 -1
M diagramzip-docs/docs/style/types/nomnoml.md +1 -1
M diagramzip-docs/docs/style/types/nwdiag.md +1 -1
M diagramzip-docs/docs/style/types/packetdiag.md +1 -1
M diagramzip-docs/docs/style/types/pikchr.md +1 -1
M diagramzip-docs/docs/style/types/plantuml.md +1 -1
M diagramzip-docs/docs/style/types/rackdiag.md +1 -1
M diagramzip-docs/docs/style/types/seqdiag.md +1 -1
M diagramzip-docs/docs/style/types/structurizr.md +1 -1
M diagramzip-docs/docs/style/types/svgbob.md +1 -1
M diagramzip-docs/docs/style/types/symbolator.md +1 -1
M diagramzip-docs/docs/style/types/tikz.md +1 -1
M diagramzip-docs/docs/style/types/umlet.md +1 -1
M diagramzip-docs/docs/style/types/vega.md +1 -1
M diagramzip-docs/docs/style/types/vegalite.md +1 -1
M diagramzip-docs/docs/style/types/wavedrom.md +1 -1
M diagramzip-docs/docs/style/types/wireviz.md +1 -1
M diagramzip-docs/scripts/check-rendered-examples.mjs +15 -0
M diagramzip-docs/scripts/generate-docs.mjs +1 -1
M diagramzip-docs/src/components/DiagramExample/index.js +12 -10
@@ -1,5 +1,5 @@
11 import React, {useCallback, useEffect, useRef, useState} from 'react';2-import {canonicalizeSvg, materializeSvg} from '../../../../diagramzip-svg/index.js';2+import {canonicalizeSvg, materializeSvg, supportedAppearances} from '../../../../diagramzip-svg/index.js';33 import {fitView, zoomView} from './viewMath.mjs';44 import {clientFrameUrlFor, httpRendererUrlFor} from './rendererRouting.mjs';55 import styles from './styles.module.css';
@@ -54,7 +54,10 @@ class RendererFrame {
5454 if (!pending) return;5555 this.pending.delete(event.data.requestId);5656 pending.finish();57- if (event.data.ok && typeof event.data.svg === 'string') pending.resolve(event.data.svg);57+ if (event.data.ok && typeof event.data.svg === 'string') pending.resolve({58+ svg: event.data.svg,59+ version: typeof event.data.version === 'string' ? event.data.version : '',60+ });5861 else pending.reject(new Error(event.data.error || 'Client rendering failed.'));5962 }6063
@@ -171,8 +174,8 @@ export default function DiagramExample({engine, label, sourceUrl}) {
171174 const clientRenderer = clientRendererFor(engine);172175 let source;173176 if (clientRenderer) {174- source = await clientRenderer.render(engine, example.source, abortController.signal);175- source = canonicalizeSvg(source, {title: '', description: ''}, engine);177+ const clientResult = await clientRenderer.render(engine, example.source, abortController.signal);178+ source = canonicalizeSvg(clientResult.svg, {title: '', description: ''}, engine, clientResult.version);176179 } else {177180 const renderResponse = await fetch(httpRendererUrlFor(engine), {178181 method: 'POST',
@@ -189,12 +192,11 @@ export default function DiagramExample({engine, label, sourceUrl}) {
189192 if (!renderResponse.ok) throw new Error(await responseError(renderResponse));190193 source = await renderResponse.text();191194 }192- try {193- source = materializeSvg(source, 'auto-transparent');194- } catch (error) {195- if (error?.code !== 'unsupported_appearance') throw error;196- source = materializeSvg(source, 'raw');197- }195+ const appearances = supportedAppearances(source);196+ const appearance = appearances.includes('auto-transparent')197+ ? 'auto-transparent'198+ : appearances.includes('auto-framed') ? 'auto-framed' : 'raw';199+ source = materializeSvg(source, appearance);198200 source = normalizeSvg(source);199201 const objectUrl = URL.createObjectURL(new Blob([source], {type: 'image/svg+xml'}));200202 if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
M diagramzip-docs/static/llms-full.txt +30 -30
@@ -60,7 +60,7 @@ sidebar_label: PlantUML
6060 6161 PlantUML supports themes, skin parameters, colors, sprites, and element-specific style rules.6262 63-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.63+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.6464 6565 ## Source controls6666
@@ -145,7 +145,7 @@ sidebar_label: Mermaid
145145 146146 Mermaid supports themes, frontmatter configuration, class definitions, and diagram-specific configuration.147147 148-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.148+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.149149 150150 ## Source controls151151
@@ -232,7 +232,7 @@ sidebar_label: Graphviz
232232 233233 DOT supports graph, node, and edge attributes.234234 235-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.235+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.236236 237237 ## Source controls238238
@@ -318,7 +318,7 @@ sidebar_label: D2
318318 319319 D2 supports themes, shape properties, style blocks, classes, and sketch mode.320320 321-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.321+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.322322 323323 ## Source controls324324
@@ -402,7 +402,7 @@ sidebar_label: C4 PlantUML
402402 403403 C4 PlantUML supports layout macros, tags, legends, and PlantUML style controls.404404 405-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.405+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.406406 407407 ## Source controls408408
@@ -483,7 +483,7 @@ sidebar_label: BlockDiag
483483 484484 BlockDiag supports diagram attributes and per-node or per-edge attributes.485485 486-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.486+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.487487 488488 ## Source controls489489
@@ -565,7 +565,7 @@ sidebar_label: SeqDiag
565565 566566 SeqDiag supports participant, message, and diagram attributes.567567 568-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.568+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.569569 570570 ## Source controls571571
@@ -652,7 +652,7 @@ sidebar_label: ActDiag
652652 653653 ActDiag supports lane, action, edge, and diagram attributes.654654 655-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.655+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.656656 657657 ## Source controls658658
@@ -736,7 +736,7 @@ sidebar_label: NwDiag
736736 737737 NwDiag supports network and host attributes.738738 739-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.739+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.740740 741741 ## Source controls742742
@@ -820,7 +820,7 @@ sidebar_label: PacketDiag
820820 821821 PacketDiag supports global layout values and field attributes.822822 823-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.823+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.824824 825825 ## Source controls826826
@@ -903,7 +903,7 @@ sidebar_label: RackDiag
903903 904904 RackDiag supports rack and device attributes.905905 906-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.906+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.907907 908908 ## Source controls909909
@@ -989,7 +989,7 @@ sidebar_label: BPMN
989989 990990 BPMN appearance usually comes from BPMN diagram interchange data and renderer defaults.991991 992-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.992+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.993993 994994 ## Source controls995995
@@ -1067,7 +1067,7 @@ sidebar_label: Bytefield
10671067 10681068 Bytefield uses drawing-form properties for spans, labels, and layout.10691069 1070-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1070+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.10711071 10721072 ## Source controls10731073
@@ -1151,7 +1151,7 @@ sidebar_label: DBML
11511151 11521152 DBML describes schema structure. It does not define visual presentation.11531153 1154-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1154+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.11551155 11561156 ## Source controls11571157
@@ -1232,7 +1232,7 @@ sidebar_label: Diagrams.net
12321232 12331233 Diagrams.net stores geometry and style values on cells in the XML source.12341234 1235-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1235+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.12361236 12371237 ## Source controls12381238
@@ -1304,7 +1304,7 @@ sidebar_label: Ditaa
13041304 13051305 Ditaa supports shape tags, color tags, dashed lines, and renderer options.13061306 1307-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1307+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.13081308 13091309 ## Source controls13101310
@@ -1396,7 +1396,7 @@ sidebar_label: ERD
13961396 13971397 ERD focuses on the data model. The renderer controls most visual values.13981398 1399-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1399+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.14001400 14011401 ## Source controls14021402
@@ -1475,7 +1475,7 @@ sidebar_label: Excalidraw
14751475 14761476 Excalidraw stores the style of each element in the JSON source.14771477 1478-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1478+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.14791479 14801480 ## Source controls14811481
@@ -1568,7 +1568,7 @@ sidebar_label: GoAT
15681568 15691569 GoAT takes most geometry from the text. Renderer options control SVG colors and character handling.15701570 1571-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1571+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.15721572 15731573 ## Source controls15741574
@@ -1653,7 +1653,7 @@ sidebar_label: Nomnoml
16531653 16541654 Nomnoml uses hash directives and custom classifier styles.16551655 1656-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1656+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.16571657 16581658 ## Source controls16591659
@@ -1729,7 +1729,7 @@ sidebar_label: Pikchr
17291729 17301730 Pikchr supports dimensions, colors, fills, line widths, and placement controls in the source.17311731 1732-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1732+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.17331733 17341734 ## Source controls17351735
@@ -1808,7 +1808,7 @@ sidebar_label: Structurizr
18081808 18091809 Structurizr supports workspace styles, themes, tags, and automatic layout.18101810 1811-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1811+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.18121812 18131813 ## Source controls18141814
@@ -1889,7 +1889,7 @@ sidebar_label: Svgbob
18891889 18901890 Svgbob takes geometry from the text and supports renderer-level SVG settings.18911891 1892-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1892+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.18931893 18941894 ## Source controls18951895
@@ -1976,7 +1976,7 @@ sidebar_label: Symbolator
19761976 19771977 Symbolator gets structure from hardware source. Renderer options control the title, scale, and output details.19781978 1979-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.1979+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.19801980 19811981 ## Source controls19821982
@@ -2062,7 +2062,7 @@ sidebar_label: TikZ
20622062 20632063 TikZ supports colors, line forms, fills, node styles, scopes, and reusable style definitions.20642064 2065-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.2065+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.20662066 20672067 ## Source controls20682068
@@ -2141,7 +2141,7 @@ sidebar_label: UMLet
21412141 21422142 UMLet stores position and style commands in the UXF XML source.21432143 2144-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.2144+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.21452145 21462146 ## Source controls21472147
@@ -2248,7 +2248,7 @@ sidebar_label: Vega
22482248 22492249 Vega controls presentation through scales, mark encodings, axes, legends, and configuration.22502250 2251-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.2251+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.22522252 22532253 ## Source controls22542254
@@ -2337,7 +2337,7 @@ sidebar_label: Vega-Lite
23372337 23382338 Vega-Lite controls presentation through marks, encodings, scales, axes, legends, and configuration.23392339 2340-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.2340+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.23412341 23422342 ## Source controls23432343
@@ -2421,7 +2421,7 @@ sidebar_label: WaveDrom
24212421 24222422 WaveDrom supports source configuration, signal attributes, and a limited set of bundled skins.24232423 2424-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.2424+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.24252425 24262426 ## Source controls24272427
@@ -2505,7 +2505,7 @@ sidebar_label: WireViz
25052505 25062506 WireViz uses YAML properties for connector, cable, wire, and output styles.25072507 2508-Start with the [general presentation settings](/style/presentation). They control the canvas background, padding, and frame for every diagram.2508+Start with the [general presentation settings](/style/presentation). Choose a shared appearance, or use the renderer defaults with custom canvas controls.25092509 25102510 ## Source controls25112511
M diagramzip-render/test/catalog.test.ts +14 -5
M diagramzip-svg/index.js +298 -20
@@ -30,20 +30,127 @@ export const RAW_NORMALIZATION = Object.freeze({
3030 conformance: 'raw',3131 appearances: Object.freeze(['raw']),3232 })33-const THEMED_APPEARANCES = Object.freeze(APPEARANCES.filter(appearance => appearance !== 'raw'))34-const GRAPHVIZ_NORMALIZATION = Object.freeze({35- schema: SVG_SCHEMA,36- normalizer: NORMALIZER_BUILD,37- profile: 'graphviz-15-semantic-1',38- palette: PALETTE_BUILD,39- conformance: 'semantic',40- appearances: APPEARANCES,41- limitations: Object.freeze(['Authored non-neutral colors are preserved and may not adapt between palettes.']),33+const FRAMED_APPEARANCES = Object.freeze(['raw', 'auto-framed', 'light-framed', 'dark-framed'])34+35+function capability(profile, conformance, limitations, appearances = APPEARANCES) {36+ return Object.freeze({37+ schema: SVG_SCHEMA,38+ normalizer: NORMALIZER_BUILD,39+ profile,40+ palette: PALETTE_BUILD,41+ conformance,42+ appearances,43+ limitations: Object.freeze(limitations),44+ })45+}46+47+const GRAPHVIZ_NORMALIZATION = capability(48+ 'graphviz-15-semantic-1',49+ 'semantic',50+ ['Authored non-neutral colors are preserved and may not adapt between palettes.'],51+)52+const D2_NORMALIZATION = capability(53+ 'd2-0.7-semantic-1',54+ 'semantic',55+ ['Authored D2 colors are preserved; the pinned neutral and primary theme roles adapt.'],56+)57+const PLANTUML_NORMALIZATION = capability(58+ 'plantuml-2026-semantic-1',59+ 'semantic',60+ ['Pinned neutral PlantUML paint adapts; explicit themes and authored colors remain renderer-defined.'],61+)62+const SVGBOB_NORMALIZATION = capability(63+ 'svgbob-0.7-semantic-1',64+ 'semantic',65+ ['Line art, labels, and ordinary surfaces adapt; source-specific non-neutral paint is preserved.'],66+)67+const NEUTRAL_SVG_NORMALIZATION = capability(68+ 'neutral-svg-semantic-1',69+ 'adaptive',70+ ['Neutral paint adapts; authored non-neutral fills, shadows, and data colors are preserved.'],71+)72+const DETAILED_SVG_NORMALIZATION = capability(73+ 'structured-svg-semantic-1',74+ 'adaptive',75+ ['Stable structural roles adapt; authored categorical and semantic colors are preserved.'],76+)77+const PRESENTATION_NORMALIZATION = capability(78+ 'authored-svg-presentation-1',79+ 'presentation-only',80+ ['The outer canvas and frame adapt. Paint inside the authored SVG remains unchanged.'],81+ FRAMED_APPEARANCES,82+)83+84+const PROFILE_BY_ENGINE = Object.freeze({85+ graphviz: GRAPHVIZ_NORMALIZATION,86+ dbml: GRAPHVIZ_NORMALIZATION,87+ erd: GRAPHVIZ_NORMALIZATION,88+ wireviz: GRAPHVIZ_NORMALIZATION,89+ d2: D2_NORMALIZATION,90+ plantuml: PLANTUML_NORMALIZATION,91+ c4plantuml: PLANTUML_NORMALIZATION,92+ structurizr: PLANTUML_NORMALIZATION,93+ svgbob: SVGBOB_NORMALIZATION,94+ ditaa: SVGBOB_NORMALIZATION,95+ blockdiag: NEUTRAL_SVG_NORMALIZATION,96+ seqdiag: NEUTRAL_SVG_NORMALIZATION,97+ actdiag: NEUTRAL_SVG_NORMALIZATION,98+ nwdiag: NEUTRAL_SVG_NORMALIZATION,99+ packetdiag: NEUTRAL_SVG_NORMALIZATION,100+ rackdiag: NEUTRAL_SVG_NORMALIZATION,101+ bytefield: NEUTRAL_SVG_NORMALIZATION,102+ mermaid: NEUTRAL_SVG_NORMALIZATION,103+ bpmn: NEUTRAL_SVG_NORMALIZATION,104+ nomnoml: NEUTRAL_SVG_NORMALIZATION,105+ pikchr: NEUTRAL_SVG_NORMALIZATION,106+ symbolator: NEUTRAL_SVG_NORMALIZATION,107+ umlet: NEUTRAL_SVG_NORMALIZATION,108+ goat: DETAILED_SVG_NORMALIZATION,109+ vega: DETAILED_SVG_NORMALIZATION,110+ vegalite: DETAILED_SVG_NORMALIZATION,111+ wavedrom: DETAILED_SVG_NORMALIZATION,112+ diagramsnet: PRESENTATION_NORMALIZATION,113+ excalidraw: PRESENTATION_NORMALIZATION,114+ tikz: PRESENTATION_NORMALIZATION,115+})116+117+const VERSION_PATTERNS = Object.freeze({118+ graphviz: /graphviz@15\.1\.1/,119+ dbml: /(?:dbml@1\.0\.31|graphviz@15\.1\.1)/,120+ erd: /(?:erd@0\.2\.1\.0|graphviz@15\.1\.1)/,121+ wireviz: /(?:wireviz@)?0\.3\.2/,122+ d2: /d2@0\.7\.1/,123+ plantuml: /plantuml@1\.2026\.6/,124+ c4plantuml: /(?:c4plantuml@2\.7\.0|plantuml@1\.2026\.6)/,125+ structurizr: /structurizr@6\.2\.2/,126+ svgbob: /svgbob@0\.7\.6/,127+ ditaa: /(?:ditaa-ascii|svgbob@0\.7\.6)/,128+ blockdiag: /(?:blockdiag@)?3\.4\.2/,129+ seqdiag: /(?:seqdiag@)?3\.0\.0/,130+ actdiag: /(?:actdiag@)?3\.0\.0/,131+ nwdiag: /(?:nwdiag@)?3\.0\.0/,132+ packetdiag: /(?:packetdiag@)?3\.0\.0/,133+ rackdiag: /(?:rackdiag@)?3\.0\.0/,134+ bytefield: /bytefield-svg@1\.11\.0/,135+ mermaid: /mermaid@11\.17\.0/,136+ bpmn: /bpmn-js@18\.25\.1/,137+ nomnoml: /nomnoml@1\.7\.0/,138+ pikchr: /pikchr@85e65b9686/,139+ symbolator: /(?:symbolator@)?1\.2\.2/,140+ umlet: /diagramzip-umlet-svg@1/,141+ goat: /goat@0\.5\.1/,142+ vega: /vega@6\.3\.1/,143+ vegalite: /vega-lite@6\.4\.3/,144+ wavedrom: /wavedrom@3\.6\.2/,145+ diagramsnet: /diagrams\.net@29\.6\.1/,146+ excalidraw: /@excalidraw\/excalidraw@0\.18\.1/,147+ tikz: /@planktimerr\/tikzjax@1\.0\.63/,42148 })43149 44150 export function normalizationFor(engine, rendererVersion = '') {45- if (engine === 'graphviz' && rendererVersion.startsWith('graphviz@15.1.1')) return GRAPHVIZ_NORMALIZATION46- return RAW_NORMALIZATION151+ const profile = PROFILE_BY_ENGINE[engine]152+ const pattern = VERSION_PATTERNS[engine]153+ return profile && pattern?.test(rendererVersion) ? profile : RAW_NORMALIZATION47154 }48155 49156 export class SvgNormalizationError extends Error {
@@ -84,7 +191,7 @@ function safeAttribute(name, value) {
84191 }85192 86193 function escapeText(value) {87- return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')194+ return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')88195 }89196 90197 function escapeAttribute(value) {
@@ -93,7 +200,10 @@ function escapeAttribute(value) {
93200 94201 function serialize(node) {95202 if (node.type === 'text') return escapeText(node.value)96- const attributes = [...node.attributes].map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join('')203+ let attributes = ''204+ node.attributes.forEach((value, name) => {205+ attributes += ` ${name}="${escapeAttribute(value)}"`206+ })97207 return `<${node.name}${attributes}>${node.children.map(serialize).join('')}</${node.name}>`98208 }99209
@@ -101,6 +211,12 @@ function localName(element) {
101211 return element.name.toLowerCase().split(':').at(-1) ?? element.name.toLowerCase()102212 }103213 214+function qualifiedName(node) {215+ if (typeof node.name === 'string' && node.name !== '') return node.name216+ if (typeof node.prefix === 'string' && node.prefix !== '') return `${node.prefix}:${node.local}`217+ return node.local218+}219+104220 function numericDimension(value) {105221 if (value === undefined || !/^-?\d+(?:\.\d+)?(?:px)?$/.test(value.trim())) return null106222 const parsed = Number.parseFloat(value)
@@ -205,8 +321,156 @@ function applyGraphvizProfile(root) {
205321 visit(root)206322 }207323 208-function applyProfile(root, capability) {324+const BLACK_PAINT = new Set(['black', '#000', '#000000', '#111', '#111111', '#111827', '#181818', '#33322e', 'rgb(0,0,0)'])325+const WHITE_PAINT = new Set(['white', '#fff', '#ffffff', '#e2e2f0', '#e8e8e8', '#eee8d5', '#f1f1f1', '#f7f8fe', 'rgb(255,255,255)'])326+const MUTED_PAINT = new Set(['gray', 'grey', '#666', '#666666', '#7f7f7f', '#888', '#888888', '#aaa', '#aaaaaa'])327+const SHAPE_ELEMENTS = new Set(['circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect'])328+const TEXT_ELEMENTS = new Set(['text', 'tspan', 'div', 'span', 'p', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'li'])329+330+function normalizedPaint(value) {331+ return (value ?? '').replaceAll(' ', '').toLowerCase()332+}333+334+function ownPaint(element, property) {335+ return normalizedPaint(element.attributes.get(property) ?? styleValue(element, property) ?? '')336+}337+338+function classNames(element) {339+ return new Set((element.attributes.get('class') ?? '').split(/\s+/).filter(Boolean))340+}341+342+function filteredShadow(element) {343+ const style = element.attributes.get('style') ?? ''344+ return /(?:^|;)\s*filter\s*:/i.test(style) && /(?:^|;)\s*opacity\s*:/i.test(style)345+}346+347+function applyNeutralSvgProfile(root, engine, extraSurfacePaint = []) {348+ const bounds = presentationBounds(root)349+ const surfaces = new Set([...WHITE_PAINT, ...extraSurfacePaint.map(normalizedPaint)])350+ const visit = (node, inheritedFill = '', inheritedStroke = '') => {351+ if (node.type === 'text') return352+ const name = localName(node)353+ const fill = ownPaint(node, 'fill') || inheritedFill354+ const stroke = ownPaint(node, 'stroke') || inheritedStroke355+ const backdrop = rendererBackdrop(node, bounds, engine)356+ 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')359+ else if (MUTED_PAINT.has(fill)) node.attributes.set('data-dz-fill', 'ink-muted')360+ } else if (SHAPE_ELEMENTS.has(name) && !backdrop) {361+ if (surfaces.has(fill)) node.attributes.set('data-dz-fill', 'surface-1')362+ else if (BLACK_PAINT.has(fill) && !filteredShadow(node)) node.attributes.set('data-dz-fill', 'line')363+ if (BLACK_PAINT.has(stroke)) node.attributes.set('data-dz-stroke', 'line')364+ else if (MUTED_PAINT.has(stroke)) node.attributes.set('data-dz-stroke', 'line-muted')365+ }366+ for (const child of node.children) visit(child, fill, stroke)367+ }368+ visit(root)369+}370+371+function applyD2Profile(root) {372+ applyNeutralSvgProfile(root, 'd2', ['#f7f8fe'])373+ const visit = node => {374+ if (node.type === 'text') return375+ const classes = classNames(node)376+ const name = localName(node)377+ if (classes.has('fill-N1') && TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')378+ if (classes.has('fill-N2') && TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink-muted')379+ if (classes.has('fill-B6') && SHAPE_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'surface-1')380+ if (classes.has('fill-B1')) node.attributes.set('data-dz-fill', 'accent-1')381+ if (classes.has('stroke-B1') || classes.has('connection')) node.attributes.set('data-dz-stroke', 'accent-1')382+ for (const child of node.children) visit(child)383+ }384+ visit(root)385+}386+387+function applySvgbobProfile(root) {388+ applyNeutralSvgProfile(root, 'svgbob')389+ const visit = node => {390+ if (node.type === 'text') return391+ const classes = classNames(node)392+ const name = localName(node)393+ if (classes.has('backdrop')) node.attributes.set('data-dz-role', 'canvas')394+ if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')395+ if (classes.has('solid')) node.attributes.set('data-dz-stroke', 'line')396+ 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)399+ }400+ visit(root)401+}402+403+function applyPlantumlProfile(root) {404+ applyNeutralSvgProfile(root, 'plantuml', ['#e2e2f0', '#f1f1f1'])405+}406+407+function applyGoatProfile(root) {408+ applyNeutralSvgProfile(root, 'goat')409+ const visit = node => {410+ if (node.type === 'text') return411+ const classes = classNames(node)412+ const name = localName(node)413+ if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')414+ 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')417+ for (const child of node.children) visit(child)418+ }419+ visit(root)420+}421+422+function applyVegaProfile(root) {423+ applyNeutralSvgProfile(root, 'vega')424+ const visit = (node, context = '') => {425+ if (node.type === 'text') return426+ const classes = classNames(node)427+ const nextContext = [...classes].find(value => value.startsWith('role-')) ?? context428+ const name = localName(node)429+ if (classes.has('background') && rendererBackdrop(node, presentationBounds(root), 'vega')) node.attributes.set('data-dz-role', 'canvas')430+ if (nextContext === 'role-axis-label' || nextContext === 'role-legend-label' || nextContext === 'role-title') {431+ if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')432+ }433+ if (nextContext === 'role-axis-tick' || nextContext === 'role-axis-domain' || nextContext === 'role-grid') {434+ if (SHAPE_ELEMENTS.has(name)) node.attributes.set('data-dz-stroke', 'line-muted')435+ }436+ for (const child of node.children) visit(child, nextContext)437+ }438+ visit(root)439+}440+441+function applyWavedromProfile(root) {442+ const accentClasses = new Map([443+ ['s8', 'accent-3'], ['s9', 'accent-3'], ['s10', 'accent-1'], ['s11', 'accent-1'],444+ ['s12', 'accent-2'], ['s13', 'accent-2'], ['s14', 'accent-3'], ['s15', 'accent-1'],445+ ])446+ const visit = node => {447+ if (node.type === 'text') return448+ const classes = classNames(node)449+ 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')452+ }453+ if ([...classes].some(value => ['s1', 's2', 's3', 's4'].includes(value))) node.attributes.set('data-dz-stroke', 'line')454+ if (classes.has('s5') || classes.has('s7')) node.attributes.set('data-dz-fill', 'surface-1')455+ if (classes.has('s6')) node.attributes.set('data-dz-fill', 'line')456+ for (const [className, role] of accentClasses) if (classes.has(className)) node.attributes.set('data-dz-fill', role)457+ if (classes.has('s16')) node.attributes.set('data-dz-stroke', 'accent-1')458+ for (const child of node.children) visit(child)459+ }460+ visit(root)461+}462+463+function applyProfile(root, capability, engine) {209464 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') {470+ if (engine === 'goat') applyGoatProfile(root)471+ else if (engine === 'vega' || engine === 'vegalite') applyVegaProfile(root)472+ else if (engine === 'wavedrom') applyWavedromProfile(root)473+ }210474 }211475 212476 function applyRootBackgroundStyle(root, background) {
@@ -283,9 +547,11 @@ function parseSafeSvg(source) {
283547 skippedDepth++284548 return285549 }286- const next = element(tag.name)287- for (const attribute of Object.values(tag.attributes)) {288- if (safeAttribute(attribute.name, attribute.value)) next.attributes.set(attribute.name, attribute.value)550+ const next = element(qualifiedName(tag))551+ for (const [attributeKey, attribute] of Object.entries(tag.attributes)) {552+ const name = typeof attribute === 'string' ? attributeKey : qualifiedName(attribute) ?? attributeKey553+ const value = typeof attribute === 'string' ? attribute : attribute.value554+ if (safeAttribute(name, value)) next.attributes.set(name, value)289555 }290556 const parent = stack.at(-1)291557 if (parent) parent.children.push(next)
@@ -330,15 +596,16 @@ export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, r
330596 export function canonicalizeSvg(source, metadata, engine, rendererVersion = '') {331597 const root = parseSafeSvg(source)332598 const capability = normalizationFor(engine, rendererVersion)333- applyProfile(root, capability)599+ applyProfile(root, capability, engine)334600 root.attributes.set('data-dz-schema', SVG_SCHEMA)335601 root.attributes.set('data-dz-normalizer', NORMALIZER_BUILD)336602 root.attributes.set('data-dz-profile', capability.profile)337- root.attributes.set('data-dz-palette', capability.palette)603+ root.attributes.set('data-dz-palette', 'renderer')338604 root.attributes.set('data-dz-engine', engine)339605 root.attributes.set('data-dz-appearance', 'raw')340606 root.attributes.set('data-dz-conformance', capability.conformance)341- if (capability !== RAW_NORMALIZATION) root.attributes.set('data-dz-appearances', THEMED_APPEARANCES.join(' '))607+ const appearances = capability.appearances.filter(appearance => appearance !== 'raw')608+ if (appearances.length > 0) root.attributes.set('data-dz-appearances', appearances.join(' '))342609 const bounds = presentationBounds(root)343610 if (bounds !== null) root.attributes.set('data-dz-bounds', bounds.join(' '))344611 addMetadata(root, metadata)
@@ -436,3 +703,14 @@ export function materializeSvg(canonical, appearance) {
436703 }437704 return serialize(root)438705 }706+707+export function supportedAppearances(canonical) {708+ const root = parseSafeSvg(canonical)709+ if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {710+ throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')711+ }712+ return Object.freeze([713+ 'raw',714+ ...(root.attributes.get('data-dz-appearances') ?? '').split(/\s+/).filter(appearance => APPEARANCES.includes(appearance)),715+ ])716+}
M diagramzip/PERSISTENCE.md +6 -1
M diagramzip/src/client-svg.js +2 -0
M diagramzip/src/main.js +43 -2
@@ -162,6 +162,19 @@ document.querySelector('#app').innerHTML = `
162162 </label>163163 <fieldset class="presentation-fields">164164 <legend>Presentation</legend>165+ <label class="appearance-field">166+ <span>Appearance</span>167+ <select id="diagram-appearance">168+ <option value="raw">Renderer default</option>169+ <option value="auto-transparent">Match device · transparent</option>170+ <option value="light-transparent">Light · transparent</option>171+ <option value="dark-transparent">Dark · transparent</option>172+ <option value="auto-framed">Match device · framed</option>173+ <option value="light-framed">Light · framed</option>174+ <option value="dark-framed">Dark · framed</option>175+ </select>176+ <small id="appearance-note">Renderer default keeps the renderer's paint and enables the canvas controls below.</small>177+ </label>165178 <label>166179 <span>Canvas</span>167180 <select id="diagram-background">
@@ -252,6 +265,7 @@ document.querySelector('#app').innerHTML = `
252265 const typePicker = document.querySelector('#diagram-type')253266 const titleInput = document.querySelector('#diagram-title')254267 const descriptionInput = document.querySelector('#diagram-description')268+const appearanceInput = document.querySelector('#diagram-appearance')255269 const backgroundInput = document.querySelector('#diagram-background')256270 const paddingInput = document.querySelector('#diagram-padding')257271 const frameInput = document.querySelector('#diagram-frame')
@@ -265,9 +279,11 @@ const initialPresentation = normalizePresentation(initialState.presentation)
265279 typePicker.value = initialState.type266280 titleInput.value = initialMetadata.title267281 descriptionInput.value = initialMetadata.description282+appearanceInput.value = initialPresentation.appearance268283 backgroundInput.value = initialPresentation.background269284 paddingInput.value = String(initialPresentation.padding)270285 frameInput.checked = initialPresentation.frame286+syncPresentationControls()271287 updateDocumentMetadata()272288 activeType = initialState.type273289 typeDrafts.set(initialState.type, initialState)
@@ -287,6 +303,7 @@ const preview = new PreviewController({
287303 minimap: document.querySelector('#minimap'),288304 minimapImage: document.querySelector('#minimap-image'),289305 minimapViewport: document.querySelector('#minimap-viewport'),306+ onAppearances: updateAppearanceOptions,290307 })291308 292309 typePicker.addEventListener('change', () => {
@@ -299,8 +316,9 @@ document.querySelector('#details').addEventListener('click', () => {
299316 document.querySelector('#details-dialog').showModal()300317 descriptionInput.focus()301318 })302-for (const input of [titleInput, descriptionInput, backgroundInput, paddingInput, frameInput]) {319+for (const input of [titleInput, descriptionInput, appearanceInput, backgroundInput, paddingInput, frameInput]) {303320 input.addEventListener('input', () => {321+ syncPresentationControls()304322 updateDocumentMetadata()305323 clearTimeout(commitDetails.timeout)306324 commitDetails.timeout = setTimeout(commitDetails, 220)
@@ -372,6 +390,7 @@ function currentState() {
372390 options: {},373391 meta: normalizeMetadata({ title: titleInput.value, description: descriptionInput.value }),374392 presentation: normalizePresentation({393+ appearance: appearanceInput.value,375394 background: backgroundInput.value,376395 padding,377396 frame: frameInput.checked,
@@ -379,6 +398,25 @@ function currentState() {
379398 }380399 }381400 401+function syncPresentationControls() {402+ const usesRendererPresentation = appearanceInput.value === 'raw'403+ backgroundInput.disabled = !usesRendererPresentation404+ paddingInput.disabled = !usesRendererPresentation405+ frameInput.disabled = !usesRendererPresentation406+ document.querySelector('#appearance-note').textContent = usesRendererPresentation407+ ? "Renderer default keeps the renderer's paint and enables the canvas controls below."408+ : 'This appearance uses the shared Diagram.zip palette. Canvas, padding, and frame come from that appearance.'409+}410+411+function updateAppearanceOptions(appearances, appliedAppearance) {412+ const supported = new Set(appearances)413+ for (const option of appearanceInput.options) option.disabled = !supported.has(option.value)414+ if (appearanceInput.value === appliedAppearance) return415+ appearanceInput.value = appliedAppearance416+ syncPresentationControls()417+ queueMicrotask(commitDetails)418+}419+382420 function scheduleUpdate(delay = 1200) {383421 clearTimeout(renderTimer)384422 renderTimer = setTimeout(commitState, delay)
@@ -448,9 +486,11 @@ function applyState(state, commit = true) {
448486 typePicker.value = state.type449487 titleInput.value = meta.title450488 descriptionInput.value = meta.description489+ appearanceInput.value = presentation.appearance451490 backgroundInput.value = presentation.background452491 paddingInput.value = String(presentation.padding)453492 frameInput.checked = presentation.frame493+ syncPresentationControls()454494 updateDocumentMetadata()455495 sourceEditor.setDocument({ source: state.source, diagramType: state.type })456496 if (commit) commitState()
@@ -471,7 +511,8 @@ function populateShareDialog() {
471511 ? editAliasUrl(location.origin, remote.aliasId, remote.writeCapability)472512 : ''473513 const savedState = savedLocked ? null : remote.savedState474- const svgLink = savedState ? stableRenderUrl(location.origin, remote.aliasId, 'svg') : ''514+ const savedAppearance = savedState ? normalizePresentation(savedState.presentation).appearance : 'raw'515+ const svgLink = savedState ? stableRenderUrl(location.origin, remote.aliasId, 'svg', savedAppearance) : ''475516 const imageLink = document.querySelector('#image-link')476517 const markdownLink = document.querySelector('#markdown-link')477518 const markdownTitle = savedState?.meta.title.trim().replace(/[\[\]\r\n]/g, ' ') || 'Diagram'
M diagramzip/src/preview.js +16 -4
@@ -1,5 +1,5 @@
11 import { clientAdapterFor } from './client-renderers.js'2-import { canonicalizeSvg, materializePresentation } from './client-svg.js'2+import { canonicalizeSvg, materializePresentation, materializeSvg, supportedAppearances } from './client-svg.js'33 import { httpRendererUnitFor } from './renderer-units.js'44 55 const STATUS_LABELS = {
@@ -14,13 +14,14 @@ function clamp(value, minimum, maximum) {
1414 }1515 1616 export class PreviewController {17- constructor({ stage, image, status, minimap, minimapImage, minimapViewport }) {17+ constructor({ stage, image, status, minimap, minimapImage, minimapViewport, onAppearances = () => {} }) {1818 this.stage = stage1919 this.image = image2020 this.status = status2121 this.minimap = minimap2222 this.minimapImage = minimapImage2323 this.minimapViewport = minimapViewport24+ this.onAppearances = onAppearances2425 this.scale = 12526 this.x = 02627 this.y = 0
@@ -109,7 +110,14 @@ export class PreviewController {
109110 }110111 if (requestNumber !== this.requestNumber) return111112 const canonical = this.normalizedSvgBlob(rendered.body)112- const displayed = this.normalizedSvgBlob(materializePresentation(rendered.body, presentation))113+ const appearances = supportedAppearances(rendered.body)114+ const requestedAppearance = presentation.appearance ?? 'raw'115+ const appearance = appearances.includes(requestedAppearance) ? requestedAppearance : 'raw'116+ this.onAppearances(appearances, appearance)117+ const displayedSvg = appearance === 'raw'118+ ? materializePresentation(rendered.body, presentation)119+ : materializeSvg(rendered.body, appearance)120+ const displayed = this.normalizedSvgBlob(displayedSvg)113121 this.latestRenderKey = renderKey114122 this.latestSvgBlob = canonical.blob115123 this.latestRendererIdentity = rendered.identity
@@ -140,7 +148,11 @@ export class PreviewController {
140148 format: 'svg',141149 options,142150 metadata: meta,143- presentation,151+ presentation: {152+ background: presentation.background ?? '',153+ padding: presentation.padding ?? 0,154+ frame: presentation.frame ?? false,155+ },144156 }, signal)145157 return this.renderResponse(response, type)146158 }
M diagramzip/src/state.js +5 -3
@@ -1,4 +1,5 @@
11 import { deflate, inflate } from 'pako'2+import { APPEARANCES } from './client-svg.js'23 34 // Leave room below Cloudflare's 16 KB request URL ceiling.45 export const MAX_IMAGE_URL_LENGTH = 15_000
@@ -73,13 +74,14 @@ export function normalizePresentation(presentation = {}) {
7374 const background = presentation.background ?? ''7475 const padding = presentation.padding ?? 07576 const frame = presentation.frame ?? false77+ const appearance = presentation.appearance ?? 'raw'7678 if (typeof background !== 'string' || (background && !/^#[0-9a-f]{6}$/i.test(background))) {7779 throw new Error('Invalid diagram presentation.')7880 }79- if (!Number.isInteger(padding) || padding < 0 || padding > 256 || typeof frame !== 'boolean') {81+ if (!Number.isInteger(padding) || padding < 0 || padding > 256 || typeof frame !== 'boolean' || !APPEARANCES.includes(appearance)) {8082 throw new Error('Invalid diagram presentation.')8183 }82- return { background, padding, frame }84+ return { background, padding, frame, appearance }8385 }8486 8587 function hasMetadata(meta) {
@@ -87,7 +89,7 @@ function hasMetadata(meta) {
8789 }8890 8991 function hasPresentation(presentation) {90- return Boolean(presentation.background || presentation.padding || presentation.frame)92+ return Boolean(presentation.background || presentation.padding || presentation.frame || presentation.appearance !== 'raw')9193 }9294 9395 export function imageUrl(origin, { type, source, options = {}, meta = {}, presentation = {} }) {
M diagramzip/src/style.css +3 -0
M diagramzip/test/encryption.test.js +1 -1
M diagramzip/test/state.test.js +14 -2
M diagramzip/test/svg-contract.test.js +65 -0
@@ -7,6 +7,8 @@ import {
77 canonicalizeSvg,88 materializePresentation,99 materializeSvg,10+ supportedAppearances,11+ normalizationFor,1012 sanitizeAndDecorateSvg,1113 } from '../../diagramzip-svg/index.js'1214
@@ -27,6 +29,11 @@ test('freezes namespaced appearance and raw capability contracts', () => {
2729 assert.deepEqual(RAW_NORMALIZATION.appearances, ['raw'])2830 })2931 32+test('falls back to safe raw for an unknown renderer build', () => {33+ assert.equal(normalizationFor('d2', 'd2@future').profile, 'safe-raw-1')34+ assert.equal(normalizationFor('unknown', 'unknown@1').profile, 'safe-raw-1')35+})36+3037 test('produces deterministic, idempotent raw canonical SVG', () => {3138 const first = sanitizeAndDecorateSvg(source, metadata, presentation, 'pikchr')3239 const repeated = sanitizeAndDecorateSvg(source, metadata, presentation, 'pikchr')
@@ -77,6 +84,18 @@ test('materializes explicit and automatic palettes without changing diagram geom
7784 assert.match(automatic, /@media\(prefers-color-scheme:dark\)/)7885 })7986 87+test('reports the appearances declared by the canonical profile', () => {88+ assert.deepEqual(supportedAppearances(semanticCanonical), [89+ 'raw',90+ 'auto-transparent',91+ 'light-transparent',92+ 'dark-transparent',93+ 'auto-framed',94+ 'light-framed',95+ 'dark-framed',96+ ])97+})98+8099 test('materializes a bounded frame and remains idempotent', () => {81100 const framed = materializeSvg(semanticCanonical, 'dark-framed')82101 const repeated = materializeSvg(framed, 'dark-framed')
@@ -110,3 +129,49 @@ test('applies the pinned GraphViz semantic profile without recoloring authored p
110129 assert.match(dark, /fill="red" stroke="red"/)111130 assert.doesNotMatch(dark, /fill="red" stroke="red"[^>]*data-dz-/)112131 })132+133+test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {134+ const fixtures = [135+ {136+ engine: 'd2', version: 'd2@0.7.1/dagre-wasm-1', profile: 'd2-0.7-semantic-1',137+ 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>',138+ roles: ['data-dz-role="canvas"', 'data-dz-fill="surface-1"', 'data-dz-stroke="accent-1"', 'data-dz-fill="ink"'],139+ },140+ {141+ engine: 'plantuml', version: 'plantuml@1.2026.6/edge-wasm-1', profile: 'plantuml-2026-semantic-1',142+ 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>',143+ roles: ['data-dz-fill="surface-1"', 'data-dz-stroke="line"', 'data-dz-fill="ink"'],144+ },145+ {146+ engine: 'svgbob', version: 'svgbob@0.7.6/edge-wasm-1', profile: 'svgbob-0.7-semantic-1',147+ 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>',148+ roles: ['data-dz-role="canvas"', 'data-dz-stroke="line"', 'data-dz-fill="surface-1"', 'data-dz-fill="ink"'],149+ },150+ {151+ engine: 'blockdiag', version: 'blockdiag@3.4.2/python-worker-1', profile: 'neutral-svg-semantic-1',152+ 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>',153+ roles: ['data-dz-fill="surface-1"', 'data-dz-stroke="line"', 'data-dz-fill="ink"'],154+ },155+ ]156+157+ for (const fixture of fixtures) {158+ const canonical = canonicalizeSvg(fixture.source, metadata, fixture.engine, fixture.version)159+ assert.match(canonical, new RegExp(`data-dz-profile="${fixture.profile}"`))160+ for (const role of fixture.roles) assert.ok(canonical.includes(role), `${fixture.engine} missing ${role}`)161+ assert.match(materializeSvg(canonical, 'dark-transparent'), /data-dz-appearance="dark-transparent"/)162+ }163+})164+165+test('keeps authored SVG paint intact while offering an outer frame', () => {166+ const source = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="hotpink"></rect></svg>'167+ const canonical = canonicalizeSvg(source, metadata, 'tikz', '@planktimerr/tikzjax@1.0.63/client-unit-1')168+ const framed = materializeSvg(canonical, 'auto-framed')169+170+ assert.match(canonical, /data-dz-conformance="presentation-only"/)171+ assert.match(canonical, /data-dz-appearances="auto-framed light-framed dark-framed"/)172+ assert.match(framed, /fill="hotpink"/)173+ assert.throws(174+ () => materializeSvg(canonical, 'dark-transparent'),175+ error => error instanceof SvgNormalizationError && error.code === 'unsupported_appearance',176+ )177+})
M docs/svg-normalization.md +21 -4
@@ -183,10 +183,27 @@ to browser and Worker renderers. Every catalog entry reports its active
183183 normalization capability. An unrecognized renderer contract remains on184184 `safe-raw-1` and supports only `raw`.185185 186-GraphViz `15.1.1` currently uses `graphviz-15-semantic-1`. The profile relies on187-GraphViz's stable `graph`, `node`, `edge`, and `cluster` groups. It adapts neutral188-surfaces, text, connectors, and arrowheads. Explicit non-neutral authored colors189-remain unchanged and are reported as a profile limitation.186+The current catalog uses these profile families:187+188+| Profile | Conformance | Engines | Supported appearances |189+| --- | --- | --- | --- |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 |197+198+Semantic profiles rely on pinned renderer structures and stable class or group199+meaning. Adaptive profiles recognize neutral paint and selected structural200+roles. They preserve authored data colors and other non-neutral paint.201+Presentation-only profiles do not claim that the authored drawing adapts. They202+add only the shared outer canvas and frame.203+204+Profile selection also checks the pinned renderer version. An engine upgrade205+does not inherit a profile until its output passes the catalog audit. Unknown206+builds fall back to `safe-raw-1`.190207 191208 ## Semantic visual roles192209