Embed canonical, versioned diagram state in SVG metadata and recover it without renderer heuristics. Add deterministic golden export/import coverage across semantic, adaptive, presentation-only, and safe-raw render profiles.
- commit
- 45de8f98e7cc4b1d3d824a8f2d0b3a85eae1973b
- parent
- 3561e26b38a8ffea85d4409dd4ffea13ca0df78b
- author
- Dami <dami.etoile@gmail.com>
| A | apps/ | 116 | ++++++++++++++++++++++++++++++ |
| A | apps/ | 115 | ++++++++++++++++++++++++++++++ |
| A | apps/ | 1 | + |
| A | apps/ | 1 | + |
| A | apps/ | 1 | + |
| A | apps/ | 1 | + |
| M | shared/ | 4 | + |
| M | shared/ | 61 | +++++++++++++++- |
@@ -0,0 +1,116 @@
1+import {2+ EDITABLE_SVG_SCHEMA,3+ attachEditableDocument,4+ extractEditableDocument,5+ materializePresentation,6+ materializeSvg,7+} from '../../../shared/svg/index.js'8+import { isKnownDiagramType } from './diagram-types.js'9+import { normalizeMetadata, normalizePresentation } from './state.js'10+11+const MAX_SOURCE_BYTES = 768 * 102412+const MAX_METADATA_TITLE = 20013+const MAX_METADATA_DESCRIPTION = 200014+const encoder = new TextEncoder()15+16+export class EditableSvgError extends Error {17+ constructor(code, message) {18+ super(message)19+ this.name = 'EditableSvgError'20+ this.code = code21+ }22+}23+24+function fail(code, message) {25+ throw new EditableSvgError(code, message)26+}27+28+function record(value, label) {29+ if (typeof value !== 'object' || value === null || Array.isArray(value)) fail('invalid_document', `${label} must be an object.`)30+ return value31+}32+33+function exactKeys(value, expected, label) {34+ const actual = Object.keys(value).sort()35+ const allowed = [...expected].sort()36+ if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) {37+ fail('invalid_document', `${label} has unsupported or missing fields.`)38+ }39+}40+41+function normalizedJson(value, label, depth = 0) {42+ if (depth > 20) fail('invalid_document', `${label} is nested too deeply.`)43+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return value44+ if (typeof value === 'number' && Number.isFinite(value)) return value45+ if (Array.isArray(value)) return value.map(item => normalizedJson(item, label, depth + 1))46+ const input = record(value, label)47+ return Object.fromEntries(Object.keys(input).sort().map(key => [key, normalizedJson(input[key], label, depth + 1)]))48+}49+50+export function normalizeEditableState(value) {51+ const manifest = record(value, 'Editable SVG document')52+ exactKeys(manifest, ['schema', 'diagram', 'metadata'], 'Editable SVG document')53+ if (manifest.schema !== Number(EDITABLE_SVG_SCHEMA)) fail('unsupported_schema', 'Editable SVG uses an unsupported document schema.')54+55+ const diagram = record(manifest.diagram, 'diagram')56+ exactKeys(diagram, ['type', 'source', 'options', 'presentation'], 'diagram')57+ if (typeof diagram.type !== 'string' || !isKnownDiagramType(diagram.type)) fail('unsupported_type', 'Editable SVG uses an unsupported diagram type.')58+ if (typeof diagram.source !== 'string') fail('invalid_document', 'diagram.source must be a string.')59+ if (encoder.encode(diagram.source).byteLength > MAX_SOURCE_BYTES) fail('source_too_large', 'Editable SVG source is too large.')60+ const options = normalizedJson(diagram.options, 'diagram.options')61+ if (Array.isArray(options) || typeof options !== 'object' || options === null) fail('invalid_document', 'diagram.options must be an object.')62+63+ const metadataValue = record(manifest.metadata, 'metadata')64+ exactKeys(metadataValue, ['title', 'description'], 'metadata')65+ const meta = normalizeMetadata(metadataValue)66+ if (meta.title.length > MAX_METADATA_TITLE || meta.description.length > MAX_METADATA_DESCRIPTION) {67+ fail('invalid_document', 'Editable SVG metadata is too large.')68+ }69+70+ const presentationValue = record(diagram.presentation, 'diagram.presentation')71+ exactKeys(presentationValue, ['background', 'padding', 'frame', 'appearance'], 'diagram.presentation')72+ let presentation73+ try {74+ presentation = normalizePresentation(presentationValue)75+ } catch (error) {76+ fail('invalid_document', error instanceof Error ? error.message : 'Editable SVG presentation is invalid.')77+ }78+79+ return { type: diagram.type, source: diagram.source, options, meta, presentation }80+}81+82+export function editableDocumentFor(state) {83+ const normalized = normalizeEditableState({84+ schema: Number(EDITABLE_SVG_SCHEMA),85+ diagram: {86+ type: state.type,87+ source: state.source,88+ options: state.options ?? {},89+ presentation: normalizePresentation(state.presentation),90+ },91+ metadata: normalizeMetadata(state.meta),92+ })93+ return {94+ schema: Number(EDITABLE_SVG_SCHEMA),95+ diagram: {96+ type: normalized.type,97+ source: normalized.source,98+ options: normalized.options,99+ presentation: normalized.presentation,100+ },101+ metadata: normalized.meta,102+ }103+}104+105+export function exportEditableSvg(canonical, state) {106+ const document = editableDocumentFor(state)107+ const { appearance, background, padding, frame } = document.diagram.presentation108+ const materialized = appearance === 'raw'109+ ? materializePresentation(canonical, { background, padding, frame })110+ : materializeSvg(canonical, appearance)111+ return attachEditableDocument(materialized, document)112+}113+114+export function importEditableSvg(source) {115+ return normalizeEditableState(extractEditableDocument(source))116+}@@ -0,0 +1,115 @@
1+import assert from 'node:assert/strict'2+import { readFile } from 'node:fs/promises'3+import test from 'node:test'4+import { fileURLToPath } from 'node:url'5+import { canonicalizeSvg } from '../../../shared/svg/index.js'6+import {7+ EditableSvgError,8+ editableDocumentFor,9+ exportEditableSvg,10+ importEditableSvg,11+} from '../src/editable-svg.js'12+13+const fixture = name => readFile(fileURLToPath(new URL(`./fixtures/editable-svg/${name}.svg`, import.meta.url)), 'utf8')14+15+const cases = [16+ {17+ name: 'graphviz',18+ version: 'graphviz@15.1.1',19+ state: {20+ type: 'graphviz',21+ source: 'digraph G { A -> B [label="café ☕"] }',22+ options: { engine: 'dot' },23+ meta: { title: 'Café flow', description: 'Unicode survives: こんにちは' },24+ presentation: { appearance: 'raw', background: '#ffffff', padding: 8, frame: true },25+ },26+ },27+ {28+ name: 'mermaid',29+ version: 'mermaid@11.17.0',30+ state: {31+ type: 'mermaid',32+ source: 'flowchart LR\n idea[Idea] --> shipped[Shipped]',33+ options: {},34+ meta: { title: 'Idea flow', description: 'A client-rendered diagram.' },35+ presentation: { appearance: 'dark-transparent', background: '', padding: 0, frame: false },36+ },37+ },38+ {39+ name: 'diagramsnet',40+ version: 'diagrams.net@29.6.1',41+ state: {42+ type: 'diagramsnet',43+ source: '<mxGraphModel><root/></mxGraphModel>',44+ options: {},45+ meta: { title: 'Authored drawing', description: '' },46+ presentation: { appearance: 'auto-framed', background: '', padding: 0, frame: false },47+ },48+ },49+ {50+ name: 'safe-raw',51+ engine: 'pikchr',52+ version: 'pikchr@future',53+ state: {54+ type: 'pikchr',55+ source: 'box "raw fallback"',56+ options: { scale: 1 },57+ meta: { title: '', description: 'Unknown renderer builds remain round-trippable as raw SVG.' },58+ presentation: { appearance: 'raw', background: '', padding: 0, frame: false },59+ },60+ },61+]62+63+test('exports and reimports a deterministic golden set of renderer SVGs', async () => {64+ for (const entry of cases) {65+ const state = entry.state66+ const rendered = await fixture(entry.name)67+ const canonical = canonicalizeSvg(rendered, state.meta, entry.engine ?? state.type, entry.version)68+ const first = exportEditableSvg(canonical, state)69+ const repeated = exportEditableSvg(canonical, state)70+ const imported = importEditableSvg(first)71+ const reexported = exportEditableSvg(canonical, imported)72+73+ assert.equal(first, repeated, `${entry.name} export must be deterministic`)74+ assert.equal(first, reexported, `${entry.name} import/export must be byte-stable`)75+ assert.deepEqual(imported, state, `${entry.name} state must round trip exactly`)76+ assert.match(first, /data-dz-document="1"/)77+ assert.equal((first.match(/<metadata data-dz-kind="document"/g) ?? []).length, 1)78+ }79+})80+81+test('canonicalizes option keys before writing the editable document', () => {82+ const state = {83+ ...cases[0].state,84+ options: { z: 1, a: { second: true, first: false } },85+ }86+ assert.deepEqual(editableDocumentFor(state).diagram.options, {87+ a: { first: false, second: true },88+ z: 1,89+ })90+})91+92+test('rejects ordinary, ambiguous, unsupported, and lossy SVG documents', async () => {93+ const plain = await fixture('safe-raw')94+ assert.throws(() => importEditableSvg(plain), error => error.code === 'not_editable')95+96+ const state = cases[3].state97+ const canonical = canonicalizeSvg(plain, state.meta, state.type, cases[3].version)98+ const editable = exportEditableSvg(canonical, state)99+ assert.throws(100+ () => importEditableSvg(editable.replace('data-dz-document="1"', 'data-dz-document="2"')),101+ error => error.code === 'not_editable',102+ )103+ assert.throws(104+ () => importEditableSvg(editable.replace('</svg>', '<metadata data-dz-kind="document" data-dz-schema="1">{}</metadata></svg>')),105+ error => error.code === 'invalid_editable_document',106+ )107+ assert.throws(108+ () => importEditableSvg(editable.replace('"type":"pikchr"', '"type":"unknown"')),109+ error => error instanceof EditableSvgError && error.code === 'unsupported_type',110+ )111+ assert.throws(112+ () => importEditableSvg(editable.replace('"appearance":"raw"', '"appearance":"invented"')),113+ error => error instanceof EditableSvgError && error.code === 'invalid_document',114+ )115+})@@ -1,4 +1,5 @@
11 export const SVG_SCHEMA: '1'2+export const EDITABLE_SVG_SCHEMA: '1'23 export const NORMALIZER_BUILD: 'svg-normalizer-2'34 export const MATERIALIZER_BUILD: 'svg-materializer-2'45 export const PALETTE_BUILD: 'diagramzip-palette-1'@@ -56,6 +57,9 @@ export function canonicalizeSvg(
5657 rendererVersion?: string,5758 ): string5859 60+export function attachEditableDocument(source: string, document: unknown): string61+export function extractEditableDocument(source: string): unknown62+5963 export function materializePresentation(6064 canonical: string,6165 presentation: SvgPresentation,@@ -1,6 +1,7 @@
11 import { SaxesParser } from 'saxes'22 33 const MAX_SVG_LENGTH = 4_194_3044+const MAX_EDITABLE_SVG_LENGTH = 5_242_88045 const BLOCKED_ELEMENTS = new Set(['script', 'iframe', 'object', 'embed', 'audio', 'video'])56 const FOREIGN_OBJECT_ELEMENTS = new Set([67 'foreignobject', 'div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'small', 'sub', 'sup', 'code', 'ul', 'ol', 'li', 'a',@@ -9,6 +10,7 @@ const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]
910 const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\/(?:x-)?font-woff2?);base64,[a-z0-9+/=\s]+$/i1011 1112 export const SVG_SCHEMA = '1'13+export const EDITABLE_SVG_SCHEMA = '1'1214 export const NORMALIZER_BUILD = 'svg-normalizer-2'1315 export const MATERIALIZER_BUILD = 'svg-materializer-2'1416 export const PALETTE_BUILD = 'diagramzip-palette-1'@@ -212,6 +214,17 @@ function serialize(node) {
212214 return `<${node.name}${attributes}>${node.children.map(serialize).join('')}</${node.name}>`213215 }214216 217+function canonicalJson(value) {218+ if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {219+ return JSON.stringify(value)220+ }221+ if (Array.isArray(value)) return `[${value.map(item => canonicalJson(item)).join(',')}]`222+ if (typeof value !== 'object' || value === null) {223+ throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG data must contain only JSON values.')224+ }225+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`226+}227+215228 function localName(element) {216229 return element.name.toLowerCase().split(':').at(-1) ?? element.name.toLowerCase()217230 }@@ -781,8 +794,8 @@ function applyLegacyPresentation(root, presentation, engine) {
781794 if (frame) root.children.push(frame)782795 }783796 784-function parseSafeSvg(source) {785- if (source.length > MAX_SVG_LENGTH) throw new SvgNormalizationError(413, 'render_too_large', 'Rendered SVG is too large.')797+function parseSafeSvg(source, maximumLength = MAX_SVG_LENGTH) {798+ if (source.length > maximumLength) throw new SvgNormalizationError(413, 'render_too_large', 'Rendered SVG is too large.')786799 let root = null787800 const stack = []788801 let skippedDepth = 0@@ -841,6 +854,50 @@ function parseSafeSvg(source) {
841854 return root842855 }843856 857+export function attachEditableDocument(source, document) {858+ const root = parseSafeSvg(source, MAX_EDITABLE_SVG_LENGTH)859+ if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {860+ throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')861+ }862+ root.children = root.children.filter(child => child.type === 'text'863+ || !(localName(child) === 'metadata' && child.attributes.get('data-dz-kind') === 'document'))864+ root.attributes.set('data-dz-document', EDITABLE_SVG_SCHEMA)865+ const metadata = element('metadata', [866+ ['data-dz-kind', 'document'],867+ ['data-dz-schema', EDITABLE_SVG_SCHEMA],868+ ], [{ type: 'text', value: canonicalJson(document) }])869+ const descriptiveEnd = root.children.findLastIndex(child => child.type !== 'text'870+ && ['title', 'desc'].includes(localName(child)))871+ root.children.splice(descriptiveEnd + 1, 0, metadata)872+ const serialized = serialize(root)873+ if (serialized.length > MAX_EDITABLE_SVG_LENGTH) {874+ throw new SvgNormalizationError(413, 'editable_svg_too_large', 'Editable SVG is too large.')875+ }876+ return serialized877+}878+879+export function extractEditableDocument(source) {880+ const root = parseSafeSvg(source, MAX_EDITABLE_SVG_LENGTH)881+ if (root.attributes.get('data-dz-document') !== EDITABLE_SVG_SCHEMA) {882+ throw new SvgNormalizationError(422, 'not_editable', 'SVG does not contain a supported Diagram.zip document.')883+ }884+ const manifests = root.children.filter(child => child.type !== 'text'885+ && localName(child) === 'metadata'886+ && child.attributes.get('data-dz-kind') === 'document')887+ if (manifests.length !== 1 || manifests[0].attributes.get('data-dz-schema') !== EDITABLE_SVG_SCHEMA) {888+ throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG must contain exactly one supported Diagram.zip document.')889+ }890+ const manifest = manifests[0]891+ if (manifest.children.some(child => child.type !== 'text')) {892+ throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG document metadata must contain JSON text only.')893+ }894+ try {895+ return JSON.parse(manifest.children.map(child => child.value).join(''))896+ } catch {897+ throw new SvgNormalizationError(422, 'invalid_editable_document', 'Editable SVG document metadata is not valid JSON.')898+ }899+}900+844901 export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, rendererVersion = '') {845902 return materializePresentation(canonicalizeSvg(source, metadata, engine, rendererVersion), presentation)846903 }