1import { SaxesParser } from 'saxes'23const MAX_SVG_LENGTH = 4_194_3044const MAX_EDITABLE_SVG_LENGTH = 5_242_8805const BLOCKED_ELEMENTS = new Set(['script', 'iframe', 'object', 'embed', 'audio', 'video'])6const FOREIGN_OBJECT_ELEMENTS = new Set([7 'foreignobject', 'div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'ul', 'ol', 'li', 'a',8])9const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]+$/i10const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\/(?:x-)?font-woff2?);base64,[a-z0-9+/=\s]+$/i1112export const SVG_SCHEMA = '1'13export const EDITABLE_SVG_SCHEMA = '1'14export const NORMALIZER_BUILD = 'svg-normalizer-4'15export const MATERIALIZER_BUILD = 'svg-materializer-4'16export const PALETTE_BUILD = 'diagramzip-palette-2'17export const RAW_PROFILE = 'safe-raw-1'18export const APPEARANCES = Object.freeze([19 'raw',20 'auto-transparent',21 'light-transparent',22 'dark-transparent',23 'auto-framed',24 'light-framed',25 'dark-framed',26])27export const RAW_NORMALIZATION = Object.freeze({28 schema: SVG_SCHEMA,29 normalizer: NORMALIZER_BUILD,30 profile: RAW_PROFILE,31 palette: 'renderer',32 conformance: 'raw',33 appearances: Object.freeze(['raw']),34})35const FRAMED_APPEARANCES = Object.freeze(['raw', 'auto-framed', 'light-framed', 'dark-framed'])3637function capability(profile, conformance, limitations, appearances = APPEARANCES) {38 return Object.freeze({39 schema: SVG_SCHEMA,40 normalizer: NORMALIZER_BUILD,41 profile,42 palette: PALETTE_BUILD,43 conformance,44 appearances,45 limitations: Object.freeze(limitations),46 })47}4849const GRAPHVIZ_NORMALIZATION = capability(50 'graphviz-15-semantic-2',51 'semantic',52 ['Authored non-neutral colors are preserved and may not adapt between palettes.'],53)54const D2_NORMALIZATION = capability(55 'd2-0.7-semantic-2',56 'semantic',57 ['Authored D2 colors are preserved; the pinned neutral and primary theme roles adapt.'],58)59const PLANTUML_NORMALIZATION = capability(60 'plantuml-2026-semantic-2',61 'semantic',62 ['Pinned neutral PlantUML paint adapts; explicit themes and authored colors remain renderer-defined.'],63)64const SVGBOB_NORMALIZATION = capability(65 'svgbob-0.7-semantic-2',66 'semantic',67 ['Line art, labels, and ordinary surfaces adapt; source-specific non-neutral paint is preserved.'],68)69const NEUTRAL_SVG_NORMALIZATION = capability(70 'neutral-svg-semantic-2',71 'adaptive',72 ['Neutral paint adapts; authored non-neutral fills, shadows, and data colors are preserved.'],73)74const DETAILED_SVG_NORMALIZATION = capability(75 'structured-svg-semantic-2',76 'adaptive',77 ['Stable structural roles adapt; authored categorical and semantic colors are preserved.'],78)79const PRESENTATION_NORMALIZATION = capability(80 'authored-svg-presentation-1',81 'presentation-only',82 ['The outer canvas and frame adapt. Paint inside the authored SVG remains unchanged.'],83 FRAMED_APPEARANCES,84)85const AUTHORED_NEUTRAL_NORMALIZATION = capability(86 'authored-neutral-semantic-1',87 'adaptive',88 ['Neutral canvas, ink, and line paint adapts; authored non-neutral paint remains renderer-defined.'],89)90const TRN_NORMALIZATION = capability(91 'trn-semantic-4',92 'semantic',93 ['Four relationship-zone branch colors adapt to the selected appearance and adjacent zones never share a color.'],94)9596const SQUARING_NORMALIZATION = capability(97 'squaring-semantic-1',98 'semantic',99 ['Voltage bands for squares and nodes, wires, and block outlines use dedicated light and dark palettes.'],100)101102const PROFILE_BY_ENGINE = Object.freeze({103 graphviz: GRAPHVIZ_NORMALIZATION,104 dbml: GRAPHVIZ_NORMALIZATION,105 erd: GRAPHVIZ_NORMALIZATION,106 wireviz: GRAPHVIZ_NORMALIZATION,107 d2: D2_NORMALIZATION,108 plantuml: PLANTUML_NORMALIZATION,109 c4plantuml: PLANTUML_NORMALIZATION,110 structurizr: PLANTUML_NORMALIZATION,111 svgbob: SVGBOB_NORMALIZATION,112 ditaa: SVGBOB_NORMALIZATION,113 blockdiag: NEUTRAL_SVG_NORMALIZATION,114 seqdiag: NEUTRAL_SVG_NORMALIZATION,115 actdiag: NEUTRAL_SVG_NORMALIZATION,116 nwdiag: NEUTRAL_SVG_NORMALIZATION,117 packetdiag: NEUTRAL_SVG_NORMALIZATION,118 rackdiag: NEUTRAL_SVG_NORMALIZATION,119 squaring: SQUARING_NORMALIZATION,120 bytefield: NEUTRAL_SVG_NORMALIZATION,121 mermaid: NEUTRAL_SVG_NORMALIZATION,122 bpmn: NEUTRAL_SVG_NORMALIZATION,123 nomnoml: NEUTRAL_SVG_NORMALIZATION,124 pikchr: NEUTRAL_SVG_NORMALIZATION,125 symbolator: NEUTRAL_SVG_NORMALIZATION,126 umlet: NEUTRAL_SVG_NORMALIZATION,127 goat: DETAILED_SVG_NORMALIZATION,128 vega: DETAILED_SVG_NORMALIZATION,129 vegalite: DETAILED_SVG_NORMALIZATION,130 wavedrom: DETAILED_SVG_NORMALIZATION,131 diagramsnet: PRESENTATION_NORMALIZATION,132 excalidraw: AUTHORED_NEUTRAL_NORMALIZATION,133 tikz: AUTHORED_NEUTRAL_NORMALIZATION,134 trn: TRN_NORMALIZATION,135})136137const VERSION_PATTERNS = Object.freeze({138 graphviz: /graphviz@15\.1\.1/,139 dbml: /(?:dbml@1\.0\.31|graphviz@15\.1\.1)/,140 erd: /(?:erd@0\.2\.1\.0|graphviz@15\.1\.1)/,141 wireviz: /(?:wireviz@)?0\.3\.2/,142 d2: /d2@0\.7\.1/,143 plantuml: /plantuml@1\.2026\.6/,144 c4plantuml: /(?:c4plantuml@2\.7\.0|plantuml@1\.2026\.6)/,145 structurizr: /structurizr@6\.2\.2/,146 svgbob: /svgbob@0\.7\.6/,147 ditaa: /(?:ditaa-ascii|svgbob@0\.7\.6)/,148 blockdiag: /(?:blockdiag@)?3\.4\.2/,149 seqdiag: /(?:seqdiag@)?3\.0\.0/,150 actdiag: /(?:actdiag@)?3\.0\.0/,151 nwdiag: /(?:nwdiag@)?3\.0\.0/,152 packetdiag: /(?:packetdiag@)?3\.0\.0/,153 rackdiag: /(?:rackdiag@)?3\.0\.0/,154 squaring: /diagramzip-squaring@3/,155 bytefield: /bytefield-svg@1\.11\.0/,156 mermaid: /mermaid@11\.17\.0/,157 bpmn: /(?:bpmn-js@18\.25\.1|diagramzip-bpmn-svg@1)/,158 nomnoml: /nomnoml@1\.7\.0/,159 pikchr: /pikchr@85e65b9686/,160 symbolator: /(?:symbolator@)?1\.2\.2/,161 umlet: /diagramzip-umlet-svg@1/,162 goat: /goat@0\.5\.1/,163 vega: /vega@6\.3\.1/,164 vegalite: /vega-lite@6\.4\.3/,165 wavedrom: /wavedrom@3\.6\.2/,166 diagramsnet: /diagrams\.net@29\.6\.1/,167 excalidraw: /@excalidraw\/excalidraw@0\.18\.1/,168 tikz: /@planktimerr\/tikzjax@1\.0\.63/,169 trn: /diagramzip-trn@16/,170})171172export function normalizationFor(engine, rendererVersion = '') {173 const profile = PROFILE_BY_ENGINE[engine]174 const pattern = VERSION_PATTERNS[engine]175 return profile && pattern?.test(rendererVersion) ? profile : RAW_NORMALIZATION176}177178export class SvgNormalizationError extends Error {179 constructor(status, code, message) {180 super(message)181 this.name = 'SvgNormalizationError'182 this.status = status183 this.code = code184 }185}186187function safeCssReference(value) {188 const normalized = value.trim()189 return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized) || SAFE_DATA_FONT.test(normalized)190}191192function unsafeCss(value) {193 if (/@import|expression\s*\(|javascript\s*:|data\s*:\s*text\/html|behavior\s*:|-moz-binding/i.test(value)) return true194 const urlPattern = /url\s*\(\s*(['"]?)(.*?)\1\s*\)/gi195 for (const match of value.matchAll(urlPattern)) {196 if (!safeCssReference(match[2] ?? '')) return true197 }198 return /url\s*\(/i.test(value.replace(urlPattern, ''))199}200201function safeReference(value) {202 const normalized = value.trim()203 return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized)204}205206function safeAttribute(name, value) {207 const normalizedName = name.toLowerCase()208 if (normalizedName.startsWith('on')) return false209 if (normalizedName === 'href' || normalizedName === 'xlink:href' || normalizedName === 'src') return safeReference(value)210 if (['style', 'filter', 'fill', 'stroke', 'clip-path', 'mask'].includes(normalizedName)) return !unsafeCss(value)211 if (normalizedName === 'xml:base') return false212 return !/javascript\s*:/i.test(value)213}214215function escapeText(value) {216 return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')217}218219function escapeAttribute(value) {220 return escapeText(value).replaceAll('"', '"')221}222223function serialize(node) {224 if (node.type === 'text') return escapeText(node.value)225 let attributes = ''226 node.attributes.forEach((value, name) => {227 attributes += ` ${name}="${escapeAttribute(value)}"`228 })229 return `<${node.name}${attributes}>${node.children.map(serialize).join('')}</${node.name}>`230}231232function canonicalJson(value) {233 if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {234 return JSON.stringify(value)235 }236 if (Array.isArray(value)) return `[${value.map(item => canonicalJson(item)).join(',')}]`237 if (typeof value !== 'object' || value === null) {238 throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG data must contain only JSON values.')239 }240 return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`241}242243function localName(element) {244 return element.name.toLowerCase().split(':').at(-1) ?? element.name.toLowerCase()245}246247function qualifiedName(node) {248 if (typeof node.name === 'string' && node.name !== '') return node.name249 if (typeof node.prefix === 'string' && node.prefix !== '') return `${node.prefix}:${node.local}`250 return node.local251}252253function numericDimension(value) {254 if (value === undefined || !/^-?\d+(?:\.\d+)?(?:px)?$/.test(value.trim())) return null255 const parsed = Number.parseFloat(value)256 return Number.isFinite(parsed) && parsed > 0 ? parsed : null257}258259function presentationBounds(root) {260 const viewBox = root.attributes.get('viewBox')?.trim().split(/[\s,]+/).map(Number)261 if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) return viewBox262 const width = numericDimension(root.attributes.get('width'))263 const height = numericDimension(root.attributes.get('height'))264 return width !== null && height !== null ? [0, 0, width, height] : null265}266267function styleValue(element, property) {268 const style = element.attributes.get('style')269 if (!style) return null270 for (const declaration of style.split(';')) {271 const separator = declaration.indexOf(':')272 if (separator >= 0 && declaration.slice(0, separator).trim().toLowerCase() === property) {273 return declaration.slice(separator + 1).trim()274 }275 }276 return null277}278279function whiteFill(element) {280 const fill = (element.attributes.get('fill') ?? styleValue(element, 'fill') ?? '').replaceAll(' ', '').toLowerCase()281 return fill === 'white' || fill === '#fff' || fill === '#ffffff' || fill === 'rgb(255,255,255)'282}283284function numericCoordinate(value) {285 if (value === undefined || !/^-?\d+(?:\.\d+)?$/.test(value.trim())) return null286 const number = Number(value)287 return Number.isFinite(number) ? number : null288}289290function rectCoversBounds(element, bounds) {291 const widthValue = element.attributes.get('width')?.trim()292 const heightValue = element.attributes.get('height')?.trim()293 if (widthValue === '100%' && heightValue === '100%') return true294 const x = numericCoordinate(element.attributes.get('x') ?? '0')295 const y = numericCoordinate(element.attributes.get('y') ?? '0')296 const width = numericCoordinate(widthValue)297 const height = numericCoordinate(heightValue)298 if ([x, y, width, height].some(value => value === null)) return false299 const [boundX, boundY, boundWidth, boundHeight] = bounds300 const tolerance = 0.01301 return x <= boundX + tolerance302 && y <= boundY + tolerance303 && x + width >= boundX + boundWidth - tolerance304 && y + height >= boundY + boundHeight - tolerance305}306307function polygonCoversBounds(element, bounds) {308 const numbers = (element.attributes.get('points') ?? '').match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []309 if (numbers.length < 8 || numbers.length % 2 !== 0 || numbers.some(value => !Number.isFinite(value))) return false310 const xs = numbers.filter((_, index) => index % 2 === 0)311 const ys = numbers.filter((_, index) => index % 2 === 1)312 const [boundX, boundY, boundWidth, boundHeight] = bounds313 const tolerance = 0.01314 return Math.min(...xs) <= boundX + tolerance315 && Math.max(...xs) >= boundX + boundWidth - tolerance316 && Math.min(...ys) <= boundY + tolerance317 && Math.max(...ys) >= boundY + boundHeight - tolerance318}319320function rendererBackdrop(element, bounds, engine) {321 if (element.attributes.get('data-dz-role') === 'canvas') return true322 const name = localName(element)323 const classes = new Set((element.attributes.get('class') ?? '').toLowerCase().split(/\s+/))324 if (name === 'rect' && classes.has('backdrop')) return true325 if (name === 'rect' && bounds !== null && whiteFill(element) && rectCoversBounds(element, bounds)) return true326 return engine === 'graphviz'327 && name === 'polygon'328 && bounds !== null329 && whiteFill(element)330 && (element.attributes.get('stroke') ?? '').toLowerCase() === 'none'331 && polygonCoversBounds(element, bounds)332}333334function removeRendererBackdrops(element, inheritedBounds, engine) {335 const bounds = localName(element) === 'svg' ? presentationBounds(element) ?? inheritedBounds : inheritedBounds336 element.children = element.children.filter(child => {337 if (child.type === 'text') return true338 if (rendererBackdrop(child, bounds, engine)) return false339 removeRendererBackdrops(child, bounds, engine)340 return true341 })342}343344function neutralPaint(element, property, accepted) {345 const value = (element.attributes.get(property) ?? styleValue(element, property) ?? '').replaceAll(' ', '').toLowerCase()346 return accepted.has(value)347}348349function applyGraphvizProfile(root, engine) {350 const neutralInk = new Set(['', 'black', '#000', '#000000', 'rgb(0,0,0)'])351 const graphvizLine = new Set([...neutralInk, ...(engine === 'dbml' ? ['#29235c'] : [])])352 const visit = (node, context = '', inheritedBounds = presentationBounds(root)) => {353 if (node.type === 'text') return354 const classes = new Set((node.attributes.get('class') ?? '').toLowerCase().split(/\s+/).filter(Boolean))355 const nextContext = classes.has('node') ? 'node' : classes.has('edge') ? 'edge' : classes.has('cluster') ? 'cluster' : classes.has('graph') ? 'graph' : context356 const name = localName(node)357 const bounds = name === 'svg' ? presentationBounds(node) ?? inheritedBounds : inheritedBounds358 const backdrop = rendererBackdrop(node, bounds, 'graphviz')359 || (nextContext === 'graph' && name === 'polygon' && whiteFill(node) && ownPaint(node, 'stroke') === 'none')360 const fill = ownPaint(node, 'fill')361 const stroke = ownPaint(node, 'stroke')362 if (backdrop) node.attributes.set('data-dz-role', 'canvas')363 if (name === 'text' && (neutralInk.has(fill) || (engine === 'dbml' && fill === '#29235c'))) {364 node.attributes.set('data-dz-fill', nextContext === 'edge' ? 'ink-muted' : 'ink')365 }366 if (name === 'text' && engine === 'dbml' && WHITE_PAINT.has(fill)) node.attributes.set('data-dz-fill', 'on-accent')367 if (nextContext === 'node' && ['ellipse', 'polygon', 'path', 'rect'].includes(name) && !backdrop && !zeroStroke(node)) {368 if ((WHITE_PAINT.has(fill) && !(engine === 'wireviz' && stroke === 'none'))369 || (engine !== 'dbml' && !(engine === 'wireviz' && name === 'path') && transparentPaint(fill))) {370 node.attributes.set('data-dz-fill', 'surface-1')371 }372 if (engine === 'dbml' && fill === '#e7e2dd') node.attributes.set('data-dz-fill', 'surface-1')373 if (engine === 'dbml' && fill === '#1d71b8') node.attributes.set('data-dz-fill', 'accent-1')374 if (graphvizLine.has(stroke)) node.attributes.set('data-dz-stroke', 'line')375 }376 if (nextContext === 'cluster' && ['polygon', 'path', 'rect'].includes(name)) {377 if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-2')378 if (graphvizLine.has(stroke)) node.attributes.set('data-dz-stroke', 'line-muted')379 }380 if (nextContext === 'edge') {381 if (['path', 'polyline', 'line', 'polygon'].includes(name) && graphvizLine.has(stroke)) node.attributes.set('data-dz-stroke', 'line')382 if (['polygon', 'path'].includes(name) && graphvizLine.has(fill)) node.attributes.set('data-dz-fill', 'line')383 }384 for (const child of node.children) visit(child, nextContext, bounds)385 }386 visit(root)387}388389const BLACK_PAINT = new Set(['black', '#000', '#000000', '#111', '#111111', '#111827', '#181818', '#22242a', '#333', '#333333', '#33322e', 'rgb(0,0,0)', 'rgb(34,36,42)'])390const WHITE_PAINT = new Set(['white', '#fff', '#ffffff', '#e2e2f0', '#e8e8e8', '#eee8d5', '#f1f1f1', '#f7f8fe', 'rgb(255,255,255)'])391const MUTED_PAINT = new Set(['gray', 'grey', '#666', '#666666', '#7f7f7f', '#888', '#888888', '#aaa', '#aaaaaa'])392const SHAPE_ELEMENTS = new Set(['circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect'])393const TEXT_ELEMENTS = new Set(['text', 'tspan', 'div', 'span', 'p', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'li'])394const NON_PAINT_CONTEXTS = new Set(['clippath', 'filter', 'mask', 'pattern'])395396function normalizedPaint(value) {397 return (value ?? '').replaceAll(' ', '').toLowerCase()398}399400function ownPaint(element, property) {401 return normalizedPaint(element.attributes.get(property) ?? styleValue(element, property) ?? '')402}403404function transparentPaint(value) {405 return value === '' || value === 'none' || value === 'transparent' || value === '#00000000' || value === 'rgba(0,0,0,0)'406}407408function zeroStroke(element) {409 const value = element.attributes.get('stroke-width') ?? styleValue(element, 'stroke-width')410 return value !== null && Number.parseFloat(value) === 0411}412413function classNames(element) {414 return new Set((element.attributes.get('class') ?? '').split(/\s+/).filter(Boolean))415}416417function filteredShadow(element) {418 const style = element.attributes.get('style') ?? ''419 return /(?:^|;)\s*filter\s*:/i.test(style) && /(?:^|;)\s*opacity\s*:/i.test(style)420}421422function applyNeutralSvgProfile(root, engine, extraSurfacePaint = []) {423 const surfaces = new Set([...WHITE_PAINT, ...extraSurfacePaint.map(normalizedPaint)])424 const visit = (node, inheritedFill = '', inheritedStroke = '', inheritedBounds = presentationBounds(root), skipPaint = false) => {425 if (node.type === 'text') return426 const name = localName(node)427 const nextSkipPaint = skipPaint || NON_PAINT_CONTEXTS.has(name)428 const bounds = name === 'svg' ? presentationBounds(node) ?? inheritedBounds : inheritedBounds429 const fill = ownPaint(node, 'fill') || inheritedFill430 const stroke = ownPaint(node, 'stroke') || inheritedStroke431 const classes = classNames(node)432 const backdrop = !nextSkipPaint && (rendererBackdrop(node, bounds, engine) || (engine === 'd2' && name === 'rect' && classes.has('fill-N7')))433 if (backdrop) node.attributes.set('data-dz-role', 'canvas')434 if (!nextSkipPaint && TEXT_ELEMENTS.has(name)) {435 if (BLACK_PAINT.has(fill) || transparentPaint(fill)) node.attributes.set('data-dz-fill', 'ink')436 else if (MUTED_PAINT.has(fill)) node.attributes.set('data-dz-fill', 'ink-muted')437 } else if (!nextSkipPaint && SHAPE_ELEMENTS.has(name) && !backdrop) {438 if (surfaces.has(fill)) node.attributes.set('data-dz-fill', 'surface-1')439 else if (BLACK_PAINT.has(fill) && !filteredShadow(node)) node.attributes.set('data-dz-fill', 'line')440 if (BLACK_PAINT.has(stroke)) node.attributes.set('data-dz-stroke', 'line')441 else if (MUTED_PAINT.has(stroke)) node.attributes.set('data-dz-stroke', 'line-muted')442 if (engine === 'pikchr' && name === 'path' && transparentPaint(fill) && /(?:z|Z)\s*$/.test(node.attributes.get('d') ?? '')) {443 node.attributes.set('data-dz-fill', 'surface-1')444 }445 }446 for (const child of node.children) visit(child, fill, stroke, bounds, nextSkipPaint)447 }448 visit(root)449}450451function applyD2Profile(root) {452 applyNeutralSvgProfile(root, 'd2', ['#f7f8fe'])453 const visit = node => {454 if (node.type === 'text') return455 const classes = classNames(node)456 const name = localName(node)457 if (classes.has('fill-N1') && TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')458 if (classes.has('fill-N2') && TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink-muted')459 if (classes.has('fill-B6') && SHAPE_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'surface-1')460 if (classes.has('fill-B1')) node.attributes.set('data-dz-fill', 'accent-1')461 if (classes.has('stroke-B1') || classes.has('connection')) node.attributes.set('data-dz-stroke', 'accent-1')462 for (const child of node.children) visit(child)463 }464 visit(root)465}466467function lineEndpoints(node) {468 if (node.type === 'text' || localName(node) !== 'line') return null469 const values = ['x1', 'y1', 'x2', 'y2'].map(name => numericCoordinate(node.attributes.get(name)))470 return values.some(value => value === null) ? null : values471}472473function addSvgbobObjectSurfaces(root) {474 const additions = []475 const visit = node => {476 if (node.type === 'text') return477 if (node.children.some(child => child.type !== 'text' && child.attributes.get('data-dz-owned') === 'normalizer')) return478 const lines = node.children.map(lineEndpoints).filter(Boolean)479 const vertical = lines.filter(([x1, y1, x2, y2]) => x1 === x2 && y1 !== y2)480 const horizontal = lines.filter(([x1, y1, x2, y2]) => y1 === y2 && x1 !== x2)481 const key = values => values.join(',')482 const horizontalKeys = new Set(horizontal.map(([x1, y1, x2]) => key([Math.min(x1, x2), y1, Math.max(x1, x2)])))483 const nodeAdditions = []484 for (let first = 0; first < vertical.length; first++) {485 const [leftX, firstY1, , firstY2] = vertical[first]486 const top = Math.min(firstY1, firstY2)487 const bottom = Math.max(firstY1, firstY2)488 for (let second = first + 1; second < vertical.length; second++) {489 const [rightX, secondY1, , secondY2] = vertical[second]490 if (Math.min(secondY1, secondY2) !== top || Math.max(secondY1, secondY2) !== bottom || leftX === rightX) continue491 const minX = Math.min(leftX, rightX)492 const maxX = Math.max(leftX, rightX)493 if (!horizontalKeys.has(key([minX, top, maxX])) || !horizontalKeys.has(key([minX, bottom, maxX]))) continue494 nodeAdditions.push(element('rect', [495 ['x', String(minX)], ['y', String(top)], ['width', String(maxX - minX)], ['height', String(bottom - top)],496 ['fill', 'none'], ['data-dz-owned', 'normalizer'], ['data-dz-role', 'object-surface'], ['data-dz-fill', 'surface-1'],497 ]))498 }499 }500 if (nodeAdditions.length > 0) {501 const transform = node.attributes.get('transform')502 additions.push(...nodeAdditions.map(surface => transform503 ? element('g', [['transform', transform], ['data-dz-owned', 'normalizer']], [surface])504 : surface))505 }506 for (const child of node.children) visit(child)507 }508 visit(root)509 // The renderer may emit labels before the line group that defines their box.510 // Keep inferred surfaces in a root-level underlay so they cannot cover text.511 root.children.unshift(...additions)512}513514function applySvgbobProfile(root) {515 applyNeutralSvgProfile(root, 'svgbob')516 addSvgbobObjectSurfaces(root)517 const visit = (node, marker = '') => {518 if (node.type === 'text') return519 const classes = classNames(node)520 const name = localName(node)521 const nextMarker = name === 'marker' ? node.attributes.get('id') ?? '' : marker522 if (classes.has('backdrop')) node.attributes.set('data-dz-role', 'canvas')523 if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')524 if (SHAPE_ELEMENTS.has(name)525 && !classes.has('backdrop')526 && node.attributes.get('data-dz-owned') !== 'normalizer') {527 node.attributes.set('data-dz-stroke', 'line')528 }529 const closedPath = name === 'path' && /(?:z|Z)\s*$/.test(node.attributes.get('d') ?? '')530 const objectSurface = classes.has('bg_filled')531 || (classes.has('nofill') && (['circle', 'ellipse', 'polygon', 'rect'].includes(name) || closedPath))532 if (objectSurface) node.attributes.set('data-dz-fill', 'surface-1')533 else if (classes.has('nofill') && node.attributes.get('data-dz-fill') === 'surface-1') {534 node.attributes.delete('data-dz-fill')535 }536 if (classes.has('filled') || ['arrow', 'diamond', 'circle'].includes(nextMarker)) node.attributes.set('data-dz-fill', 'line')537 if (classes.has('filled') || classes.has('bg_filled') || nextMarker !== '') node.attributes.set('data-dz-stroke', 'line')538 for (const child of node.children) visit(child, nextMarker)539 }540 visit(root)541}542543function applyPlantumlProfile(root, engine) {544 applyNeutralSvgProfile(root, 'plantuml', ['#e2e2f0', '#f1f1f1'])545 const markActorHeads = node => {546 if (node.type === 'text') return547 const children = node.children.filter(child => child.type !== 'text')548 for (let index = 0; index < children.length - 1; index++) {549 const head = children[index]550 const body = children[index + 1]551 if (localName(head) === 'circle'552 && numericCoordinate(head.attributes.get('r')) === 8553 && localName(body) === 'path'554 && transparentPaint(ownPaint(body, 'fill'))555 && ((body.attributes.get('d') ?? '').match(/\bL\b|L(?=[\d.-])/g) ?? []).length >= 4) {556 head.attributes.set('data-dz-fill', 'none')557 }558 }559 for (const child of node.children) markActorHeads(child)560 }561 markActorHeads(root)562 if (engine !== 'c4plantuml') return563 const c4Accent = new Set(['#08427b', '#1168bd'])564 const visit = node => {565 if (node.type === 'text') return566 const name = localName(node)567 const fill = ownPaint(node, 'fill')568 if (SHAPE_ELEMENTS.has(name) && c4Accent.has(fill)) node.attributes.set('data-dz-fill', 'accent-1')569 for (const child of node.children) visit(child)570 const ownsAccent = node.children.some(child => child.type !== 'text' && child.attributes.get('data-dz-fill') === 'accent-1')571 if (ownsAccent) {572 for (const child of node.children) {573 if (child.type !== 'text' && TEXT_ELEMENTS.has(localName(child))) child.attributes.set('data-dz-fill', 'on-accent')574 }575 }576 }577 visit(root)578}579580function applyGoatProfile(root) {581 applyNeutralSvgProfile(root, 'goat')582 const visit = node => {583 if (node.type === 'text') return584 const classes = classNames(node)585 const name = localName(node)586 if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')587 if (classes.has('path')) node.attributes.set('data-dz-stroke', 'line')588 if (classes.has('arrowhead') || classes.has('filled')) {589 node.attributes.set('data-dz-fill', 'line')590 node.attributes.set('data-dz-stroke', 'line')591 }592 if (classes.has('hollow')) {593 node.attributes.set('data-dz-fill', 'none')594 node.attributes.set('data-dz-stroke', 'line')595 }596 for (const child of node.children) visit(child)597 }598 visit(root)599}600601function applyVegaProfile(root) {602 applyNeutralSvgProfile(root, 'vega')603 const visit = (node, context = '') => {604 if (node.type === 'text') return605 const classes = classNames(node)606 const nextContext = [...classes].find(value => value.startsWith('role-')) ?? context607 const name = localName(node)608 if (classes.has('background') && rendererBackdrop(node, presentationBounds(root), 'vega')) node.attributes.set('data-dz-role', 'canvas')609 if (nextContext === 'role-axis-label' || nextContext === 'role-legend-label' || nextContext === 'role-title') {610 if (TEXT_ELEMENTS.has(name)) node.attributes.set('data-dz-fill', 'ink')611 }612 if (nextContext === 'role-axis-tick' || nextContext === 'role-axis-domain' || nextContext === 'role-grid') {613 if (SHAPE_ELEMENTS.has(name)) node.attributes.set('data-dz-stroke', 'line-muted')614 }615 for (const child of node.children) visit(child, nextContext)616 }617 visit(root)618}619620function applyWavedromProfile(root) {621 applyNeutralSvgProfile(root, 'wavedrom')622 const visit = node => {623 if (node.type === 'text') return624 const classes = classNames(node)625 const name = localName(node)626 if (TEXT_ELEMENTS.has(name)) {627 if ([...classes].some(value => ['muted', 'info'].includes(value))) node.attributes.set('data-dz-fill', 'ink-muted')628 else if (![...classes].some(value => ['warning', 'error', 'success'].includes(value))) node.attributes.set('data-dz-fill', 'ink')629 }630 if ([...classes].some(value => ['s1', 's2', 's3', 's4'].includes(value))) node.attributes.set('data-dz-stroke', 'line')631 if (classes.has('s5') || classes.has('s7')) node.attributes.set('data-dz-fill', 'surface-1')632 if (classes.has('s6')) node.attributes.set('data-dz-fill', 'line')633 // s8-s15 are authored categorical values. Preserve their renderer colors634 // instead of collapsing eight distinct values into three shared accents.635 if (classes.has('s16')) node.attributes.set('data-dz-stroke', 'accent-1')636 for (const child of node.children) visit(child)637 }638 visit(root)639}640641function applyNeutralRendererDetails(root, engine) {642 const visit = (node, marker = '', context = '') => {643 if (node.type === 'text') return644 const name = localName(node)645 const classes = classNames(node)646 const fill = ownPaint(node, 'fill')647 const nextMarker = engine === 'mermaid' && name === 'marker' ? node.attributes.get('id') ?? '' : marker648 const nextContext = classes.has('cluster') ? 'cluster' : classes.has('edgeLabel') ? 'edge-label' : classes.has('cluster-label') ? '' : context649 if (engine === 'mermaid') {650 if (classes.has('label-container')) {651 node.attributes.set('data-dz-fill', 'surface-1')652 node.attributes.set('data-dz-stroke', 'line')653 }654 // Sequence diagrams use stylesheet-driven actor and label boxes. Their655 // literal fills are light Mermaid presentation values, so leaving them656 // unannotated makes dark appearances put light text on pale boxes.657 if (SHAPE_ELEMENTS.has(name) && (classes.has('actor') || classes.has('labelBox'))) {658 node.attributes.set('data-dz-fill', 'surface-1')659 node.attributes.set('data-dz-stroke', 'line')660 }661 if (classes.has('actor-line')) node.attributes.set('data-dz-stroke', 'line-muted')662 if (classes.has('messageLine0') || classes.has('messageLine1')) node.attributes.set('data-dz-stroke', 'line')663 if (name === 'path' && (nextMarker.endsWith('-arrowhead') || nextMarker.endsWith('-crosshead'))) {664 node.attributes.set('data-dz-fill', nextMarker.endsWith('-arrowhead') ? 'line' : 'none')665 node.attributes.set('data-dz-stroke', 'line')666 }667 if (classes.has('flowchart-link')) node.attributes.set('data-dz-stroke', 'line')668 if (classes.has('arrowMarkerPath')) {669 node.attributes.set('data-dz-fill', 'line')670 node.attributes.set('data-dz-stroke', 'line')671 }672 if (name === 'rect' && (nextContext === 'cluster' || (nextContext === 'edge-label' && classes.has('background')))673 && neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) {674 node.attributes.set('data-dz-fill', nextContext === 'cluster' ? 'surface-2' : 'surface-1')675 if (nextContext === 'cluster'676 && neutralPaint(node, 'stroke', new Set(['', 'none', 'gray', 'grey', '#aaa', '#aaaaaa', '#aaaa33']))) {677 node.attributes.set('data-dz-stroke', 'line-muted')678 }679 }680 }681 if (engine === 'blockdiag' && SHAPE_ELEMENTS.has(name) && (fill === '#dbeafe' || fill === 'rgb(219,234,254)')) {682 node.attributes.set('data-dz-fill', 'surface-2')683 }684 if (engine === 'umlet' && classes.has('solid')) {685 node.attributes.set('data-dz-fill', 'line')686 node.attributes.set('data-dz-stroke', 'line')687 }688 for (const child of node.children) visit(child, engine === 'mermaid' ? nextMarker : marker, engine === 'mermaid' ? nextContext : context)689 }690 visit(root)691}692693function elementPaintBounds(node) {694 if (node.type === 'text') return null695 const name = localName(node)696 if (name === 'rect') {697 const x = numericCoordinate(node.attributes.get('x') ?? '0')698 const y = numericCoordinate(node.attributes.get('y') ?? '0')699 const width = numericCoordinate(node.attributes.get('width'))700 const height = numericCoordinate(node.attributes.get('height'))701 return [x, y, width, height].some(value => value === null) ? null : [x, y, x + width, y + height]702 }703 if (name === 'circle' || name === 'ellipse') {704 const cx = numericCoordinate(node.attributes.get('cx'))705 const cy = numericCoordinate(node.attributes.get('cy'))706 const rx = numericCoordinate(node.attributes.get(name === 'circle' ? 'r' : 'rx'))707 const ry = numericCoordinate(node.attributes.get(name === 'circle' ? 'r' : 'ry'))708 return [cx, cy, rx, ry].some(value => value === null) ? null : [cx - rx, cy - ry, cx + rx, cy + ry]709 }710 if (name === 'polygon') {711 const values = (node.attributes.get('points') ?? '').match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []712 if (values.length < 6 || values.length % 2 !== 0) return null713 const xs = values.filter((_, index) => index % 2 === 0)714 const ys = values.filter((_, index) => index % 2 === 1)715 return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]716 }717 return null718}719720function resolveSiblingTextContrast(root) {721 const visit = node => {722 if (node.type === 'text') return723 const surfaces = []724 for (const child of node.children) {725 if (child.type === 'text') continue726 const bounds = elementPaintBounds(child)727 const role = child.attributes.get('data-dz-fill') ?? ''728 const structuralRole = child.attributes.get('data-dz-role') ?? ''729 const fill = ownPaint(child, 'fill')730 if (bounds !== null && structuralRole !== 'canvas' && !filteredShadow(child)731 && (role.startsWith('surface-') || role.startsWith('accent-') || (!transparentPaint(fill) && !BLACK_PAINT.has(fill)))) {732 surfaces.push({ bounds, role, fill })733 }734 if (TEXT_ELEMENTS.has(localName(child))) {735 const x = numericCoordinate(child.attributes.get('x'))736 const y = numericCoordinate(child.attributes.get('y'))737 const owner = x === null || y === null ? null : surfaces.findLast(surface => {738 const [left, top, right, bottom] = surface.bounds739 return x >= left && x <= right && y >= top && y <= bottom740 })741 if (owner?.role.startsWith('accent-')) child.attributes.set('data-dz-fill', 'on-accent')742 else if (owner?.role.startsWith('surface-')) child.attributes.set('data-dz-fill', 'ink')743 else if (owner !== null && owner !== undefined && !(child.attributes.get('data-dz-fill') ?? '').startsWith('squaring-')) child.attributes.delete('data-dz-fill')744 }745 }746 for (const child of node.children) visit(child)747 }748 visit(root)749}750751function applySquaringProfile(root) {752 applyNeutralSvgProfile(root, 'squaring')753 const visit = node => {754 if (node.type === 'text') return755 const classes = classNames(node)756 const band = node.attributes.get('data-band') ?? '0'757 if (classes.has('squaring-square-fill')) {758 node.attributes.set('data-dz-fill', `squaring-square-${band}`)759 node.attributes.set('data-dz-stroke', 'squaring-edge')760 }761 if (classes.has('squaring-node-dot')) node.attributes.set('data-dz-fill', `squaring-node-${band}`)762 if (classes.has('squaring-node-rung')) node.attributes.set('data-dz-stroke', `squaring-node-${band}`)763 if (classes.has('squaring-square-label')) node.attributes.set('data-dz-fill', 'squaring-label')764 if (classes.has('squaring-wire-path')) node.attributes.set('data-dz-stroke', 'squaring-wire')765 if (classes.has('squaring-wire-arrow')) node.attributes.set('data-dz-fill', 'squaring-wire')766 if (classes.has('squaring-wire-label') || classes.has('squaring-summary')) node.attributes.set('data-dz-fill', 'ink-muted')767 if (classes.has('squaring-node-label') || classes.has('squaring-title')) node.attributes.set('data-dz-fill', 'ink')768 if (classes.has('squaring-panel-title')) node.attributes.set('data-dz-fill', 'ink-muted')769 if (classes.has('squaring-frame')) node.attributes.set('data-dz-stroke', 'line-muted')770 if (classes.has('squaring-block')) node.attributes.set('data-dz-stroke', 'squaring-block')771 for (const child of node.children) visit(child)772 }773 visit(root)774}775776function applyTrnProfile(root) {777 const visit = (node, inGrid = false) => {778 if (node.type === 'text') return779 const classes = classNames(node)780 const name = localName(node)781 const nextInGrid = inGrid || classes.has('trn-grid')782783 if (classes.has('trn-canvas')) node.attributes.set('data-dz-role', 'canvas')784 if (classes.has('trn-header')) {785 node.attributes.set('data-dz-fill', 'surface-2')786 node.attributes.set('data-dz-stroke', 'line-muted')787 }788 if (classes.has('trn-table-surface')) node.attributes.set('data-dz-fill', 'surface-1')789 if (classes.has('trn-ingredient-cell') || classes.has('trn-flow-cell') || classes.has('trn-instruction-cell')) {790 node.attributes.set('data-dz-fill', 'surface-1')791 node.attributes.set('data-dz-stroke', 'line-muted')792 }793 if (classes.has('trn-zone-fill') || classes.has('trn-zone-shape')) {794 const branch = [1, 2, 3, 4].find(index => classes.has(`trn-branch-${index}`)) ?? 1795 node.attributes.set('data-dz-fill', `accent-${branch}`)796 }797 if (classes.has('trn-zone-border') || classes.has('trn-zone-shape')) node.attributes.set('data-dz-stroke', 'line-muted')798 if (nextInGrid && SHAPE_ELEMENTS.has(name)) node.attributes.set('data-dz-stroke', 'line-muted')799 if (classes.has('trn-title') || classes.has('trn-ingredient-label') || classes.has('trn-instruction-label')) {800 node.attributes.set('data-dz-fill', 'ink')801 }802 if (classes.has('trn-operation-label')) node.attributes.set('data-dz-fill', 'on-accent')803804 for (const child of node.children) visit(child, nextInGrid)805 }806 visit(root)807}808809function applyProfile(root, capability, engine) {810 if (capability.profile === 'graphviz-15-semantic-2') applyGraphvizProfile(root, engine)811 else if (capability.profile === 'd2-0.7-semantic-2') applyD2Profile(root)812 else if (capability.profile === 'plantuml-2026-semantic-2') applyPlantumlProfile(root, engine)813 else if (capability.profile === 'svgbob-0.7-semantic-2') applySvgbobProfile(root)814 else if (capability.profile === 'neutral-svg-semantic-2') {815 const surfacePaint = engine === 'mermaid' ? ['#ececff', '#f9f9f9'] : []816 applyNeutralSvgProfile(root, engine, surfacePaint)817 applyNeutralRendererDetails(root, engine)818 }819 else if (capability.profile === 'structured-svg-semantic-2') {820 if (engine === 'goat') applyGoatProfile(root)821 else if (engine === 'vega' || engine === 'vegalite') applyVegaProfile(root)822 else if (engine === 'wavedrom') applyWavedromProfile(root)823 }824 else if (capability.profile === 'authored-neutral-semantic-1') applyNeutralSvgProfile(root, engine)825 else if (capability.profile === 'trn-semantic-4') applyTrnProfile(root)826 else if (capability.profile === 'squaring-semantic-1') applySquaringProfile(root)827 if (capability.conformance !== 'presentation-only') resolveSiblingTextContrast(root)828}829830function applyRootBackgroundStyle(root, background) {831 const declarations = (root.attributes.get('style') ?? '')832 .split(';')833 .map(value => value.trim())834 .filter(Boolean)835 .filter(value => !/^background(?:-color)?\s*:/i.test(value))836 declarations.push(`background-color:${background}`)837 root.attributes.set('style', `${declarations.join(';')};`)838}839840function element(name, attributes = [], children = []) {841 return { type: 'element', name, attributes: new Map(attributes), children }842}843844function addMetadata(root, metadata) {845 const additions = []846 const directText = name => root.children.some(child => child.type !== 'text'847 && localName(child) === name848 && child.children.length === 1849 && child.children[0].type === 'text'850 && child.children[0].value === (name === 'title' ? metadata.title : metadata.description))851 if (metadata.title && !directText('title')) additions.push(element('title', [], [{ type: 'text', value: metadata.title }]))852 if (metadata.description && !directText('desc')) additions.push(element('desc', [], [{ type: 'text', value: metadata.description }]))853 root.children.unshift(...additions)854}855856function removeStaleNormalizerOutput(root) {857 const visit = node => {858 if (node.type === 'text') return859 node.attributes.delete('data-dz-role')860 node.attributes.delete('data-dz-fill')861 node.attributes.delete('data-dz-stroke')862 node.children = node.children.filter(child => child.type === 'text'863 || !['materializer', 'normalizer'].includes(child.attributes.get('data-dz-owned')))864 for (const child of node.children) visit(child)865 }866 visit(root)867 root.attributes.delete('data-dz-materializer')868}869870function applyLegacyPresentation(root, presentation, engine) {871 const additions = []872 let frame = null873 const bounds = presentationBounds(root)874 if ((presentation.padding || presentation.background || presentation.frame) && bounds === null) {875 throw new SvgNormalizationError(422, 'missing_dimensions', 'Renderer output has no usable SVG dimensions for presentation settings.')876 }877 if (bounds !== null) {878 const [x, y, width, height] = bounds879 const padding = presentation.padding880 const next = [x - padding, y - padding, width + padding * 2, height + padding * 2]881 root.attributes.set('viewBox', next.join(' '))882 if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))883 if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))884 if (presentation.background) {885 additions.push(element('rect', [886 ['x', String(next[0])], ['y', String(next[1])], ['width', String(next[2])], ['height', String(next[3])], ['fill', presentation.background],887 ]))888 }889 if (presentation.frame) {890 frame = element('rect', [891 ['x', String(next[0] + 0.5)], ['y', String(next[1] + 0.5)],892 ['width', String(Math.max(0, next[2] - 1))], ['height', String(Math.max(0, next[3] - 1))],893 ['fill', 'none'], ['stroke', '#000000'], ['stroke-width', '1'], ['vector-effect', 'non-scaling-stroke'],894 ])895 }896 }897 if (presentation.background) {898 removeRendererBackdrops(root, bounds, engine)899 applyRootBackgroundStyle(root, presentation.background)900 }901 root.children.unshift(...additions)902 if (frame) root.children.push(frame)903}904905function parseSafeSvg(source, maximumLength = MAX_SVG_LENGTH) {906 if (source.length > maximumLength) throw new SvgNormalizationError(413, 'render_too_large', 'Rendered SVG is too large.')907 let root = null908 const stack = []909 let skippedDepth = 0910 let parseError = null911 const parser = new SaxesParser({ xmlns: true })912913 parser.on('doctype', value => {914 if (value.includes('[')) parseError = new Error('Internal DOCTYPE subsets are not allowed')915 })916 parser.on('opentag', tag => {917 const tagLocalName = tag.local.toLowerCase()918 const insideForeignObject = tagLocalName === 'foreignobject' || stack.some(item => localName(item) === 'foreignobject')919 if (skippedDepth > 0 || BLOCKED_ELEMENTS.has(tagLocalName) || (insideForeignObject && !FOREIGN_OBJECT_ELEMENTS.has(tagLocalName))) {920 skippedDepth++921 return922 }923 const next = element(qualifiedName(tag))924 for (const [attributeKey, attribute] of Object.entries(tag.attributes)) {925 const name = typeof attribute === 'string' ? attributeKey : qualifiedName(attribute) ?? attributeKey926 const value = typeof attribute === 'string' ? attribute : attribute.value927 if (safeAttribute(name, value)) next.attributes.set(name, value)928 }929 const parent = stack.at(-1)930 if (parent) parent.children.push(next)931 else if (root === null) root = next932 else parseError = new Error('Multiple root elements')933 stack.push(next)934 })935 const appendText = value => {936 if (skippedDepth > 0 || value === '') return937 const parent = stack.at(-1)938 if (parent) {939 if (localName(parent) === 'style' && unsafeCss(value)) throw new SvgNormalizationError(422, 'unsafe_svg', 'Renderer output contains unsafe CSS.')940 parent.children.push({ type: 'text', value })941 } else if (value.trim() !== '') {942 parseError = new Error('Text outside root element')943 }944 }945 parser.on('text', appendText)946 parser.on('cdata', appendText)947 parser.on('closetag', () => {948 if (skippedDepth > 0) skippedDepth--949 else stack.pop()950 })951 parser.on('error', error => { parseError = error })952953 try {954 parser.write(source).close()955 } catch (error) {956 if (error instanceof SvgNormalizationError) throw error957 parseError = error958 }959 if (parseError !== null || root === null || localName(root) !== 'svg') {960 throw new SvgNormalizationError(422, 'invalid_svg', 'Renderer returned invalid SVG.')961 }962 return root963}964965export function attachEditableDocument(source, document) {966 const root = parseSafeSvg(source, MAX_EDITABLE_SVG_LENGTH)967 if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {968 throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')969 }970 const appearance = document?.diagram?.presentation?.appearance971 if (typeof appearance === 'string' && APPEARANCES.includes(appearance)) {972 root.attributes.set('data-dz-appearance', appearance)973 }974 root.children = root.children.filter(child => child.type === 'text'975 || !(localName(child) === 'metadata' && child.attributes.get('data-dz-kind') === 'document'))976 root.attributes.set('data-dz-document', EDITABLE_SVG_SCHEMA)977 const metadata = element('metadata', [978 ['data-dz-kind', 'document'],979 ['data-dz-schema', EDITABLE_SVG_SCHEMA],980 ], [{ type: 'text', value: canonicalJson(document) }])981 const descriptiveEnd = root.children.findLastIndex(child => child.type !== 'text'982 && ['title', 'desc'].includes(localName(child)))983 root.children.splice(descriptiveEnd + 1, 0, metadata)984 const serialized = serialize(root)985 if (serialized.length > MAX_EDITABLE_SVG_LENGTH) {986 throw new SvgNormalizationError(413, 'editable_svg_too_large', 'Editable SVG is too large.')987 }988 return serialized989}990991export function extractEditableDocument(source) {992 const root = parseSafeSvg(source, MAX_EDITABLE_SVG_LENGTH)993 if (root.attributes.get('data-dz-document') !== EDITABLE_SVG_SCHEMA) {994 throw new SvgNormalizationError(422, 'not_editable', 'SVG does not contain a supported Diagram.zip document.')995 }996 const manifests = root.children.filter(child => child.type !== 'text'997 && localName(child) === 'metadata'998 && child.attributes.get('data-dz-kind') === 'document')999 if (manifests.length !== 1 || manifests[0].attributes.get('data-dz-schema') !== EDITABLE_SVG_SCHEMA) {1000 throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG must contain exactly one supported Diagram.zip document.')1001 }1002 const manifest = manifests[0]1003 if (manifest.children.some(child => child.type !== 'text')) {1004 throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG document metadata must contain JSON text only.')1005 }1006 try {1007 return JSON.parse(manifest.children.map(child => child.value).join(''))1008 } catch {1009 throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG document metadata is not valid JSON.')1010 }1011}10121013export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, rendererVersion = '') {1014 return materializePresentation(canonicalizeSvg(source, metadata, engine, rendererVersion), presentation)1015}10161017export function canonicalizeSvg(source, metadata, engine, rendererVersion = '') {1018 const root = parseSafeSvg(source)1019 const previousNormalizer = root.attributes.get('data-dz-normalizer')1020 if (previousNormalizer !== undefined && previousNormalizer !== NORMALIZER_BUILD) removeStaleNormalizerOutput(root)1021 else if (root.attributes.has('data-dz-materializer')) {1022 removeMaterializerOutput(root)1023 root.attributes.delete('data-dz-materializer')1024 }1025 const capability = normalizationFor(engine, rendererVersion)1026 applyProfile(root, capability, engine)1027 root.attributes.set('data-dz-schema', SVG_SCHEMA)1028 root.attributes.set('data-dz-normalizer', NORMALIZER_BUILD)1029 root.attributes.set('data-dz-profile', capability.profile)1030 root.attributes.set('data-dz-palette', 'renderer')1031 root.attributes.set('data-dz-engine', engine)1032 root.attributes.set('data-dz-appearance', 'raw')1033 root.attributes.set('data-dz-conformance', capability.conformance)1034 root.attributes.delete('data-dz-appearances')1035 const appearances = capability.appearances.filter(appearance => appearance !== 'raw')1036 if (appearances.length > 0) root.attributes.set('data-dz-appearances', appearances.join(' '))1037 root.attributes.delete('data-dz-bounds')1038 const bounds = presentationBounds(root)1039 if (bounds !== null) root.attributes.set('data-dz-bounds', bounds.join(' '))1040 addMetadata(root, metadata)1041 return serialize(root)1042}10431044export function materializePresentation(canonical, presentation) {1045 const root = parseSafeSvg(canonical)1046 if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {1047 throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')1048 }1049 applyLegacyPresentation(root, presentation, root.attributes.get('data-dz-engine') ?? '')1050 return serialize(root)1051}10521053const LIGHT_PALETTE = Object.freeze({1054 canvas: '#f8fafc', surface1: '#ffffff', surface2: '#f1f5f9', surface3: '#e2e8f0',1055 ink: '#0f172a', inkMuted: '#475569', line: '#334155', lineMuted: '#94a3b8',1056 accent1: '#2563eb', accent2: '#7c3aed', accent3: '#c2410c', accent4: '#047857', onAccent: '#ffffff', frame: '#cbd5e1',1057})1058const DARK_PALETTE = Object.freeze({1059 canvas: '#0f172a', surface1: '#1e293b', surface2: '#273449', surface3: '#334155',1060 ink: '#f8fafc', inkMuted: '#cbd5e1', line: '#e2e8f0', lineMuted: '#94a3b8',1061 accent1: '#60a5fa', accent2: '#c4b5fd', accent3: '#fb923c', accent4: '#34d399', onAccent: '#0f172a', frame: '#475569',1062})1063const TRN_LIGHT_BRANCH_PALETTE = Object.freeze({1064 accent1: '#dbeafe', accent2: '#fef3c7', accent3: '#ede9fe', accent4: '#ccfbf1', onAccent: '#0f172a',1065})10661067const SQUARING_LIGHT_PALETTE = Object.freeze({1068 squares: ['#93c5fd', '#b3d1fb', '#cddaf2', '#e2e4e8', '#f1d6d2', '#fbbfba', '#fca5a5', '#f87171'],1069 nodes: ['#2563eb', '#3b6fe0', '#4f74c8', '#6b7280', '#b45454', '#d13b3b', '#dc2626', '#b91c1c'],1070 edge: '#1e293b', label: '#0f172a', wire: '#1e293b', block: '#db2777',1071})1072const SQUARING_DARK_PALETTE = Object.freeze({1073 squares: ['#1e40af', '#2d55b8', '#3f5f9f', '#4f5b73', '#7f414d', '#a23a3f', '#c12f33', '#dc2626'],1074 nodes: ['#60a5fa', '#7cb0f5', '#93b7e8', '#a8b0c4', '#d6a3a3', '#f38f8f', '#f87171', '#ef4444'],1075 edge: '#0b1220', label: '#f8fafc', wire: '#a5b4fc', block: '#f472b6',1076})10771078function squaringPaletteVariables(palette) {1079 const bands = palette.squares.map((value, index) => `--dz-sq-square-${index}:${value};--dz-sq-node-${index}:${palette.nodes[index]};`).join('')1080 return `${bands}--dz-sq-edge:${palette.edge};--dz-sq-label:${palette.label};--dz-sq-wire:${palette.wire};--dz-sq-block:${palette.block};`1081}10821083function squaringRoleCss(themed) {1084 const scope = `${themed}[data-dz-profile="squaring-semantic-1"]`1085 const bands = SQUARING_LIGHT_PALETTE.squares.map((_, index) =>1086 `${scope} [data-dz-fill="squaring-square-${index}"]{fill:var(--dz-sq-square-${index})!important}${scope} [data-dz-fill="squaring-node-${index}"]{fill:var(--dz-sq-node-${index})!important}${scope} [data-dz-stroke="squaring-node-${index}"]{stroke:var(--dz-sq-node-${index})!important}`,1087 ).join('')1088 return `${bands}${scope} [data-dz-stroke="squaring-edge"]{stroke:var(--dz-sq-edge)!important}${scope} [data-dz-fill="squaring-label"]{fill:var(--dz-sq-label)!important}${scope} [data-dz-stroke="squaring-wire"]{stroke:var(--dz-sq-wire)!important}${scope} [data-dz-fill="squaring-wire"]{fill:var(--dz-sq-wire)!important}${scope} [data-dz-stroke="squaring-block"]{stroke:var(--dz-sq-block)!important}`1089}10901091function paletteVariables(palette) {1092 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-accent-4:${palette.accent4};--dz-on-accent:${palette.onAccent};--dz-frame:${palette.frame};`1093}10941095function branchPaletteVariables(palette) {1096 return `--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-accent-4:${palette.accent4};--dz-on-accent:${palette.onAccent};`1097}10981099function materializerCss() {1100 const light = ':root[data-dz-appearance^="light-"]'1101 const dark = ':root[data-dz-appearance^="dark-"]'1102 const automatic = ':root[data-dz-appearance^="auto-"]'1103 const themed = ':root:not([data-dz-appearance="raw"])'1104 const trnLight = ':root[data-dz-profile="trn-semantic-4"][data-dz-appearance^="light-"]'1105 const trnAutomatic = ':root[data-dz-profile="trn-semantic-4"][data-dz-appearance^="auto-"]'1106 const variables = `${light}{${paletteVariables(LIGHT_PALETTE)}}${dark}{${paletteVariables(DARK_PALETTE)}}${automatic}{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){${automatic}{${paletteVariables(DARK_PALETTE)}}}`1107 const squaringLight = ':root[data-dz-profile="squaring-semantic-1"][data-dz-appearance^="light-"]'1108 const squaringDark = ':root[data-dz-profile="squaring-semantic-1"][data-dz-appearance^="dark-"]'1109 const squaringAutomatic = ':root[data-dz-profile="squaring-semantic-1"][data-dz-appearance^="auto-"]'1110 const squaringVariables = `${squaringLight}{${squaringPaletteVariables(SQUARING_LIGHT_PALETTE)}}${squaringDark}{${squaringPaletteVariables(SQUARING_DARK_PALETTE)}}${squaringAutomatic}{${squaringPaletteVariables(SQUARING_LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){${squaringAutomatic}{${squaringPaletteVariables(SQUARING_DARK_PALETTE)}}}`1111 const trnVariables = `${trnLight}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}${trnAutomatic}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}@media(prefers-color-scheme:dark){${trnAutomatic}{${branchPaletteVariables(DARK_PALETTE)}}}`1112 const materializerVisibility = `:root[data-dz-appearance="raw"] [data-dz-owned="materializer"],:root[data-dz-appearance$="-transparent"] [data-dz-owned="materializer"]{display:none!important}`1113 const roles = `${themed} [data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}${themed} [data-dz-fill="none"]{fill:none!important}${themed} [data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}${themed} [data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}${themed} [data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}${themed} [data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}${themed} [data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}${themed} [data-dz-fill="line"]{fill:var(--dz-line)!important}${themed} [data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}${themed} [data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}${themed} [data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}${themed} [data-dz-fill="accent-4"]{fill:var(--dz-accent-4)!important}${themed} [data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}${themed} [data-dz-stroke="line"]{stroke:var(--dz-line)!important}${themed} [data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}${themed} [data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}${themed} [data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}${themed} [data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}${themed} [data-dz-stroke="accent-4"]{stroke:var(--dz-accent-4)!important}`1114 return `${variables}${trnVariables}${squaringVariables}${materializerVisibility}${roles}${squaringRoleCss(themed)}`1115}11161117function canonicalBounds(root) {1118 const value = root.attributes.get('data-dz-bounds')?.trim().split(/\s+/).map(Number)1119 if (value?.length === 4 && value.every(Number.isFinite) && value[2] > 0 && value[3] > 0) return value1120 return presentationBounds(root)1121}11221123function removeMaterializerOutput(root) {1124 root.children = root.children.filter(child => child.type === 'text' || child.attributes.get('data-dz-owned') !== 'materializer')1125}11261127export function materializeSvg(canonical, appearance) {1128 if (!APPEARANCES.includes(appearance)) {1129 throw new SvgNormalizationError(400, 'invalid_appearance', `Unknown SVG appearance: ${appearance}.`)1130 }1131 const root = parseSafeSvg(canonical)1132 if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {1133 throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')1134 }1135 removeMaterializerOutput(root)1136 if (appearance === 'raw') {1137 root.attributes.set('data-dz-appearance', 'raw')1138 root.attributes.set('data-dz-palette', 'renderer')1139 root.attributes.delete('data-dz-materializer')1140 return serialize(root)1141 }1142 const supported = new Set((root.attributes.get('data-dz-appearances') ?? '').split(/\s+/).filter(Boolean))1143 if (!supported.has(appearance)) {1144 throw new SvgNormalizationError(422, 'unsupported_appearance', `The active SVG normalization profile does not support ${appearance}.`)1145 }1146 const bounds = canonicalBounds(root)1147 if (bounds === null) throw new SvgNormalizationError(422, 'missing_dimensions', 'Canonical SVG has no usable bounds for appearance materialization.')1148 root.attributes.set('data-dz-appearance', appearance)1149 root.attributes.set('data-dz-palette', PALETTE_BUILD)1150 root.attributes.set('data-dz-materializer', MATERIALIZER_BUILD)1151 root.children.unshift(element('style', [['data-dz-owned', 'materializer']], [{ type: 'text', value: materializerCss() }]))1152 if (appearance.endsWith('-framed')) {1153 const [x, y, width, height] = bounds1154 const padding = 241155 const next = [x - padding, y - padding, width + padding * 2, height + padding * 2]1156 root.attributes.set('viewBox', next.join(' '))1157 if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))1158 if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))1159 root.children.splice(1, 0, element('rect', [1160 ['data-dz-owned', 'materializer'], ['data-dz-role', 'canvas'], ['x', String(next[0])], ['y', String(next[1])],1161 ['width', String(next[2])], ['height', String(next[3])], ['fill', 'var(--dz-canvas)'],1162 ]))1163 root.children.push(element('rect', [1164 ['data-dz-owned', 'materializer'], ['data-dz-role', 'frame'], ['x', String(next[0] + 0.5)], ['y', String(next[1] + 0.5)],1165 ['width', String(next[2] - 1)], ['height', String(next[3] - 1)], ['fill', 'none'], ['stroke', 'var(--dz-frame)'],1166 ['stroke-width', '1'], ['vector-effect', 'non-scaling-stroke'],1167 ]))1168 } else {1169 root.attributes.set('viewBox', bounds.join(' '))1170 if (root.attributes.has('width')) root.attributes.set('width', String(bounds[2]))1171 if (root.attributes.has('height')) root.attributes.set('height', String(bounds[3]))1172 }1173 return serialize(root)1174}11751176export function supportedAppearances(canonical) {1177 const root = parseSafeSvg(canonical)1178 if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {1179 throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')1180 }1181 return Object.freeze([1182 'raw',1183 ...(root.attributes.get('data-dz-appearances') ?? '').split(/\s+/).filter(appearance => APPEARANCES.includes(appearance)),1184 ])1185}