Wrap long instruction rows with dynamic SVG geometry and retain the experimental test layout. Refresh stale published render heads automatically on clean writable opens, and bump the TRN renderer build to 15.
- commit
- 3d50aae5905eaefea06ba9e4f9e46dff65b1fb13
- parent
- eff59d537ebc92256a780bc22576f50d4b2d79f1
- author
- Dami <dami.etoile@gmail.com>
@@ -391,7 +391,7 @@ export const diagramTypes = [
391391 format: 'Tabular Recipe Notation text',392392 summary: 'TRN stands for Tabular Recipe Notation. It shows how ingredients and intermediate outcomes combine into one or more final results.',393393 use: 'Use TRN for recipes, crafting trees, assembly instructions, and other nested transformations.',394- syntax: ['Optionally begin with `.layout combined` or `.layout individual`; the default is `combined`.', 'Declare each leaf value with `ingredient`.', 'Declare a meaningful final or reusable result with an `outcome` block.', 'Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.', 'Add a full-width preparation row with `instruction`.', 'Add a quoted display label after an identifier when needed.', 'Each `+` queues an input for the next `->` action.', 'Combined layout places prerequisite outcomes before direct ingredients in stable FIFO order.', 'Each `->` adds an operation column from left to right.', 'Add `portion` after an outcome label or identifier when the result has a useful quantity.', 'Use `{portion}` in an action to embed that outcome quantity.', 'Start comments with `#`; OCR importers should record uncertain text in comments instead of guessing.'],394+ syntax: ['Optionally begin with `.layout combined` or `.layout individual`; the default is `combined`.', 'Declare each leaf value with `ingredient`.', 'Declare a meaningful final or reusable result with an `outcome` block.', 'Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.', 'Add a full-width preparation row with `instruction`.', 'Long instruction rows wrap and grow vertically to preserve their complete text.', 'Add a quoted display label after an identifier when needed.', 'Each `+` queues an input for the next `->` action.', 'Combined layout places prerequisite outcomes before direct ingredients in stable FIFO order.', 'Each `->` adds an operation column from left to right.', 'Add `portion` after an outcome label or identifier when the result has a useful quantity.', 'Use `{portion}` in an action to embed that outcome quantity.', 'Start comments with `#`; OCR importers should record uncertain text in comments instead of guessing.'],395395 language: 'text',396396 example: [397397 '.layout combined',@@ -413,7 +413,7 @@ export const diagramTypes = [
413413 '}',414414 ],415415 styleSummary: 'TRN colors complete relationship zones as branches. Adjacent zones receive different normalized colors, while ingredients run downward and operations run right.',416- sourceStyle: ['Use `.layout combined` for one merged table or `.layout individual` for dependency-positioned recipe tables.', 'Combined layout places prerequisite outcomes before direct ingredients and preserves source order within each FIFO group.', 'Order `->` lines as operations should proceed from left to right.', 'Use outcomes for meaningful reusable or final results; keep incidental conversions inside the consuming outcome.', 'Use short action labels because operation text is vertical.'],416+ sourceStyle: ['Use `.layout combined` for one merged table or `.layout individual` for dependency-positioned recipe tables.', 'Combined layout places prerequisite outcomes before direct ingredients and preserves source order within each FIFO group.', 'Order `->` lines as operations should proceed from left to right.', 'Use outcomes for meaningful reusable or final results; keep incidental conversions inside the consuming outcome.', 'Use short action labels because operation text is vertical.', 'Full-width `instruction` rows wrap automatically; operation labels remain single-line and vertical.'],417417 rendererOptions: [],418418 limitations: ['The source selects one layout; the SVG does not embed an interactive layout toggle.'],419419 origin: {@@ -27,6 +27,7 @@ The source format is **Tabular Recipe Notation text**. Enter the source in the d
2727 - Declare a meaningful final or reusable result with an `outcome` block.2828 - Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.2929 - Add a full-width preparation row with `instruction`.30+- Long instruction rows wrap and grow vertically to preserve their complete text.3031 - Add a quoted display label after an identifier when needed.3132 - Each `+` queues an input for the next `->` action.3233 - Combined layout places prerequisite outcomes before direct ingredients in stable FIFO order.@@ -19,6 +19,7 @@ Start with the [general presentation settings](/style/presentation). Edit shared
1919 - Order `->` lines as operations should proceed from left to right.2020 - Use outcomes for meaningful reusable or final results; keep incidental conversions inside the consuming outcome.2121 - Use short action labels because operation text is vertical.22+- Full-width `instruction` rows wrap automatically; operation labels remain single-line and vertical.2223 ## Limitations2324 2425 - The source selects one layout; the SVG does not embed an interactive layout toggle.@@ -2821,6 +2821,7 @@ The source format is **Tabular Recipe Notation text**. Enter the source in the d
28212821 - Declare a meaningful final or reusable result with an `outcome` block.28222822 - Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.28232823 - Add a full-width preparation row with `instruction`.2824+- Long instruction rows wrap and grow vertically to preserve their complete text.28242825 - Add a quoted display label after an identifier when needed.28252826 - Each `+` queues an input for the next `->` action.28262827 - Combined layout places prerequisite outcomes before direct ingredients in stable FIFO order.@@ -2890,6 +2891,7 @@ Start with the [general presentation settings](/style/presentation). Edit shared
28902891 - Order `->` lines as operations should proceed from left to right.28912892 - Use outcomes for meaningful reusable or final results; keep incidental conversions inside the consuming outcome.28922893 - Use short action labels because operation text is vertical.2894+- Full-width `instruction` rows wrap automatically; operation labels remain single-line and vertical.28932895 ## Limitations28942896 28952897 - The source selects one layout; the SVG does not embed an interactive layout toggle.@@ -190,6 +190,13 @@ AES-GCM ciphertext encoded as JSON. Open embeds use the stable URL
190190 that route. An unlocked browser can fetch opaque bytes with `?encrypted=1` and191191 decrypt them with the same in-memory bundle key.192192 193+When a writable, unchanged alias opens, the editor compares the current194+renderer identity with the published render head. If they differ, it silently195+renders the saved source and uploads fresh SVG/PNG bytes through the same196+guarded `PUT`. This advances the render head without changing the alias revision197+or inventing a second `/update` mutation route. Dirty diagrams are never198+refreshed, so unpublished source cannot be attached to a published render ID.199+193200 An open SVG read may add an `appearance` query parameter:194201 195202 ```text@@ -233,6 +240,8 @@ changes stay blocked.
233240 URL and create no alias.234241 - New draft: **Publish** creates an alias and changes the browser to its read URL.235242 - Writable alias: **Publish** updates with the last revision.243+- Writable unchanged alias: stale cached SVG/PNG bytes refresh automatically244+ when the owner opens the diagram, without creating a diagram revision.236245 - Local document: the launcher can reopen, duplicate, or explicitly delete it.237246 - Read-only alias: edits form a local fork; **Publish a Copy** creates a new alias.238247 - Conflict: offer **Reload published** or **Publish as new**. Never overwrite silently.@@ -1325,12 +1325,54 @@ async function lockedPayloadForState(state) {
13251325 return encryptLockedState(state, remote.bundleKey, remote.keyEnvelope)13261326 }13271327 1328-async function cacheSavedRenders(state) {1329- if (!remote.aliasId || !remote.writeCapability) return1328+function rendererIdentitiesMatch(left, right) {1329+ return Boolean(1330+ left1331+ && right1332+ && left.unit === right.unit1333+ && left.build === right.build1334+ && left.pipeline.join(',') === right.pipeline.join(',')1335+ )1336+}1337+1338+function savedRenderCanRefresh(state) {1339+ return Boolean(1340+ remote.aliasId1341+ && remote.writeCapability1342+ && remote.savedState === state1343+ && !remote.dirty1344+ && !remote.keyEnvelopeDirty1345+ && detailsValid1346+ && workingStateSnapshot(currentState(), remote.mode) === remote.savedSnapshot1347+ )1348+}1349+1350+async function refreshSavedRenderIfStale() {1351+ const state = remote.savedState1352+ if (!savedRenderCanRefresh(state)) return1353+ const target = `${remote.aliasId}:${remote.revision}:${remote.renderId}`1354+ try {1355+ await preview.render(state)1356+ if (!savedRenderCanRefresh(state)1357+ || target !== `${remote.aliasId}:${remote.revision}:${remote.renderId}`) return1358+ const current = preview.rendererIdentityFor(state)1359+ if (!current) return1360+ const published = await persistence.getRenderIdentity(remote.aliasId, 'svg', remote.mode)1361+ if (!savedRenderCanRefresh(state)1362+ || target !== `${remote.aliasId}:${remote.revision}:${remote.renderId}`1363+ || rendererIdentitiesMatch(current, published)) return1364+ await cacheSavedRenders(state, { silent: true })1365+ } catch {1366+ // Render freshness is best effort and must not interrupt opening a diagram.1367+ }1368+}1369+1370+async function cacheSavedRenders(state, { silent = false } = {}) {1371+ if (!remote.aliasId || !remote.writeCapability) return false13301372 await preview.render(state)13311373 const svg = preview.svgBlobFor(state)13321374 const renderer = preview.rendererIdentityFor(state)1333- if (!svg || !renderer) return1375+ if (!svg || !renderer) return false13341376 13351377 const renders = [['svg', svg]]13361378 try {@@ -1356,8 +1398,10 @@ async function cacheSavedRenders(state) {
13561398 })13571399 const results = await Promise.allSettled(uploads)13581400 if (results.every(result => result.status === 'rejected')) {1359- showToast('Published, but the render cache is unavailable.', 4000)1401+ if (!silent) showToast('Published, but the render cache is unavailable.', 4000)1402+ return false13601403 }1404+ return results[0]?.status === 'fulfilled'13611405 }13621406 13631407 async function svgToPngBlob(svg) {@@ -1879,4 +1923,5 @@ function showToast(message, duration = 1800) {
18791923 }18801924 18811925 commitState()1926+void refreshSavedRenderIfStale()18821927 if (initialLoadError) showToast(initialLoadError)@@ -242,6 +242,34 @@ export class PersistenceClient {
242242 if (!response.ok) throw await this.errorFor(response)243243 }244244 245+ async getRenderIdentity(aliasId, format = 'svg', mode = 'open') {246+ if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')247+ if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')248+ if (mode !== 'open' && mode !== 'locked') throw new Error('Invalid diagram mode.')249+ const url = this.url(`/${aliasId}/renders/${format}`)250+ if (mode === 'locked') url.searchParams.set('encrypted', '1')251+ const response = await this.fetch(url, { method: 'HEAD' })252+ if (response.status === 404) return null253+ if (!response.ok) throw await this.errorFor(response)254+ const identity = {255+ unit: response.headers.get('X-Renderer-Unit')?.trim().toLowerCase() ?? '',256+ build: response.headers.get('X-Renderer-Build')?.trim() ?? '',257+ pipeline: (response.headers.get('X-Renderer-Pipeline') ?? '')258+ .split(',')259+ .map(value => value.trim().toLowerCase())260+ .filter(Boolean),261+ }262+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(identity.unit)263+ || !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/.test(identity.build)264+ || identity.pipeline.length < 1265+ || identity.pipeline.some(value => !/^[a-z][a-z0-9-]{0,31}$/.test(value))) {266+ throw new PersistenceError('The persistence service returned an invalid renderer identity.', {267+ code: 'invalid_response',268+ })269+ }270+ return identity271+ }272+245273 async getEncryptedRender(aliasId, format) {246274 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')247275 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')@@ -212,6 +212,32 @@ test('uploads and retrieves an opaque locked render', async () => {
212212 assert.equal(requests[1].url, `http://localhost/api/v1/aliases/${aliasId}/renders/png?encrypted=1`)213213 })214214 215+test('reads the published renderer identity without downloading render bytes', async () => {216+ let captured217+ const client = new PersistenceClient({218+ fetchImpl: async (url, init) => {219+ captured = { url: url.toString(), init }220+ return new Response(null, {221+ headers: {222+ 'X-Renderer-Unit': renderer.unit,223+ 'X-Renderer-Build': renderer.build,224+ 'X-Renderer-Pipeline': renderer.pipeline.join(','),225+ },226+ })227+ },228+ })229+ assert.deepEqual(await client.getRenderIdentity(aliasId, 'svg', 'locked'), renderer)230+ assert.equal(captured.url, `http://localhost/api/v1/aliases/${aliasId}/renders/svg?encrypted=1`)231+ assert.equal(captured.init.method, 'HEAD')232+})233+234+test('treats a missing published render as stale', async () => {235+ const client = new PersistenceClient({236+ fetchImpl: async () => new Response(null, { status: 404 }),237+ })238+ assert.equal(await client.getRenderIdentity(aliasId), null)239+})240+215241 test('surfaces structured persistence errors', async () => {216242 const client = new PersistenceClient({217243 fetchImpl: async () => Response.json({@@ -182,7 +182,7 @@ test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {
182182 test('normalizes TRN table structure and relationship zones', () => {183183 const trn = canonicalizeSvg(184184 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 40"><rect class="trn-canvas" width="80" height="40" fill="#fbf8dc"></rect><text class="trn-title" fill="#111827">Bread</text><rect class="trn-table-surface" y="10" width="80" height="30" fill="white"></rect><path class="trn-zone-shape trn-branch-4" fill="white" stroke="#65a268"></path><text class="trn-operation-label" fill="#111827">mix</text></svg>',185- metadata, 'trn', 'diagramzip-trn@14',185+ metadata, 'trn', 'diagramzip-trn@15',186186 )187187 188188 assert.match(trn, /data-dz-profile="trn-semantic-4"/)@@ -108,7 +108,7 @@ const versions: Record<EngineId, string> = {
108108 ditaa: 'ditaa-ascii+svgbob@0.7.6/edge-wasm-1',109109 symbolator: 'symbolator@1.2.2/python-translation-1',110110 tikz: '@planktimerr/tikzjax@1.0.63',111- trn: 'diagramzip-trn@14',111+ trn: 'diagramzip-trn@15',112112 umlet: 'diagramzip-umlet-svg@1',113113 }114114 @@ -5,6 +5,6 @@
55 "compatibility_date": "2026-08-20",66 "compatibility_flags": ["nodejs_compat"],77 "observability": { "enabled": true, "logs": { "head_sampling_rate": 1 } },8- "vars": { "RENDERER_BUILD": "diagramzip-trn-14" },8+ "vars": { "RENDERER_BUILD": "diagramzip-trn-15" },99 "routes": [{ "pattern": "trn.render.diagram.zip", "custom_domain": true }]1010 }@@ -4,7 +4,7 @@ import { parseTrn } from '../languages/trn'
44 import { renderTrn } from '../languages/trn-renderer'55 import { edgeFailure, edgeResult } from './types'66 7-const VERSION = 'diagramzip-trn@14'7+const VERSION = 'diagramzip-trn@15'88 99 function validateOptions(options: Record<string, string>): void {1010 for (const name of Object.keys(options)) {@@ -6,6 +6,10 @@ const MAX_LABEL_WIDTH = 390
66 const OPERATION_WIDTH = 3477 const OPERATION_FONT_SIZE = 1088 const OPERATION_TEXT_PADDING = 149+const INSTRUCTION_FONT_SIZE = 1210+const INSTRUCTION_LINE_HEIGHT = 1611+const INSTRUCTION_HORIZONTAL_PADDING = 1812+const INSTRUCTION_VERTICAL_PADDING = 10913 const MARGIN = 121014 const HEADER_HEIGHT = 281115 const ROOT_GAP = 1@@ -15,6 +19,9 @@ const RECIPE_LINK_CLEARANCE = 4
1519 const FONT_FAMILY = 'Arial,Helvetica,sans-serif'1620 const GRID_COLOR = '#65a268'1721 const BRANCH_COLORS = ['#dbeafe', '#fef3c7', '#ede9fe', '#ccfbf1'] as const22+const TEST_INGREDIENT_WIDTH = 4423+const TEST_OPERATION_FONT_SIZE = 1124+const TEST_INGREDIENT_FONT_SIZE = 101825 1926 interface LeafLayout {2027 row: number@@ -49,6 +56,11 @@ interface Geometry {
4956 tableWidth: number5057 }5158 59+interface InstructionLayout {60+ items: Array<{ lines: string[]; height: number; offset: number }>61+ height: number62+}63+5264 function escapeXml(value: string): string {5365 return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')5466 }@@ -57,6 +69,65 @@ function textWidth(value: string, fontSize: number, padding: number): number {
5769 return value.length * fontSize * 0.56 + padding5870 }5971 72+function breakWord(value: string, maximumWidth: number, fontSize: number): string[] {73+ const characters = Array.from(value)74+ const lines: string[] = []75+ let current = ''76+ for (const character of characters) {77+ const candidate = current + character78+ if (current !== '' && textWidth(candidate, fontSize, 0) > maximumWidth) {79+ lines.push(current)80+ current = character81+ } else {82+ current = candidate83+ }84+ }85+ if (current !== '') lines.push(current)86+ return lines87+}88+89+function wrapText(value: string, maximumWidth: number, fontSize: number): string[] {90+ const words = value.trim().split(/\s+/).filter(Boolean)91+ if (words.length === 0) return ['']92+ const lines: string[] = []93+ let current = ''94+95+ for (const word of words) {96+ const candidate = current === '' ? word : `${current} ${word}`97+ if (textWidth(candidate, fontSize, 0) <= maximumWidth) {98+ current = candidate99+ continue100+ }101+ if (current !== '') {102+ lines.push(current)103+ current = ''104+ }105+ if (textWidth(word, fontSize, 0) <= maximumWidth) {106+ current = word107+ continue108+ }109+ const parts = breakWord(word, maximumWidth, fontSize)110+ lines.push(...parts.slice(0, -1))111+ current = parts.at(-1) ?? ''112+ }113+114+ if (current !== '') lines.push(current)115+ return lines116+}117+118+function layoutInstructions(document: TrnDocument, tableWidth: number): InstructionLayout {119+ const maximumWidth = Math.max(1, tableWidth - INSTRUCTION_HORIZONTAL_PADDING)120+ const items: InstructionLayout['items'] = []121+ let offset = 0122+ for (const instruction of document.instructions) {123+ const lines = wrapText(instruction.text, maximumWidth, INSTRUCTION_FONT_SIZE)124+ const height = Math.max(MIN_ROW_HEIGHT, lines.length * INSTRUCTION_LINE_HEIGHT + INSTRUCTION_VERTICAL_PADDING)125+ items.push({ lines, height, offset })126+ offset += height127+ }128+ return { items, height: offset }129+}130+60131 function clamp(value: number, minimum: number, maximum: number): number {61132 return Math.max(minimum, Math.min(maximum, value))62133 }@@ -294,28 +365,87 @@ function geometryFor(layout: Layout): Geometry {
294365 return { labelWidth, columnWidths, columnOffsets, rowHeights, rowOffsets, bodyHeight, tableWidth: offset }295366 }296367 297-function leafSvg(leaf: LeafLayout, geometry: Geometry, bodyY: number): string {368+function testGeometryFor(layout: Layout): Geometry {369+ const columnWidths = Array.from({ length: layout.columns }, (_, index) => {370+ const actions = layout.stages371+ .filter(stage => stage.column === index + 1)372+ .map(stage => textWidth(actionText(stage), TEST_OPERATION_FONT_SIZE, 28))373+ return Math.ceil(clamp(Math.max(82, ...actions), 82, 280))374+ })375+ const columnOffsets: number[] = []376+ let offset = TEST_INGREDIENT_WIDTH377+ for (const width of columnWidths) {378+ columnOffsets.push(offset)379+ offset += width380+ }381+ const rowHeights = Array.from({ length: layout.rows }, () => MIN_ROW_HEIGHT)382+ for (const leaf of layout.leaves) {383+ rowHeights[leaf.row] = Math.ceil(clamp(384+ textWidth(ingredientText(leaf), TEST_INGREDIENT_FONT_SIZE, 20),385+ 64,386+ 240,387+ ))388+ }389+ const rowOffsets: number[] = []390+ let bodyHeight = 0391+ for (const height of rowHeights) {392+ rowOffsets.push(bodyHeight)393+ bodyHeight += height394+ }395+ return {396+ labelWidth: TEST_INGREDIENT_WIDTH,397+ columnWidths,398+ columnOffsets,399+ rowHeights,400+ rowOffsets,401+ bodyHeight,402+ tableWidth: offset,403+ }404+}405+406+function leafSvg(407+ leaf: LeafLayout,408+ geometry: Geometry,409+ bodyY: number,410+ mirrored = false,411+ verticalLabel = false,412+): string {298413 const text = ingredientText(leaf)299- const x = MARGIN + 7414+ const cellX = MARGIN + (mirrored ? geometry.tableWidth - geometry.labelWidth : 0)300415 const height = geometry.rowHeights[leaf.row]!301416 const top = rowY(leaf.row, bodyY, geometry)302- const y = top + height / 2 + 4303- const available = geometry.labelWidth - 14304- const length = Math.max(1, Math.floor(available / 6.7))417+ const centerX = cellX + geometry.labelWidth / 2418+ const centerY = top + height / 2419+ const x = verticalLabel ? centerX : cellX + 7420+ const y = verticalLabel ? centerY : centerY + 4421+ const fontSize = verticalLabel ? TEST_INGREDIENT_FONT_SIZE : 12422+ const available = (verticalLabel ? height : geometry.labelWidth) - 14423+ const length = Math.max(1, Math.floor(available / (fontSize * 0.56)))305424 const shown = text.length <= length ? text : `${text.slice(0, length - 1)}…`306- return `<g class="trn-ingredient" data-value-id="${escapeXml(leaf.input.id)}"><title>${escapeXml(text)}</title><rect class="trn-ingredient-cell" x="${MARGIN}" y="${top}" width="${geometry.labelWidth}" height="${height}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-ingredient-label" x="${x}" y="${y}" fill="#111827" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(shown)}</text></g>`425+ const transform = verticalLabel ? ` transform="rotate(90 ${centerX} ${centerY})"` : ''426+ return `<g class="trn-ingredient" data-value-id="${escapeXml(leaf.input.id)}"><title>${escapeXml(text)}</title><rect class="trn-ingredient-cell" x="${cellX}" y="${top}" width="${geometry.labelWidth}" height="${height}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-ingredient-label" x="${x}" y="${y}" text-anchor="${verticalLabel ? 'middle' : 'start'}" dominant-baseline="${verticalLabel ? 'middle' : 'auto'}" fill="#111827" font-size="${fontSize}" font-family="${FONT_FAMILY}"${transform}>${escapeXml(shown)}</text></g>`307427 }308428 309-function stageSvg(item: StageLayout, geometry: Geometry, bodyY: number): string {310- const x = MARGIN + geometry.columnOffsets[item.column - 1]!311- const y = rowY(item.startRow, bodyY, geometry)429+function stageSvg(430+ item: StageLayout,431+ geometry: Geometry,432+ bodyY: number,433+ mirrored = false,434+ horizontalLabel = false,435+): string {312436 const width = geometry.columnWidths[item.column - 1]!437+ const columnLeft = (column: number): number => {438+ const offset = geometry.columnOffsets[column - 1]!439+ const columnWidth = geometry.columnWidths[column - 1]!440+ return MARGIN + (mirrored ? geometry.tableWidth - offset - columnWidth : offset)441+ }442+ const columnRight = (column: number): number => columnLeft(column) + geometry.columnWidths[column - 1]!443+ const x = columnLeft(item.column)444+ const y = rowY(item.startRow, bodyY, geometry)313445 const height = geometry.rowOffsets[item.endRow]! + geometry.rowHeights[item.endRow]! - geometry.rowOffsets[item.startRow]!314446 const result = item.outcome.portion === '' ? item.outcome.label : `${item.outcome.label} (${item.outcome.portion})`315447 const action = actionText(item)316448 const cells = stageCells(item)317- const columnLeft = (column: number): number => MARGIN + geometry.columnOffsets[column - 1]!318- const columnRight = (column: number): number => columnLeft(column) + geometry.columnWidths[column - 1]!319449 const edges: Array<{ start: [number, number]; end: [number, number] }> = []320450 for (const value of cells) {321451 const [column, row] = value.split(':').map(Number) as [number, number]@@ -324,9 +454,11 @@ function stageSvg(item: StageLayout, geometry: Geometry, bodyY: number): string
324454 const top = rowY(row, bodyY, geometry)325455 const bottom = top + geometry.rowHeights[row]!326456 if (!cells.has(`${column}:${row - 1}`)) edges.push({ start: [left, top], end: [right, top] })327- if (!cells.has(`${column + 1}:${row}`)) edges.push({ start: [right, top], end: [right, bottom] })457+ const rightNeighbor = mirrored ? column - 1 : column + 1458+ const leftNeighbor = mirrored ? column + 1 : column - 1459+ if (!cells.has(`${rightNeighbor}:${row}`)) edges.push({ start: [right, top], end: [right, bottom] })328460 if (!cells.has(`${column}:${row + 1}`)) edges.push({ start: [right, bottom], end: [left, bottom] })329- if (!cells.has(`${column - 1}:${row}`)) edges.push({ start: [left, bottom], end: [left, top] })461+ if (!cells.has(`${leftNeighbor}:${row}`)) edges.push({ start: [left, bottom], end: [left, top] })330462 }331463 const pointKey = (point: [number, number]): string => `${point[0]}:${point[1]}`332464 const edgesByStart = new Map<string, number[]>()@@ -354,19 +486,32 @@ function stageSvg(item: StageLayout, geometry: Geometry, bodyY: number): string
354486 paths.push(`M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z`)355487 }356488 const zonePath = paths.join('')489+ const labelX = x + width / 2490+ const labelY = y + height / 2491+ const transform = horizontalLabel ? '' : ` transform="rotate(90 ${labelX} ${labelY})"`357492 return [358493 `<g class="trn-operation-zone" data-outcome-id="${escapeXml(item.outcome.id)}" data-column="${item.column}" data-cell-count="${cells.size}" data-arm-count="${item.arms.length}" data-branch-color="${item.branchColor + 1}">`,359494 `<title>${escapeXml(action)} → ${escapeXml(result)}</title>`,360495 `<path class="trn-zone-shape trn-branch-${item.branchColor + 1}" d="${zonePath}" fill="${BRANCH_COLORS[item.branchColor]}" stroke="${GRID_COLOR}" stroke-width="1"/>`,361- `<text class="trn-operation-label" x="${x + width / 2}" y="${y + height / 2}" text-anchor="middle" dominant-baseline="middle" fill="#111827" font-size="${OPERATION_FONT_SIZE}" font-family="${FONT_FAMILY}" transform="rotate(90 ${x + width / 2} ${y + height / 2})">${escapeXml(action)}</text>`,496+ `<text class="trn-operation-label" x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" fill="#111827" font-size="${horizontalLabel ? TEST_OPERATION_FONT_SIZE : OPERATION_FONT_SIZE}" font-family="${FONT_FAMILY}"${transform}>${escapeXml(action)}</text>`,362497 '</g>',363498 ].join('')364499 }365500 366-function instructionSvg(document: TrnDocument, tableY: number, tableWidth: number): string {501+function instructionSvg(502+ document: TrnDocument,503+ layout: InstructionLayout,504+ tableY: number,505+ tableWidth: number,506+): string {367507 return document.instructions.map((instruction, index) => {368- const y = tableY + index * MIN_ROW_HEIGHT369- return `<g class="trn-instruction"><rect class="trn-instruction-cell" x="${MARGIN}" y="${y}" width="${tableWidth}" height="${MIN_ROW_HEIGHT}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-instruction-label" x="${MARGIN + tableWidth / 2}" y="${y + MIN_ROW_HEIGHT / 2 + 4}" text-anchor="middle" fill="#111827" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(instruction.text)}</text></g>`508+ const item = layout.items[index]!509+ const y = tableY + item.offset510+ const firstBaseline = y + (item.height - item.lines.length * INSTRUCTION_LINE_HEIGHT) / 2 + INSTRUCTION_FONT_SIZE511+ const x = MARGIN + tableWidth / 2512+ const lines = item.lines.map((line, lineIndex) =>513+ `<tspan class="trn-instruction-line" x="${x}" y="${firstBaseline + lineIndex * INSTRUCTION_LINE_HEIGHT}">${escapeXml(line)}</tspan>`).join('')514+ return `<g class="trn-instruction"><title>${escapeXml(instruction.text)}</title><rect class="trn-instruction-cell" x="${MARGIN}" y="${y}" width="${tableWidth}" height="${item.height}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-instruction-label" text-anchor="middle" fill="#111827" font-size="${INSTRUCTION_FONT_SIZE}" font-family="${FONT_FAMILY}">${lines}</text></g>`370515 }).join('')371516 }372517 @@ -374,12 +519,13 @@ function renderCombinedTrn(document: TrnDocument): string {
374519 const layout = layoutTree(document)375520 const geometry = geometryFor(layout)376521 const tableY = MARGIN + HEADER_HEIGHT377- const instructionHeight = document.instructions.length * MIN_ROW_HEIGHT522+ const instructionLayout = layoutInstructions(document, geometry.tableWidth)523+ const instructionHeight = instructionLayout.height378524 const bodyY = tableY + instructionHeight379525 const width = MARGIN * 2 + geometry.tableWidth380526 const height = MARGIN * 2 + HEADER_HEIGHT + instructionHeight + geometry.bodyHeight381527 const title = document.roots.map(root => root.portion === '' ? root.label : `${root.label} (yields ${root.portion})`).join(' + ')382- const instructions = instructionSvg(document, tableY, geometry.tableWidth)528+ const instructions = instructionSvg(document, instructionLayout, tableY, geometry.tableWidth)383529 const leaves = layout.leaves.map(item => leafSvg(item, geometry, bodyY)).join('')384530 const stages = layout.stages.map(item => stageSvg(item, geometry, bodyY)).join('')385531 @@ -455,12 +601,14 @@ function renderIndividualTrn(document: TrnDocument): string {
455601 }456602 const treeHeight = Math.max(...recipes.map(recipe => centers.get(recipe.outcome.id)! + recipe.height / 2))457603 const width = columnOffset - RECIPE_COLUMN_GAP + MARGIN * 2458- const instructionHeight = document.instructions.length * MIN_ROW_HEIGHT604+ const instructionWidth = width - MARGIN * 2605+ const instructionLayout = layoutInstructions(document, instructionWidth)606+ const instructionHeight = instructionLayout.height459607 const instructionY = MARGIN + HEADER_HEIGHT460608 const treeBaseY = instructionY + instructionHeight + (instructionHeight > 0 ? RECIPE_GAP : 0)461609 const dependencyLinks = recipes.flatMap(parent => (dependencies.get(parent.outcome.id) ?? []).map(child => ({ parent, child })))462610 const treeY = treeBaseY463- const instructions = instructionSvg(document, instructionY, width - MARGIN * 2)611+ const instructions = instructionSvg(document, instructionLayout, instructionY, instructionWidth)464612 const recipeRects = recipes.map(recipe => {465613 const depth = depthOf(recipe.outcome)466614 const top = treeY + centers.get(recipe.outcome.id)! - recipe.height / 2@@ -531,6 +679,34 @@ function renderIndividualTrn(document: TrnDocument): string {
531679 </svg>`532680 }533681 682+function renderTestTrn(document: TrnDocument): string {683+ const layout = layoutTree(document)684+ const geometry = testGeometryFor(layout)685+ const title = document.roots.map(root => root.portion === '' ? root.label : `${root.label} (yields ${root.portion})`).join(' + ')686+ const tableY = MARGIN + HEADER_HEIGHT687+ const instructionLayout = layoutInstructions(document, geometry.tableWidth)688+ const instructionHeight = instructionLayout.height689+ const bodyY = tableY + instructionHeight690+ const width = MARGIN * 2 + geometry.tableWidth691+ const height = MARGIN * 2 + HEADER_HEIGHT + instructionHeight + geometry.bodyHeight692+ const instructions = instructionSvg(document, instructionLayout, tableY, geometry.tableWidth)693+ const leaves = layout.leaves.map(item => leafSvg(item, geometry, bodyY, true, true)).join('')694+ const stages = layout.stages.map(item => stageSvg(item, geometry, bodyY, true, true)).join('')695+696+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" class="trn-diagram trn-layout-test" data-layout="test">697+ <title>${escapeXml(title)} test TRN layout</title>698+ <desc>Tabular Recipe Notation with horizontal operation labels on the left and vertical ingredient labels on the right.</desc>699+ <rect class="trn-canvas" data-dz-role="canvas" x="0" y="0" width="${width}" height="${height}" fill="#fbf8dc"/>700+ <text class="trn-title" x="${MARGIN + 7}" y="${MARGIN + 19}" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)}</text>701+ <rect class="trn-table-surface" x="${MARGIN}" y="${tableY}" width="${geometry.tableWidth}" height="${instructionHeight + geometry.bodyHeight}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/>702+ ${instructions}703+ ${leaves}704+ ${stages}705+</svg>`706+}707+534708 export function renderTrn(document: TrnDocument): string {535- return document.layout === 'individual' ? renderIndividualTrn(document) : renderCombinedTrn(document)709+ if (document.layout === 'individual') return renderIndividualTrn(document)710+ if (document.layout === 'test') return renderTestTrn(document)711+ return renderCombinedTrn(document)536712 }@@ -26,7 +26,7 @@ export interface TrnOutcome {
2626 line: number2727 }2828 29-export type TrnLayout = 'combined' | 'individual'29+export type TrnLayout = 'combined' | 'individual' | 'test'3030 3131 export interface TrnDocument {3232 layout: TrnLayout@@ -141,7 +141,7 @@ export function parseTrn(source: string): TrnDocument {
141141 sourceError(lineNumber, "Expected '+ value', '-> action', or '}'.")142142 }143143 144- const layoutMatch = line.match(/^\.layout\s+(combined|individual)\s*$/)144+ const layoutMatch = line.match(/^\.layout\s+(combined|individual|test)\s*$/)145145 if (layoutMatch !== null) {146146 if (declarationsStarted) sourceError(lineNumber, 'The .layout directive must appear before recipe declarations.')147147 if (layoutDeclared) sourceError(lineNumber, 'The .layout directive may only appear once.')@@ -149,7 +149,7 @@ export function parseTrn(source: string): TrnDocument {
149149 layoutDeclared = true150150 continue151151 }152- if (line.startsWith('.layout')) sourceError(lineNumber, "Expected '.layout combined' or '.layout individual'.")152+ if (line.startsWith('.layout')) sourceError(lineNumber, "Expected '.layout combined', '.layout individual', or '.layout test'.")153153 154154 const instructionMatch = line.match(/^instruction\s+"((?:[^"\\]|\\.)*)"\s*$/)155155 if (instructionMatch !== null) {@@ -59,7 +59,7 @@ describe('TRN language', () => {
5959 6060 it('validates the source layout directive and rejects renderer options', () => {6161 expect(() => parseTrn(zenith.replace('.layout individual', '.layout grid')))62- .toThrow(/Expected '.layout combined' or '.layout individual'/)62+ .toThrow(/Expected '.layout combined', '.layout individual', or '.layout test'/)6363 expect(() => parseTrn('.layout combined\n.layout individual\ningredient flour\noutcome bread {\n + flour\n -> bake\n}'))6464 .toThrow(/may only appear once/)6565 expect(() => parseTrn('ingredient flour\n.layout individual\noutcome bread {\n + flour\n -> bake\n}'))@@ -87,6 +87,35 @@ describe('TRN language', () => {
8787 }8888 })8989 90+ it('renders the test layout with instructions on the left and ingredients on the right', async () => {91+ const source = `.layout test\n${marshmallows}`92+ const document = parseTrn(source)93+ const result = await trnAdapter.render(request(source), new AbortController().signal)94+95+ expect(document.layout).toBe('test')96+ expect(result.body).toContain('data-layout="test"')97+ expect(result.body).toContain('horizontal operation labels on the left and vertical ingredient labels on the right')98+ expect(result.body).toContain('class="trn-instruction-cell"')99+ expect(result.body).toContain('>Grease 9x13-in. pan and powder with powdered sugar</tspan>')100+ expect(result.body).toContain('>powder</text>')101+ expect(result.body).toContain('class="trn-operation-zone"')102+ expect(result.body).toContain('data-outcome-id="marshmallows" data-column="7"')103+ expect(result.body).toContain('class="trn-ingredient" data-value-id="gelatin"')104+ expect(result.body).toContain('>3 Tbs. (21 g) gelatin</text>')105+ expect(result.body).toContain('class="trn-ingredient" data-value-id="powdered_sugar"')106+ expect(result.body).toContain('>Powdered sugar</text>')107+108+ const ingredientX = Number(result.body.match(/class="trn-ingredient-cell" x="(\d+)"/)?.[1])109+ const ingredientLabel = result.body.match(/class="trn-ingredient-label"[^>]+/)?.[0] ?? ''110+ const operationLabel = result.body.match(/class="trn-operation-label"[^>]+/)?.[0] ?? ''111+ const operationXs = [...result.body.matchAll(/class="trn-operation-label" x="([\d.]+)"/g)]112+ .map(match => Number(match[1]))113+ expect(ingredientX).toBeGreaterThan(12)114+ expect(ingredientLabel).toContain('transform="rotate(90')115+ expect(operationLabel).not.toContain('transform=')116+ expect(Math.max(...operationXs)).toBeLessThan(ingredientX)117+ })118+90119 it('rejects unknown values and dependency cycles with source locations', () => {91120 expect(() => parseTrn('outcome bread {\n + flour\n -> mix\n}')).toThrow(/Line 2: Unknown value 'flour'/)92121 expect(() => parseTrn('outcome a {\n + b\n -> make\n}\noutcome b {\n + a\n -> make\n}')).toThrow(/Outcome cycle/)@@ -118,6 +147,39 @@ outcome blade {
118147 expect(result.body).toContain('>forge a finished copper shortsword</text>')119148 })120149 150+ it('wraps long instructions and expands the table before recipe rows begin', async () => {151+ const source = `instruction "Position a rack in the center of the oven, preheat thoroughly, and grease the pan all the way into the corners before dusting it lightly with flour."152+ingredient flour153+outcome cake {154+ + flour 2 cups155+ -> bake156+}`157+ const result = await trnAdapter.render(request(source), new AbortController().signal)158+ const instructionCell = result.body.match(/class="trn-instruction-cell"[^>]*y="([\d.]+)"[^>]*width="([\d.]+)"[^>]*height="([\d.]+)"/)159+ const ingredientCell = result.body.match(/class="trn-ingredient-cell"[^>]*y="([\d.]+)"/)160+ const lines = [...result.body.matchAll(/class="trn-instruction-line"/g)]161+162+ expect(lines.length).toBeGreaterThan(1)163+ expect(Number(instructionCell?.[3])).toBeGreaterThan(28)164+ expect(Number(ingredientCell?.[1])).toBe(Number(instructionCell?.[1]) + Number(instructionCell?.[3]))165+ expect(result.body).toContain('<title>Position a rack in the center of the oven')166+ expect(result.body).toContain('>the corners before dusting it</tspan>')167+ expect(result.body).toContain('>lightly with flour.</tspan>')168+ })169+170+ it('breaks a single instruction token that is wider than the table', async () => {171+ const source = `instruction "supercalifragilisticexpialidocioussupercalifragilisticexpialidocious"172+ingredient flour173+outcome cake {174+ + flour175+ -> bake176+}`177+ const result = await trnAdapter.render(request(source), new AbortController().signal)178+179+ expect(result.body.match(/class="trn-instruction-line"/g)?.length).toBeGreaterThan(1)180+ expect(result.body).not.toContain('…')181+ })182+121183 it('merges waiting cells and grows operations across the inputs they consume', async () => {122184 const source = `ingredient gelatin123185 ingredient water@@ -329,6 +329,46 @@ describe('open aliases', () => {
329329 })330330 })331331 332+ it('refreshes a render head for a newer renderer build without advancing the alias revision', async () => {333+ const created = await create()334+ const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()335+ const first = canonicalizeSvg(336+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><text>old</text></svg>',337+ { title: '', description: '' },338+ 'd2',339+ 'd2@0.7.1',340+ )341+ const refreshed = canonicalizeSvg(342+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><text>new</text></svg>',343+ { title: '', description: '' },344+ 'd2',345+ 'd2@0.7.2',346+ )347+ const upload = (body: string, build: string) => worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`, {348+ method: 'PUT',349+ headers: {350+ Authorization: `Bearer ${capability}`,351+ 'Content-Type': 'image/svg+xml',352+ 'If-Match': '"1"',353+ 'X-Render-Id': renderId,354+ 'X-Renderer-Unit': 'd2',355+ 'X-Renderer-Build': build,356+ 'X-Renderer-Pipeline': 'd2',357+ },358+ body,359+ })360+361+ expect((await upload(first, 'd2-compat-unit-1')).status).toBe(204)362+ expect((await upload(refreshed, 'd2-compat-unit-2')).status).toBe(204)363+364+ const render = await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}/renders/svg`)365+ expect(render.headers.get('X-Renderer-Build')).toBe('d2-compat-unit-2')366+ expect(await render.text()).toBe(refreshed)367+ expect((await worker.fetch(`https://diagram.zip/api/v1/aliases/${aliasId}`)).headers.get('ETag')).toBe('"1"')368+ expect(await env.CONTENT.get(`renders/open/d2/d2-compat-unit-1/${renderId}.svg`)).not.toBeNull()369+ expect(await env.CONTENT.get(`renders/open/d2/d2-compat-unit-2/${renderId}.svg`)).not.toBeNull()370+ })371+332372 it('materializes supported appearances from one canonical SVG object', async () => {333373 const created = await create()334374 const { aliasId, renderId } = await created.json<{ aliasId: string; renderId: string }>()@@ -158,7 +158,7 @@ const VERSION_PATTERNS = Object.freeze({
158158 diagramsnet: /diagrams\.net@29\.6\.1/,159159 excalidraw: /@excalidraw\/excalidraw@0\.18\.1/,160160 tikz: /@planktimerr\/tikzjax@1\.0\.63/,161- trn: /diagramzip-trn@14/,161+ trn: /diagramzip-trn@15/,162162 })163163 164164 export function normalizationFor(engine, rendererVersion = '') {