- commit
- d3b87c1fcf1fc14be5af21f55573624664d7b9ce
- parent
- aa5f209734f9f64cec7d1fc116167a53bc64cc7b
- author
- Dami <dami.etoile@gmail.com>
| M | diagramzip-render/ | 3 | + |
| M | diagramzip-render/ | 293 | +-------------- |
| M | diagramzip-render/ | 2 | + |
| M | diagramzip-render/ | 12 | + |
| M | diagramzip-render/ | 6 | + |
| A | diagramzip-svg/ | 45 | ++ |
| A | diagramzip-svg/ | 274 | ++++++++++++++ |
| M | diagramzip/ | 252 | +------------- |
| M | diagramzip/ | 3 | + |
| A | diagramzip/ | 46 | ++ |
| A | docs/ | 576 | ++++++++++++++++++++++++++++++ |
@@ -1,4 +1,5 @@
11 import { ENGINE_IDS, type EngineId, type EngineRuntime } from './types'2+import { RAW_NORMALIZATION, type NormalizationCapability } from '../../diagramzip-svg/index.js'23 34 export interface EngineCatalogEntry {45 id: EngineId@@ -6,6 +7,7 @@ export interface EngineCatalogEntry {
67 activeRuntime: EngineRuntime78 version: string89 knownLosses: readonly string[]10+ normalization: NormalizationCapability911 }1012 1113 const targetRuntime: Record<EngineId, EngineRuntime> = {@@ -219,6 +221,7 @@ export const ENGINE_CATALOG: readonly EngineCatalogEntry[] = ENGINE_IDS.map(id =
219221 activeRuntime: activeRuntime[id],220222 version: versions[id],221223 knownLosses: losses[id] ?? [],224+ normalization: RAW_NORMALIZATION,222225 }))223226 224227 export const ENGINE_CATALOG_BY_ID = new Map(ENGINE_CATALOG.map(entry => [entry.id, entry]))@@ -1,285 +1,22 @@
1-import { SaxesParser, type SaxesTagNS } from 'saxes'1+import {2+ SvgNormalizationError,3+ sanitizeAndDecorateSvg as normalizeSharedSvg,4+} from '../../diagramzip-svg/index.js'25 import { RenderError } from './errors'36 import type { EngineId, RenderMetadata, RenderPresentation } from './types'47 5-type SvgNode = SvgElement | { type: 'text'; value: string }6-7-interface SvgElement {8- type: 'element'9- name: string10- attributes: Map<string, string>11- children: SvgNode[]12-}13-14-const MAX_SVG_LENGTH = 4_194_30415-const BLOCKED_ELEMENTS = new Set(['script', 'iframe', 'object', 'embed', 'audio', 'video'])16-const FOREIGN_OBJECT_ELEMENTS = new Set([17- 'foreignobject', 'div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'ul', 'ol', 'li', 'a',18-])19-const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]+$/i20-const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\/(?:x-)?font-woff2?);base64,[a-z0-9+/=\s]+$/i21-22-function safeCssReference(value: string): boolean {23- const normalized = value.trim()24- return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized) || SAFE_DATA_FONT.test(normalized)25-}26-27-function unsafeCss(value: string): boolean {28- if (/@import|expression\s*\(|javascript\s*:|data\s*:\s*text\/html|behavior\s*:|-moz-binding/i.test(value)) return true29- const urlPattern = /url\s*\(\s*(['"]?)(.*?)\1\s*\)/gi30- const urls = value.matchAll(urlPattern)31- for (const match of urls) {32- if (!safeCssReference(match[2] ?? '')) return true33- }34- return /url\s*\(/i.test(value.replace(urlPattern, ''))35-}36-37-function safeReference(value: string): boolean {38- const normalized = value.trim()39- return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized)40-}41-42-function safeAttribute(name: string, value: string): boolean {43- const normalizedName = name.toLowerCase()44- if (normalizedName.startsWith('on')) return false45- if (normalizedName === 'href' || normalizedName === 'xlink:href' || normalizedName === 'src') {46- return safeReference(value)47- }48- if (normalizedName === 'style' || normalizedName === 'filter' || normalizedName === 'fill' || normalizedName === 'stroke' || normalizedName === 'clip-path' || normalizedName === 'mask') {49- return !unsafeCss(value)50- }51- if (normalizedName === 'xml:base') return false52- return !/javascript\s*:/i.test(value)53-}54-55-function escapeText(value: string): string {56- return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')57-}58-59-function escapeAttribute(value: string): string {60- return escapeText(value).replaceAll('"', '"')61-}62-63-function serialize(node: SvgNode): string {64- if (node.type === 'text') return escapeText(node.value)65- const attributes = [...node.attributes]66- .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)67- .join('')68- return `<${node.name}${attributes}>${node.children.map(serialize).join('')}</${node.name}>`69-}70-71-function localName(element: SvgElement): string {72- return element.name.toLowerCase().split(':').at(-1) ?? element.name.toLowerCase()73-}74-75-function numericDimension(value: string | undefined): number | null {76- if (value === undefined || !/^-?\d+(?:\.\d+)?(?:px)?$/.test(value.trim())) return null77- const parsed = Number.parseFloat(value)78- return Number.isFinite(parsed) && parsed > 0 ? parsed : null79-}80-81-function presentationBounds(root: SvgElement): [number, number, number, number] | null {82- const viewBox = root.attributes.get('viewBox')?.trim().split(/[\s,]+/).map(Number)83- if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2]! > 0 && viewBox[3]! > 0) {84- return [viewBox[0]!, viewBox[1]!, viewBox[2]!, viewBox[3]!]85- }86- const width = numericDimension(root.attributes.get('width'))87- const height = numericDimension(root.attributes.get('height'))88- return width !== null && height !== null ? [0, 0, width, height] : null89-}90-91-function styleValue(element: SvgElement, property: string): string | null {92- const style = element.attributes.get('style')93- if (!style) return null94- for (const declaration of style.split(';')) {95- const separator = declaration.indexOf(':')96- if (separator < 0) continue97- if (declaration.slice(0, separator).trim().toLowerCase() === property) {98- return declaration.slice(separator + 1).trim()99- }100- }101- return null102-}103-104-function whiteFill(element: SvgElement): boolean {105- const fill = (element.attributes.get('fill') ?? styleValue(element, 'fill') ?? '').replaceAll(' ', '').toLowerCase()106- return fill === 'white' || fill === '#fff' || fill === '#ffffff' || fill === 'rgb(255,255,255)'107-}108-109-function numericCoordinate(value: string | undefined): number | null {110- if (value === undefined || !/^-?\d+(?:\.\d+)?$/.test(value.trim())) return null111- const number = Number(value)112- return Number.isFinite(number) ? number : null113-}114-115-function rectCoversBounds(element: SvgElement, bounds: [number, number, number, number]): boolean {116- const widthValue = element.attributes.get('width')?.trim()117- const heightValue = element.attributes.get('height')?.trim()118- if (widthValue === '100%' && heightValue === '100%') return true119- const x = numericCoordinate(element.attributes.get('x') ?? '0')120- const y = numericCoordinate(element.attributes.get('y') ?? '0')121- const width = numericCoordinate(widthValue)122- const height = numericCoordinate(heightValue)123- if (x === null || y === null || width === null || height === null) return false124- const [boundX, boundY, boundWidth, boundHeight] = bounds125- const tolerance = 0.01126- return x <= boundX + tolerance127- && y <= boundY + tolerance128- && x + width >= boundX + boundWidth - tolerance129- && y + height >= boundY + boundHeight - tolerance130-}131-132-function rendererBackdrop(element: SvgElement, bounds: [number, number, number, number] | null, engine: EngineId): boolean {133- const name = localName(element)134- const classes = new Set((element.attributes.get('class') ?? '').toLowerCase().split(/\s+/))135- if (name === 'rect' && classes.has('backdrop')) return true136- if (name === 'rect' && bounds !== null && whiteFill(element) && rectCoversBounds(element, bounds)) return true137- return engine === 'graphviz'138- && name === 'polygon'139- && whiteFill(element)140- && (element.attributes.get('stroke') ?? '').toLowerCase() === 'none'141-}142-143-function removeRendererBackdrops(element: SvgElement, inheritedBounds: [number, number, number, number] | null, engine: EngineId): void {144- const bounds = localName(element) === 'svg' ? presentationBounds(element) ?? inheritedBounds : inheritedBounds145- element.children = element.children.filter(child => {146- if (child.type === 'text') return true147- if (rendererBackdrop(child, bounds, engine)) return false148- removeRendererBackdrops(child, bounds, engine)149- return true150- })151-}152-153-function applyRootBackgroundStyle(root: SvgElement, background: string): void {154- const declarations = (root.attributes.get('style') ?? '')155- .split(';')156- .map(value => value.trim())157- .filter(Boolean)158- .filter(value => !/^background(?:-color)?\s*:/i.test(value))159- declarations.push(`background-color:${background}`)160- root.attributes.set('style', `${declarations.join(';')};`)161-}162-163-function decorate(root: SvgElement, metadata: RenderMetadata, presentation: RenderPresentation, engine: EngineId): void {164- const additions: SvgNode[] = []165- let frame: SvgElement | null = null166- if (metadata.title) additions.push({ type: 'element', name: 'title', attributes: new Map(), children: [{ type: 'text', value: metadata.title }] })167- if (metadata.description) additions.push({ type: 'element', name: 'desc', attributes: new Map(), children: [{ type: 'text', value: metadata.description }] })168-169- const bounds = presentationBounds(root)170- if ((presentation.padding || presentation.background || presentation.frame) && bounds === null) {171- throw new RenderError(422, 'missing_dimensions', 'Renderer output has no usable SVG dimensions for presentation settings.')172- }173- if (bounds !== null) {174- const [x, y, width, height] = bounds175- const padding = presentation.padding176- const next: [number, number, number, number] = [x - padding, y - padding, width + padding * 2, height + padding * 2]177- root.attributes.set('viewBox', next.join(' '))178- if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))179- if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))180- if (presentation.background) {181- const attributes = new Map([182- ['x', String(next[0])],183- ['y', String(next[1])],184- ['width', String(next[2])],185- ['height', String(next[3])],186- ['fill', presentation.background],187- ])188- additions.push({ type: 'element', name: 'rect', attributes, children: [] })189- }190- if (presentation.frame) {191- frame = {192- type: 'element',193- name: 'rect',194- attributes: new Map([195- ['x', String(next[0] + 0.5)],196- ['y', String(next[1] + 0.5)],197- ['width', String(Math.max(0, next[2] - 1))],198- ['height', String(Math.max(0, next[3] - 1))],199- ['fill', 'none'],200- ['stroke', '#000000'],201- ['stroke-width', '1'],202- ['vector-effect', 'non-scaling-stroke'],203- ]),204- children: [],205- }206- }207- }208- if (presentation.background) {209- removeRendererBackdrops(root, bounds, engine)210- applyRootBackgroundStyle(root, presentation.background)211- }212- root.children.unshift(...additions)213- if (frame) root.children.push(frame)214-}215-216-export function sanitizeAndDecorateSvg(source: string, metadata: RenderMetadata, presentation: RenderPresentation, engine: EngineId): string {217- if (source.length > MAX_SVG_LENGTH) throw new RenderError(413, 'render_too_large', 'Rendered SVG is too large.')218- let root: SvgElement | null = null219- const stack: SvgElement[] = []220- let skippedDepth = 0221- let parseError: Error | null = null222- const parser = new SaxesParser({ xmlns: true })223-224- parser.on('doctype', value => {225- // Saxes does not fetch external DTDs. Standard renderer declarations are226- // discarded, while internal subsets are rejected to avoid custom entities.227- if (value.includes('[')) parseError = new Error('Internal DOCTYPE subsets are not allowed')228- })229- parser.on('opentag', (tag: SaxesTagNS) => {230- const tagLocalName = tag.local.toLowerCase()231- const insideForeignObject = tagLocalName === 'foreignobject' || stack.some(element => localName(element) === 'foreignobject')232- if (skippedDepth > 0 || BLOCKED_ELEMENTS.has(tagLocalName) || (insideForeignObject && !FOREIGN_OBJECT_ELEMENTS.has(tagLocalName))) {233- skippedDepth++234- return235- }236- const element: SvgElement = { type: 'element', name: tag.name, attributes: new Map(), children: [] }237- for (const attribute of Object.values(tag.attributes)) {238- const name = attribute.name239- if (safeAttribute(name, attribute.value)) element.attributes.set(name, attribute.value)240- }241- const parent = stack.at(-1)242- if (parent) parent.children.push(element)243- else if (root === null) root = element244- else parseError = new Error('Multiple root elements')245- stack.push(element)246- })247- const appendText = (value: string) => {248- if (skippedDepth > 0 || value === '') return249- const parent = stack.at(-1)250- if (parent) {251- if (localName(parent) === 'style' && unsafeCss(value)) {252- throw new RenderError(422, 'unsafe_svg', 'Renderer output contains unsafe CSS.')253- }254- parent.children.push({ type: 'text', value })255- } else if (value.trim() !== '') {256- parseError = new Error('Text outside root element')257- }258- }259- parser.on('text', appendText)260- parser.on('cdata', appendText)261- parser.on('closetag', () => {262- if (skippedDepth > 0) {263- skippedDepth--264- return265- }266- stack.pop()267- })268- parser.on('error', error => {269- parseError = error270- })271-8+export function sanitizeAndDecorateSvg(9+ source: string,10+ metadata: RenderMetadata,11+ presentation: RenderPresentation,12+ engine: EngineId,13+): string {27214 try {273- parser.write(source).close()15+ return normalizeSharedSvg(source, metadata, presentation, engine)27416 } catch (error) {275- if (error instanceof RenderError) throw error276- parseError = error instanceof Error ? error : new Error(String(error))277- }278- if (parseError !== null || root === null) {279- throw new RenderError(422, 'invalid_svg', 'Renderer returned invalid SVG.')17+ if (error instanceof SvgNormalizationError) {18+ throw new RenderError(error.status, error.code, error.message)19+ }20+ throw error28021 }281- const parsedRoot = root as SvgElement282- if (parsedRoot.name.toLowerCase() !== 'svg') throw new RenderError(422, 'invalid_svg', 'Renderer returned invalid SVG.')283- decorate(parsedRoot, metadata, presentation, engine)284- return serialize(parsedRoot)28522 }@@ -2,6 +2,7 @@ import { RenderError, RequestError } from './errors'
22 import { sanitizeAndDecorateSvg } from './svg'33 import type { EngineId, RendererAdapter, RenderRequest } from './types'44 import { parseUnitRenderRequest } from './validation'5+import { RAW_NORMALIZATION } from '../../diagramzip-svg/index.js'56 67 const CACHE_MAX_AGE = 1_80078 const CACHE_SCHEMA = '2'@@ -105,6 +106,7 @@ function capabilities(unitId: string, descriptor: RendererUnitDescriptor, build:
105106 build,106107 pipeline: [unitId, ...(descriptor.pipeline ?? [])],107108 knownLosses: descriptor.knownLosses ?? [],109+ normalization: RAW_NORMALIZATION,108110 }, { headers: { 'Cache-Control': 'public, max-age=300' } })109111 }110112 @@ -9,6 +9,18 @@ describe('engine catalog', () => {
99 expect(ENGINE_CATALOG.map(engine => engine.id)).toEqual(ENGINE_IDS)1010 })1111 12+ it('reports raw-only normalization until an engine profile earns themed support', () => {13+ for (const engine of ENGINE_CATALOG) {14+ expect(engine.normalization).toMatchObject({15+ schema: '1',16+ normalizer: 'svg-normalizer-1',17+ profile: 'safe-raw-1',18+ conformance: 'raw',19+ appearances: ['raw'],20+ })21+ }22+ })23+1224 it('marks the BlockDiag family as edge Python', () => {1325 const family = ENGINE_CATALOG.filter(engine => [1426 'blockdiag', 'seqdiag', 'actdiag', 'nwdiag', 'packetdiag', 'rackdiag',@@ -62,6 +62,12 @@ describe('renderer unit protocol', () => {
6262 id: 'vegalite',6363 kind: 'translate',6464 pipeline: ['vegalite', 'vega'],65+ normalization: {66+ schema: '1',67+ profile: 'safe-raw-1',68+ conformance: 'raw',69+ appearances: ['raw'],70+ },6571 })6672 })6773 })@@ -0,0 +1,45 @@
1+export const SVG_SCHEMA: '1'2+export const NORMALIZER_BUILD: 'svg-normalizer-1'3+export const RAW_PROFILE: 'safe-raw-1'4+export const APPEARANCES: readonly [5+ 'raw',6+ 'auto-transparent',7+ 'light-transparent',8+ 'dark-transparent',9+ 'auto-framed',10+ 'light-framed',11+ 'dark-framed',12+]13+export interface NormalizationCapability {14+ readonly schema: string15+ readonly normalizer: string16+ readonly profile: string17+ readonly palette: string18+ readonly conformance: 'raw' | 'semantic' | 'adaptive' | 'presentation-only' | 'unsupported'19+ readonly appearances: readonly string[]20+}21+export const RAW_NORMALIZATION: NormalizationCapability22+23+export class SvgNormalizationError extends Error {24+ readonly status: number25+ readonly code: string26+ constructor(status: number, code: string, message: string)27+}28+29+export interface SvgMetadata {30+ title: string31+ description: string32+}33+34+export interface SvgPresentation {35+ background: string36+ padding: number37+ frame: boolean38+}39+40+export function sanitizeAndDecorateSvg(41+ source: string,42+ metadata: SvgMetadata,43+ presentation: SvgPresentation,44+ engine: string,45+): string@@ -0,0 +1,274 @@
1+import { SaxesParser } from 'saxes'2+3+const MAX_SVG_LENGTH = 4_194_3044+const BLOCKED_ELEMENTS = new Set(['script', 'iframe', 'object', 'embed', 'audio', 'video'])5+const FOREIGN_OBJECT_ELEMENTS = new Set([6+ 'foreignobject', 'div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'ul', 'ol', 'li', 'a',7+])8+const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]+$/i9+const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\/(?:x-)?font-woff2?);base64,[a-z0-9+/=\s]+$/i10+11+export const SVG_SCHEMA = '1'12+export const NORMALIZER_BUILD = 'svg-normalizer-1'13+export const RAW_PROFILE = 'safe-raw-1'14+export const APPEARANCES = Object.freeze([15+ 'raw',16+ 'auto-transparent',17+ 'light-transparent',18+ 'dark-transparent',19+ 'auto-framed',20+ 'light-framed',21+ 'dark-framed',22+])23+export const RAW_NORMALIZATION = Object.freeze({24+ schema: SVG_SCHEMA,25+ normalizer: NORMALIZER_BUILD,26+ profile: RAW_PROFILE,27+ palette: 'renderer',28+ conformance: 'raw',29+ appearances: Object.freeze(['raw']),30+})31+32+export class SvgNormalizationError extends Error {33+ constructor(status, code, message) {34+ super(message)35+ this.name = 'SvgNormalizationError'36+ this.status = status37+ this.code = code38+ }39+}40+41+function safeCssReference(value) {42+ const normalized = value.trim()43+ return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized) || SAFE_DATA_FONT.test(normalized)44+}45+46+function unsafeCss(value) {47+ if (/@import|expression\s*\(|javascript\s*:|data\s*:\s*text\/html|behavior\s*:|-moz-binding/i.test(value)) return true48+ const urlPattern = /url\s*\(\s*(['"]?)(.*?)\1\s*\)/gi49+ for (const match of value.matchAll(urlPattern)) {50+ if (!safeCssReference(match[2] ?? '')) return true51+ }52+ return /url\s*\(/i.test(value.replace(urlPattern, ''))53+}54+55+function safeReference(value) {56+ const normalized = value.trim()57+ return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized)58+}59+60+function safeAttribute(name, value) {61+ const normalizedName = name.toLowerCase()62+ if (normalizedName.startsWith('on')) return false63+ if (normalizedName === 'href' || normalizedName === 'xlink:href' || normalizedName === 'src') return safeReference(value)64+ if (['style', 'filter', 'fill', 'stroke', 'clip-path', 'mask'].includes(normalizedName)) return !unsafeCss(value)65+ if (normalizedName === 'xml:base') return false66+ return !/javascript\s*:/i.test(value)67+}68+69+function escapeText(value) {70+ return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')71+}72+73+function escapeAttribute(value) {74+ return escapeText(value).replaceAll('"', '"')75+}76+77+function serialize(node) {78+ if (node.type === 'text') return escapeText(node.value)79+ const attributes = [...node.attributes].map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join('')80+ return `<${node.name}${attributes}>${node.children.map(serialize).join('')}</${node.name}>`81+}82+83+function localName(element) {84+ return element.name.toLowerCase().split(':').at(-1) ?? element.name.toLowerCase()85+}86+87+function numericDimension(value) {88+ if (value === undefined || !/^-?\d+(?:\.\d+)?(?:px)?$/.test(value.trim())) return null89+ const parsed = Number.parseFloat(value)90+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null91+}92+93+function presentationBounds(root) {94+ const viewBox = root.attributes.get('viewBox')?.trim().split(/[\s,]+/).map(Number)95+ if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) return viewBox96+ const width = numericDimension(root.attributes.get('width'))97+ const height = numericDimension(root.attributes.get('height'))98+ return width !== null && height !== null ? [0, 0, width, height] : null99+}100+101+function styleValue(element, property) {102+ const style = element.attributes.get('style')103+ if (!style) return null104+ for (const declaration of style.split(';')) {105+ const separator = declaration.indexOf(':')106+ if (separator >= 0 && declaration.slice(0, separator).trim().toLowerCase() === property) {107+ return declaration.slice(separator + 1).trim()108+ }109+ }110+ return null111+}112+113+function whiteFill(element) {114+ const fill = (element.attributes.get('fill') ?? styleValue(element, 'fill') ?? '').replaceAll(' ', '').toLowerCase()115+ return fill === 'white' || fill === '#fff' || fill === '#ffffff' || fill === 'rgb(255,255,255)'116+}117+118+function numericCoordinate(value) {119+ if (value === undefined || !/^-?\d+(?:\.\d+)?$/.test(value.trim())) return null120+ const number = Number(value)121+ return Number.isFinite(number) ? number : null122+}123+124+function rectCoversBounds(element, bounds) {125+ const widthValue = element.attributes.get('width')?.trim()126+ const heightValue = element.attributes.get('height')?.trim()127+ if (widthValue === '100%' && heightValue === '100%') return true128+ const x = numericCoordinate(element.attributes.get('x') ?? '0')129+ const y = numericCoordinate(element.attributes.get('y') ?? '0')130+ const width = numericCoordinate(widthValue)131+ const height = numericCoordinate(heightValue)132+ if ([x, y, width, height].some(value => value === null)) return false133+ const [boundX, boundY, boundWidth, boundHeight] = bounds134+ const tolerance = 0.01135+ return x <= boundX + tolerance136+ && y <= boundY + tolerance137+ && x + width >= boundX + boundWidth - tolerance138+ && y + height >= boundY + boundHeight - tolerance139+}140+141+function rendererBackdrop(element, bounds, engine) {142+ const name = localName(element)143+ const classes = new Set((element.attributes.get('class') ?? '').toLowerCase().split(/\s+/))144+ if (name === 'rect' && classes.has('backdrop')) return true145+ if (name === 'rect' && bounds !== null && whiteFill(element) && rectCoversBounds(element, bounds)) return true146+ return engine === 'graphviz' && name === 'polygon' && whiteFill(element) && (element.attributes.get('stroke') ?? '').toLowerCase() === 'none'147+}148+149+function removeRendererBackdrops(element, inheritedBounds, engine) {150+ const bounds = localName(element) === 'svg' ? presentationBounds(element) ?? inheritedBounds : inheritedBounds151+ element.children = element.children.filter(child => {152+ if (child.type === 'text') return true153+ if (rendererBackdrop(child, bounds, engine)) return false154+ removeRendererBackdrops(child, bounds, engine)155+ return true156+ })157+}158+159+function applyRootBackgroundStyle(root, background) {160+ const declarations = (root.attributes.get('style') ?? '')161+ .split(';')162+ .map(value => value.trim())163+ .filter(Boolean)164+ .filter(value => !/^background(?:-color)?\s*:/i.test(value))165+ declarations.push(`background-color:${background}`)166+ root.attributes.set('style', `${declarations.join(';')};`)167+}168+169+function element(name, attributes = [], children = []) {170+ return { type: 'element', name, attributes: new Map(attributes), children }171+}172+173+function decorate(root, metadata, presentation, engine) {174+ const additions = []175+ let frame = null176+ if (metadata.title) additions.push(element('title', [], [{ type: 'text', value: metadata.title }]))177+ if (metadata.description) additions.push(element('desc', [], [{ type: 'text', value: metadata.description }]))178+ const bounds = presentationBounds(root)179+ if ((presentation.padding || presentation.background || presentation.frame) && bounds === null) {180+ throw new SvgNormalizationError(422, 'missing_dimensions', 'Renderer output has no usable SVG dimensions for presentation settings.')181+ }182+ if (bounds !== null) {183+ const [x, y, width, height] = bounds184+ const padding = presentation.padding185+ const next = [x - padding, y - padding, width + padding * 2, height + padding * 2]186+ root.attributes.set('viewBox', next.join(' '))187+ if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))188+ if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))189+ if (presentation.background) {190+ additions.push(element('rect', [191+ ['x', String(next[0])], ['y', String(next[1])], ['width', String(next[2])], ['height', String(next[3])], ['fill', presentation.background],192+ ]))193+ }194+ if (presentation.frame) {195+ frame = element('rect', [196+ ['x', String(next[0] + 0.5)], ['y', String(next[1] + 0.5)],197+ ['width', String(Math.max(0, next[2] - 1))], ['height', String(Math.max(0, next[3] - 1))],198+ ['fill', 'none'], ['stroke', '#000000'], ['stroke-width', '1'], ['vector-effect', 'non-scaling-stroke'],199+ ])200+ }201+ }202+ if (presentation.background) {203+ removeRendererBackdrops(root, bounds, engine)204+ applyRootBackgroundStyle(root, presentation.background)205+ }206+ root.children.unshift(...additions)207+ if (frame) root.children.push(frame)208+}209+210+export function sanitizeAndDecorateSvg(source, metadata, presentation, engine) {211+ if (source.length > MAX_SVG_LENGTH) throw new SvgNormalizationError(413, 'render_too_large', 'Rendered SVG is too large.')212+ let root = null213+ const stack = []214+ let skippedDepth = 0215+ let parseError = null216+ const parser = new SaxesParser({ xmlns: true })217+218+ parser.on('doctype', value => {219+ if (value.includes('[')) parseError = new Error('Internal DOCTYPE subsets are not allowed')220+ })221+ parser.on('opentag', tag => {222+ const tagLocalName = tag.local.toLowerCase()223+ const insideForeignObject = tagLocalName === 'foreignobject' || stack.some(item => localName(item) === 'foreignobject')224+ if (skippedDepth > 0 || BLOCKED_ELEMENTS.has(tagLocalName) || (insideForeignObject && !FOREIGN_OBJECT_ELEMENTS.has(tagLocalName))) {225+ skippedDepth++226+ return227+ }228+ const next = element(tag.name)229+ for (const attribute of Object.values(tag.attributes)) {230+ if (safeAttribute(attribute.name, attribute.value)) next.attributes.set(attribute.name, attribute.value)231+ }232+ const parent = stack.at(-1)233+ if (parent) parent.children.push(next)234+ else if (root === null) root = next235+ else parseError = new Error('Multiple root elements')236+ stack.push(next)237+ })238+ const appendText = value => {239+ if (skippedDepth > 0 || value === '') return240+ const parent = stack.at(-1)241+ if (parent) {242+ if (localName(parent) === 'style' && unsafeCss(value)) throw new SvgNormalizationError(422, 'unsafe_svg', 'Renderer output contains unsafe CSS.')243+ parent.children.push({ type: 'text', value })244+ } else if (value.trim() !== '') {245+ parseError = new Error('Text outside root element')246+ }247+ }248+ parser.on('text', appendText)249+ parser.on('cdata', appendText)250+ parser.on('closetag', () => {251+ if (skippedDepth > 0) skippedDepth--252+ else stack.pop()253+ })254+ parser.on('error', error => { parseError = error })255+256+ try {257+ parser.write(source).close()258+ } catch (error) {259+ if (error instanceof SvgNormalizationError) throw error260+ parseError = error261+ }262+ if (parseError !== null || root === null || localName(root) !== 'svg') {263+ throw new SvgNormalizationError(422, 'invalid_svg', 'Renderer returned invalid SVG.')264+ }265+ root.attributes.set('data-dz-schema', SVG_SCHEMA)266+ 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')269+ root.attributes.set('data-dz-engine', engine)270+ root.attributes.set('data-dz-appearance', 'raw')271+ root.attributes.set('data-dz-conformance', 'raw')272+ decorate(root, metadata, presentation, engine)273+ return serialize(root)274+}@@ -1,244 +1,8 @@
1-import { SaxesParser } from 'saxes'2-3-const MAX_SVG_LENGTH = 4_194_3044-const BLOCKED_ELEMENTS = new Set(['script', 'iframe', 'object', 'embed', 'audio', 'video'])5-const FOREIGN_OBJECT_ELEMENTS = new Set([6- 'foreignobject', 'div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'ul', 'ol', 'li', 'a',7-])8-const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]+$/i9-const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\/(?:x-)?font-woff2?);base64,[a-z0-9+/=\s]+$/i10-11-export class ClientSvgError extends Error {12- constructor(message) {13- super(message)14- this.name = 'ClientSvgError'15- }16-}17-18-function safeCssReference(value) {19- const normalized = value.trim()20- return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized) || SAFE_DATA_FONT.test(normalized)21-}22-23-function unsafeCss(value) {24- if (/@import|expression\s*\(|javascript\s*:|data\s*:\s*text\/html|behavior\s*:|-moz-binding/i.test(value)) return true25- const urlPattern = /url\s*\(\s*(['"]?)(.*?)\1\s*\)/gi26- for (const match of value.matchAll(urlPattern)) {27- if (!safeCssReference(match[2] ?? '')) return true28- }29- return /url\s*\(/i.test(value.replace(urlPattern, ''))30-}31-32-function safeReference(value) {33- const normalized = value.trim()34- return normalized.startsWith('#') || SAFE_DATA_IMAGE.test(normalized)35-}36-37-function safeAttribute(name, value) {38- const normalizedName = name.toLowerCase()39- if (normalizedName.startsWith('on')) return false40- if (normalizedName === 'href' || normalizedName === 'xlink:href' || normalizedName === 'src') return safeReference(value)41- if (['style', 'filter', 'fill', 'stroke', 'clip-path', 'mask'].includes(normalizedName)) return !unsafeCss(value)42- if (normalizedName === 'xml:base') return false43- return !/javascript\s*:/i.test(value)44-}45-46-function escapeText(value) {47- return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')48-}49-50-function escapeAttribute(value) {51- return escapeText(value).replaceAll('"', '"')52-}53-54-function serialize(node) {55- if (node.type === 'text') return escapeText(node.value)56- const attributes = [...node.attributes].map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join('')57- return `<${node.name}${attributes}>${node.children.map(serialize).join('')}</${node.name}>`58-}59-60-function localName(element) {61- return element.name.toLowerCase().split(':').at(-1) ?? element.name.toLowerCase()62-}63-64-function numericDimension(value) {65- if (value === undefined || !/^-?\d+(?:\.\d+)?(?:px)?$/.test(value.trim())) return null66- const parsed = Number.parseFloat(value)67- return Number.isFinite(parsed) && parsed > 0 ? parsed : null68-}69-70-function presentationBounds(root) {71- const viewBox = root.attributes.get('viewBox')?.trim().split(/[\s,]+/).map(Number)72- if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) return viewBox73- const width = numericDimension(root.attributes.get('width'))74- const height = numericDimension(root.attributes.get('height'))75- return width !== null && height !== null ? [0, 0, width, height] : null76-}77-78-function styleValue(element, property) {79- const style = element.attributes.get('style')80- if (!style) return null81- for (const declaration of style.split(';')) {82- const separator = declaration.indexOf(':')83- if (separator >= 0 && declaration.slice(0, separator).trim().toLowerCase() === property) {84- return declaration.slice(separator + 1).trim()85- }86- }87- return null88-}89-90-function whiteFill(element) {91- const fill = (element.attributes.get('fill') ?? styleValue(element, 'fill') ?? '').replaceAll(' ', '').toLowerCase()92- return fill === 'white' || fill === '#fff' || fill === '#ffffff' || fill === 'rgb(255,255,255)'93-}94-95-function numericCoordinate(value) {96- if (value === undefined || !/^-?\d+(?:\.\d+)?$/.test(value.trim())) return null97- const number = Number(value)98- return Number.isFinite(number) ? number : null99-}100-101-function rectCoversBounds(element, bounds) {102- const widthValue = element.attributes.get('width')?.trim()103- const heightValue = element.attributes.get('height')?.trim()104- if (widthValue === '100%' && heightValue === '100%') return true105- const x = numericCoordinate(element.attributes.get('x') ?? '0')106- const y = numericCoordinate(element.attributes.get('y') ?? '0')107- const width = numericCoordinate(widthValue)108- const height = numericCoordinate(heightValue)109- if ([x, y, width, height].some(value => value === null)) return false110- const [boundX, boundY, boundWidth, boundHeight] = bounds111- const tolerance = 0.01112- return x <= boundX + tolerance113- && y <= boundY + tolerance114- && x + width >= boundX + boundWidth - tolerance115- && y + height >= boundY + boundHeight - tolerance116-}117-118-function rendererBackdrop(element, bounds, engine) {119- const name = localName(element)120- const classes = new Set((element.attributes.get('class') ?? '').toLowerCase().split(/\s+/))121- if (name === 'rect' && classes.has('backdrop')) return true122- if (name === 'rect' && bounds !== null && whiteFill(element) && rectCoversBounds(element, bounds)) return true123- return engine === 'graphviz' && name === 'polygon' && whiteFill(element) && (element.attributes.get('stroke') ?? '').toLowerCase() === 'none'124-}125-126-function removeRendererBackdrops(element, inheritedBounds, engine) {127- const bounds = localName(element) === 'svg' ? presentationBounds(element) ?? inheritedBounds : inheritedBounds128- element.children = element.children.filter(child => {129- if (child.type === 'text') return true130- if (rendererBackdrop(child, bounds, engine)) return false131- removeRendererBackdrops(child, bounds, engine)132- return true133- })134-}135-136-function applyRootBackgroundStyle(root, background) {137- const declarations = (root.attributes.get('style') ?? '')138- .split(';')139- .map(value => value.trim())140- .filter(Boolean)141- .filter(value => !/^background(?:-color)?\s*:/i.test(value))142- declarations.push(`background-color:${background}`)143- root.attributes.set('style', `${declarations.join(';')};`)144-}145-146-function element(name, attributes = [], children = []) {147- return { type: 'element', name, attributes: new Map(attributes), children }148-}149-150-function decorate(root, metadata, presentation, engine) {151- const additions = []152- let frame = null153- if (metadata.title) additions.push(element('title', [], [{ type: 'text', value: metadata.title }]))154- if (metadata.description) additions.push(element('desc', [], [{ type: 'text', value: metadata.description }]))155- const bounds = presentationBounds(root)156- if ((presentation.padding || presentation.background || presentation.frame) && bounds === null) {157- throw new ClientSvgError('Renderer output has no usable SVG dimensions for presentation settings.')158- }159- if (bounds !== null) {160- const [x, y, width, height] = bounds161- const padding = presentation.padding162- const next = [x - padding, y - padding, width + padding * 2, height + padding * 2]163- root.attributes.set('viewBox', next.join(' '))164- if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))165- if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))166- if (presentation.background) {167- additions.push(element('rect', [168- ['x', String(next[0])], ['y', String(next[1])], ['width', String(next[2])], ['height', String(next[3])], ['fill', presentation.background],169- ]))170- }171- if (presentation.frame) {172- frame = element('rect', [173- ['x', String(next[0] + 0.5)], ['y', String(next[1] + 0.5)],174- ['width', String(Math.max(0, next[2] - 1))], ['height', String(Math.max(0, next[3] - 1))],175- ['fill', 'none'], ['stroke', '#000000'], ['stroke-width', '1'], ['vector-effect', 'non-scaling-stroke'],176- ])177- }178- }179- if (presentation.background) {180- removeRendererBackdrops(root, bounds, engine)181- applyRootBackgroundStyle(root, presentation.background)182- }183- root.children.unshift(...additions)184- if (frame) root.children.push(frame)185-}186-187-export function sanitizeAndDecorateSvg(source, metadata, presentation, engine) {188- if (source.length > MAX_SVG_LENGTH) throw new ClientSvgError('Rendered SVG is too large.')189- let root = null190- const stack = []191- let skippedDepth = 0192- let parseError = null193- const parser = new SaxesParser({ xmlns: true })194-195- parser.on('doctype', value => {196- if (value.includes('[')) parseError = new Error('Internal DOCTYPE subsets are not allowed')197- })198- parser.on('opentag', tag => {199- const tagLocalName = tag.local.toLowerCase()200- const insideForeignObject = tagLocalName === 'foreignobject' || stack.some(item => localName(item) === 'foreignobject')201- if (skippedDepth > 0 || BLOCKED_ELEMENTS.has(tagLocalName) || (insideForeignObject && !FOREIGN_OBJECT_ELEMENTS.has(tagLocalName))) {202- skippedDepth++203- return204- }205- const next = element(tag.name)206- for (const attribute of Object.values(tag.attributes)) {207- if (safeAttribute(attribute.name, attribute.value)) next.attributes.set(attribute.name, attribute.value)208- }209- const parent = stack.at(-1)210- if (parent) parent.children.push(next)211- else if (root === null) root = next212- else parseError = new Error('Multiple root elements')213- stack.push(next)214- })215- const appendText = value => {216- if (skippedDepth > 0 || value === '') return217- const parent = stack.at(-1)218- if (parent) {219- if (localName(parent) === 'style' && unsafeCss(value)) throw new ClientSvgError('Renderer output contains unsafe CSS.')220- parent.children.push({ type: 'text', value })221- } else if (value.trim() !== '') {222- parseError = new Error('Text outside root element')223- }224- }225- parser.on('text', appendText)226- parser.on('cdata', appendText)227- parser.on('closetag', () => {228- if (skippedDepth > 0) skippedDepth--229- else stack.pop()230- })231- parser.on('error', error => { parseError = error })232-233- try {234- parser.write(source).close()235- } catch (error) {236- if (error instanceof ClientSvgError) throw error237- parseError = error238- }239- if (parseError !== null || root === null || localName(root) !== 'svg') {240- throw new ClientSvgError('Renderer returned invalid SVG.')241- }242- decorate(root, metadata, presentation, engine)243- return serialize(root)244-}1+export {2+ APPEARANCES,3+ NORMALIZER_BUILD,4+ RAW_PROFILE,5+ SVG_SCHEMA,6+ SvgNormalizationError as ClientSvgError,7+ sanitizeAndDecorateSvg,8+} from '../../diagramzip-svg/index.js'@@ -22,6 +22,9 @@ test('sanitizes and decorates a client-rendered SVG', () => {
2222 assert.match(rendered, /Alice Agent/)2323 assert.match(rendered, /@keyframes dashdraw/)2424 assert.match(rendered, /vector-effect="non-scaling-stroke"/)25+ assert.match(rendered, /data-dz-schema="1"/)26+ assert.match(rendered, /data-dz-engine="mermaid"/)27+ assert.match(rendered, /data-dz-appearance="raw"/)2528 assert.match(rendered, /width="20" height="10" fill="white" stroke="black"/)2629 assert.doesNotMatch(rendered, /<script|<img|https:\/\/example\.com|onload=/)2730 })@@ -0,0 +1,46 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import {4+ APPEARANCES,5+ RAW_NORMALIZATION,6+ SvgNormalizationError,7+ sanitizeAndDecorateSvg,8+} from '../../diagramzip-svg/index.js'9+10+const metadata = { title: '', description: '' }11+const presentation = { background: '', padding: 0, frame: false }12+const source = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M0 0L10 10" stroke="black"></path></svg>'13+14+test('freezes namespaced appearance and raw capability contracts', () => {15+ assert.deepEqual(APPEARANCES, [16+ 'raw',17+ 'auto-transparent',18+ 'light-transparent',19+ 'dark-transparent',20+ 'auto-framed',21+ 'light-framed',22+ 'dark-framed',23+ ])24+ assert.deepEqual(RAW_NORMALIZATION.appearances, ['raw'])25+})26+27+test('produces deterministic, idempotent raw canonical SVG', () => {28+ const first = sanitizeAndDecorateSvg(source, metadata, presentation, 'pikchr')29+ const repeated = sanitizeAndDecorateSvg(source, metadata, presentation, 'pikchr')30+ const normalizedAgain = sanitizeAndDecorateSvg(first, metadata, presentation, 'pikchr')31+32+ assert.equal(first, repeated)33+ assert.equal(first, normalizedAgain)34+ assert.match(first, /data-dz-normalizer="svg-normalizer-1"/)35+ assert.match(first, /data-dz-profile="safe-raw-1"/)36+ assert.match(first, /data-dz-palette="renderer"/)37+ assert.match(first, /data-dz-conformance="raw"/)38+ assert.doesNotMatch(first, /data-theme=/)39+})40+41+test('exposes structured normalization failures to both callers', () => {42+ assert.throws(43+ () => sanitizeAndDecorateSvg('<svg><style>@import "https://example.com/x";</style></svg>', metadata, presentation, 'pikchr'),44+ error => error instanceof SvgNormalizationError && error.code === 'unsafe_svg' && error.status === 422,45+ )46+})@@ -0,0 +1,576 @@
1+# SVG normalization contract2+3+## Purpose4+5+SVG is the rendering contract between a diagram engine and its consumers. An6+engine may use any language, runtime, renderer, layout algorithm, or deployment7+topology, but its accepted output becomes a safe, deterministic SVG that can be8+presented with one of seven Diagram.zip appearance presets:9+10+```text11+raw12+auto-transparent13+light-transparent14+dark-transparent15+auto-framed16+light-framed17+dark-framed18+```19+20+Normalization gives these outputs a coherent Diagram.zip visual language21+without requiring engines to produce identical markup or use identical native22+themes. The contract is defined in terms of output capabilities and semantic23+roles rather than a fixed inventory of engines. A new engine or a new release24+of an existing engine may use a different normalization pattern when its output25+traits change.26+27+This contract does not require pixel compatibility between different engines,28+replace an engine's layout algorithm, or make arbitrary SVG artwork fully29+themeable. It does define what an engine must provide before each normalized30+appearance can be advertised as supported.31+32+## Terminology33+34+- **Engine SVG** is the SVG returned by a renderer before shared processing.35+- **Sanitized SVG** is engine SVG with unsafe content and disallowed resources36+ removed.37+- **Normalization profile** is a versioned set of configuration, recognition,38+ and annotation rules for a particular renderer output contract.39+- **Canonical SVG** is safe SVG whose geometry is preserved and whose relevant40+ visual elements have Diagram.zip semantic roles.41+- **Appearance preset** is a versioned mapping from semantic roles to concrete42+ paint and presentation values.43+- **Materialized SVG** is a self-contained SVG with one appearance preset44+ selected. It is the public render artifact.45+- **Canvas** is the backdrop around the diagram. It is distinct from the fills46+ of nodes, notes, groups, labels, and other diagram objects.47+48+## Pipeline contract49+50+Every render follows the same conceptual pipeline:51+52+```text53+source and options54+ -> renderer configuration55+ -> engine render56+ -> SVG sanitization57+ -> structural normalization58+ -> semantic annotation59+ -> canonical SVG60+ -> appearance materialization61+ -> final validation62+ -> artifact63+```64+65+Stages may execute in the browser, at the edge, in a service, or in the same66+process as the engine. Their location does not change their contracts.67+68+The engine render is the only stage allowed to change diagram geometry as part69+of layout. Later stages may normalize document bounds and add presentation70+padding or a frame, but they must not reroute connectors, reflow labels, change71+font metrics after layout, or otherwise reinterpret the source language.72+73+Sanitization occurs before semantic processing. Normalization rules must never74+depend on active content, external stylesheets, remote resources, or other75+content that the sanitizer will remove. A final validation pass verifies that76+normalization did not introduce invalid or unsafe SVG.77+78+## Appearance presets79+80+The appearance name is a closed product-level enum, not a bag of independent81+flags. This prevents combinations whose contrast and presentation have not82+been tested.83+84+| Appearance | Canvas | Frame | Diagram palette |85+| --- | --- | --- | --- |86+| `raw` | Renderer-defined | Renderer-defined | Renderer-defined |87+| `auto-transparent` | Transparent | None | Matches the viewing color scheme |88+| `light-transparent` | Transparent | None | Light |89+| `dark-transparent` | Transparent | None | Dark |90+| `auto-framed` | Matching surface | Standard | Matches the viewing color scheme |91+| `light-framed` | Light surface | Standard | Light |92+| `dark-framed` | Dark surface | Standard | Dark |93+94+Transparent means that the canvas is transparent. It does not mean that all95+object fills are transparent. A light-transparent node may use an opaque light96+surface so that its dark label remains readable. A dark-transparent node may97+use an opaque dark surface for the same reason.98+99+The transparent variants are intended for a surrounding surface of the same100+lightness. Free-standing text and connectors in a light-transparent diagram may101+not be readable on an arbitrary dark page, and the reverse is true for a102+dark-transparent diagram. Framed variants are self-contained and must remain103+readable regardless of the embedding page.104+105+The automatic variants include both palettes and select between them with an106+internal `prefers-color-scheme` media query. They do not depend on CSS from the107+embedding page, which cannot style an SVG displayed through an image element.108+109+The standard frame owns:110+111+- a canvas surface;112+- deterministic padding derived from the normalized bounds;113+- a palette-defined border;114+- any standard corner treatment;115+- any frame-only shadow or elevation treatment.116+117+Frame strokes should use non-scaling behavior when necessary. Frame geometry118+must be included in the final view box, and rounded corners must not expose an119+unintended opaque rectangular canvas behind them.120+121+`raw` means visually faithful, safe renderer output. It does not mean122+byte-for-byte preservation, unsanitized output, or exemption from deterministic123+serialization. Removal of unsafe content may necessarily change the result.124+125+## Canonical SVG contract126+127+A canonical SVG preserves the engine's geometry while making presentation128+intent explicit. It should identify its normalization schema, renderer129+identity, normalization profile, and palette compatibility in machine-readable130+metadata or root attributes.131+132+Schema 1 uses the following root attributes:133+134+```xml135+<svg136+ data-dz-schema="1"137+ data-dz-normalizer="svg-normalizer-1"138+ data-dz-engine="example"139+ data-dz-profile="semantic-markup-2"140+ data-dz-palette="diagramzip-1"141+ data-dz-appearance="light-transparent">142+ <style>/* self-contained tokens and role rules */</style>143+ <g data-dz-role="node">144+ <rect data-dz-paint="surface-1" />145+ <text data-dz-paint="ink">Service</text>146+ </g>147+</svg>148+```149+150+These attributes are namespaced to Diagram.zip. The generic `data-theme`151+attribute is not part of the contract because it can collide with renderer and152+embedding conventions. Geometry and semantic paint roles remain independent153+of the selected appearance.154+155+Canonical SVG must be:156+157+- valid, namespace-correct SVG;158+- safe to display as an image under the product's content security policy;159+- free of unapproved network dependencies;160+- deterministic for identical renderer output, profile, and normalizer builds;161+- idempotent under the same normalization build;162+- self-contained after materialization;163+- explicit about document bounds;164+- stable under a change of appearance, except for presentation bounds added by165+ the framed variants.166+167+The normalizer may preserve original IDs when safe. Any IDs it creates must be168+deterministically namespaced to avoid collisions with engine IDs, paint169+servers, masks, markers, filters, or embedded SVG fragments.170+171+## Semantic visual roles172+173+The normalized palette is expressed through semantic roles rather than literal174+colors. The minimum role vocabulary is:175+176+| Role | Meaning |177+| --- | --- |178+| `canvas` | Framed diagram backdrop |179+| `surface-1` | Ordinary node or object surface |180+| `surface-2` | Container, group, pool, cluster, or section surface |181+| `surface-3` | Nested, elevated, note, header, or callout surface |182+| `ink` | Primary text and high-emphasis symbols |183+| `ink-muted` | Secondary labels and annotations |184+| `line` | Primary boundaries and connectors |185+| `line-muted` | Grids, separators, guides, and secondary boundaries |186+| `accent-N` | Stable categorical or semantic hue slot |187+| `on-accent` | Content placed on a strong accent surface |188+189+Profiles may define more precise structural roles, but public appearance190+presets should resolve them through this small shared vocabulary. This keeps191+the palette bounded and prevents engine-specific colors from becoming part of192+the public contract.193+194+Four surface levels are enough to express canvas, ordinary objects,195+containers, and elevated or nested objects. Profiles need not use every level.196+Surface numbers describe hierarchy, not fixed lightness: the appearance preset197+chooses appropriate values in light and dark modes.198+199+Accent slots preserve distinctions, not literal shades. An authored blue and200+an authored orange may map to stable blue-like and orange-like accent slots,201+while the light and dark presets choose different shades for contrast. Color202+must not be the sole carrier of meaning when the source format provides shape,203+label, line, or pattern distinctions.204+205+## Palette contract206+207+The palette is a small, pinned data set embedded into materialized SVG. It must208+not depend on a remote CSS resource or on the embedding page. A versioned subset209+of the [Pico color palette](https://picocss.com/docs/colors) is a suitable source210+for neutral surfaces and accent ramps, but Diagram.zip role assignments and211+contrast pairs are their own versioned contract.212+213+The palette contains only the families and shades used by the role table. At a214+minimum it defines:215+216+- four light surfaces and four dark surfaces;217+- primary and muted ink for each scheme;218+- primary and muted line colors for each scheme;219+- paired light and dark values for every supported accent slot;220+- safe foreground values for strong accent surfaces;221+- frame border and optional shadow values.222+223+Selected foreground/background pairs must be measured rather than assumed to224+be accessible because they came from a color library. Text contrast is checked225+against the surface that actually owns the text. Strong accent fills should use226+a known `on-accent` value; subtle categorical fills should use a tinted surface227+with a stronger accent border when that provides more reliable contrast.228+229+Palette changes create a new palette build. Existing artifacts remain tied to230+the palette build with which they were produced.231+232+## Renderer configuration and normalization233+234+The renderer and the normalizer share responsibility, but the normalizer owns235+the final contract.236+237+Configure a renderer when configuration affects output before layout or makes238+semantic intent more reliable. Examples include:239+240+- selecting deterministic output;241+- selecting a known font before text measurement;242+- suppressing an unnecessary canvas;243+- enabling stable semantic groups or classes;244+- disabling renderer-owned shadows or decorations that cannot be adapted;245+- asking for neutral rather than independently themed output;246+- preventing network or filesystem resource loading.247+248+Post-process renderer output when the change is presentation-only and does not249+need source-language knowledge. Examples include:250+251+- sanitization;252+- canvas recognition and removal;253+- deterministic bounds and serialization;254+- role annotations based on stable renderer structure;255+- semantic paint overrides;256+- adding the Diagram.zip frame;257+- embedding metadata and the selected appearance.258+259+Do not render separate native light and dark layouts merely to obtain theme260+support unless the renderer proves that the two modes have identical geometry261+and semantic structure. Native themes can change font selection, layout,262+filters, markup, and measurements. A single canonical geometry is preferred.263+264+Do not replace fonts after layout unless the profile proves metric265+compatibility. A color-only post-processing step is generally safe; a font266+change can overflow labels and move perceived alignment even when SVG geometry267+is unchanged.268+269+## Normalization profiles270+271+A normalization profile is selected for a renderer output contract, not merely272+for an engine name. Selection may consider renderer identity, build, declared273+capabilities, output schema, and tested structural signatures. A renderer274+upgrade does not inherit a profile automatically.275+276+Profiles are built from composable patterns. Common patterns include:277+278+### Native semantic output279+280+The renderer emits stable semantic groups, classes, or tokens for nodes, edges,281+containers, labels, and accents. The profile validates and maps those semantics282+onto Diagram.zip roles. This is the strongest and least heuristic pattern.283+284+### Configurable neutral output285+286+The renderer can be configured to emit a predictable neutral palette and no287+canvas. The profile recognizes a small set of known paints and structural288+elements, then assigns roles. Configuration and post-processing work together.289+290+### Stylesheet-driven output291+292+Visual properties are controlled by embedded CSS rather than presentation293+attributes. The profile must account for cascade and inheritance. It may append294+appearance rules with sufficient specificity, rewrite a bounded known295+stylesheet, or resolve selected properties before annotation. Blind string296+replacement inside CSS is not acceptable.297+298+### Structural geometry output299+300+The renderer emits groups and geometry with little or no semantic styling. The301+profile identifies object boundaries from stable structure and supplies302+surfaces, ink, and lines. This pattern may synthesize a missing object fill only303+when it has positively identified the element's semantic role.304+305+### Flat paint output306+307+The renderer emits literal paints with weak structural semantics. The profile308+maps neutral paints to shared roles and hue-bearing paints to accent slots.309+It must preserve distinctions and avoid treating every equal literal color as310+the same semantic role when context says otherwise.311+312+### Authored-visual output313+314+Colors, gradients, patterns, or opacity are significant user-authored content.315+The profile preserves the raw appearance and uses semantic or hue-preserving316+adaptation only where it can distinguish structure from authored artwork. Full317+theme support requires stronger renderer metadata; a frame alone must not be318+advertised as full dark-mode normalization.319+320+### Composite output321+322+The SVG contains embedded raster images, nested SVG, complex filters, or other323+opaque visual subtrees. The profile normalizes the parts it understands and324+declares the remainder. Opaque content cannot be claimed as theme-adaptive325+without a renderer-provided alternate representation.326+327+### Translated pipeline output328+329+One stage translates source into another renderer's input. Semantic annotations330+may be carried through the translation, applied by the final renderer's331+profile, or composed by both. Artifact identity records the ordered pipeline332+and every normalization profile that affected the result.333+334+These patterns are neither exclusive nor permanent categories. One renderer335+build may combine semantic markup with stylesheet-driven labels and composite336+icons. A later build may expose native tokens and stop using the flat-paint337+pattern. The profile documents and tests the combination actually used.338+339+## Paint assignment rules340+341+Paint assignment operates on parsed SVG and known style structure, not regular342+expressions over serialized markup.343+344+The profile distinguishes at least:345+346+- canvas paint;347+- object surface paint;348+- text paint;349+- connector and boundary paint;350+- markers and arrowheads;351+- categorical or semantic accents;352+- authored artwork;353+- paint servers such as gradients and patterns;354+- filters and shadows.355+356+SVG defaults and inheritance matter. An absent `fill` does not necessarily mean357+transparent, and an explicit `fill="none"` does not reveal whether a shape is a358+node boundary or an open decorative path. The normalizer must inspect computed359+intent within the bounded styling model supported by the profile.360+361+A profile may turn an unfilled boundary into an opaque themed surface only362+when it has identified the element as an object surface. It must not infer that363+every closed rectangle, polygon, or path is a node. Arrowheads, icons, chart364+marks, separators, clipping geometry, masks, hit targets, and invisible layout365+elements are common counterexamples.366+367+Text is assigned an ink role relative to its owning surface. Text without an368+identifiable owner uses the scheme's canvas ink. A profile must account for369+text rendered through ordinary SVG text, paths, and approved embedded markup;370+support for one form does not imply support for the others.371+372+Markers should inherit or be assigned the connector's role. If the renderer373+reuses one marker definition across differently colored connectors, the374+profile may need deterministic marker cloning or a supported contextual paint375+mechanism.376+377+Gradients and patterns require explicit profile rules. Mapping only a gradient378+reference while leaving incompatible stops unchanged is not normalization.379+When safe adaptation is unavailable, preserve the authored paint in `raw` and380+report the themed appearance as unsupported or incomplete rather than silently381+producing misleading output.382+383+## Raw preservation and themed overrides384+385+Raw values remain available independently of Diagram.zip role values. A386+canonical representation may preserve renderer paint in its original387+attributes and apply scoped appearance overrides, or it may store normalized388+raw fallbacks in deterministic custom properties. The exact mechanism is an389+implementation choice as long as these invariants hold:390+391+- selecting `raw` restores the safe renderer appearance;392+- selecting a themed appearance does not require rerendering geometry;393+- appearance rules affect only elements positively assigned a role;394+- engine styles cannot accidentally override required themed contrast;395+- themed rules do not destroy the information needed for `raw`.396+397+Literal byte equality is not an invariant. Visual equivalence is tested with a398+documented tolerance, alongside structural assertions for content that is399+difficult to compare by pixels.400+401+## Materialization402+403+Canonical SVG is the reusable geometry and role representation. Materializing404+an appearance selects a root appearance value, embeds the required palette405+tokens and role rules, applies canvas or frame geometry, and serializes a406+self-contained SVG.407+408+Consumers may inline canonical SVG and change its root appearance in a trusted409+environment. Consumers displaying SVG through an image element cannot assume410+that page CSS will style the SVG document. Public image endpoints and downloaded411+artifacts therefore return a materialized SVG with an explicit appearance.412+413+Materialization must not invoke the diagram engine. Switching between light414+and dark or transparent and framed variants reuses canonical geometry. Framed415+and transparent variants may have different outer view boxes because padding416+and frame geometry are presentation, not diagram layout.417+418+## Conformance and capability reporting419+420+An appearance is supported only when the active profile satisfies its421+invariants. Capability reporting should distinguish:422+423+- `raw`: safe, visually faithful output;424+- `semantic`: roles are derived from renderer semantics or proven stable425+ structure;426+- `adaptive`: paints are mapped with sufficient context to meet contrast and427+ distinction requirements;428+- `presentation-only`: only the canvas and frame are normalized;429+- `unsupported`: the appearance cannot be produced honestly.430+431+The four themed appearances require `semantic` or `adaptive` conformance.432+`presentation-only` output may be useful for experimentation, but it must not433+be presented as a normalized dark or light diagram. A production engine434+upgrade should remain on its last conforming renderer/profile pair until the435+new output contract passes normalization tests.436+437+If a profile selector does not recognize a renderer build or structural438+signature, it fails closed. It must not silently apply rules written for a439+different output contract. A generic safe fallback may still provide `raw`,440+but it does not inherit themed conformance.441+442+Materialized SVG should expose enough metadata for diagnostics, including:443+444+- renderer identity and build;445+- ordered renderer or translator pipeline;446+- normalizer schema and build;447+- normalization profile and build;448+- palette build;449+- selected appearance;450+- conformance level and declared limitations.451+452+## Artifact identity and caching453+454+Renderer, normalization, and appearance versions are independent inputs to455+artifact identity. A cache key for a final SVG includes at least:456+457+```text458+source identity459+renderer options460+renderer pipeline and builds461+normalizer schema and build462+normalization profile builds463+palette build464+appearance465+render-affecting metadata466+```467+468+Caching may be split into layers:469+470+```text471+engine render cache472+ = source + renderer options + renderer pipeline/builds473+474+canonical SVG cache475+ = engine SVG identity + sanitizer/normalizer/profile builds476+477+materialized SVG cache478+ = canonical identity + palette build + appearance + metadata479+```480+481+This split is an optimization, not part of the public API. The correctness rule482+is that changing a renderer, sanitizer, profile, normalizer, palette, or483+appearance cannot accidentally reuse an artifact created by an incompatible484+build.485+486+## Validation487+488+Each renderer/profile pair has a representative golden corpus. The corpus489+covers the output traits used by its patterns rather than only a minimal smoke490+diagram. Where applicable it includes:491+492+- unfilled and filled objects with labels;493+- nested containers and repeated surface levels;494+- free-standing text;495+- multiple connector types and markers;496+- explicit authored colors and categorical accents;497+- gradients, patterns, opacity, and filters;498+- embedded markup, nested SVG, or raster content;499+- unusual view boxes, dimensions, and coordinate origins;500+- international text and the renderer's supported font behaviors;501+- empty, very small, and bounded large diagrams;502+- hostile SVG and resource-loading attempts.503+504+Conformance tests verify:505+506+1. The engine SVG is sanitized and the result remains valid SVG.507+2. `raw` remains visually faithful within the declared tolerance.508+3. Repeated normalization is byte-deterministic.509+4. Normalization is idempotent for the same build.510+5. All appearances preserve canonical diagram geometry.511+6. Transparent appearances contain no canvas backdrop.512+7. Transparent appearances retain required object surfaces.513+8. Framed appearances include the complete frame within their bounds.514+9. Text and meaningful marks meet the palette's contrast requirements against515+ their owning surfaces.516+10. Accent distinctions remain stable across light and dark appearances.517+11. Unknown or decorative geometry is not assigned an invented surface.518+12. External resources and active content do not survive or reappear.519+13. The SVG renders consistently through supported image-element, inline, and520+ rasterization paths.521+522+Visual tests render transparent appearances over both expected and adversarial523+checkerboard surfaces. This makes accidental canvas fills and unreadable free524+text visible. Framed variants are tested over unrelated embedding colors to525+verify that they are self-contained.526+527+## Versioning and change management528+529+The following are separately versioned contracts:530+531+- renderer identity and build;532+- renderer pipeline;533+- sanitizer behavior;534+- canonical SVG schema;535+- normalization profile;536+- palette;537+- appearance preset geometry and rules.538+539+A renderer upgrade, even under the same engine name, is treated as a new output540+contract until its existing profile passes the full corpus. A profile may then541+declare compatibility with the new renderer build, or a new profile may be542+introduced. Pattern membership is updated based on observed capabilities, not543+historical engine classification.544+545+Changes that alter materialized pixels or bounds require a new relevant build546+identifier. Schema migrations should preserve the ability to serve or547+regenerate artifacts whose identities refer to an older supported contract.548+549+## Anti-patterns550+551+The following approaches violate this contract:552+553+- global search-and-replace of color strings;554+- treating every `fill="none"` shape as an object that needs a surface;555+- using the presence of a rectangle alone to identify the canvas;556+- applying a profile solely by engine name across untested renderer upgrades;557+- claiming dark support after changing only the outer canvas;558+- changing fonts after layout without metric-compatibility proof;559+- allowing embedding-page CSS to be required for a public SVG;560+- loading palette styles or fonts from the network at display time;561+- mutating geometry separately for light and dark appearances;562+- silently falling back from an unrecognized profile to heuristic theming;563+- omitting normalizer, profile, or palette builds from artifact identity;564+- calling unsafe renderer output `raw` and bypassing sanitization.565+566+## Contract summary567+568+The engine owns source interpretation and layout. The sanitizer owns safety.569+The normalization profile owns the mapping from a tested renderer output570+contract to semantic roles. The appearance preset owns palette, canvas, and571+frame. Materialization combines one canonical geometry with one explicit572+appearance to produce a deterministic, self-contained SVG.573+574+Engines and their versions may change normalization patterns freely. Consumers575+continue to receive the same five appearance names and the same semantic visual576+contract.