clone

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

add SVG appearance materializer

Dami committed 04a59f1
commit
04a59f11928fa10d357a882b812041463b7a1fad
parent
d3b87c1fcf1fc14be5af21f55573624664d7b9ce
author
Dami <dami.etoile@gmail.com>
9 files changed, +242 -12
Mdiagramzip-render/src/catalog.ts4+-
Mdiagramzip-render/src/svg.ts3+-
Mdiagramzip-render/src/unit.ts2+-
Mdiagramzip-render/test/catalog.test.ts7+-
Mdiagramzip-svg/index.d.ts8++
Mdiagramzip-svg/index.js154+++++++++++++++++++++++++++++-
Mdiagramzip/src/preview.js2+-
Mdiagramzip/test/svg-contract.test.js53++++++++++
Mdocs/svg-normalization.md21++++-
M diagramzip-render/src/catalog.ts +2 -2
M diagramzip-render/src/svg.ts +2 -1
M diagramzip-render/src/unit.ts +1 -1
M diagramzip-render/test/catalog.test.ts +6 -1
M diagramzip-svg/index.d.ts +8 -0
M diagramzip-svg/index.js +150 -4
@@ -10,6 +10,8 @@ const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\
1010 1111 export const SVG_SCHEMA = '1'1212 export const NORMALIZER_BUILD = 'svg-normalizer-1'13+export const MATERIALIZER_BUILD = 'svg-materializer-1'14+export const PALETTE_BUILD = 'diagramzip-palette-1'1315 export const RAW_PROFILE = 'safe-raw-1'1416 export const APPEARANCES = Object.freeze([1517 'raw',
@@ -28,6 +30,21 @@ export const RAW_NORMALIZATION = Object.freeze({
2830 conformance: 'raw',2931 appearances: Object.freeze(['raw']),3032 })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.']),42+})43+44+export function normalizationFor(engine, rendererVersion = '') {45+ if (engine === 'graphviz' && rendererVersion.startsWith('graphviz@15.1.1')) return GRAPHVIZ_NORMALIZATION46+ return RAW_NORMALIZATION47+}3148 3249 export class SvgNormalizationError extends Error {3350 constructor(status, code, message) {
@@ -156,6 +173,42 @@ function removeRendererBackdrops(element, inheritedBounds, engine) {
156173 })157174 }158175 176+function neutralPaint(element, property, accepted) {177+ const value = (element.attributes.get(property) ?? styleValue(element, property) ?? '').replaceAll(' ', '').toLowerCase()178+ return accepted.has(value)179+}180+181+function applyGraphvizProfile(root) {182+ const visit = (node, context = '') => {183+ if (node.type === 'text') return184+ const classes = new Set((node.attributes.get('class') ?? '').toLowerCase().split(/\s+/).filter(Boolean))185+ const nextContext = classes.has('node') ? 'node' : classes.has('edge') ? 'edge' : classes.has('cluster') ? 'cluster' : context186+ const name = localName(node)187+ if (rendererBackdrop(node, presentationBounds(root), 'graphviz')) node.attributes.set('data-dz-role', 'canvas')188+ if (name === 'text' && neutralPaint(node, 'fill', new Set(['', 'black', '#000', '#000000', 'rgb(0,0,0)']))) {189+ node.attributes.set('data-dz-fill', nextContext === 'edge' ? 'ink-muted' : 'ink')190+ }191+ if (nextContext === 'node' && ['ellipse', 'polygon', 'path', 'rect'].includes(name)) {192+ if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-1')193+ if (neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line')194+ }195+ if (nextContext === 'cluster' && ['polygon', 'path', 'rect'].includes(name)) {196+ if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-2')197+ if (neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line-muted')198+ }199+ if (nextContext === 'edge') {200+ if (['path', 'polyline', 'line'].includes(name) && neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line')201+ if (['polygon', 'path'].includes(name) && neutralPaint(node, 'fill', new Set(['black', '#000', '#000000']))) node.attributes.set('data-dz-fill', 'line')202+ }203+ for (const child of node.children) visit(child, nextContext)204+ }205+ visit(root)206+}207+208+function applyProfile(root, capability) {209+ if (capability.profile === 'graphviz-15-semantic-1') applyGraphvizProfile(root)210+}211+159212 function applyRootBackgroundStyle(root, background) {160213 const declarations = (root.attributes.get('style') ?? '')161214 .split(';')
@@ -207,7 +260,7 @@ function decorate(root, metadata, presentation, engine) {
207260 if (frame) root.children.push(frame)208261 }209262 210-export function sanitizeAndDecorateSvg(source, metadata, presentation, engine) {263+function parseSafeSvg(source) {211264 if (source.length > MAX_SVG_LENGTH) throw new SvgNormalizationError(413, 'render_too_large', 'Rendered SVG is too large.')212265 let root = null213266 const stack = []
@@ -262,13 +315,106 @@ export function sanitizeAndDecorateSvg(source, metadata, presentation, engine) {
262315 if (parseError !== null || root === null || localName(root) !== 'svg') {263316 throw new SvgNormalizationError(422, 'invalid_svg', 'Renderer returned invalid SVG.')264317 }318+ return root319+}320+321+export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, rendererVersion = '') {322+ const root = parseSafeSvg(source)323+ const capability = normalizationFor(engine, rendererVersion)324+ applyProfile(root, capability)265325 root.attributes.set('data-dz-schema', SVG_SCHEMA)266326 root.attributes.set('data-dz-normalizer', NORMALIZER_BUILD)267- root.attributes.set('data-dz-profile', RAW_PROFILE)268- root.attributes.set('data-dz-palette', 'renderer')327+ root.attributes.set('data-dz-profile', capability.profile)328+ root.attributes.set('data-dz-palette', capability.palette)269329 root.attributes.set('data-dz-engine', engine)270330 root.attributes.set('data-dz-appearance', 'raw')271- root.attributes.set('data-dz-conformance', 'raw')331+ root.attributes.set('data-dz-conformance', capability.conformance)332+ if (capability !== RAW_NORMALIZATION) root.attributes.set('data-dz-appearances', THEMED_APPEARANCES.join(' '))333+ const bounds = presentationBounds(root)334+ if (bounds !== null) root.attributes.set('data-dz-bounds', bounds.join(' '))272335 decorate(root, metadata, presentation, engine)273336 return serialize(root)274337 }338+339+const LIGHT_PALETTE = Object.freeze({340+ canvas: '#f8fafc', surface1: '#ffffff', surface2: '#f1f5f9', surface3: '#e2e8f0',341+ ink: '#0f172a', inkMuted: '#475569', line: '#334155', lineMuted: '#94a3b8',342+ accent1: '#2563eb', accent2: '#7c3aed', accent3: '#c2410c', onAccent: '#ffffff', frame: '#cbd5e1',343+})344+const DARK_PALETTE = Object.freeze({345+ canvas: '#0f172a', surface1: '#1e293b', surface2: '#273449', surface3: '#334155',346+ ink: '#f8fafc', inkMuted: '#cbd5e1', line: '#e2e8f0', lineMuted: '#94a3b8',347+ accent1: '#60a5fa', accent2: '#c4b5fd', accent3: '#fb923c', onAccent: '#0f172a', frame: '#475569',348+})349+350+function paletteVariables(palette) {351+ return `--dz-canvas:${palette.canvas};--dz-surface-1:${palette.surface1};--dz-surface-2:${palette.surface2};--dz-surface-3:${palette.surface3};--dz-ink:${palette.ink};--dz-ink-muted:${palette.inkMuted};--dz-line:${palette.line};--dz-line-muted:${palette.lineMuted};--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-on-accent:${palette.onAccent};--dz-frame:${palette.frame};`352+}353+354+function materializerCss(appearance) {355+ const scheme = appearance.startsWith('dark-') ? DARK_PALETTE : LIGHT_PALETTE356+ const automatic = appearance.startsWith('auto-')357+ const variables = automatic358+ ? `:root{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){:root{${paletteVariables(DARK_PALETTE)}}}`359+ : `:root{${paletteVariables(scheme)}}`360+ return `${variables}[data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}[data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}[data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}[data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}[data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}[data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}[data-dz-fill="line"]{fill:var(--dz-line)!important}[data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}[data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}[data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}[data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}[data-dz-stroke="line"]{stroke:var(--dz-line)!important}[data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}[data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}[data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}[data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}`361+}362+363+function canonicalBounds(root) {364+ const value = root.attributes.get('data-dz-bounds')?.trim().split(/\s+/).map(Number)365+ if (value?.length === 4 && value.every(Number.isFinite) && value[2] > 0 && value[3] > 0) return value366+ return presentationBounds(root)367+}368+369+function removeMaterializerOutput(root) {370+ root.children = root.children.filter(child => child.type === 'text' || child.attributes.get('data-dz-owned') !== 'materializer')371+}372+373+export function materializeSvg(canonical, appearance) {374+ if (!APPEARANCES.includes(appearance)) {375+ throw new SvgNormalizationError(400, 'invalid_appearance', `Unknown SVG appearance: ${appearance}.`)376+ }377+ const root = parseSafeSvg(canonical)378+ if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {379+ throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')380+ }381+ removeMaterializerOutput(root)382+ if (appearance === 'raw') {383+ root.attributes.set('data-dz-appearance', 'raw')384+ root.attributes.set('data-dz-palette', 'renderer')385+ root.attributes.delete('data-dz-materializer')386+ return serialize(root)387+ }388+ const supported = new Set((root.attributes.get('data-dz-appearances') ?? '').split(/\s+/).filter(Boolean))389+ if (!supported.has(appearance)) {390+ throw new SvgNormalizationError(422, 'unsupported_appearance', `The active SVG normalization profile does not support ${appearance}.`)391+ }392+ const bounds = canonicalBounds(root)393+ if (bounds === null) throw new SvgNormalizationError(422, 'missing_dimensions', 'Canonical SVG has no usable bounds for appearance materialization.')394+ root.attributes.set('data-dz-appearance', appearance)395+ root.attributes.set('data-dz-palette', PALETTE_BUILD)396+ root.attributes.set('data-dz-materializer', MATERIALIZER_BUILD)397+ root.children.unshift(element('style', [['data-dz-owned', 'materializer']], [{ type: 'text', value: materializerCss(appearance) }]))398+ if (appearance.endsWith('-framed')) {399+ const [x, y, width, height] = bounds400+ const padding = 24401+ const next = [x - padding, y - padding, width + padding * 2, height + padding * 2]402+ root.attributes.set('viewBox', next.join(' '))403+ if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))404+ if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))405+ root.children.splice(1, 0, element('rect', [406+ ['data-dz-owned', 'materializer'], ['data-dz-role', 'canvas'], ['x', String(next[0])], ['y', String(next[1])],407+ ['width', String(next[2])], ['height', String(next[3])], ['fill', 'var(--dz-canvas)'],408+ ]))409+ root.children.push(element('rect', [410+ ['data-dz-owned', 'materializer'], ['data-dz-role', 'frame'], ['x', String(next[0] + 0.5)], ['y', String(next[1] + 0.5)],411+ ['width', String(next[2] - 1)], ['height', String(next[3] - 1)], ['fill', 'none'], ['stroke', 'var(--dz-frame)'],412+ ['stroke-width', '1'], ['vector-effect', 'non-scaling-stroke'],413+ ]))414+ } else {415+ root.attributes.set('viewBox', bounds.join(' '))416+ if (root.attributes.has('width')) root.attributes.set('width', String(bounds[2]))417+ if (root.attributes.has('height')) root.attributes.set('height', String(bounds[3]))418+ }419+ return serialize(root)420+}
M diagramzip/src/preview.js +1 -1
M diagramzip/test/svg-contract.test.js +53 -0
@@ -4,6 +4,7 @@ import {
44 APPEARANCES,55 RAW_NORMALIZATION,66 SvgNormalizationError,7+ materializeSvg,78 sanitizeAndDecorateSvg,89 } from '../../diagramzip-svg/index.js'910
@@ -44,3 +45,55 @@ test('exposes structured normalization failures to both callers', () => {
4445 error => error instanceof SvgNormalizationError && error.code === 'unsafe_svg' && error.status === 422,4546 )4647 })48+49+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>'50+51+test('materializes explicit and automatic palettes without changing diagram geometry', () => {52+ const light = materializeSvg(semanticCanonical, 'light-transparent')53+ const dark = materializeSvg(semanticCanonical, 'dark-transparent')54+ const automatic = materializeSvg(semanticCanonical, 'auto-transparent')55+56+ for (const output of [light, dark, automatic]) {57+ assert.match(output, /data-dz-materializer="svg-materializer-1"/)58+ assert.match(output, /data-dz-palette="diagramzip-palette-1"/)59+ assert.match(output, /<rect x="1" y="1" width="8" height="8"/)60+ assert.doesNotMatch(output, /<(?:rect|path|polygon)[^>]*data-dz-role="canvas"/)61+ }62+ assert.match(light, /--dz-ink:#0f172a/)63+ assert.match(dark, /--dz-ink:#f8fafc/)64+ assert.match(automatic, /@media\(prefers-color-scheme:dark\)/)65+})66+67+test('materializes a bounded frame and remains idempotent', () => {68+ const framed = materializeSvg(semanticCanonical, 'dark-framed')69+ const repeated = materializeSvg(framed, 'dark-framed')70+71+ assert.equal(framed, repeated)72+ assert.match(framed, /viewBox="-24 -24 58 58"/)73+ assert.match(framed, /data-dz-role="canvas"/)74+ assert.match(framed, /data-dz-role="frame"/)75+ assert.match(framed, /vector-effect="non-scaling-stroke"/)76+})77+78+test('fails closed when a raw-only profile requests a themed appearance', () => {79+ const raw = sanitizeAndDecorateSvg(source, metadata, presentation, 'pikchr')80+ assert.throws(81+ () => materializeSvg(raw, 'dark-framed'),82+ error => error instanceof SvgNormalizationError && error.code === 'unsupported_appearance',83+ )84+})85+86+test('applies the pinned GraphViz semantic profile without recoloring authored paint', () => {87+ const graphviz = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><g class="graph"><polygon fill="white" stroke="none" points="0,0 100,0 100,50 0,50"></polygon><g class="node"><ellipse fill="none" stroke="black" cx="25" cy="25" rx="20" ry="10"></ellipse><text fill="black">A</text></g><g class="node"><ellipse fill="red" stroke="red" cx="75" cy="25" rx="20" ry="10"></ellipse><text fill="white">B</text></g><g class="edge"><path fill="none" stroke="black" d="M45 25L55 25"></path><polygon fill="black" stroke="black" points="55,25 50,20 50,30"></polygon></g></g></svg>'88+ const canonical = sanitizeAndDecorateSvg(graphviz, metadata, presentation, 'graphviz', 'graphviz@15.1.1')89+ const dark = materializeSvg(canonical, 'dark-transparent')90+91+ assert.match(canonical, /data-dz-profile="graphviz-15-semantic-1"/)92+ assert.match(canonical, /data-dz-conformance="semantic"/)93+ assert.match(canonical, /data-dz-role="canvas"/)94+ assert.match(canonical, /data-dz-fill="surface-1"/)95+ assert.match(canonical, /data-dz-stroke="line"/)96+ assert.match(dark, /data-dz-role="canvas"\]:not/)97+ assert.match(dark, /fill="red" stroke="red"/)98+ assert.doesNotMatch(dark, /fill="red" stroke="red"[^>]*data-dz-/)99+})
M docs/svg-normalization.md +19 -2
@@ -141,8 +141,8 @@ Schema 1 uses the following root attributes:
141141 data-dz-appearance="light-transparent">142142 <style>/* self-contained tokens and role rules */</style>143143 <g data-dz-role="node">144- <rect data-dz-paint="surface-1" />145- <text data-dz-paint="ink">Service</text>144+ <rect data-dz-fill="surface-1" data-dz-stroke="line" />145+ <text data-dz-fill="ink">Service</text>146146 </g>147147 </svg>148148 ```
@@ -168,6 +168,23 @@ The normalizer may preserve original IDs when safe. Any IDs it creates must be
168168 deterministically namespaced to avoid collisions with engine IDs, paint169169 servers, masks, markers, filters, or embedded SVG fragments.170170 171+Schema 1 separates fill and stroke roles. `data-dz-fill` controls paint inside172+an element, while `data-dz-stroke` controls its boundary or connector. This173+keeps arrowheads, unfilled nodes, and line geometry from inheriting the wrong174+property during materialization.175+176+## Current profile coverage177+178+The shared sanitizer, canonical serializer, and appearance materializer apply179+to browser and Worker renderers. Every catalog entry reports its active180+normalization capability. An unrecognized renderer contract remains on181+`safe-raw-1` and supports only `raw`.182+183+GraphViz `15.1.1` currently uses `graphviz-15-semantic-1`. The profile relies on184+GraphViz's stable `graph`, `node`, `edge`, and `cluster` groups. It adapts neutral185+surfaces, text, connectors, and arrowheads. Explicit non-neutral authored colors186+remain unchanged and are reported as a profile limitation.187+171188 ## Semantic visual roles172189 173190 The normalized palette is expressed through semantic roles rather than literal