clone

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

fix: close SVG rendering review gaps

Dami committed cf9fe25
commit
cf9fe25be513277a2cbee4d7b05ea4c6b0a057f6
parent
1dfcd6aefd6cf205f402e95efa962ab144206b69
author
Dami <dami.etoile@gmail.com>
12 files changed, +110 -17
Mapps/docs/data/diagram-types.mjs2+-
Mapps/docs/docs/create/types/wireviz.md1+
Aapps/docs/src/components/DiagramExample/httpSvg.mjs17+++++++++++
Mapps/docs/src/components/DiagramExample/index.js3+-
Mapps/docs/static/examples/wireviz.json2+-
Mapps/docs/static/llms-full.txt1+
Mapps/docs/test/diagram-example.test.mjs16+++++++++++
Mapps/editor/test/svg-contract.test.js35+++++++++++++++++++++++-
Mdocs/svg-normalization.md1+
Mservices/api/test/aliases.test.ts2+-
Mshared/svg/index.d.ts2+-
Mshared/svg/index.js45+++++++++++++++++++++++-------
M apps/docs/data/diagram-types.mjs +1 -1
M apps/docs/docs/create/types/wireviz.md +1 -0
A apps/docs/src/components/DiagramExample/httpSvg.mjs +17 -0
M apps/docs/src/components/DiagramExample/index.js +2 -1
M apps/docs/static/examples/wireviz.json +1 -1
M apps/docs/static/llms-full.txt +1 -0
M apps/docs/test/diagram-example.test.mjs +16 -0
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
22 import test from 'node:test';33 import {fitView, zoomView} from '../src/components/DiagramExample/viewMath.mjs';44 import {clientFrameUrlFor, httpRendererUrlFor} from '../src/components/DiagramExample/rendererRouting.mjs';5+import {canonicalizeHttpRendererSvg, rendererVersionFromHeaders} from '../src/components/DiagramExample/httpSvg.mjs';6+import {materializeSvg} from '../../../shared/svg/index.js';57 68 test('routes client examples to their isolated renderer frame', () => {79 assert.equal(clientFrameUrlFor('tikz'), 'https://tikz.render.diagram.zip/index.html?v=1');
@@ -12,6 +14,20 @@ test('routes HTTP examples to their dedicated renderer unit', () => {
1214 assert.equal(httpRendererUrlFor('graphviz'), 'https://graphviz.render.diagram.zip/v1/svg');1315 });1416 17+test('canonicalizes raw HTTP renderer SVG before docs appearance materialization', () => {18+ const headers = new Headers({19+ 'X-Diagram-Engine-Version': '3.4.2',20+ 'X-Renderer-Build': 'blockdiag-3.4.2-family-python-1',21+ });22+ const raw = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><rect x="2" y="2" width="36" height="16" fill="white" stroke="black"></rect><text x="8" y="14" fill="black">block</text></svg>';23+ const canonical = canonicalizeHttpRendererSvg(raw, 'blockdiag', headers);24+25+ assert.equal(rendererVersionFromHeaders(headers), '3.4.2 blockdiag-3.4.2-family-python-1');26+ assert.match(canonical, /data-dz-schema="1"/);27+ assert.match(canonical, /data-dz-profile="neutral-svg-semantic-2"/);28+ assert.match(materializeSvg(canonical, 'auto-transparent'), /data-dz-appearance="auto-transparent"/);29+});30+1531 test('fits a wide diagram without assuming a fixed aspect ratio', () => {1632 assert.deepEqual(fitView(800, 400, 1600, 200, 40), {scale: 0.45, x: 40, y: 155});1733 });
M apps/editor/test/svg-contract.test.js +34 -1
@@ -91,7 +91,7 @@ test('materializes explicit and automatic palettes without changing diagram geom
9191 const automatic = materializeSvg(semanticCanonical, 'auto-transparent')9292 9393 for (const output of [light, dark, automatic]) {94- assert.match(output, /data-dz-materializer="svg-materializer-1"/)94+ assert.match(output, /data-dz-materializer="svg-materializer-2"/)9595 assert.match(output, /data-dz-palette="diagramzip-palette-1"/)9696 assert.match(output, /<rect x="1" y="1" width="8" height="8"/)9797 assert.doesNotMatch(output, /<(?:rect|path|polygon)[^>]*data-dz-role="canvas"/)
@@ -276,6 +276,39 @@ test('creates addressable surfaces for closed Pikchr and line-built Svgbob boxes
276276 assert.match(svgbob, /data-dz-owned="normalizer" data-dz-role="object-surface" data-dz-fill="surface-1"/)277277 assert.equal((svgbob.match(/data-dz-role="object-surface"/g) ?? []).length, 1)278278 assert.equal(canonicalizeSvg(svgbob, metadata, 'svgbob', 'svgbob@0.7.6/edge-wasm-1'), svgbob)279+280+ const labeled = canonicalizeSvg(281+ '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30" class="svgbob"><text x="8" y="16">User</text><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>',282+ metadata, 'svgbob', 'svgbob@0.7.6/edge-wasm-1',283+ )284+ assert.ok(labeled.indexOf('data-dz-role="object-surface"') < labeled.indexOf('>User</text>'))285+})286+287+test('keeps hollow semantic markers unpainted in themed appearances', () => {288+ const plantuml = canonicalizeSvg(289+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 80"><g><circle fill="#E2E2F0" stroke="#181818" r="8" cx="50" cy="10"></circle><path fill="#00000000" stroke="#181818" d="M50,18 L50,45 M37,26 L63,26 M50,45 L37,60 M50,45 L63,60"></path></g></svg>',290+ metadata, 'plantuml', 'plantuml@1.2026.6/edge-wasm-1',291+ )292+ const goat = canonicalizeSvg(293+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle class="hollow" cx="10" cy="10" r="6"></circle></svg>',294+ metadata, 'goat', 'goat@0.5.1/edge-wasm-1',295+ )296+ for (const canonical of [plantuml, goat]) {297+ assert.match(canonical, /data-dz-fill="none"/)298+ assert.match(materializeSvg(canonical, 'dark-transparent'), /\[data-dz-fill="none"\]\{fill:none!important\}/)299+ }300+})301+302+test('preserves WaveDrom authored categorical colors', () => {303+ const canonical = canonicalizeSvg(304+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><style>.s8{fill:#ffe0b9}.s9{fill:#ffc0a0}</style><path class="s8" d="M0 0H20V20H0Z"></path><path class="s9" d="M20 0H40V20H20Z"></path></svg>',305+ metadata, 'wavedrom', 'wavedrom@3.6.2',306+ )307+ assert.doesNotMatch(canonical.match(/<path class="s8"[^>]*>/)?.[0] ?? '', /data-dz-fill/)308+ assert.doesNotMatch(canonical.match(/<path class="s9"[^>]*>/)?.[0] ?? '', /data-dz-fill/)309+ const themed = materializeSvg(canonical, 'dark-transparent')310+ assert.match(themed, /\.s8\{fill:#ffe0b9\}/)311+ assert.match(themed, /\.s9\{fill:#ffc0a0\}/)279312 })280313 281314 test('resolves text against normalized light surfaces and C4 accents', () => {
M docs/svg-normalization.md +1 -0
M services/api/test/aliases.test.ts +1 -1
M shared/svg/index.d.ts +1 -1
M shared/svg/index.js +34 -11
@@ -10,7 +10,7 @@ const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\
1010 1111 export const SVG_SCHEMA = '1'1212 export const NORMALIZER_BUILD = 'svg-normalizer-2'13-export const MATERIALIZER_BUILD = 'svg-materializer-1'13+export const MATERIALIZER_BUILD = 'svg-materializer-2'1414 export const PALETTE_BUILD = 'diagramzip-palette-1'1515 export const RAW_PROFILE = 'safe-raw-1'1616 export const APPEARANCES = Object.freeze([
@@ -443,6 +443,7 @@ function lineEndpoints(node) {
443443 }444444 445445 function addSvgbobObjectSurfaces(root) {446+ const additions = []446447 const visit = node => {447448 if (node.type === 'text') return448449 if (node.children.some(child => child.type !== 'text' && child.attributes.get('data-dz-owned') === 'normalizer')) return
@@ -451,7 +452,7 @@ function addSvgbobObjectSurfaces(root) {
451452 const horizontal = lines.filter(([x1, y1, x2, y2]) => y1 === y2 && x1 !== x2)452453 const key = values => values.join(',')453454 const horizontalKeys = new Set(horizontal.map(([x1, y1, x2]) => key([Math.min(x1, x2), y1, Math.max(x1, x2)])))454- const additions = []455+ const nodeAdditions = []455456 for (let first = 0; first < vertical.length; first++) {456457 const [leftX, firstY1, , firstY2] = vertical[first]457458 const top = Math.min(firstY1, firstY2)
@@ -462,16 +463,24 @@ function addSvgbobObjectSurfaces(root) {
462463 const minX = Math.min(leftX, rightX)463464 const maxX = Math.max(leftX, rightX)464465 if (!horizontalKeys.has(key([minX, top, maxX])) || !horizontalKeys.has(key([minX, bottom, maxX]))) continue465- additions.push(element('rect', [466+ nodeAdditions.push(element('rect', [466467 ['x', String(minX)], ['y', String(top)], ['width', String(maxX - minX)], ['height', String(bottom - top)],467468 ['fill', 'none'], ['data-dz-owned', 'normalizer'], ['data-dz-role', 'object-surface'], ['data-dz-fill', 'surface-1'],468469 ]))469470 }470471 }471- if (additions.length > 0) node.children.unshift(...additions)472+ if (nodeAdditions.length > 0) {473+ const transform = node.attributes.get('transform')474+ additions.push(...nodeAdditions.map(surface => transform475+ ? element('g', [['transform', transform], ['data-dz-owned', 'normalizer']], [surface])476+ : surface))477+ }472478 for (const child of node.children) visit(child)473479 }474480 visit(root)481+ // The renderer may emit labels before the line group that defines their box.482+ // Keep inferred surfaces in a root-level underlay so they cannot cover text.483+ root.children.unshift(...additions)475484 }476485 477486 function applySvgbobProfile(root) {
@@ -495,6 +504,23 @@ function applySvgbobProfile(root) {
495504 496505 function applyPlantumlProfile(root, engine) {497506 applyNeutralSvgProfile(root, 'plantuml', ['#e2e2f0', '#f1f1f1'])507+ const markActorHeads = node => {508+ if (node.type === 'text') return509+ const children = node.children.filter(child => child.type !== 'text')510+ for (let index = 0; index < children.length - 1; index++) {511+ const head = children[index]512+ const body = children[index + 1]513+ if (localName(head) === 'circle'514+ && numericCoordinate(head.attributes.get('r')) === 8515+ && localName(body) === 'path'516+ && transparentPaint(ownPaint(body, 'fill'))517+ && ((body.attributes.get('d') ?? '').match(/\bL\b|L(?=[\d.-])/g) ?? []).length >= 4) {518+ head.attributes.set('data-dz-fill', 'none')519+ }520+ }521+ for (const child of node.children) markActorHeads(child)522+ }523+ markActorHeads(root)498524 if (engine !== 'c4plantuml') return499525 const c4Accent = new Set(['#08427b', '#1168bd'])500526 const visit = node => {
@@ -526,7 +552,7 @@ function applyGoatProfile(root) {
526552 node.attributes.set('data-dz-stroke', 'line')527553 }528554 if (classes.has('hollow')) {529- node.attributes.set('data-dz-fill', 'surface-1')555+ node.attributes.set('data-dz-fill', 'none')530556 node.attributes.set('data-dz-stroke', 'line')531557 }532558 for (const child of node.children) visit(child)
@@ -555,10 +581,6 @@ function applyVegaProfile(root) {
555581 556582 function applyWavedromProfile(root) {557583 applyNeutralSvgProfile(root, 'wavedrom')558- const accentClasses = new Map([559- ['s8', 'accent-3'], ['s9', 'accent-3'], ['s10', 'accent-1'], ['s11', 'accent-1'],560- ['s12', 'accent-2'], ['s13', 'accent-2'], ['s14', 'accent-3'], ['s15', 'accent-1'],561- ])562584 const visit = node => {563585 if (node.type === 'text') return564586 const classes = classNames(node)
@@ -570,7 +592,8 @@ function applyWavedromProfile(root) {
570592 if ([...classes].some(value => ['s1', 's2', 's3', 's4'].includes(value))) node.attributes.set('data-dz-stroke', 'line')571593 if (classes.has('s5') || classes.has('s7')) node.attributes.set('data-dz-fill', 'surface-1')572594 if (classes.has('s6')) node.attributes.set('data-dz-fill', 'line')573- for (const [className, role] of accentClasses) if (classes.has(className)) node.attributes.set('data-dz-fill', role)595+ // s8-s15 are authored categorical values. Preserve their renderer colors596+ // instead of collapsing eight distinct values into three shared accents.574597 if (classes.has('s16')) node.attributes.set('data-dz-stroke', 'accent-1')575598 for (const child of node.children) visit(child)576599 }
@@ -879,7 +902,7 @@ function materializerCss(appearance) {
879902 const variables = automatic880903 ? `:root{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){:root{${paletteVariables(DARK_PALETTE)}}}`881904 : `:root{${paletteVariables(scheme)}}`882- 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}`905+ return `${variables}[data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}[data-dz-fill="none"]{fill: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}`883906 }884907 885908 function canonicalBounds(root) {