- commit
- 0fe5a564f260e752003bf40a4c7f64571e40bf68
- parent
- 04a59f11928fa10d357a882b812041463b7a1fad
- author
- Dami <dami.etoile@gmail.com>
| M | diagramzip-api/ | 74 | ++++++++++++++++++++++++++-- |
| M | diagramzip-api/ | 81 | ++++++++++++++++++++++++++++++- |
| M | diagramzip-docs/ | 10 | +++- |
| M | diagramzip-render/ | 17 | ++++++ |
| M | diagramzip-render/ | 11 | ++-- |
| M | diagramzip-svg/ | 12 | ++++ |
| M | diagramzip-svg/ | 24 | ++++++++- |
| M | diagramzip/ | 18 | ++++++- |
| M | diagramzip/ | 2 | + |
| M | diagramzip/ | 9 | +++- |
| M | diagramzip/ | 15 | +++--- |
| M | diagramzip/ | 2 | + |
| M | diagramzip/ | 13 | +++++ |
| M | docs/ | 9 | ++- |
@@ -17,6 +17,15 @@ import {
1717 readJsonBody,1818 RequestError,1919 } from './validation'20+import {21+ APPEARANCES,22+ MATERIALIZER_BUILD,23+ PALETTE_BUILD,24+ SVG_SCHEMA,25+ SvgNormalizationError,26+ materializeSvg,27+ type SvgAppearance,28+} from '../../diagramzip-svg/index.js'2029 2130 const API_PREFIX = '/api/v1'2231 const encoder = new TextEncoder()@@ -26,6 +35,38 @@ const MAX_RENDER_BYTES = 12 * 1024 * 1024
2635 const rendererUnitPattern = /^[a-z][a-z0-9-]{0,31}$/2736 const rendererBuildPattern = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/2837 38+function normalizationError(error: unknown): never {39+ if (error instanceof SvgNormalizationError) {40+ throw new RequestError(error.status, error.code, error.message)41+ }42+ throw error43+}44+45+function requestedAppearance(request: Request, format: 'svg' | 'png'): SvgAppearance {46+ const value = new URL(request.url).searchParams.get('appearance') ?? 'raw'47+ if (!APPEARANCES.some(appearance => appearance === value)) {48+ throw new RequestError(400, 'invalid_appearance', `Unknown SVG appearance: ${value}.`)49+ }50+ if (format !== 'svg' && value !== 'raw') {51+ throw new RequestError(400, 'appearance_requires_svg', 'SVG appearances are only available for SVG renders.')52+ }53+ return value as SvgAppearance54+}55+56+function canonicalSvg(bytes: Uint8Array, appearance: SvgAppearance): Uint8Array {57+ let source: string58+ try {59+ source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)60+ } catch {61+ throw new RequestError(422, 'invalid_svg', 'Rendered SVG must use valid UTF-8.')62+ }63+ try {64+ return encoder.encode(materializeSvg(source, appearance))65+ } catch (error) {66+ normalizationError(error)67+ }68+}69+2970 interface RendererIdentity {3071 unit: string3172 build: string@@ -470,6 +511,8 @@ async function putRender(
470511 contentType = 'application/json; charset=utf-8'471512 } else if (request.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase() !== mediaType) {472513 throw new RequestError(415, 'unsupported_media_type', `Render must use ${mediaType}.`)514+ } else if (format === 'svg') {515+ storedBytes = canonicalSvg(bytes, 'raw')473516 }474517 475518 const objectKey = renderObjectKey(record, format, renderer)@@ -502,7 +545,12 @@ async function getRender(
502545 ): Promise<Response> {503546 const record = await findAlias(env, aliasId)504547 if (record === null) throw new RequestError(404, 'alias_not_found', 'Diagram alias not found.')505- const encryptedRequest = new URL(request.url).searchParams.get('encrypted') === '1'548+ const url = new URL(request.url)549+ const encryptedRequest = url.searchParams.get('encrypted') === '1'550+ if (record.mode === 'locked' && url.searchParams.has('appearance')) {551+ throw new RequestError(422, 'encrypted_appearance_unavailable', 'Encrypted renders cannot be materialized by the server.')552+ }553+ const appearance = requestedAppearance(request, format)506554 if (record.mode === 'locked' && !encryptedRequest) {507555 throw new RequestError(403, 'encrypted_diagram_not_embeddable', 'Password-locked diagrams cannot be embedded.')508556 }@@ -514,12 +562,20 @@ async function getRender(
514562 const head = normalizeRenderHead(await headObject.json<unknown>(), record, format)515563 const object = await env.CONTENT.get(head.objectKey)516564 if (object === null) throw new RequestError(404, 'render_not_found', 'Saved render not found.')517- if (request.headers.get('If-None-Match') === object.httpEtag) {518- return new Response(null, { status: 304, headers: { ETag: object.httpEtag } })565+ let body: ReadableStream | Uint8Array | null = object.body566+ let etag = object.httpEtag567+ if (record.mode === 'open' && format === 'svg') {568+ const bytes = canonicalSvg(new Uint8Array(await object.arrayBuffer()), appearance)569+ const identity = await contentId(bytes)570+ etag = `"${identity.id}"`571+ body = bytes572+ }573+ if (request.headers.get('If-None-Match') === etag) {574+ return new Response(null, { status: 304, headers: { ETag: etag } })519575 }520576 const headers = new Headers()521577 object.writeHttpMetadata(headers)522- headers.set('ETag', object.httpEtag)578+ headers.set('ETag', etag)523579 headers.set('X-Content-Type-Options', 'nosniff')524580 headers.set('Content-Security-Policy', format === 'svg'525581 ? "default-src 'none'; style-src 'unsafe-inline'; sandbox"@@ -527,10 +583,18 @@ async function getRender(
527583 headers.set('X-Renderer-Unit', head.unit)528584 headers.set('X-Renderer-Build', head.build)529585 headers.set('X-Renderer-Pipeline', head.pipeline.join(','))586+ if (format === 'svg' && record.mode === 'open') {587+ headers.set('X-Diagram-Appearance', appearance)588+ headers.set('X-SVG-Schema', SVG_SCHEMA)589+ if (appearance !== 'raw') {590+ headers.set('X-SVG-Materializer', MATERIALIZER_BUILD)591+ headers.set('X-SVG-Palette', PALETTE_BUILD)592+ }593+ }530594 headers.set('Cache-Control', record.mode === 'open'531595 ? 'public, max-age=60, stale-while-revalidate=300'532596 : 'no-store')533- return new Response(request.method === 'HEAD' ? null : object.body, { headers })597+ return new Response(request.method === 'HEAD' ? null : body, { headers })534598 }535599 536600 async function route(request: Request, env: Env): Promise<Response> {@@ -1,6 +1,7 @@
11 import { env, exports } from 'cloudflare:workers'22 import { applyD1Migrations } from 'cloudflare:test'33 import { beforeAll, describe, expect, it } from 'vitest'4+import { canonicalizeSvg } from '../../diagramzip-svg/index.js'45 56 const capability = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'67 const otherCapability = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'@@ -288,7 +289,12 @@ describe('open aliases', () => {
288289 it('stores and serves a content-addressed open SVG render', async () => {289290 const created = await create()290291 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()291- const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'292+ const svg = canonicalizeSvg(293+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',294+ { title: '', description: '' },295+ 'd2',296+ 'd2@0.7.1',297+ )292298 const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {293299 method: 'PUT',294300 headers: {@@ -309,6 +315,8 @@ describe('open aliases', () => {
309315 expect(render.headers.get('X-Renderer-Unit')).toBe('d2')310316 expect(render.headers.get('X-Renderer-Build')).toBe('d2-compat-unit-1')311317 expect(render.headers.get('X-Renderer-Pipeline')).toBe('d2')318+ expect(render.headers.get('X-Diagram-Appearance')).toBe('raw')319+ expect(render.headers.get('X-SVG-Schema')).toBe('1')312320 expect(render.headers.get('Content-Security-Policy')).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox")313321 expect(await render.text()).toBe(svg)314322 @@ -321,6 +329,73 @@ describe('open aliases', () => {
321329 })322330 })323331 332+ it('materializes supported appearances from one canonical SVG object', async () => {333+ const created = await create()334+ const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()335+ const svg = canonicalizeSvg(336+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20"><g class="graph"><polygon fill="white" stroke="none" points="0,0 40,0 40,20 0,20"></polygon><g class="node"><rect x="5" y="5" width="30" height="10" fill="white" stroke="black"></rect><text x="10" y="12" fill="black">Node</text></g></g></svg>',337+ { title: '', description: '' },338+ 'graphviz',339+ 'graphviz@15.1.1',340+ )341+ const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {342+ method: 'PUT',343+ headers: {344+ Authorization: `Bearer ${capability}`,345+ 'Content-Type': 'image/svg+xml',346+ 'If-Match': '"1"',347+ 'X-Render-Id': renderId,348+ 'X-Renderer-Unit': 'graphviz',349+ 'X-Renderer-Build': 'graphviz-wasm-unit-1',350+ 'X-Renderer-Pipeline': 'graphviz',351+ },352+ body: svg,353+ })354+ expect(stored.status).toBe(204)355+356+ const raw = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`)357+ const framed = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=dark-framed`)358+ expect(raw.status).toBe(200)359+ expect(framed.status).toBe(200)360+ expect(framed.headers.get('X-Diagram-Appearance')).toBe('dark-framed')361+ expect(framed.headers.get('X-SVG-Materializer')).toBe('svg-materializer-1')362+ expect(framed.headers.get('X-SVG-Palette')).toBe('diagramzip-palette-1')363+ expect(framed.headers.get('ETag')).not.toBe(raw.headers.get('ETag'))364+ const framedSvg = await framed.text()365+ expect(framedSvg).toContain('data-dz-appearance="dark-framed"')366+ expect(framedSvg).toContain('data-dz-owned="materializer"')367+ expect(framedSvg).toContain('viewBox="-24 -24 88 68"')368+369+ const unchanged = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=dark-framed`, {370+ headers: { 'If-None-Match': framed.headers.get('ETag')! },371+ })372+ expect(unchanged.status).toBe(304)373+ const objects = await env.CONTENT.list({ prefix: 'renders/open/graphviz/' })374+ expect(objects.objects).toHaveLength(1)375+ })376+377+ it('rejects non-canonical SVG uploads and unsupported appearances', async () => {378+ const created = await create()379+ const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()380+ const stored = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {381+ method: 'PUT',382+ headers: {383+ Authorization: `Bearer ${capability}`,384+ 'Content-Type': 'image/svg+xml',385+ 'If-Match': '"1"',386+ 'X-Render-Id': renderId,387+ ...rendererHeaders,388+ },389+ body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',390+ })391+ expect(stored.status).toBe(422)392+ expect((await stored.json<{ error: { code: string } }>()).error.code).toBe('not_canonical')393+394+ const invalid = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=sepia`)395+ expect(invalid.status).toBe(400)396+ expect((await invalid.json<{ error: { code: string } }>()).error.code).toBe('invalid_appearance')397+ })398+324399 it('stores locked renders opaquely and refuses an embed response', async () => {325400 const created = await create(lockedPayload())326401 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()@@ -345,6 +420,10 @@ describe('open aliases', () => {
345420 const encrypted = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/png?encrypted=1`)346421 expect(encrypted.status).toBe(200)347422 expect(await encrypted.json()).toEqual(encryptedRender)423+424+ const themed = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/png?encrypted=1&appearance=dark-framed`)425+ expect(themed.status).toBe(422)426+ expect((await themed.json<{ error: { code: string } }>()).error.code).toBe('encrypted_appearance_unavailable')348427 })349428 350429 it('rejects a stale render upload after an alias update', async () => {@@ -1,4 +1,5 @@
11 import React, {useCallback, useEffect, useRef, useState} from 'react';2+import {canonicalizeSvg, materializeSvg} from '../../../../diagramzip-svg/index.js';23 import {fitView, zoomView} from './viewMath.mjs';34 import {clientFrameUrlFor, httpRendererUrlFor} from './rendererRouting.mjs';45 import styles from './styles.module.css';@@ -171,6 +172,7 @@ export default function DiagramExample({engine, label, sourceUrl}) {
171172 let source;172173 if (clientRenderer) {173174 source = await clientRenderer.render(engine, example.source, abortController.signal);175+ source = canonicalizeSvg(source, {title: '', description: ''}, engine);174176 } else {175177 const renderResponse = await fetch(httpRendererUrlFor(engine), {176178 method: 'POST',@@ -180,13 +182,19 @@ export default function DiagramExample({engine, label, sourceUrl}) {
180182 format: 'svg',181183 options: {},182184 metadata: {},183- presentation: {background: '', padding: 24, frame: false},185+ presentation: {background: '', padding: 0, frame: false},184186 }),185187 signal: abortController.signal,186188 });187189 if (!renderResponse.ok) throw new Error(await responseError(renderResponse));188190 source = await renderResponse.text();189191 }192+ try {193+ source = materializeSvg(source, 'auto-transparent');194+ } catch (error) {195+ if (error?.code !== 'unsupported_appearance') throw error;196+ source = materializeSvg(source, 'raw');197+ }190198 source = normalizeSvg(source);191199 const objectUrl = URL.createObjectURL(new Blob([source], {type: 'image/svg+xml'}));192200 if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);@@ -1,5 +1,6 @@
11 import {22 SvgNormalizationError,3+ canonicalizeSvg as canonicalizeSharedSvg,34 sanitizeAndDecorateSvg as normalizeSharedSvg,45 } from '../../diagramzip-svg/index.js'56 import { RenderError } from './errors'@@ -21,3 +22,19 @@ export function sanitizeAndDecorateSvg(
2122 throw error2223 }2324 }25+26+export function canonicalizeSvg(27+ source: string,28+ metadata: RenderMetadata,29+ engine: EngineId,30+ rendererVersion = '',31+): string {32+ try {33+ return canonicalizeSharedSvg(source, metadata, engine, rendererVersion)34+ } catch (error) {35+ if (error instanceof SvgNormalizationError) {36+ throw new RenderError(error.status, error.code, error.message)37+ }38+ throw error39+ }40+}@@ -1,8 +1,8 @@
11 import { RenderError, RequestError } from './errors'2-import { sanitizeAndDecorateSvg } from './svg'2+import { canonicalizeSvg } from './svg'33 import type { EngineId, RendererAdapter, RenderRequest } from './types'44 import { parseUnitRenderRequest } from './validation'5-import { RAW_NORMALIZATION } from '../../diagramzip-svg/index.js'5+import { normalizationFor } from '../../diagramzip-svg/index.js'66 77 const CACHE_MAX_AGE = 1_80088 const CACHE_SCHEMA = '2'@@ -47,7 +47,8 @@ function errorResponse(error: unknown): Response {
4747 4848 function canonicalRequest(request: RenderRequest): string {4949 const options = Object.fromEntries(Object.entries(request.options).sort(([left], [right]) => left.localeCompare(right)))50- return JSON.stringify({ ...request, options })50+ const { presentation: _presentation, ...canonical } = request51+ return JSON.stringify({ ...canonical, options })5152 }5253 5354 function base64Url(bytes: Uint8Array): string {@@ -76,7 +77,7 @@ async function render(
7677 ): Promise<Response> {7778 const rendered = await descriptor.adapter.render(request, signal)7879 if (signal.aborted) throw signal.reason79- const body = sanitizeAndDecorateSvg(rendered.body, request.metadata, request.presentation, descriptor.id, rendered.engineVersion)80+ const body = canonicalizeSvg(rendered.body, request.metadata, descriptor.id, rendered.engineVersion)8081 return new Response(body, {8182 headers: {8283 'Access-Control-Allow-Origin': '*',@@ -106,7 +107,7 @@ function capabilities(unitId: string, descriptor: RendererUnitDescriptor, build:
106107 build,107108 pipeline: [unitId, ...(descriptor.pipeline ?? [])],108109 knownLosses: descriptor.knownLosses ?? [],109- normalization: RAW_NORMALIZATION,110+ normalization: normalizationFor(descriptor.id, descriptor.adapter.version),110111 }, { headers: { 'Cache-Control': 'public, max-age=300' } })111112 }112113 @@ -49,5 +49,17 @@ export function sanitizeAndDecorateSvg(
4949 rendererVersion?: string,5050 ): string5151 52+export function canonicalizeSvg(53+ source: string,54+ metadata: SvgMetadata,55+ engine: string,56+ rendererVersion?: string,57+): string58+59+export function materializePresentation(60+ canonical: string,61+ presentation: SvgPresentation,62+): string63+5264 export type SvgAppearance = typeof APPEARANCES[number]5365 export function materializeSvg(canonical: string, appearance: SvgAppearance): string@@ -223,11 +223,16 @@ function element(name, attributes = [], children = []) {
223223 return { type: 'element', name, attributes: new Map(attributes), children }224224 }225225 226-function decorate(root, metadata, presentation, engine) {226+function addMetadata(root, metadata) {227227 const additions = []228- let frame = null229228 if (metadata.title) additions.push(element('title', [], [{ type: 'text', value: metadata.title }]))230229 if (metadata.description) additions.push(element('desc', [], [{ type: 'text', value: metadata.description }]))230+ root.children.unshift(...additions)231+}232+233+function applyLegacyPresentation(root, presentation, engine) {234+ const additions = []235+ let frame = null231236 const bounds = presentationBounds(root)232237 if ((presentation.padding || presentation.background || presentation.frame) && bounds === null) {233238 throw new SvgNormalizationError(422, 'missing_dimensions', 'Renderer output has no usable SVG dimensions for presentation settings.')@@ -319,6 +324,10 @@ function parseSafeSvg(source) {
319324 }320325 321326 export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, rendererVersion = '') {327+ return materializePresentation(canonicalizeSvg(source, metadata, engine, rendererVersion), presentation)328+}329+330+export function canonicalizeSvg(source, metadata, engine, rendererVersion = '') {322331 const root = parseSafeSvg(source)323332 const capability = normalizationFor(engine, rendererVersion)324333 applyProfile(root, capability)@@ -332,7 +341,16 @@ export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, r
332341 if (capability !== RAW_NORMALIZATION) root.attributes.set('data-dz-appearances', THEMED_APPEARANCES.join(' '))333342 const bounds = presentationBounds(root)334343 if (bounds !== null) root.attributes.set('data-dz-bounds', bounds.join(' '))335- decorate(root, metadata, presentation, engine)344+ addMetadata(root, metadata)345+ return serialize(root)346+}347+348+export function materializePresentation(canonical, presentation) {349+ const root = parseSafeSvg(canonical)350+ if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {351+ throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')352+ }353+ applyLegacyPresentation(root, presentation, root.attributes.get('data-dz-engine') ?? '')336354 return serialize(root)337355 }338356 @@ -149,8 +149,8 @@ Orphans can be collected later with a grace period.
149149 150150 ### Durable renders151151 152-After a successful save, the browser stores the normalized SVG and, where the153-browser can rasterize it, a PNG:152+After a successful save, the browser stores one canonical safe SVG and, where153+the browser can rasterize it, a PNG:154154 155155 ```http156156 PUT /api/v1/aliases/{alias_id}/renders/{svg|png}@@ -162,13 +162,25 @@ X-Renderer-Build: vegalite-6.4.3-vega-6.3.1-unit-1
162162 X-Renderer-Pipeline: vegalite,vega163163 ```164164 165-The revision and render ID prevent a slow render from being attached after the165+The SVG upload must use the current Diagram.zip canonical schema. The API166+validates and deterministically serializes it before R2 accepts it. The167+revision and render ID prevent a slow render from being attached after the166168 alias has changed. Open bytes use their image media type. Locked bytes are167169 AES-GCM ciphertext encoded as JSON. Open embeds use the stable URL168170 `/api/v1/aliases/{alias_id}/renders/svg`; locked aliases return a clear 403 from169171 that route. An unlocked browser can fetch opaque bytes with `?encrypted=1` and170172 decrypt them with the same in-memory bundle key.171173 174+An open SVG read may add an `appearance` query parameter:175+176+```text177+/api/v1/aliases/{alias_id}/renders/svg?appearance=auto-framed178+```179+180+The API materializes the requested appearance from the canonical R2 object. It181+does not store a second SVG. The appearance has its own ETag. Locked renders182+cannot use this server-side path because the API cannot decrypt their SVG.183+172184 ## Editor and share UX173185 174186 Open drafts continue to autosave locally and render after the existing debounce.@@ -1,4 +1,5 @@
11 import { normalizeMetadata, normalizePresentation } from './state.js'2+import { APPEARANCES } from './client-svg.js'23 34 export const API_PREFIX = '/api/v1'45 export const ALIAS_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/@@ -31,10 +32,14 @@ export function editAliasUrl(origin, aliasId, writeCapability) {
3132 return url.toString()3233 }3334 34-export function stableRenderUrl(origin, aliasId, format = 'svg') {35+export function stableRenderUrl(origin, aliasId, format = 'svg', appearance = 'raw') {3536 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')3637 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')37- return new URL(`${API_PREFIX}/aliases/${aliasId}/renders/${format}`, origin).toString()38+ if (!APPEARANCES.includes(appearance)) throw new Error('Invalid SVG appearance.')39+ if (format !== 'svg' && appearance !== 'raw') throw new Error('SVG appearances require SVG format.')40+ const url = new URL(`${API_PREFIX}/aliases/${aliasId}/renders/${format}`, origin)41+ if (appearance !== 'raw') url.searchParams.set('appearance', appearance)42+ return url.toString()3843 }3944 4045 export function writeCapabilityFromHash(hash) {@@ -1,5 +1,5 @@
11 import { clientAdapterFor } from './client-renderers.js'2-import { sanitizeAndDecorateSvg } from './client-svg.js'2+import { canonicalizeSvg, materializePresentation } from './client-svg.js'33 import { httpRendererUnitFor } from './renderer-units.js'44 55 const STATUS_LABELS = {@@ -95,7 +95,7 @@ export class PreviewController {
9595 if (clientAdapter) {9696 const clientRender = await clientAdapter.render({ type, source, options }, abortController.signal)9797 rendered = {98- body: sanitizeAndDecorateSvg(clientRender.body, meta, presentation, type, clientRender.version || clientRender.build || ''),98+ body: canonicalizeSvg(clientRender.body, meta, type, clientRender.version || clientRender.build || ''),9999 identity: {100100 unit: type,101101 build: clientRender.build || clientRender.version,@@ -108,13 +108,14 @@ export class PreviewController {
108108 rendered = await this.renderThroughUnit({ type, source, options, meta, presentation }, abortController.signal)109109 }110110 if (requestNumber !== this.requestNumber) return111- const { blob, background } = this.normalizedSvgBlob(rendered.body)111+ const canonical = this.normalizedSvgBlob(rendered.body)112+ const displayed = this.normalizedSvgBlob(materializePresentation(rendered.body, presentation))112113 this.latestRenderKey = renderKey113- this.latestSvgBlob = blob114+ this.latestSvgBlob = canonical.blob114115 this.latestRendererIdentity = rendered.identity115- this.imageFallbackUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await blob.text())}`116- this.stage.style.setProperty('--render-background', background)117- const nextObjectUrl = URL.createObjectURL(blob)116+ this.imageFallbackUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await displayed.blob.text())}`117+ this.stage.style.setProperty('--render-background', displayed.background)118+ const nextObjectUrl = URL.createObjectURL(displayed.blob)118119 const previousObjectUrl = this.objectUrl119120 this.objectUrl = nextObjectUrl120121 this.activeImageUrl = nextObjectUrl@@ -73,6 +73,8 @@ test('builds short read and capability-bearing edit links', () => {
7373 assert.equal(readAliasUrl('https://diagram.zip', aliasId), `https://diagram.zip/d/${aliasId}`)7474 assert.equal(editAliasUrl('https://diagram.zip', aliasId, writeCapability), `https://diagram.zip/d/${aliasId}#w=${writeCapability}`)7575 assert.equal(stableRenderUrl('https://diagram.zip', aliasId), `https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`)76+ assert.equal(stableRenderUrl('https://diagram.zip', aliasId, 'svg', 'auto-framed'), `https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg?appearance=auto-framed`)77+ assert.throws(() => stableRenderUrl('https://diagram.zip', aliasId, 'png', 'dark-framed'), /require SVG/)7678 assert.equal(writeCapabilityFromHash(`#w=${writeCapability}`), writeCapability)7779 })7880 @@ -4,6 +4,8 @@ import {
44 APPEARANCES,55 RAW_NORMALIZATION,66 SvgNormalizationError,7+ canonicalizeSvg,8+ materializePresentation,79 materializeSvg,810 sanitizeAndDecorateSvg,911 } from '../../diagramzip-svg/index.js'@@ -39,6 +41,17 @@ test('produces deterministic, idempotent raw canonical SVG', () => {
3941 assert.doesNotMatch(first, /data-theme=/)4042 })4143 44+test('keeps legacy editor presentation outside the canonical render', () => {45+ const canonical = canonicalizeSvg(source, metadata, 'pikchr')46+ const displayed = materializePresentation(canonical, { background: '#ffffff', padding: 8, frame: true })47+48+ assert.match(canonical, /data-dz-bounds="0 0 10 10"/)49+ assert.doesNotMatch(canonical, /fill="#ffffff"/)50+ assert.doesNotMatch(canonical, /viewBox="-8 -8 26 26"/)51+ assert.match(displayed, /fill="#ffffff"/)52+ assert.match(displayed, /viewBox="-8 -8 26 26"/)53+})54+4255 test('exposes structured normalization failures to both callers', () => {4356 assert.throws(4457 () => sanitizeAndDecorateSvg('<svg><style>@import "https://example.com/x";</style></svg>', metadata, presentation, 'pikchr'),@@ -137,9 +137,8 @@ Schema 1 uses the following root attributes:
137137 data-dz-normalizer="svg-normalizer-1"138138 data-dz-engine="example"139139 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>140+ data-dz-palette="renderer"141+ data-dz-appearance="raw">143142 <g data-dz-role="node">144143 <rect data-dz-fill="surface-1" data-dz-stroke="line" />145144 <text data-dz-fill="ink">Service</text>@@ -147,6 +146,10 @@ Schema 1 uses the following root attributes:
147146 </svg>148147 ```149148 149+Appearance materialization adds the self-contained tokens, owned canvas, and150+owned frame after the canonical SVG is stored. The canonical object remains151+unchanged.152+150153 These attributes are namespaced to Diagram.zip. The generic `data-theme`151154 attribute is not part of the contract because it can collide with renderer and152155 embedding conventions. Geometry and semantic paint roles remain independent