Show Obsidian vault diagrams (.canvas, Excalidraw, draw.io, BPMN) as SVG in Claude Desktop side panes, with landscape/portrait and zoom

A Claude Code mod that renders diagram files from an Obsidian vault — or any folder — as SVG in side panes of the Claude Desktop Code tab. Claude Desktop shows Markdown, images and PDFs, but not diagram sources; this fills the gap.
| Format | Extensions | Rendered by |
|---|---|---|
| JSON Canvas (Obsidian) | .canvas | built-in renderer |
| Excalidraw (Obsidian plugin) | .excalidraw.md, .excalidraw | the plugin's own SVG export when newer, else built-in (plain and compressed-json) |
| draw.io / diagrams.net | .drawio | the draw.io Desktop CLI export when installed, else built-in (multi-page) |
| BPMN 2.0 | .bpmn | built-in, from the file's own BPMN DI layout (pools, lanes, tasks, events, gateways, audit colours) |
| D2 | .d2 | the d2 CLI (on the PATH, or inside WSL) |
Each format opens in its own pane tab, and every tab has the same toolbar:
direction: down / right.The pane redraws by itself after Claude edits the file with Write or Edit.
/diagram <path> [page] [--landscape|--portrait]mcp__diagram-preview__open with path, page and orientation.This is a mod: a plugin of Claude Code function hooks (early access; written against Claude Code 2.1.286). The plugin is named diagram-preview.
CLAUDE_CODE_PLUGIN_DIRS in the env block of your user settings (~/.claude/settings.json; project settings cannot set it), e.g. on Windows with the clone inside WSL: { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "\\\\wsl.localhost\\Ubuntu\\home\\you\\projects\\dev\\claude-diagrams-renderer-mod" } }
plugin-authoring skill sets that up) — not both at once, or it loads twice.Required: Claude Code with mods (function hooks), 2.1.286 or later. The mod has no npm packages: it imports only the engine (claude-code) and its own files.
Optional tools. The mod installs nothing itself. It looks for these tools when it needs them; when one is missing it falls back to its own renderer or says what is missing:
| Tool | Used for | When it is missing |
|---|---|---|
d2 CLI | .d2 files (needed: there is no built-in D2 renderer) | The pane says "d2 is not installed (looked on the PATH and inside WSL)" |
| draw.io Desktop | Exact draw.io renders (its CLI export) | The built-in renderer draws the file |
| Obsidian with the Advanced URI plugin | The Open in Obsidian button (only shown for files inside a vault) | The button cannot open the file |
| Excalidraw plugin with auto-export SVG on | Excalidraw's hand-drawn picture (<name>.excalidraw.svg, used when newer than the file) | The built-in renderer draws the file |
The mod finds d2 on the PATH first, then inside WSL (wsl.exe -e bash -lc 'd2 …'). Installing it:
# Linux, macOS or WSL
curl -fsSL https://d2lang.com/install.sh | sh -s --
# Windows
winget install Terrastruct.D2
draw.io Desktop is looked for at its default install locations: %ProgramFiles%\draw.io\, %LOCALAPPDATA%\Programs\draw.io\, /Applications/draw.io.app, /usr/bin/drawio and /snap/bin/drawio. On Windows, install it with winget install JGraph.Draw.
claude plugin validate .
claude plugin test .
hooks/register.tsx — the hooks module: command, model tool, panes, toolbar, external renderers.hooks/formats/*.ts — pure renderers, one per format; hooks/formats/index.ts is the registry.hooks/common.ts, hooks/xml.ts — shared pure helpers (orientation, zoom, paths, a DOM-free XML reader).tests/ — 30 tests run by claude plugin test against the engine (no real filesystem: the tests' hooks serve the files, environment and processes).The engine only follows $ (the engine interface) inside the hooks module itself, so every function that touches files or processes lives in register.tsx; everything else is pure.
hooks/formats/lzstring.ts ports decompressFromBase64 from lz-string by pieroxy (MIT).
hooks/register.tsx 436 lines1import { memberOf, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DiagramTarget } from '../types'
5import {
6 SVG_LIMIT,
7 asOrientation,
8 cleanArg,
9 errorText,
10 launchers,
11 obsidianUri,
12 orientationOf,
13 panStep,
14 parentOf,
15 samePath,
16 sepOf,
17 stepZoom,
18 stripFonts,
19 targetIn,
20 tooLargeMessage,
21 withSize,
22 zoomLabel,
23 zoomView,
24} from './common'
25import type { Orientation, Target } from './common'
26import {
27 ALL_EXTS,
28 FORMATS,
29 FORMAT_INFO,
30 describeD2,
31 displayName,
32 formatOf,
33 pageIndex,
34 parseArgs,
35 renderBuiltin,
36 withD2Direction,
37} from './formats/index'
38import type { Format } from './formats/index'
39
40/** One target per format, keyed by the pane's id (which is the format's name). */
41const TARGET = { plugin: 'diagram-preview', key: 'target' } as const
42
43type Drawn = {
44 svg: string
45 summary: string
46 source: string
47 orientation: Orientation
48 pages?: string[]
49 note?: string
50}
51
52// ── files ────────────────────────────────────────────────────────────────
53
54/** Resolve a typed path; the vault is the nearest folder above it holding `.obsidian`. */
55async function resolveTarget($: EngineInterface, path: string): Promise<Target | { error: string }> {
56 const clean = cleanArg(path)
57 if (!clean) return { error: `give a path to a diagram (${ALL_EXTS.join(', ')})` }
58 if (!formatOf(clean)) return { error: `${clean} is not a diagram this mod draws (${ALL_EXTS.join(', ')})` }
59 const stat = await $.fs.stat(clean, { resolve: true }).catch(() => undefined)
60 if (!stat || stat.kind !== 'file' || !stat.realPath) return { error: `${clean} not found` }
61 const real = stat.realPath
62 for (let dir = parentOf(real); dir; dir = parentOf(dir)) {
63 if (await $.fs.exists(`${dir}${sepOf(real)}.obsidian`).catch(() => false)) return targetIn(dir, real)
64 }
65 return { rel: clean.replace(/\\/g, '/'), real, rev: 0 }
66}
67
68async function openInObsidian($: EngineInterface, target: Target): Promise<string> {
69 const uri = obsidianUri(target)
70 if (!uri) throw new Error('the file is not inside an Obsidian vault (no .obsidian folder above it)')
71 let last = 'no launcher available'
72 for (const argv of launchers(uri)) {
73 try {
74 const run = await $.process.run(argv, { timeoutMs: 15000 })
75 if (run.exitCode === 0) return argv[0] ?? 'launcher'
76 last = `${argv[0]} exited ${run.exitCode}: ${run.stderr.trim().slice(0, 200)}`
77 } catch (err) {
78 last = `${argv[0]}: ${errorText(err)}`
79 }
80 }
81 throw new Error(last)
82}
83
84// ── external renderers (fixed pictures, natural orientation only) ────────
85
86/** The Excalidraw plugin's auto-export: `x.excalidraw.md` → `x.excalidraw.svg`. */
87function excalidrawExportPath(real: string): string {
88 return `${real.slice(0, real.lastIndexOf('.'))}.svg`
89}
90
91async function excalidrawExport($: EngineInterface, real: string): Promise<{ svg?: string; note?: string }> {
92 const exported = excalidrawExportPath(real)
93 const [drawing, svgStat] = await Promise.all([$.fs.stat(real), $.fs.stat(exported).catch(() => undefined)])
94 if (!svgStat || svgStat.kind !== 'file') return {}
95 if (svgStat.mtimeMs < drawing.mtimeMs) return { note: 'the plugin export is older than the drawing' }
96 const svg = (await $.fs.read(exported)).replace(/^[\s\S]*?(<svg[\s>])/, '$1')
97 if (!svg.startsWith('<svg')) return { note: 'the plugin export is not an SVG' }
98 if (svg.length > SVG_LIMIT) return { note: 'the plugin export is too large for the pane' }
99 return { svg }
100}
101
102/** Where draw.io Desktop installs on Windows and macOS; `drawio` on PATH elsewhere. */
103async function findDrawio($: EngineInterface): Promise<string | undefined> {
104 const programFiles = await $.env.get('ProgramFiles')
105 const localAppData = await $.env.get('LOCALAPPDATA')
106 const candidates = [
107 programFiles && `${programFiles}\\draw.io\\draw.io.exe`,
108 localAppData && `${localAppData}\\Programs\\draw.io\\draw.io.exe`,
109 '/Applications/draw.io.app/Contents/MacOS/draw.io',
110 '/usr/bin/drawio',
111 '/snap/bin/drawio',
112 ].filter((p): p is string => !!p)
113 for (const path of candidates) {
114 if (await $.fs.exists(path).catch(() => false)) return path
115 }
116 return undefined
117}
118
119/** Export one draw.io page with its CLI into a temp folder; reuse a newer export. */
120async function drawioExport($: EngineInterface, exe: string, real: string, page: number): Promise<string> {
121 const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
122 const sep = sepOf(temp)
123 const key = Array.from(real).reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7)
124 const out = `${temp.replace(/[\\/]$/, '')}${sep}diagram-preview${sep}${(key >>> 0).toString(16)}-p${page}.svg`
125 const [source, cached] = await Promise.all([$.fs.stat(real), $.fs.stat(out).catch(() => undefined)])
126 if (!cached || cached.mtimeMs < source.mtimeMs) {
127 // --page-index is 1-based in the draw.io Desktop CLI.
128 const run = await $.process.run(
129 [exe, '--export', '--format', 'svg', '--page-index', String(page + 1), '--output', out, real],
130 { timeoutMs: 60000 },
131 )
132 if (run.exitCode !== 0) throw new Error(`draw.io export exited ${run.exitCode}: ${run.stderr.trim().slice(0, 200)}`)
133 }
134 return (await $.fs.read(out)).replace(/^[\s\S]*?(<svg[\s>])/, '$1')
135}
136
137// ── D2 (the d2 CLI is its layout engine) ─────────────────────────────────
138
139/**
140 * Rendered D2 SVGs by file, modification time and direction. A render costs seconds (most
141 * of it starting WSL), and every zoom or pan press redraws the pane. A module variable is
142 * enough: a reload only costs one fresh render.
143 */
144const d2Cache = new Map<string, string>()
145
146/** d2 on the PATH first (macOS, Linux, a Windows install), then the copy inside WSL. */
147const D2_COMMANDS: string[][] = [
148 ['d2', '--pad', '20', '-', '-'],
149 ['wsl.exe', '-e', 'bash', '-lc', 'd2 --pad 20 - -'],
150]
151
152async function renderD2($: EngineInterface, real: string, source: string, direction?: 'right' | 'down'): Promise<string> {
153 const stat = await $.fs.stat(real)
154 const key = `${real}|${stat.mtimeMs}|${direction ?? 'natural'}`
155 const cached = d2Cache.get(key)
156 if (cached) return cached
157 const input = direction ? withD2Direction(source, direction) : source
158 for (const argv of D2_COMMANDS) {
159 let run
160 try {
161 run = await $.process.run(argv, { stdin: input, timeoutMs: 60000 })
162 } catch {
163 continue // not installed this way; try the next
164 }
165 const errors = run.stderr.split('\n').filter(line => line.startsWith('err:'))
166 if (errors.length) throw new Error(`d2 could not compile it: ${errors.slice(0, 3).map(l => l.slice(4).trim()).join('; ')}`)
167 const at = run.stdout.indexOf('<svg')
168 if (run.exitCode !== 0 || at < 0) continue // e.g. bash: d2: command not found (127)
169 let svg = withSize(run.stdout.slice(at))
170 if (svg.length > SVG_LIMIT) svg = stripFonts(svg)
171 d2Cache.set(key, svg)
172 return svg
173 }
174 throw new Error('d2 is not installed (looked on the PATH and inside WSL); see https://d2lang.com')
175}
176
177// ── drawing ──────────────────────────────────────────────────────────────
178
179/**
180 * Draw a target. An external picture (the Excalidraw plugin's export, draw.io's CLI
181 * export) is used when it exists and is in the orientation asked for; the other
182 * orientation is always the built-in renderer's transposed layout.
183 */
184async function draw($: EngineInterface, t: DiagramTarget): Promise<Drawn> {
185 const source = await $.fs.read(t.real)
186
187 if (t.format === 'd2') {
188 // D2 lays the diagram out itself, so the other orientation is a real re-layout:
189 // the root `direction` set to `down` (portrait) or `right` (landscape).
190 const natural = await renderD2($, t.real, source)
191 const naturalOrientation = orientationOf(natural)
192 if (t.orientation && t.orientation !== naturalOrientation) {
193 const direction = t.orientation === 'portrait' ? 'down' : 'right'
194 const turned = await renderD2($, t.real, source, direction)
195 return { svg: turned, summary: describeD2(turned), source: `d2, direction: ${direction}`, orientation: orientationOf(turned) }
196 }
197 return { svg: natural, summary: describeD2(natural), source: 'd2', orientation: naturalOrientation }
198 }
199
200 const builtin = await renderBuiltin(t.format, source, t.page, false)
201 let svg = builtin.svg
202 let from = builtin.detail ? `built-in renderer (${builtin.detail})` : 'built-in renderer'
203 let note: string | undefined
204 let external = false
205
206 if (t.format === 'excalidraw') {
207 const exported = await excalidrawExport($, t.real)
208 note = exported.note
209 if (exported.svg) {
210 svg = exported.svg
211 from = 'Excalidraw plugin export'
212 external = true
213 }
214 } else if (t.format === 'drawio' && t.prefer === 'auto') {
215 const exe = await findDrawio($)
216 if (!exe) {
217 note = 'draw.io Desktop is not installed, so the built-in renderer drew this'
218 } else {
219 try {
220 const exported = await drawioExport($, exe, t.real, t.page)
221 if (exported.length <= SVG_LIMIT) {
222 svg = exported
223 from = `draw.io export${exported.includes('<foreignObject') ? ' (HTML labels)' : ''}`
224 external = true
225 } else {
226 note = 'the draw.io export is too large for the pane'
227 }
228 } catch (err) {
229 note = `draw.io export failed (${errorText(err)})`
230 }
231 }
232 }
233
234 const natural = orientationOf(svg)
235 if (t.orientation && t.orientation !== natural) {
236 // The layout swap keeps shape sizes so labels still fit; when very wide (or tall)
237 // shapes keep the drawing in its natural orientation, swap every size as well.
238 let turned = await renderBuiltin(t.format, source, t.page, true)
239 let how = 'transposed'
240 if (orientationOf(turned.svg) !== t.orientation) {
241 turned = await renderBuiltin(t.format, source, t.page, 'swap')
242 how = 'transposed, shapes turned'
243 }
244 return {
245 svg: turned.svg,
246 summary: turned.summary,
247 source: `built-in renderer, ${how}`,
248 orientation: orientationOf(turned.svg),
249 pages: turned.pages,
250 note: external ? `${t.orientation} is drawn by the built-in renderer; the ${from} is a fixed picture` : note,
251 }
252 }
253 return { svg, summary: builtin.summary, source: from, orientation: natural, pages: builtin.pages, note }
254}
255
256async function show(
257 $: EngineInterface,
258 args: string,
259 opts: { page?: string; orientation?: Orientation } = {},
260): Promise<string> {
261 const parsed = parseArgs(args)
262 const target = await resolveTarget($, parsed.path)
263 if ('error' in target) return `diagram-preview: ${target.error}`
264 const format = formatOf(target.real) as Format
265 const page = pageIndex(await $.fs.read(target.real), format, opts.page ?? parsed.page)
266 if (typeof page !== 'number') return `diagram-preview: ${target.rel}: ${page.error}`
267 const orientation = opts.orientation ?? parsed.orientation
268 const value: DiagramTarget = { ...target, format, rev: Date.now(), page, zoom: 1, pan: 0, prefer: 'auto' }
269 if (orientation) value.orientation = orientation
270 await $.state.set({ ...TARGET, id: format }, value)
271 // Opening an open pane only retitles it, behind whichever tab is in front: close it
272 // first so the new file always lands in front.
273 const isOpen = await $.ui.panes().then(ps => ps.some(p => p.id === format)).catch(() => false)
274 if (isOpen) await $.ui.close({ id: format }).catch(() => undefined)
275 const opened = await $.ui.open({ id: format, title: `${FORMAT_INFO[format].label} · ${displayName(target.rel, format)}`, focus: true })
276 const extra = [page > 0 ? `page ${page + 1}` : '', orientation ?? ''].filter(Boolean).join(', ')
277 return opened.isPlaced
278 ? `Showing ${target.rel}${extra ? ` (${extra})` : ''} in the ${FORMAT_INFO[format].label} pane.`
279 : `The ${FORMAT_INFO[format].label} pane holds ${target.rel} but is not placed yet (${opened.reason}).`
280}
281
282// ── hooks ────────────────────────────────────────────────────────────────
283
284export const register: Register = on => {
285 on('session.start', async ($, e, next) => {
286 await $.command.register({
287 name: 'diagram',
288 description: 'Show a vault diagram (.canvas, .excalidraw.md, .drawio, .bpmn, .d2) in a side pane',
289 argumentHint: '<path> [page] [--landscape|--portrait]',
290 })
291 await $.tool.register({
292 name: 'open',
293 description:
294 "Open a diagram from the Obsidian vault in the person's side pane, rendered from the file: " +
295 'JSON Canvas (.canvas), Excalidraw (.excalidraw.md), draw.io (.drawio), BPMN 2.0 (.bpmn) or D2 (.d2). ' +
296 'The pane refreshes by itself after you Write or Edit that file. Optional: page (1-based ' +
297 'number or name, for draw.io pages and BPMN diagrams) and orientation (landscape or ' +
298 'portrait; the other orientation than the drawing natural one draws its transposed layout).',
299 inputSchema: {
300 type: 'object',
301 properties: {
302 path: { type: 'string', description: 'Vault-relative or absolute path to the diagram' },
303 page: { type: 'string', description: 'Optional page or diagram: 1-based number or name' },
304 orientation: { type: 'string', enum: ['landscape', 'portrait'], description: 'Optional orientation' },
305 },
306 required: ['path'],
307 },
308 })
309 return next(e)
310 })
311
312 on('command.run', { command: 'diagram' }, async ($, e) => ({ text: await show($, e.args) }))
313
314 on('tool.call', { tool: 'mcp__diagram-preview__open' }, async ($, e) => ({
315 result: await show($, `"${String(e.path ?? '')}"`, {
316 page: e.page === undefined ? undefined : String(e.page),
317 orientation: asOrientation(e.orientation),
318 }),
319 }))
320
321 // Refresh a pane after the agent writes the file it shows (or an Excalidraw export of it).
322 for (const tool of ['Write', 'Edit'] as const) {
323 on('tool.call', { tool }, async ($, e, next) => {
324 const ran = await next(e)
325 if (ran.deny !== undefined || ran.isError) return ran
326 const path = String(e.file_path ?? '')
327 for (const format of FORMATS) {
328 const ref = { ...TARGET, id: format }
329 const { value } = await $.state.get(ref)
330 if (value && (samePath(path, value.real) || (format === 'excalidraw' && samePath(path, excalidrawExportPath(value.real))))) {
331 await update($, ref, cur => (cur ? { ...cur, rev: cur.rev + 1 } : cur))
332 }
333 }
334 return ran
335 })
336 }
337
338 for (const format of FORMATS) {
339 on('ui.render', { component: 'Pane', requestId: format }, async ($, e) => {
340 const ref = memberOf(TARGET, e)
341 const { value: t } = await $.state.get(ref)
342 const patch = (change: Partial<DiagramTarget>) =>
343 update($, ref, cur => (cur ? { ...cur, ...change, rev: cur.rev + 1 } : cur))
344 const obsidian = async () => {
345 if (!t) return
346 try {
347 $.ui.toast(`Opened ${t.rel} in Obsidian (${await openInObsidian($, t)})`)
348 } catch (err) {
349 $.ui.toast(`Could not open Obsidian: ${errorText(err)}`)
350 }
351 }
352 const empty = `No ${FORMAT_INFO[format].label} diagram open. Run /diagram with a path.`
353
354 if (e.surface === 'terminal') {
355 const { Box, Text, Button } = $.ui.resolve(e)
356 return (
357 <Box flexDirection="column">
358 <Text>{t ? t.rel : empty}</Text>
359 <Text dimColor>The terminal cannot draw SVG; use the Desktop Code tab, or open it in Obsidian.</Text>
360 {t?.vault && <Button key="open-obsidian" label="Open in Obsidian" onPress={obsidian} />}
361 </Box>
362 )
363 }
364
365 const elements = $.ui.resolve(e)
366 const { Box, Text, Button, Svg } = elements
367 const Select = 'Select' in elements ? elements.Select : undefined // the mobile app draws no Select
368 if (!t) return <Text dimColor>{empty}</Text>
369
370 let drawn: Drawn | undefined
371 let failure: string | undefined
372 try {
373 drawn = await draw($, t)
374 } catch (err) {
375 failure = errorText(err)
376 }
377 const zoom = t.zoom > 0 ? t.zoom : 1
378 const pan = t.pan ?? 0
379 const view = drawn ? zoomView(drawn.svg, zoom, pan) : undefined
380 const zoomTo = (direction: 1 | -1) => patch({ zoom: stepZoom(zoom, direction) })
381 const panBy = (direction: 1 | -1) => patch({ pan: Math.min(1, Math.max(0, pan + direction * panStep(zoom))) })
382 const orientation = drawn?.orientation ?? t.orientation ?? 'landscape'
383 const pageWord = FORMAT_INFO[format].pageWord
384 const caption = drawn && view
385 ? [drawn.summary, orientation, zoomLabel(zoom, view.from, view.to), `source: ${drawn.source}`].filter(Boolean).join(' · ')
386 : ''
387
388 return (
389 <Box flexDirection="column" gap={1}>
390 <Text bold wrap="truncate-middle">{t.rel}</Text>
391 <Box flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
392 {/* One press switches orientation; the label says what the press does, the caption
393 says what is shown. A dropdown for two values was one click too many. */}
394 <Button
395 key="orientation"
396 label={orientation === 'landscape' ? 'Switch to portrait' : 'Switch to landscape'}
397 onPress={() => patch({ orientation: orientation === 'landscape' ? 'portrait' : 'landscape' })}
398 />
399 <Button key="zoom-out" label="−" onPress={() => zoomTo(-1)} />
400 <Button key="zoom-fit" label="Fit" onPress={() => patch({ zoom: 1, pan: 0 })} />
401 <Button key="zoom-in" label="+" onPress={() => zoomTo(1)} />
402 {zoom > 1 && <Button key="pan-left" label="◀" onPress={() => panBy(-1)} />}
403 {zoom > 1 && <Button key="pan-right" label="▶" onPress={() => panBy(1)} />}
404 <Button key="refresh" label="Refresh" onPress={() => patch({})} />
405 {t.vault && <Button key="open-obsidian" label="Open in Obsidian" variant="primary" onPress={obsidian} />}
406 </Box>
407 {Select && pageWord && (drawn?.pages?.length ?? 0) > 1 && (
408 <Select
409 key="page"
410 label={pageWord}
411 value={String(Math.min(t.page, (drawn?.pages?.length ?? 1) - 1))}
412 options={(drawn?.pages ?? []).map((name, i) => ({ value: String(i), label: `${i + 1}. ${name}` }))}
413 onSelect={value => patch({ page: Number(value) })}
414 />
415 )}
416 {format === 'drawio' && (
417 <Button
418 key="renderer"
419 label={t.prefer === 'auto' ? 'Use built-in renderer' : 'Use draw.io export'}
420 onPress={() => patch({ prefer: t.prefer === 'auto' ? 'builtin' : 'auto' })}
421 />
422 )}
423 {caption && <Text dimColor>{caption}</Text>}
424 {drawn?.note && <Text dimColor italic>Note: {drawn.note}.</Text>}
425 {failure && <Text color="red">Cannot draw {t.rel}: {failure}</Text>}
426 {drawn && view && (view.svg.length > SVG_LIMIT ? (
427 <Text>{tooLargeMessage(view.svg.length)}</Text>
428 ) : (
429 <Svg source={view.svg} alt={`${FORMAT_INFO[format].label} diagram ${t.rel}: ${drawn.summary}`} />
430 ))}
431 </Box>
432 )
433 })
434 }
435}
436hooks/common.ts 239 lines1// Pure helpers shared by the vault diagram preview mods (no `$` here: the engine
2// only follows `$` within the hooks module itself). Each mod ships its own copy,
3// because a plugin may only import files inside its own folder.
4
5/** SvgProps.source is bounded at this many characters. */
6export const SVG_LIMIT = 131072
7
8/** Approximate glyph width of a sans-serif font, for wrapping without a layout engine. */
9export const CHAR_WIDTH = 0.56
10
11export type Target = {
12 /** Vault-relative path with forward slashes (what Obsidian's URI wants). */
13 rel: string
14 /** Resolved absolute path on this machine. */
15 real: string
16 /** Obsidian vault name (the vault folder's name); absent outside a vault. */
17 vault?: string
18 /** Bumped on every refresh, so readers redraw. */
19 rev: number
20}
21
22export function esc(text: string): string {
23 return text
24 .replace(/&/g, '&')
25 .replace(/</g, '<')
26 .replace(/>/g, '>')
27 .replace(/"/g, '"')
28}
29
30/** Greedy word wrap to at most `maxChars` per line; long words are cut. */
31export function wrapLines(text: string, maxChars: number): string[] {
32 const width = Math.max(4, Math.floor(maxChars))
33 const out: string[] = []
34 for (const raw of text.replace(/\r/g, '').split('\n')) {
35 let line = ''
36 for (const word of raw.split(/\s+/).filter(Boolean)) {
37 let piece = word
38 while (piece.length > width) {
39 if (line) {
40 out.push(line)
41 line = ''
42 }
43 out.push(piece.slice(0, width))
44 piece = piece.slice(width)
45 }
46 if (!line) line = piece
47 else if (line.length + 1 + piece.length <= width) line += ' ' + piece
48 else {
49 out.push(line)
50 line = piece
51 }
52 }
53 out.push(line)
54 }
55 return out
56}
57
58export function sepOf(path: string): string {
59 return path.includes('\\') ? '\\' : '/'
60}
61
62/** The folder above `path`, or undefined at a drive or filesystem root. */
63export function parentOf(path: string): string | undefined {
64 const trimmed = path.replace(/[\\/]+$/, '')
65 const cut = trimmed.lastIndexOf(sepOf(trimmed))
66 if (cut <= 0) return undefined
67 const parent = trimmed.slice(0, cut)
68 return /^[A-Za-z]:$/.test(parent) ? undefined : parent
69}
70
71export function samePath(a: string, b: string): boolean {
72 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
73 return norm(a) === norm(b)
74}
75
76/** Strip surrounding quotes and a leading "./" from a typed argument. */
77export function cleanArg(arg: string): string {
78 return arg.trim().replace(/^["']|["']$/g, '').replace(/^\.[\\/]/, '')
79}
80
81export function hasExt(path: string, exts: readonly string[]): boolean {
82 return exts.some(ext => path.toLowerCase().endsWith(ext))
83}
84
85/** A Target for `real` inside the vault rooted at `root`. */
86export function targetIn(root: string, real: string): Target {
87 const sep = sepOf(real)
88 return {
89 rel: real.slice(root.length + 1).split(sep).join('/'),
90 real,
91 vault: root.slice(root.lastIndexOf(sep) + 1),
92 rev: 0,
93 }
94}
95
96export function obsidianUri(target: Target): string | undefined {
97 if (!target.vault) return undefined
98 return `obsidian://adv-uri?vault=${encodeURIComponent(target.vault)}` +
99 `&filepath=${encodeURIComponent(target.rel)}&openmode=tab`
100}
101
102/**
103 * Ways to hand an obsidian:// URI to the OS, tried in order. A `Link` element only
104 * takes https:, so the URI goes through a process: rundll32's URL handler on Windows
105 * (no shell, so the `&` in the URI is safe), `open` on macOS, `xdg-open` elsewhere.
106 */
107export function launchers(uri: string): string[][] {
108 return [
109 ['rundll32.exe', 'url.dll,FileProtocolHandler', uri],
110 ['open', uri],
111 ['xdg-open', uri],
112 ]
113}
114
115/** The fallback shown instead of an SVG that would exceed the Svg element's bound. */
116export function tooLargeMessage(length: number): string {
117 return `The drawing is ${length.toLocaleString('en')} characters as SVG, over the ` +
118 `${SVG_LIMIT.toLocaleString('en')} the pane can show. Open it in Obsidian instead.`
119}
120
121export function errorText(err: unknown): string {
122 return err instanceof Error ? err.message : String(err)
123}
124
125// ── zoom ─────────────────────────────────────────────────────────────────
126// The pane always draws an Svg at its own width (a wider image is capped, never scrolled),
127// so zoom never changes the image's width: it changes which part of the drawing the image
128// shows. Zoom is relative to "fit" (1 = the whole width): above 1 the viewBox is a slice of
129// the drawing's width, stretched to the pane, panned left/right; below 1 the viewBox is wider
130// than the drawing, so it shrinks inside the pane. The pane's own scroll handles height.
131
132export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6, 8] as const
133
134/** The next zoom level in `direction`; 0 (an older "fit") reads as 1. */
135export function stepZoom(current: number, direction: 1 | -1): number {
136 const from = current > 0 ? current : 1
137 const next = direction > 0
138 ? ZOOM_LEVELS.find(z => z > from * 1.01)
139 : [...ZOOM_LEVELS].reverse().find(z => z < from * 0.99)
140 return next ?? (direction > 0 ? ZOOM_LEVELS[ZOOM_LEVELS.length - 1] : ZOOM_LEVELS[0]) ?? 1
141}
142
143type ViewBox = { x: number; y: number; w: number; h: number }
144
145/** The root element's viewBox, or one made from its width and height. */
146export function viewBoxOf(svg: string): ViewBox | undefined {
147 const root = /<svg\b[^>]*>/.exec(svg)?.[0]
148 if (!root) return undefined
149 const vb = /\sviewBox="\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*"/.exec(root)
150 if (vb) return { x: Number(vb[1]), y: Number(vb[2]), w: Number(vb[3]), h: Number(vb[4]) }
151 const w = /\swidth="([\d.]+)(?:px)?"/.exec(root)
152 const h = /\sheight="([\d.]+)(?:px)?"/.exec(root)
153 return w && h ? { x: 0, y: 0, w: Number(w[1]), h: Number(h[1]) } : undefined
154}
155
156/** The pan step for one press: half the visible slice, as a fraction of the pan range. */
157export function panStep(zoom: number): number {
158 return zoom > 1 ? Math.min(1, 0.5 / (zoom - 1)) : 1
159}
160
161/**
162 * Rewrite the root `<svg>` to show the drawing at `zoom` (relative to fit), panned to
163 * `pan` (0 = left edge … 1 = right edge). Returns the markup and the visible span as
164 * fractions of the drawing's width.
165 */
166export function zoomView(svg: string, zoom: number, pan = 0): { svg: string; from: number; to: number } {
167 const z = zoom > 0 ? zoom : 1
168 const vb = viewBoxOf(svg)
169 const root = /<svg\b[^>]*>/.exec(svg)?.[0]
170 if (!vb || !root || z === 1) return { svg, from: 0, to: 1 }
171 const vw = vb.w / z
172 const p = Math.min(1, Math.max(0, pan))
173 const x = z < 1 ? vb.x - (vw - vb.w) / 2 : vb.x + p * (vb.w - vw)
174 const stripped = root
175 .replace(/\s(viewBox|width|height|preserveAspectRatio)="[^"]*"/g, '')
176 .replace(/\s*\/?>$/, '')
177 const rounded = (v: number) => Math.round(v * 100) / 100
178 const newRoot = `${stripped} width="${rounded(vw)}" height="${rounded(vb.h)}" viewBox="${rounded(x)} ${rounded(vb.y)} ${rounded(vw)} ${rounded(vb.h)}" preserveAspectRatio="xMinYMin meet">`
179 const from = z < 1 ? 0 : (x - vb.x) / vb.w
180 return { svg: svg.replace(root, newRoot), from, to: z < 1 ? 1 : from + vw / vb.w }
181}
182
183export function zoomLabel(zoom: number, from = 0, to = 1): string {
184 const z = zoom > 0 ? zoom : 1
185 if (z === 1) return 'fit width'
186 const span = z > 1 ? ` · showing ${Math.round(from * 100)}–${Math.round(to * 100)}%` : ''
187 return `zoom ${Math.round(z * 100)}%${span}`
188}
189
190// ── orientation ──────────────────────────────────────────────────────────
191// Landscape or portrait is absolute: a drawing has a natural orientation (wider than tall
192// is landscape), and asking for the other one draws its transposed layout.
193
194export type Orientation = 'landscape' | 'portrait'
195
196export const ORIENTATION_OPTIONS = [
197 { value: 'landscape', label: 'Landscape' },
198 { value: 'portrait', label: 'Portrait' },
199] as const
200
201/** The drawing's size: the root's width/height, else its viewBox (d2 writes only a viewBox). */
202export function svgSize(svg: string): { w: number; h: number } {
203 const root = /<svg\b[^>]*>/.exec(svg)?.[0] ?? ''
204 const w = /\swidth="([\d.]+)(?:px)?"/.exec(root)
205 const h = /\sheight="([\d.]+)(?:px)?"/.exec(root)
206 if (w && h) return { w: Number(w[1]), h: Number(h[1]) }
207 const vb = viewBoxOf(svg)
208 return vb ? { w: vb.w, h: vb.h } : { w: 0, h: 0 }
209}
210
211/** Give a root that has only a viewBox explicit width/height, so it draws at its own aspect. */
212export function withSize(svg: string): string {
213 const root = /<svg\b[^>]*>/.exec(svg)?.[0]
214 if (!root || /\swidth="/.test(root)) return svg
215 const vb = viewBoxOf(svg)
216 return vb ? svg.replace(root, root.replace(/^<svg\b/, `<svg width="${vb.w}" height="${vb.h}"`)) : svg
217}
218
219/** Drop embedded @font-face blocks (base64 fonts) — the fallback when an SVG is too large. */
220export function stripFonts(svg: string): string {
221 return svg.replace(/@font-face\s*\{[^}]*\}/g, '')
222}
223
224export function orientationOf(svg: string): Orientation {
225 const { w, h } = svgSize(svg)
226 return w >= h ? 'landscape' : 'portrait'
227}
228
229/** Split a trailing `--landscape` / `--portrait` off a typed argument. */
230export function parseOrientationFlag(arg: string): { rest: string; orientation?: Orientation } {
231 const m = /\s+--(landscape|portrait)\s*$/i.exec(arg)
232 if (!m) return { rest: arg }
233 return { rest: arg.slice(0, m.index), orientation: (m[1] ?? '').toLowerCase() as Orientation }
234}
235
236export function asOrientation(value: unknown): Orientation | undefined {
237 return value === 'landscape' || value === 'portrait' ? value : undefined
238}
239hooks/formats/index.ts 135 lines1// The format registry: which renderer draws which file, and one pure entry point over all four.
2import { parseOrientationFlag } from '../common'
3import type { Orientation } from '../common'
4import { listDiagrams, diagramToSvg } from './bpmn'
5import { canvasToSvg } from './canvas'
6import { inflatePage, listPages, pageToSvg } from './drawio'
7import { excalidrawToSvg } from './excalidraw'
8
9export type Format = 'canvas' | 'excalidraw' | 'drawio' | 'bpmn' | 'd2'
10
11/** Pane ids double as format names, so each format keeps its own tab. */
12export const FORMATS: readonly Format[] = ['canvas', 'excalidraw', 'drawio', 'bpmn', 'd2']
13
14export const FORMAT_INFO: Record<Format, { label: string; exts: readonly string[]; pageWord?: string }> = {
15 canvas: { label: 'Canvas', exts: ['.canvas'] },
16 excalidraw: { label: 'Excalidraw', exts: ['.excalidraw.md', '.excalidraw'] },
17 drawio: { label: 'Draw.io', exts: ['.drawio'], pageWord: 'Page' },
18 bpmn: { label: 'BPMN', exts: ['.bpmn'], pageWord: 'Diagram' },
19 // D2 has no built-in renderer: its layout engine is the d2 CLI (see register.tsx).
20 d2: { label: 'D2', exts: ['.d2'] },
21}
22
23/**
24 * D2 source with the root layout direction forced: `right` lays it out landscape, `down`
25 * portrait. Only top-level `direction:` lines are replaced (nested boards keep theirs).
26 */
27export function withD2Direction(source: string, direction: 'right' | 'down'): string {
28 return `direction: ${direction}\n${source.replace(/^direction\s*:.*$/gm, '')}`
29}
30
31/** "12 shapes · 14 connections" from d2's SVG classes. */
32export function describeD2(svg: string): string {
33 const shapes = (svg.match(/class="shape"/g) ?? []).length
34 // A connection is a stroked path (`connection stroke-…`) plus arrowheads (`connection fill-…`).
35 const connections = (svg.match(/class="connection stroke-/g) ?? []).length
36 return shapes || connections ? `${shapes} shapes · ${connections} connections` : 'D2 diagram'
37}
38
39export const ALL_EXTS: readonly string[] = FORMATS.flatMap(f => FORMAT_INFO[f].exts)
40
41export function formatOf(path: string): Format | undefined {
42 const lower = path.toLowerCase()
43 return FORMATS.find(f => FORMAT_INFO[f].exts.some(ext => lower.endsWith(ext)))
44}
45
46/** The name a pane tab shows: the file name without its diagram extension. */
47export function displayName(rel: string, format: Format): string {
48 const name = rel.split('/').pop() ?? rel
49 const ext = FORMAT_INFO[format].exts.find(e => name.toLowerCase().endsWith(e))
50 return ext ? name.slice(0, name.length - ext.length) : name
51}
52
53/**
54 * `<path> [page] [--landscape|--portrait]`: the path may contain spaces or be quoted; a
55 * page (number or name) only applies to the multi-page formats, draw.io and BPMN.
56 */
57export function parseArgs(args: string): { path: string; page?: string; orientation?: Orientation } {
58 const { rest, orientation } = parseOrientationFlag(args)
59 const exts = ALL_EXTS.map(e => e.replace(/\./g, '\\.')).join('|')
60 const m = new RegExp(`^\\s*(["']?)(.*?(?:${exts}))\\1(?:\\s+(.+?))?\\s*$`, 'i').exec(rest)
61 return m ? { path: m[2] ?? '', page: m[3], orientation } : { path: rest.trim(), orientation }
62}
63
64export type BuiltinRender = {
65 svg: string
66 /** What the caption says about the drawing, e.g. "21 nodes · 9 edges". */
67 summary: string
68 /** Page or diagram names, for formats that have several. */
69 pages?: string[]
70 /** Extra detail for the caption, e.g. "compressed-json". */
71 detail?: string
72}
73
74function describeBpmn(counts: Record<string, number>): string {
75 const sum = (pred: (k: string) => boolean) =>
76 Object.entries(counts).filter(([k]) => pred(k)).reduce((a, [, v]) => a + v, 0)
77 const parts: [number, string][] = [
78 [sum(k => /Task$|^task$|callActivity|subProcess/.test(k)), 'tasks'],
79 [sum(k => k.endsWith('Gateway')), 'gateways'],
80 [sum(k => k.endsWith('Event')), 'events'],
81 [sum(k => k === 'lane'), 'lanes'],
82 [sum(k => k.endsWith('Flow')), 'flows'],
83 ]
84 return parts.filter(([v]) => v > 0).map(([v, label]) => `${v} ${label}`).join(' · ')
85}
86
87/**
88 * Draw a file with the mod's own renderer for its format. `transpose`: `true` swaps the
89 * layout's axes keeping shape sizes; `'swap'` swaps every size too (always turns it).
90 */
91export async function renderBuiltin(format: Format, source: string, page: number, transpose: boolean | 'swap'): Promise<BuiltinRender> {
92 switch (format) {
93 case 'canvas': {
94 const r = canvasToSvg(source, { transpose })
95 return { svg: r.svg, summary: `${r.nodes} nodes · ${r.edges} edges` }
96 }
97 case 'excalidraw': {
98 const r = excalidrawToSvg(source, { transpose })
99 const skipped = r.skipped.length ? ` · ${r.skipped.length} shown as placeholders` : ''
100 return { svg: r.svg, summary: `${r.elements} elements${skipped}`, detail: r.format }
101 }
102 case 'drawio': {
103 const pages = listPages(source)
104 const index = Math.min(Math.max(0, page), pages.length - 1)
105 const chosen = pages[index]
106 if (!chosen) throw new Error('the file has no pages')
107 const r = pageToSvg(await inflatePage(chosen), { transpose })
108 return {
109 svg: r.svg,
110 summary: `page ${index + 1}/${pages.length} · ${r.vertices} shapes · ${r.edges} edges`,
111 pages: pages.map(p => p.name),
112 }
113 }
114 case 'bpmn': {
115 const r = diagramToSvg(source, page, { transpose })
116 return { svg: r.svg, summary: describeBpmn(r.counts), pages: listDiagrams(source).map(d => d.name) }
117 }
118 case 'd2':
119 throw new Error('D2 is drawn by the d2 CLI, not a built-in renderer')
120 }
121}
122
123/** Resolve a typed page (1-based number or name) against a file's pages. */
124export function pageIndex(source: string, format: Format, page: string | undefined): number | { error: string } {
125 if (!page) return 0
126 if (format !== 'drawio' && format !== 'bpmn') return { error: `${FORMAT_INFO[format].label} files have no pages` }
127 const names = format === 'drawio' ? listPages(source).map(p => p.name) : listDiagrams(source).map(d => d.name)
128 const asNumber = Number(page)
129 const found = Number.isInteger(asNumber) ? asNumber - 1 : names.findIndex(n => n.toLowerCase() === page.toLowerCase())
130 if (found < 0 || found >= names.length) {
131 return { error: `no ${FORMAT_INFO[format].pageWord?.toLowerCase()} "${page}"; there are: ${names.map((n, i) => `${i + 1}. ${n}`).join(', ')}` }
132 }
133 return found
134}
135hooks/formats/bpmn.ts 414 lines1// BPMN 2.0 (.bpmn) → standalone SVG, no scripts. The layout is the file's own BPMN DI
2// (shape bounds and edge waypoints), so nothing is laid out here; this only paints it.
3import { CHAR_WIDTH, esc, wrapLines } from '../common'
4import { child, parseXml, walk } from '../xml'
5import type { XmlNode } from '../xml'
6
7export type BpmnDiagram = { index: number; id: string; name: string; plane: XmlNode }
8export type BpmnRender = { svg: string; shapes: number; edges: number; diagram: string; counts: Record<string, number> }
9
10type Box = { x: number; y: number; w: number; h: number }
11
12const INK = '#22242a'
13const FILL = '#ffffff'
14const FONT = 'Arial, Helvetica, system-ui, sans-serif'
15
16const TASKS = new Set([
17 'task', 'userTask', 'serviceTask', 'sendTask', 'receiveTask', 'manualTask', 'scriptTask',
18 'businessRuleTask', 'callActivity', 'subProcess', 'transaction', 'adHocSubProcess',
19])
20const EVENTS = new Set([
21 'startEvent', 'endEvent', 'intermediateCatchEvent', 'intermediateThrowEvent', 'boundaryEvent',
22])
23const GATEWAYS = new Set(['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway', 'complexGateway'])
24
25function n(value: string | undefined, fallback = 0): number {
26 const parsed = Number(value)
27 return Number.isFinite(parsed) ? parsed : fallback
28}
29
30function boundsOf(node: XmlNode | undefined): Box | undefined {
31 const b = node && child(node, 'Bounds')
32 return b ? { x: n(b.attrs.x), y: n(b.attrs.y), w: n(b.attrs.width), h: n(b.attrs.height) } : undefined
33}
34
35function safeColor(value: string | undefined): string | undefined {
36 return value && /^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$|^rgba?\([\d.,\s%]+\)$/.test(value) ? value : undefined
37}
38
39export function listDiagrams(source: string): BpmnDiagram[] {
40 const doc = parseXml(source)
41 const diagrams: BpmnDiagram[] = []
42 walk(doc, node => {
43 if (node.local !== 'BPMNDiagram') return
44 const plane = child(node, 'BPMNPlane')
45 if (plane) {
46 diagrams.push({ index: diagrams.length, id: node.attrs.id ?? '', name: node.attrs.name ?? `Diagram ${diagrams.length + 1}`, plane })
47 }
48 })
49 if (diagrams.length === 0) throw new Error('no BPMN diagram (BPMNDiagram/BPMNPlane) in the file — it has no layout to draw')
50 return diagrams
51}
52
53/**
54 * Text lines centred in a box, wrapped to its width and kept inside it: lines that do
55 * not fit are cut, the last kept line ending in "…", so a long name never spills over
56 * the shape's border or the flows around it.
57 */
58function centred(text: string, box: Box, size: number, color = INK, wrap = true): string {
59 const perLine = (box.w - 8) / (size * CHAR_WIDTH)
60 let lines = wrap ? wrapLines(text, perLine) : text.split('\n')
61 const step = size * 1.2
62 const room = Math.max(1, Math.floor(box.h / step))
63 if (lines.length > room) {
64 lines = lines.slice(0, room)
65 const lastLine = lines[room - 1] ?? ''
66 lines[room - 1] = `${lastLine.slice(0, Math.max(1, Math.floor(perLine) - 1)).trimEnd()}…`
67 }
68 const top = box.y + Math.max(0, (box.h - lines.length * step) / 2) + size * 0.9
69 return `<text font-size="${size}" fill="${color}" text-anchor="middle">` +
70 lines.map((l, i) => `<tspan x="${box.x + box.w / 2}" y="${top + i * step}">${esc(l) || ' '}</tspan>`).join('') +
71 `</text>`
72}
73
74/** A small marker drawn in a task's top-left corner. */
75function taskMarker(kind: string, x: number, y: number, stroke: string): string {
76 const s = `stroke="${stroke}" stroke-width="1.2" fill="none"`
77 switch (kind) {
78 case 'userTask':
79 return `<circle cx="${x + 9}" cy="${y + 6}" r="3.5" ${s}/><path d="M${x + 2},${y + 18} q0,-7 7,-7 q7,0 7,7 z" ${s}/>`
80 case 'serviceTask':
81 // A gear: a thick dashed ring (the teeth) around a hub.
82 return `<circle cx="${x + 9}" cy="${y + 10}" r="6" stroke="${stroke}" stroke-width="2.4" stroke-dasharray="2.5 1.6" fill="none"/>` +
83 `<circle cx="${x + 9}" cy="${y + 10}" r="2.5" ${s}/>`
84 case 'sendTask':
85 return `<rect x="${x + 1}" y="${y + 4}" width="16" height="11" fill="${stroke}"/><path d="M${x + 1},${y + 4} l8,6 l8,-6" stroke="${FILL}" stroke-width="1.2" fill="none"/>`
86 case 'receiveTask':
87 return `<rect x="${x + 1}" y="${y + 4}" width="16" height="11" ${s}/><path d="M${x + 1},${y + 4} l8,6 l8,-6" ${s}/>`
88 case 'scriptTask':
89 return `<path d="M${x + 4},${y + 2} h11 q-4,8 0,16 h-11 q4,-8 0,-16 z M${x + 6},${y + 7} h6 M${x + 6},${y + 11} h6" ${s}/>`
90 case 'businessRuleTask':
91 return `<rect x="${x + 1}" y="${y + 3}" width="16" height="12" ${s}/><path d="M${x + 1},${y + 7} h16 M${x + 6},${y + 7} v8" ${s}/>`
92 case 'manualTask':
93 return `<path d="M${x + 2},${y + 14} v-6 l4,-4 h9 v3 h-6 m-3,0 h9 v3 h-8 m0,0 h7 v3 h-8 v1 h-4 z" ${s}/>`
94 default:
95 return ''
96 }
97}
98
99function eventMarker(defs: string[], box: Box, stroke: string, isThrow: boolean): string {
100 const cx = box.x + box.w / 2
101 const cy = box.y + box.h / 2
102 const r = box.w / 2
103 const fill = isThrow ? stroke : 'none'
104 const s = `stroke="${stroke}" stroke-width="1.4"`
105 if (defs.includes('timerEventDefinition')) {
106 const rr = r * 0.6
107 const ticks = Array.from({ length: 12 }, (_, i) => {
108 const a = (i * Math.PI) / 6
109 return `M${cx + Math.cos(a) * rr * 0.8},${cy + Math.sin(a) * rr * 0.8} L${cx + Math.cos(a) * rr},${cy + Math.sin(a) * rr}`
110 }).join(' ')
111 return `<circle cx="${cx}" cy="${cy}" r="${rr}" fill="${FILL}" ${s}/><path d="${ticks} M${cx},${cy} L${cx},${cy - rr * 0.7} M${cx},${cy} L${cx + rr * 0.5},${cy}" ${s} fill="none"/>`
112 }
113 if (defs.includes('messageEventDefinition')) {
114 const w = r * 1.1
115 const h = r * 0.75
116 return `<rect x="${cx - w / 2}" y="${cy - h / 2}" width="${w}" height="${h}" fill="${fill}" ${s}/>` +
117 `<path d="M${cx - w / 2},${cy - h / 2} L${cx},${cy + h * 0.1} L${cx + w / 2},${cy - h / 2}" fill="none" stroke="${isThrow ? FILL : stroke}" stroke-width="1.4"/>`
118 }
119 if (defs.includes('terminateEventDefinition')) return `<circle cx="${cx}" cy="${cy}" r="${r * 0.55}" fill="${stroke}"/>`
120 if (defs.includes('signalEventDefinition')) {
121 return `<path d="M${cx},${cy - r * 0.55} L${cx + r * 0.5},${cy + r * 0.35} L${cx - r * 0.5},${cy + r * 0.35} z" fill="${fill}" ${s}/>`
122 }
123 if (defs.includes('errorEventDefinition')) {
124 return `<path d="M${cx - r * 0.45},${cy + r * 0.45} L${cx - r * 0.15},${cy - r * 0.45} L${cx + r * 0.1},${cy + r * 0.1} L${cx + r * 0.45},${cy - r * 0.45} L${cx + r * 0.15},${cy + r * 0.45} L${cx - r * 0.1},${cy - r * 0.1} z" fill="${fill}" ${s}/>`
125 }
126 if (defs.includes('escalationEventDefinition')) {
127 return `<path d="M${cx},${cy - r * 0.5} L${cx + r * 0.35},${cy + r * 0.45} L${cx},${cy + r * 0.1} L${cx - r * 0.35},${cy + r * 0.45} z" fill="${fill}" ${s}/>`
128 }
129 if (defs.includes('conditionalEventDefinition')) {
130 const w = r * 0.8
131 return `<rect x="${cx - w / 2}" y="${cy - w * 0.6}" width="${w}" height="${w * 1.2}" fill="${FILL}" ${s}/>` +
132 `<path d="M${cx - w * 0.3},${cy - w * 0.3} h${w * 0.6} M${cx - w * 0.3},${cy} h${w * 0.6} M${cx - w * 0.3},${cy + w * 0.3} h${w * 0.6}" ${s}/>`
133 }
134 return ''
135}
136
137function gatewayMarker(kind: string, box: Box, stroke: string, showX: boolean): string {
138 const cx = box.x + box.w / 2
139 const cy = box.y + box.h / 2
140 const k = box.w * 0.2
141 const s = `stroke="${stroke}" stroke-width="3.5" stroke-linecap="round" fill="none"`
142 switch (kind) {
143 case 'exclusiveGateway':
144 return showX ? `<path d="M${cx - k},${cy - k} L${cx + k},${cy + k} M${cx + k},${cy - k} L${cx - k},${cy + k}" ${s}/>` : ''
145 case 'parallelGateway':
146 return `<path d="M${cx},${cy - k * 1.3} L${cx},${cy + k * 1.3} M${cx - k * 1.3},${cy} L${cx + k * 1.3},${cy}" ${s}/>`
147 case 'inclusiveGateway':
148 return `<circle cx="${cx}" cy="${cy}" r="${k * 1.2}" stroke="${stroke}" stroke-width="2.5" fill="none"/>`
149 case 'complexGateway':
150 return `<path d="M${cx},${cy - k * 1.3} L${cx},${cy + k * 1.3} M${cx - k * 1.3},${cy} L${cx + k * 1.3},${cy} M${cx - k},${cy - k} L${cx + k},${cy + k} M${cx + k},${cy - k} L${cx - k},${cy + k}" ${s}/>`
151 case 'eventBasedGateway': {
152 const r = k * 1.4
153 const pent = Array.from({ length: 5 }, (_, i) => {
154 const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5
155 return `${cx + Math.cos(a) * r * 0.6},${cy + Math.sin(a) * r * 0.6}`
156 }).join(' ')
157 return `<circle cx="${cx}" cy="${cy}" r="${r}" stroke="${stroke}" stroke-width="1.2" fill="none"/>` +
158 `<circle cx="${cx}" cy="${cy}" r="${r * 0.82}" stroke="${stroke}" stroke-width="1.2" fill="none"/>` +
159 `<polygon points="${pent}" stroke="${stroke}" stroke-width="1.2" fill="none"/>`
160 }
161 default:
162 return ''
163 }
164}
165
166function median(values: number[]): number {
167 const sorted = values.filter(v => v > 0).sort((a, b) => a - b)
168 return sorted[Math.floor(sorted.length / 2)] ?? 0
169}
170
171/**
172 * The transpose mapping: swap the axes and rescale them by the typical task's aspect
173 * ratio (new x = old y · w/h, new y = old x · h/w). A task keeps its size, so its label
174 * still fits, and a flow that met its right edge now meets its bottom edge. Containers
175 * (pools, lanes) map corner to corner instead, so rows of lanes become columns.
176 */
177type Mapping = { box: (b: Box, isContainer: boolean) => Box; point: (x: number, y: number) => { x: number; y: number } }
178
179function transposeMapping(taskBoxes: Box[], swap = false): Mapping {
180 // `swap` is the exact axis swap (every shape's width and height swap too): the fallback
181 // when keeping task sizes does not turn the diagram to the other orientation.
182 const mw = median(taskBoxes.map(b => b.w))
183 const mh = median(taskBoxes.map(b => b.h))
184 const sx = !swap && mw && mh ? mw / mh : 1
185 const sy = !swap && mw && mh ? mh / mw : 1
186 const point = (x: number, y: number) => ({ x: y * sx, y: x * sy })
187 return {
188 point,
189 box: (b, isContainer) => {
190 const c = point(b.x + b.w / 2, b.y + b.h / 2)
191 const [w, h] = swap || isContainer ? [b.h * sx, b.w * sy] : [b.w, b.h]
192 return { x: c.x - w / 2, y: c.y - h / 2, w, h }
193 },
194 }
195}
196
197const IDENTITY: Mapping = { box: b => b, point: (x, y) => ({ x, y }) }
198
199export function diagramToSvg(source: string, index = 0, options: { transpose?: boolean | 'swap' } = {}): BpmnRender & { diagrams: number } {
200 const diagrams = listDiagrams(source)
201 const diagram = diagrams[Math.min(Math.max(0, index), diagrams.length - 1)] as BpmnDiagram
202
203 // Semantic model: every element with an id, outside the diagram interchange.
204 const doc = parseXml(source)
205 const byId = new Map<string, XmlNode>()
206 walk(doc, node => {
207 if (node.attrs.id && !node.name.startsWith('bpmndi:') && node.local !== 'BPMNDiagram') byId.set(node.attrs.id, node)
208 })
209
210 const map = options.transpose
211 ? transposeMapping(diagram.plane.children
212 .filter(di => di.local === 'BPMNShape' && TASKS.has(byId.get(di.attrs.bpmnElement ?? '')?.local ?? ''))
213 .map(di => boundsOf(di))
214 .filter((b): b is Box => !!b), options.transpose === 'swap')
215 : IDENTITY
216
217 const pools: string[] = []
218 const lanes: string[] = []
219 const flows: string[] = []
220 const nodes: string[] = []
221 const overlays: string[] = []
222 const labels: string[] = []
223 const extents: Box[] = []
224 const counts: Record<string, number> = {}
225 let shapes = 0
226 let edges = 0
227 const markers = new Map<string, string>()
228 const arrowFor = (color: string, open = false): string => {
229 const key = `${color}|${open}`
230 if (!markers.has(key)) markers.set(key, `ar${markers.size}`)
231 return markers.get(key) as string
232 }
233
234 for (const di of diagram.plane.children) {
235 const element = di.attrs.bpmnElement ? byId.get(di.attrs.bpmnElement) : undefined
236 const kind = element?.local ?? 'unknown'
237 const name = element?.attrs.name ?? ''
238 const stroke = safeColor(di.attrs['bioc:stroke'] ?? di.attrs['color:border-color']) ?? INK
239 const fill = safeColor(di.attrs['bioc:fill'] ?? di.attrs['color:background-color']) ?? FILL
240 const rawLabel = boundsOf(child(di, 'BPMNLabel') ?? undefined)
241 const labelBox = rawLabel && map.box(rawLabel, false)
242
243 if (di.local === 'BPMNShape') {
244 const raw = boundsOf(di)
245 if (!raw) continue
246 const isContainer = kind === 'participant' || kind === 'lane'
247 const box = map.box(raw, isContainer)
248 shapes++
249 counts[kind] = (counts[kind] ?? 0) + 1
250 extents.push(box)
251 const { x, y, w, h } = box
252 // Transposed, a horizontal pool or lane becomes a vertical one (header on top).
253 const horizontal = (di.attrs.isHorizontal !== 'false') !== !!options.transpose
254
255 if (kind === 'participant' || kind === 'lane') {
256 const head = 30
257 const target = kind === 'participant' ? pools : lanes
258 const isPool = kind === 'participant'
259 target.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${isPool ? fill : 'none'}" stroke="${stroke}" stroke-width="1.5"/>`)
260 if (isPool || name) {
261 if (horizontal) {
262 target.push(`<line x1="${x + head}" y1="${y}" x2="${x + head}" y2="${y + h}" stroke="${stroke}" stroke-width="1.5"/>`)
263 const cx = x + head / 2
264 const cy = y + h / 2
265 labels.push(`<text font-size="12" fill="${INK}" text-anchor="middle" transform="rotate(-90 ${cx} ${cy})" x="${cx}" y="${cy + 4}">${esc(name)}</text>`)
266 } else {
267 target.push(`<line x1="${x}" y1="${y + head}" x2="${x + w}" y2="${y + head}" stroke="${stroke}" stroke-width="1.5"/>`)
268 labels.push(centred(name, { x, y, w, h: head }, 12, INK, false))
269 }
270 }
271 continue
272 }
273
274 if (TASKS.has(kind)) {
275 const isCall = kind === 'callActivity'
276 const expanded = di.attrs.isExpanded === 'true'
277 nodes.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="10" fill="${fill}" stroke="${stroke}" stroke-width="${isCall ? 4 : 2}"/>`)
278 if (kind === 'transaction') nodes.push(`<rect x="${x + 3}" y="${y + 3}" width="${w - 6}" height="${h - 6}" rx="8" fill="none" stroke="${stroke}" stroke-width="1.2"/>`)
279 const marker = taskMarker(kind, x + 5, y + 5, stroke)
280 nodes.push(marker)
281 if ((kind === 'subProcess' || isCall) && !expanded) {
282 nodes.push(`<rect x="${x + w / 2 - 7}" y="${y + h - 16}" width="14" height="14" fill="none" stroke="${stroke}"/>` +
283 `<path d="M${x + w / 2 - 4},${y + h - 9} h8 M${x + w / 2},${y + h - 13} v8" stroke="${stroke}"/>`)
284 }
285 if (name) {
286 // A marker owns the top-left corner, so the label starts below it.
287 const top = marker ? 22 : 4
288 labels.push(expanded
289 ? centred(name, { x, y: y + top, w, h: 20 }, 12, INK)
290 : centred(name, { x: x + 2, y: y + top, w: w - 4, h: h - top - 6 }, 12, INK))
291 }
292 continue
293 }
294
295 if (EVENTS.has(kind)) {
296 const defs = (element?.children ?? []).map(c => c.local).filter(l => l.endsWith('EventDefinition'))
297 const cx = x + w / 2
298 const cy = y + h / 2
299 const r = w / 2
300 const isEnd = kind === 'endEvent'
301 const isIntermediate = kind.startsWith('intermediate') || kind === 'boundaryEvent'
302 const dashed = (kind === 'boundaryEvent' && element?.attrs.cancelActivity === 'false') ||
303 (kind === 'startEvent' && element?.attrs.isInterrupting === 'false')
304 const dash = dashed ? ' stroke-dasharray="5 3"' : ''
305 const target = kind === 'boundaryEvent' ? overlays : nodes
306 target.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${isEnd ? 4 : isIntermediate ? 1.5 : 2}"${dash}/>`)
307 if (isIntermediate) target.push(`<circle cx="${cx}" cy="${cy}" r="${r - 3}" fill="none" stroke="${stroke}" stroke-width="1.5"${dash}/>`)
308 target.push(eventMarker(defs, box, stroke, isEnd || kind === 'intermediateThrowEvent'))
309 if (name) labels.push(centred(name, labelBox ?? { x: cx - 50, y: y + h + 2, w: 100, h: 28 }, 11))
310 continue
311 }
312
313 if (GATEWAYS.has(kind)) {
314 const cx = x + w / 2
315 const cy = y + h / 2
316 nodes.push(`<polygon points="${cx},${y} ${x + w},${cy} ${cx},${y + h} ${x},${cy}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`)
317 nodes.push(gatewayMarker(kind, box, stroke, di.attrs.isMarkerVisible !== 'false'))
318 if (name) labels.push(centred(name, labelBox ?? { x: cx - 60, y: y + h + 2, w: 120, h: 28 }, 11))
319 continue
320 }
321
322 if (kind === 'dataObjectReference' || kind === 'dataObject' || kind === 'dataInput' || kind === 'dataOutput') {
323 const fold = Math.min(10, w / 3)
324 nodes.push(`<path d="M${x},${y} h${w - fold} l${fold},${fold} v${h - fold} h${-w} z M${x + w - fold},${y} v${fold} h${fold}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`)
325 if (name) labels.push(centred(name, labelBox ?? { x: x - 30, y: y + h + 2, w: w + 60, h: 28 }, 11))
326 continue
327 }
328 if (kind === 'dataStoreReference') {
329 const e = Math.min(8, h / 5)
330 nodes.push(`<path d="M${x},${y + e} a${w / 2},${e} 0 0 0 ${w},0 a${w / 2},${e} 0 0 0 ${-w},0 v${h - 2 * e} a${w / 2},${e} 0 0 0 ${w},0 v${-(h - 2 * e)}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`)
331 if (name) labels.push(centred(name, labelBox ?? { x: x - 30, y: y + h + 2, w: w + 60, h: 28 }, 11))
332 continue
333 }
334 if (kind === 'textAnnotation') {
335 const text = element ? (child(element, 'text')?.text ?? '') : ''
336 nodes.push(`<path d="M${x + 12},${y} h-12 v${h} h12" fill="none" stroke="${stroke}" stroke-width="1.5"/>`)
337 const lines = wrapLines(text.trim(), (w - 10) / (11 * CHAR_WIDTH))
338 labels.push(`<text font-size="11" fill="${INK}">` +
339 lines.map((l, i) => `<tspan x="${x + 6}" y="${y + 14 + i * 13}">${esc(l) || ' '}</tspan>`).join('') + `</text>`)
340 continue
341 }
342 if (kind === 'group') {
343 nodes.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="10" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-dasharray="8 4 2 4"/>`)
344 const categoryName = element?.attrs.categoryValueRef ? byId.get(element.attrs.categoryValueRef)?.attrs.value ?? '' : ''
345 if (categoryName) labels.push(centred(categoryName, labelBox ?? { x, y: y + 2, w, h: 18 }, 11))
346 continue
347 }
348 // Unknown shape kinds still show where they are.
349 nodes.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="${stroke}" stroke-dasharray="4 3"/>`)
350 if (name) labels.push(centred(name, box, 11))
351 continue
352 }
353
354 if (di.local === 'BPMNEdge') {
355 const pts = di.children.filter(c => c.local === 'waypoint').map(p => map.point(n(p.attrs.x), n(p.attrs.y)))
356 if (pts.length < 2) continue
357 edges++
358 counts[kind] = (counts[kind] ?? 0) + 1
359 pts.forEach(p => extents.push({ x: p.x, y: p.y, w: 0, h: 0 }))
360 const d = `M${pts.map(p => `${p.x},${p.y}`).join(' L')}`
361 const edgeStroke = safeColor(di.attrs['bioc:stroke'] ?? di.attrs['color:border-color']) ?? INK
362 if (kind === 'messageFlow') {
363 flows.push(`<path d="${d}" fill="none" stroke="${edgeStroke}" stroke-width="1.5" stroke-dasharray="8 5" marker-end="url(#${arrowFor(edgeStroke, true)})"/>`)
364 const p0 = pts[0] as { x: number; y: number }
365 flows.push(`<circle cx="${p0.x}" cy="${p0.y}" r="4" fill="${FILL}" stroke="${edgeStroke}" stroke-width="1.5"/>`)
366 } else if (kind === 'association' || kind === 'dataInputAssociation' || kind === 'dataOutputAssociation') {
367 const arrow = kind === 'association' ? '' : ` marker-end="url(#${arrowFor(edgeStroke, true)})"`
368 flows.push(`<path d="${d}" fill="none" stroke="${edgeStroke}" stroke-width="1.5" stroke-dasharray="2 4"${arrow}/>`)
369 } else {
370 flows.push(`<path d="${d}" fill="none" stroke="${edgeStroke}" stroke-width="2" stroke-linejoin="round" marker-end="url(#${arrowFor(edgeStroke)})"/>`)
371 // A default flow carries a slash near its source.
372 const sourceId = element?.attrs.sourceRef
373 if (sourceId && byId.get(sourceId)?.attrs.default === element?.attrs.id) {
374 const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }]
375 const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
376 const ux = (b.x - a.x) / len
377 const uy = (b.y - a.y) / len
378 const mx = a.x + ux * 12
379 const my = a.y + uy * 12
380 flows.push(`<path d="M${mx - uy * 6 - ux * 4},${my + ux * 6 - uy * 4} L${mx + uy * 6 + ux * 4},${my - ux * 6 + uy * 4}" stroke="${edgeStroke}" stroke-width="2"/>`)
381 }
382 }
383 if (name) {
384 const mid = pts[Math.floor((pts.length - 1) / 2)] as { x: number; y: number }
385 const next = pts[Math.floor((pts.length - 1) / 2) + 1] ?? mid
386 const fallback = { x: (mid.x + next.x) / 2 - 50, y: (mid.y + next.y) / 2 - 22, w: 100, h: 20 }
387 labels.push(centred(name, labelBox ?? fallback, 11))
388 }
389 }
390 }
391
392 if (extents.length === 0) throw new Error(`diagram "${diagram.name}" has no shapes`)
393 const pad = 20
394 const minX = Math.min(...extents.map(b => b.x)) - pad
395 const minY = Math.min(...extents.map(b => b.y)) - pad
396 const width = Math.round(Math.max(...extents.map(b => b.x + b.w)) + pad - minX)
397 const height = Math.round(Math.max(...extents.map(b => b.y + b.h)) + pad - minY)
398 const defs = [...markers.entries()]
399 .map(([key, id]) => {
400 const [color, open] = key.split('|')
401 const head = open === 'true'
402 ? `<path d="M1,1 L10,5 L1,9" fill="none" stroke="${color}" stroke-width="1.4"/>`
403 : `<path d="M0,0 L10,5 L0,10 z" fill="${color}"/>`
404 return `<marker id="${id}" viewBox="0 0 11 10" refX="10" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">${head}</marker>`
405 })
406 .join('')
407 const svg =
408 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${minX} ${minY} ${width} ${height}" font-family="${FONT}">` +
409 `<defs>${defs}</defs><rect x="${minX}" y="${minY}" width="${width}" height="${height}" fill="${FILL}"/>` +
410 pools.join('') + lanes.join('') + flows.join('') + nodes.join('') + overlays.join('') + labels.join('') +
411 `</svg>`
412 return { svg, shapes, edges, diagram: diagram.name, counts, diagrams: diagrams.length }
413}
414hooks/formats/canvas.ts 265 lines1// JSON Canvas (https://jsoncanvas.org) → standalone SVG, no scripts.
2import { CHAR_WIDTH, esc, wrapLines } from '../common'
3
4type Side = 'top' | 'right' | 'bottom' | 'left'
5
6type CanvasNode = {
7 id: string
8 type: 'text' | 'file' | 'link' | 'group' | string
9 x: number
10 y: number
11 width: number
12 height: number
13 color?: string
14 text?: string
15 file?: string
16 subpath?: string
17 url?: string
18 label?: string
19}
20
21type CanvasEdge = {
22 id: string
23 fromNode: string
24 toNode: string
25 fromSide?: Side
26 toSide?: Side
27 fromEnd?: 'none' | 'arrow'
28 toEnd?: 'none' | 'arrow'
29 color?: string
30 label?: string
31}
32
33export type CanvasRender = { svg: string; nodes: number; edges: number }
34
35/** Obsidian's six canvas colour presets. */
36const PRESETS: Record<string, string> = {
37 '1': '#e93147',
38 '2': '#ec7500',
39 '3': '#e0ac00',
40 '4': '#08b94e',
41 '5': '#00bfbc',
42 '6': '#7852ee',
43}
44const INK = '#1f2328'
45const MUTED = '#59636e'
46const LINE = '#8c959f'
47const FONT = 'system-ui, -apple-system, Segoe UI, sans-serif'
48
49function colorOf(value: string | undefined): string | undefined {
50 if (!value) return undefined
51 if (PRESETS[value]) return PRESETS[value]
52 return /^#[0-9a-fA-F]{3,8}$/.test(value) ? value : undefined
53}
54
55function num(value: unknown, fallback = 0): number {
56 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
57}
58
59function anchor(node: CanvasNode, side: Side): { x: number; y: number } {
60 switch (side) {
61 case 'top':
62 return { x: node.x + node.width / 2, y: node.y }
63 case 'bottom':
64 return { x: node.x + node.width / 2, y: node.y + node.height }
65 case 'left':
66 return { x: node.x, y: node.y + node.height / 2 }
67 case 'right':
68 return { x: node.x + node.width, y: node.y + node.height / 2 }
69 }
70}
71
72/** When an edge names no side, take the pair of sides facing each other. */
73function facingSides(a: CanvasNode, b: CanvasNode): [Side, Side] {
74 const dx = b.x + b.width / 2 - (a.x + a.width / 2)
75 const dy = b.y + b.height / 2 - (a.y + a.height / 2)
76 if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? ['right', 'left'] : ['left', 'right']
77 return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom']
78}
79
80function normal(side: Side): { x: number; y: number } {
81 return { top: { x: 0, y: -1 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }[side]
82}
83
84function textBlock(lines: string[], x: number, y: number, size: number, color: string, weight = 400): string {
85 const step = Math.round(size * 1.35)
86 return lines
87 .map((line, i) =>
88 `<text x="${x}" y="${y + size + i * step}" font-size="${size}" font-weight="${weight}" fill="${color}">${esc(line)}</text>`)
89 .join('')
90}
91
92function fitLines(text: string, width: number, height: number, size: number): string[] {
93 const perLine = (width - 24) / (size * CHAR_WIDTH)
94 const room = Math.max(1, Math.floor((height - 16) / (size * 1.35)))
95 const lines = wrapLines(text, perLine)
96 if (lines.length <= room) return lines
97 const kept = lines.slice(0, room)
98 kept[room - 1] = `${(kept[room - 1] ?? '').slice(0, Math.max(0, Math.floor(perLine) - 1))}…`
99 return kept
100}
101
102function baseName(path: string): string {
103 const name = path.slice(path.lastIndexOf('/') + 1)
104 return name.replace(/\.md$/i, '')
105}
106
107function median(values: number[]): number {
108 const sorted = values.filter(v => v > 0).sort((a, b) => a - b)
109 return sorted[Math.floor(sorted.length / 2)] ?? 0
110}
111
112const SWAPPED_SIDE: Record<Side, Side> = { left: 'top', top: 'left', right: 'bottom', bottom: 'right' }
113
114/**
115 * Swap the layout's axes (landscape ↔ portrait). Positions swap and are rescaled by the
116 * typical card's aspect ratio (new x = old y · w/h, new y = old x · h/w) so cards keep
117 * their size and text; groups map corner to corner, so a wide group becomes a tall one;
118 * an edge's named sides swap with the axes (left ↔ top, right ↔ bottom).
119 */
120export function transposeCanvas(nodes: CanvasNode[], edges: CanvasEdge[], swap = false): [CanvasNode[], CanvasEdge[]] {
121 // `swap` is the exact axis swap (every card's width and height swap too): the fallback
122 // when keeping card sizes leaves very wide cards still wider than the layout is tall.
123 const cards = nodes.filter(n => n.type !== 'group' && n.width <= 3 * n.height && n.height <= 3 * n.width)
124 const mw = median(cards.map(n => n.width))
125 const mh = median(cards.map(n => n.height))
126 const sx = !swap && mw && mh ? mw / mh : 1
127 const sy = !swap && mw && mh ? mh / mw : 1
128 const mapped = nodes.map(n => {
129 const cx = (n.y + n.height / 2) * sx
130 const cy = (n.x + n.width / 2) * sy
131 const [width, height] = swap || n.type === 'group' ? [n.height * sx, n.width * sy] : [n.width, n.height]
132 return { ...n, x: cx - width / 2, y: cy - height / 2, width, height }
133 })
134 const swapped = edges.map(e => ({
135 ...e,
136 fromSide: e.fromSide && SWAPPED_SIDE[e.fromSide],
137 toSide: e.toSide && SWAPPED_SIDE[e.toSide],
138 }))
139 return [mapped, swapped]
140}
141
142export function canvasToSvg(source: string, options: { transpose?: boolean | 'swap' } = {}): CanvasRender {
143 const data = JSON.parse(source) as { nodes?: unknown; edges?: unknown }
144 const parsedNodes = (Array.isArray(data.nodes) ? data.nodes : [])
145 .filter((n): n is CanvasNode => !!n && typeof n === 'object' && typeof (n as CanvasNode).id === 'string')
146 .map(n => ({ ...n, x: num(n.x), y: num(n.y), width: num(n.width, 250), height: num(n.height, 60) }))
147 const parsedEdges = (Array.isArray(data.edges) ? data.edges : [])
148 .filter((e): e is CanvasEdge => !!e && typeof e === 'object' && typeof (e as CanvasEdge).fromNode === 'string')
149 const [nodes, edges] = options.transpose
150 ? transposeCanvas(parsedNodes, parsedEdges, options.transpose === 'swap')
151 : [parsedNodes, parsedEdges]
152 const byId = new Map(nodes.map(n => [n.id, n]))
153
154 if (nodes.length === 0) {
155 return {
156 svg: `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80">` +
157 `<rect width="320" height="80" rx="8" fill="#ffffff"/>` +
158 `<text x="160" y="45" text-anchor="middle" font-family="${FONT}" font-size="14" fill="${MUTED}">Empty canvas</text></svg>`,
159 nodes: 0,
160 edges: 0,
161 }
162 }
163
164 const pad = 40
165 const minX = Math.min(...nodes.map(n => n.x)) - pad
166 const minY = Math.min(...nodes.map(n => n.y)) - pad - 24 // room for group labels
167 const maxX = Math.max(...nodes.map(n => n.x + n.width)) + pad
168 const maxY = Math.max(...nodes.map(n => n.y + n.height)) + pad
169 const width = Math.round(maxX - minX)
170 const height = Math.round(maxY - minY)
171
172 const parts: string[] = []
173 const markers = new Map<string, string>()
174 const markerFor = (color: string): string => {
175 const id = `a${markers.size}`
176 if (!markers.has(color)) {
177 markers.set(color, id)
178 }
179 return markers.get(color) as string
180 }
181
182 // Groups first so everything else paints over them.
183 for (const n of nodes.filter(n => n.type === 'group')) {
184 const color = colorOf(n.color) ?? LINE
185 parts.push(
186 `<rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="12" ` +
187 `fill="${color}" fill-opacity="0.06" stroke="${color}" stroke-width="2"/>`,
188 )
189 if (n.label) parts.push(textBlock([n.label], n.x + 4, n.y - 26, 16, INK, 600))
190 }
191
192 // Edges under the cards they connect.
193 for (const e of edges) {
194 const from = byId.get(e.fromNode)
195 const to = byId.get(e.toNode)
196 if (!from || !to) continue
197 const [autoFrom, autoTo] = facingSides(from, to)
198 const fromSide = e.fromSide ?? autoFrom
199 const toSide = e.toSide ?? autoTo
200 const a = anchor(from, fromSide)
201 const b = anchor(to, toSide)
202 const reach = Math.max(40, Math.hypot(b.x - a.x, b.y - a.y) / 3)
203 const na = normal(fromSide)
204 const nb = normal(toSide)
205 const color = colorOf(e.color) ?? LINE
206 const marker = markerFor(color)
207 const startArrow = e.fromEnd === 'arrow' ? ` marker-start="url(#${marker})"` : ''
208 const endArrow = (e.toEnd ?? 'arrow') === 'arrow' ? ` marker-end="url(#${marker})"` : ''
209 parts.push(
210 `<path d="M${a.x},${a.y} C${a.x + na.x * reach},${a.y + na.y * reach} ` +
211 `${b.x + nb.x * reach},${b.y + nb.y * reach} ${b.x},${b.y}" fill="none" ` +
212 `stroke="${color}" stroke-width="2.5"${startArrow}${endArrow}/>`,
213 )
214 if (e.label) {
215 const mx = (a.x + b.x) / 2
216 const my = (a.y + b.y) / 2
217 const w = e.label.length * 14 * CHAR_WIDTH + 16
218 parts.push(
219 `<rect x="${mx - w / 2}" y="${my - 13}" width="${w}" height="26" rx="6" fill="#ffffff" stroke="${color}"/>` +
220 `<text x="${mx}" y="${my + 5}" text-anchor="middle" font-size="14" fill="${INK}">${esc(e.label)}</text>`,
221 )
222 }
223 }
224
225 // Cards.
226 for (const n of nodes.filter(n => n.type !== 'group')) {
227 const color = colorOf(n.color)
228 const stroke = color ?? '#d0d7de'
229 parts.push(
230 `<rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="10" fill="#ffffff" stroke="${stroke}" stroke-width="2"/>`,
231 )
232 if (color) {
233 parts.push(
234 `<rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="10" fill="${color}" fill-opacity="0.08"/>`,
235 )
236 }
237 const x = n.x + 12
238 const y = n.y + 8
239 if (n.type === 'file' && n.file) {
240 const title = baseName(n.file) + (n.subpath ?? '')
241 parts.push(textBlock(fitLines(title, n.width, n.height - 22, 18), x, y, 18, INK, 600))
242 parts.push(textBlock(fitLines(n.file, n.width, 30, 12), x, n.y + n.height - 28, 12, MUTED))
243 } else if (n.type === 'link' && n.url) {
244 parts.push(textBlock(fitLines(n.url, n.width, n.height, 15), x, y, 15, '#0969da'))
245 } else {
246 parts.push(textBlock(fitLines(n.text ?? n.label ?? '', n.width, n.height, 15), x, y, 15, INK))
247 }
248 }
249
250 const defs = [...markers.entries()]
251 .map(([color, id]) =>
252 `<marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
253 `<path d="M0,0 L10,5 L0,10 z" fill="${color}"/></marker>`)
254 .join('')
255
256 const svg =
257 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
258 `viewBox="${minX} ${minY} ${width} ${height}" font-family="${FONT}">` +
259 `<defs>${defs}</defs>` +
260 `<rect x="${minX}" y="${minY}" width="${width}" height="${height}" fill="#f6f8fa"/>` +
261 parts.join('') +
262 `</svg>`
263 return { svg, nodes: nodes.length, edges: edges.length }
264}
265hooks/formats/drawio.ts 410 lines1// draw.io / diagrams.net (.drawio, mxGraphModel XML) → standalone SVG, no scripts.
2// The built-in renderer, used when the draw.io command-line export is unavailable or
3// the person asked for the other orientation (an export is a fixed picture).
4import { CHAR_WIDTH, esc, wrapLines } from '../common'
5import { decodeEntities, parseXml } from '../xml'
6import type { XmlNode } from '../xml'
7
8export type DrawioPage = { index: number; name: string; id: string; compressed: boolean; content: string; model?: XmlNode }
9export type DrawioRender = { svg: string; vertices: number; edges: number; page: string }
10
11function find(node: XmlNode, name: string): XmlNode | undefined {
12 for (const c of node.children) {
13 if (c.name === name) return c
14 const deeper = find(c, name)
15 if (deeper) return deeper
16 }
17 return undefined
18}
19
20// ── pages ────────────────────────────────────────────────────────────────
21
22export function listPages(source: string): DrawioPage[] {
23 const doc = parseXml(source)
24 const file = find(doc, 'mxfile')
25 if (!file) {
26 const model = find(doc, 'mxGraphModel')
27 if (!model) throw new Error('not a draw.io file (no <mxfile> or <mxGraphModel>)')
28 return [{ index: 0, name: 'Page-1', id: 'page-1', compressed: false, content: '', model }]
29 }
30 return file.children
31 .filter(d => d.name === 'diagram')
32 .map((d, index) => {
33 const model = find(d, 'mxGraphModel')
34 return {
35 index,
36 name: d.attrs.name ?? `Page-${index + 1}`,
37 id: d.attrs.id ?? `page-${index + 1}`,
38 compressed: !model && d.text.trim().length > 0,
39 content: model ? '' : d.text.trim(),
40 model,
41 }
42 })
43}
44
45/** A compressed page holds base64 of raw-deflated, URI-encoded XML. */
46export async function inflatePage(page: DrawioPage): Promise<DrawioPage> {
47 if (!page.compressed) return page
48 const Stream = (globalThis as { DecompressionStream?: new (format: string) => TransformStream<Uint8Array, Uint8Array> })
49 .DecompressionStream
50 if (!Stream) throw new Error('this page is stored compressed and this environment cannot inflate it')
51 const bytes = Uint8Array.from(atob(page.content.replace(/\s+/g, '')), c => c.charCodeAt(0))
52 const stream = new Blob([bytes]).stream().pipeThrough(new Stream('deflate-raw'))
53 const xml = decodeURIComponent(await new Response(stream).text())
54 const model = find(parseXml(xml), 'mxGraphModel')
55 if (!model) throw new Error('the compressed page did not contain a diagram')
56 return { ...page, compressed: false, model }
57}
58
59// ── styles and labels ─────────────────────────────────────────────────────
60
61type Style = Record<string, string> & { shape: string }
62
63function parseStyle(style: string | undefined): Style {
64 const out: Record<string, string> = {}
65 let shape = 'rect'
66 for (const part of (style ?? '').split(';')) {
67 if (!part) continue
68 const eq = part.indexOf('=')
69 if (eq < 0) shape = part
70 else out[part.slice(0, eq)] = part.slice(eq + 1)
71 }
72 return { ...out, shape: out.shape ?? shape }
73}
74
75function color(value: string | undefined, fallback: string): string {
76 if (!value || value === 'default') return fallback
77 if (value === 'none') return 'none'
78 return /^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$|^rgba?\([\d.,\s%]+\)$/.test(value) ? value : fallback
79}
80
81/** draw.io labels are often HTML: keep the line structure, drop the markup. */
82export function labelText(value: string | undefined, isHtml: boolean): string {
83 if (!value) return ''
84 if (!isHtml) return value
85 return decodeEntities(
86 value
87 .replace(/<br\s*\/?>/gi, '\n')
88 .replace(/<\/(div|p|li|h[1-6]|tr)>/gi, '\n')
89 .replace(/<li[^>]*>/gi, '• ')
90 .replace(/<[^>]+>/g, ''),
91 )
92 .replace(/ /g, ' ')
93 .replace(/\n{3,}/g, '\n\n')
94 .trim()
95}
96
97// ── geometry ──────────────────────────────────────────────────────────────
98
99type Box = { x: number; y: number; w: number; h: number }
100type Point = { x: number; y: number }
101type Cell = {
102 id: string
103 parent?: string
104 value: string
105 style: Style
106 vertex: boolean
107 edge: boolean
108 source?: string
109 target?: string
110 geo?: XmlNode
111}
112
113function n(value: string | undefined, fallback = 0): number {
114 const parsed = Number(value)
115 return Number.isFinite(parsed) ? parsed : fallback
116}
117
118function median(values: number[]): number {
119 const sorted = values.filter(v => v > 0).sort((a, b) => a - b)
120 return sorted[Math.floor(sorted.length / 2)] ?? 0
121}
122
123function collectCells(model: XmlNode): Cell[] {
124 const root = model.children.find(c => c.name === 'root') ?? model
125 const cells: Cell[] = []
126 for (const node of root.children) {
127 // <object>/<UserObject> wrap an mxCell and carry the label and id.
128 const wrapper = node.name === 'object' || node.name === 'UserObject' ? node : undefined
129 const cell = wrapper ? wrapper.children.find(c => c.name === 'mxCell') : node.name === 'mxCell' ? node : undefined
130 if (!cell) continue
131 cells.push({
132 id: wrapper?.attrs.id ?? cell.attrs.id ?? '',
133 parent: cell.attrs.parent,
134 value: wrapper?.attrs.label ?? cell.attrs.value ?? '',
135 style: parseStyle(cell.attrs.style),
136 vertex: cell.attrs.vertex === '1',
137 edge: cell.attrs.edge === '1',
138 source: cell.attrs.source,
139 target: cell.attrs.target,
140 geo: cell.children.find(c => c.name === 'mxGeometry'),
141 })
142 }
143 return cells
144}
145
146function clipToBox(box: Box, toward: Point): Point {
147 const cx = box.x + box.w / 2
148 const cy = box.y + box.h / 2
149 const dx = toward.x - cx
150 const dy = toward.y - cy
151 if (dx === 0 && dy === 0) return { x: cx, y: cy }
152 const s = Math.min(box.w / 2 / Math.abs(dx || 1e-9), box.h / 2 / Math.abs(dy || 1e-9))
153 return { x: cx + dx * s, y: cy + dy * s }
154}
155
156// ── drawing ───────────────────────────────────────────────────────────────
157
158function textLines(lines: string[], box: Box, st: Style, defaultColor: string): string {
159 const size = n(st.fontSize, 12)
160 const step = size * 1.25
161 const align = st.align ?? 'center'
162 const valign = st.verticalAlign ?? 'middle'
163 const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle'
164 const pad = 4
165 const x = align === 'left' ? box.x + pad + n(st.spacingLeft) : align === 'right' ? box.x + box.w - pad : box.x + box.w / 2
166 const total = lines.length * step
167 const top = valign === 'top' ? box.y + pad + n(st.spacingTop) : valign === 'bottom' ? box.y + box.h - total - pad : box.y + (box.h - total) / 2
168 const fontStyle = n(st.fontStyle)
169 const weight = fontStyle & 1 ? ' font-weight="700"' : ''
170 const italic = fontStyle & 2 ? ' font-style="italic"' : ''
171 const underline = fontStyle & 4 ? ' text-decoration="underline"' : ''
172 const tspans = lines
173 .map((line, i) => `<tspan x="${x}" y="${top + size * 0.95 + i * step}">${esc(line) || ' '}</tspan>`)
174 .join('')
175 return `<text font-size="${size}" fill="${color(st.fontColor, defaultColor)}" text-anchor="${anchor}"${weight}${italic}${underline}>${tspans}</text>`
176}
177
178/** Wrap a label to its box; `force` wraps even unwrapped labels (their box was turned). */
179function wrapLabel(text: string, box: Box, st: Style, force = false): string[] {
180 const size = n(st.fontSize, 12)
181 if (st.whiteSpace !== 'wrap' && !force) return text.split('\n')
182 return wrapLines(text, (box.w - 8) / (size * CHAR_WIDTH))
183}
184
185export function pageToSvg(page: DrawioPage, options: { transpose?: boolean | 'swap' } = {}): DrawioRender {
186 if (!page.model) throw new Error(`page "${page.name}" is compressed; inflate it first`)
187 const cells = collectCells(page.model)
188 const byId = new Map(cells.map(c => [c.id, c]))
189 const background = color(page.model.attrs.background, '#ffffff')
190 const dark = /^#[0-3][0-9a-fA-F]/.test(background) || background === 'black'
191 const defaultInk = dark ? '#f0f0f0' : '#1f2328'
192 const defaultStroke = dark ? '#c0c0c0' : '#1f2328'
193
194 // Absolute boxes as drawn in the file: geometry is relative to a vertex parent.
195 const rawBoxes = new Map<string, Box>()
196 const rawBoxOf = (cell: Cell, depth = 0): Box | undefined => {
197 if (rawBoxes.has(cell.id)) return rawBoxes.get(cell.id)
198 if (!cell.vertex || !cell.geo || depth > 50) return undefined
199 const g = cell.geo.attrs
200 let box: Box = { x: n(g.x), y: n(g.y), w: n(g.width), h: n(g.height) }
201 const parent = cell.parent ? byId.get(cell.parent) : undefined
202 if (parent?.vertex) {
203 const pb = rawBoxOf(parent, depth + 1)
204 if (pb) box = { ...box, x: box.x + pb.x, y: box.y + pb.y }
205 }
206 rawBoxes.set(cell.id, box)
207 return box
208 }
209
210 // Transposed (landscape ↔ portrait): swap axes, rescaled by the typical shape's aspect
211 // ratio, so ordinary shapes keep their size and label; containers map corner to corner.
212 const hasChildren = new Set(cells.filter(c => c.parent).map(c => c.parent as string))
213 const shapeBoxes = cells.filter(c => c.vertex && c.style.shape !== 'text').map(c => rawBoxOf(c)).filter((b): b is Box => !!b)
214 const ordinary = shapeBoxes.filter(b => b.w > 0 && b.h > 0 && b.w <= 3 * b.h && b.h <= 3 * b.w)
215 const mw = median(ordinary.map(b => b.w))
216 const mh = median(ordinary.map(b => b.h))
217 // `swap` is the exact axis swap (every shape's width and height swap too): the fallback
218 // when keeping shape sizes does not turn the page to the other orientation.
219 const swap = options.transpose === 'swap'
220 const sx = options.transpose === true && mw && mh ? mw / mh : 1
221 const sy = options.transpose === true && mw && mh ? mh / mw : 1
222 const mapPoint = (p: Point): Point => (options.transpose ? { x: p.y * sx, y: p.x * sy } : p)
223 const isContainer = (cell: Cell, b: Box) =>
224 swap ||
225 cell.style.shape === 'swimlane' || hasChildren.has(cell.id) ||
226 (cell.style.shape !== 'text' && ((b.w > 3 * b.h && b.w > 3 * mw) || (b.h > 3 * b.w && b.h > 3 * mh)))
227 const boxes = new Map<string, Box>()
228 const boxOf = (cell: Cell): Box | undefined => {
229 if (boxes.has(cell.id)) return boxes.get(cell.id)
230 const raw = rawBoxOf(cell)
231 if (!raw) return undefined
232 let box = raw
233 if (options.transpose) {
234 const c = mapPoint({ x: raw.x + raw.w / 2, y: raw.y + raw.h / 2 })
235 const [w, h] = isContainer(cell, raw) ? [raw.h * sx, raw.w * sy] : [raw.w, raw.h]
236 box = { x: c.x - w / 2, y: c.y - h / 2, w, h }
237 }
238 boxes.set(cell.id, box)
239 return box
240 }
241
242 const markers = new Map<string, string>()
243 const markerFor = (c: string): string => {
244 if (!markers.has(c)) markers.set(c, `m${markers.size}`)
245 return markers.get(c) as string
246 }
247
248 const shapeParts: string[] = []
249 const lineParts: string[] = []
250 const labels: string[] = []
251 const extents: Box[] = []
252 let vertices = 0
253 let edges = 0
254
255 const edgePath = (cell: Cell): Point[] | undefined => {
256 const geo = cell.geo
257 const waypoints = (geo?.children.find(c => c.name === 'Array' && c.attrs.as === 'points')?.children ?? [])
258 .filter(p => p.name === 'mxPoint')
259 .map(p => mapPoint({ x: n(p.attrs.x), y: n(p.attrs.y) }))
260 const point = (as: string) => {
261 const p = geo?.children.find(c => c.name === 'mxPoint' && c.attrs.as === as)
262 return p ? mapPoint({ x: n(p.attrs.x), y: n(p.attrs.y) }) : undefined
263 }
264 const src = cell.source ? byId.get(cell.source) : undefined
265 const tgt = cell.target ? byId.get(cell.target) : undefined
266 const sb = src ? boxOf(src) : undefined
267 const tb = tgt ? boxOf(tgt) : undefined
268 const centre = (b: Box) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 })
269 const firstAim = waypoints[0] ?? (tb ? centre(tb) : point('targetPoint'))
270 const lastAim = waypoints[waypoints.length - 1] ?? (sb ? centre(sb) : point('sourcePoint'))
271 const start = sb && firstAim ? clipToBox(sb, firstAim) : point('sourcePoint')
272 const end = tb && lastAim ? clipToBox(tb, lastAim) : point('targetPoint')
273 if (!start || !end) return undefined
274 return [start, ...waypoints, end]
275 }
276
277 for (const cell of cells) {
278 const st = cell.style
279 const isHtml = st.html === '1'
280 if (cell.edge) {
281 const path = edgePath(cell)
282 if (!path) continue
283 edges++
284 const stroke = color(st.strokeColor, defaultStroke)
285 if (stroke === 'none') continue
286 const width = n(st.strokeWidth, 1)
287 const dash = st.dashed === '1' ? ` stroke-dasharray="${width * 4} ${width * 3}"` : ''
288 const end = (st.endArrow ?? 'classic') !== 'none' ? ` marker-end="url(#${markerFor(stroke)})"` : ''
289 const start = st.startArrow && st.startArrow !== 'none' ? ` marker-start="url(#${markerFor(stroke)})"` : ''
290 const opacity = st.opacity ? ` opacity="${n(st.opacity, 100) / 100}"` : ''
291 lineParts.push(`<path d="M${path.map(p => `${p.x},${p.y}`).join(' L')}" fill="none" stroke="${stroke}" stroke-width="${width}"${dash}${start}${end}${opacity}/>`)
292 path.forEach(p => extents.push({ x: p.x, y: p.y, w: 0, h: 0 }))
293 const text = labelText(cell.value, isHtml)
294 if (text) {
295 const mid = path[Math.floor(path.length / 2) - (path.length % 2 === 0 ? 1 : 0)] as Point
296 const next = path[path.indexOf(mid) + 1] ?? mid
297 const at = { x: (mid.x + next.x) / 2, y: (mid.y + next.y) / 2 }
298 const size = n(st.fontSize, 11)
299 const textArr = text.split('\n')
300 const w = Math.max(...textArr.map(t => t.length)) * size * CHAR_WIDTH + 8
301 const h = textArr.length * size * 1.25 + 4
302 const box = { x: at.x - w / 2, y: at.y - h / 2, w, h }
303 labels.push(`<rect x="${box.x}" y="${box.y}" width="${w}" height="${h}" fill="${background}" opacity="0.85"/>`)
304 labels.push(textLines(textArr, box, { ...st, align: 'center', verticalAlign: 'middle' }, defaultInk))
305 }
306 continue
307 }
308 if (!cell.vertex) continue
309 const parent = cell.parent ? byId.get(cell.parent) : undefined
310 if (parent?.edge) {
311 // An edge label stored as a child vertex: place it at the edge's middle.
312 const path = edgePath(parent)
313 const mid = path?.[Math.floor((path.length - 1) / 2)]
314 const next = path?.[Math.floor((path.length - 1) / 2) + 1] ?? mid
315 if (!mid || !next) continue
316 const text = labelText(cell.value, isHtml)
317 const size = n(st.fontSize, 11)
318 const w = Math.max(...text.split('\n').map(t => t.length), 1) * size * CHAR_WIDTH + 8
319 labels.push(textLines(text.split('\n'), { x: (mid.x + next.x) / 2 - w / 2, y: (mid.y + next.y) / 2 - size, w, h: size * 2 }, st, defaultInk))
320 continue
321 }
322 const box = boxOf(cell)
323 if (!box) continue
324 vertices++
325 extents.push(box)
326 const fill = color(st.fillColor, st.shape === 'text' ? 'none' : dark ? '#2b2b3d' : '#ffffff')
327 const stroke = color(st.strokeColor, st.shape === 'text' ? 'none' : defaultStroke)
328 const width = n(st.strokeWidth, 1)
329 const dash = st.dashed === '1' ? ` stroke-dasharray="${width * 4} ${width * 3}"` : ''
330 const paint = `fill="${fill}" stroke="${stroke}" stroke-width="${width}"${dash}`
331 const opacity = st.opacity ? ` opacity="${n(st.opacity, 100) / 100}"` : ''
332 const rotate = st.rotation ? ` transform="rotate(${n(st.rotation)} ${box.x + box.w / 2} ${box.y + box.h / 2})"` : ''
333 const { x, y, w, h } = box
334 let shape = ''
335 let labelBox: Box = box
336 switch (st.shape) {
337 case 'text':
338 case 'label':
339 shape = fill !== 'none' || stroke !== 'none' ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${paint}/>` : ''
340 break
341 case 'ellipse':
342 case 'doubleEllipse':
343 shape = `<ellipse cx="${x + w / 2}" cy="${y + h / 2}" rx="${w / 2}" ry="${h / 2}" ${paint}/>`
344 break
345 case 'rhombus':
346 shape = `<polygon points="${x + w / 2},${y} ${x + w},${y + h / 2} ${x + w / 2},${y + h} ${x},${y + h / 2}" ${paint}/>`
347 break
348 case 'triangle':
349 shape = `<polygon points="${x},${y} ${x + w},${y + h / 2} ${x},${y + h}" ${paint}/>`
350 break
351 case 'swimlane': {
352 const head = n(st.startSize, 23)
353 // Transposed, a horizontal swimlane becomes a vertical one and back.
354 const horizontal = (st.horizontal !== '0') !== !!options.transpose
355 shape = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${st.swimlaneFillColor ? color(st.swimlaneFillColor, 'none') : 'none'}" stroke="${stroke}" stroke-width="${width}"/>` +
356 (horizontal
357 ? `<rect x="${x}" y="${y}" width="${w}" height="${head}" ${paint}/>`
358 : `<rect x="${x}" y="${y}" width="${head}" height="${h}" ${paint}/>`)
359 labelBox = horizontal ? { x, y, w, h: head } : { x, y, w: head, h }
360 break
361 }
362 case 'image':
363 shape = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="#adb5bd" stroke-dasharray="6 4"/>`
364 break
365 default: {
366 const rx = st.rounded === '1' ? Math.min(w, h) * (n(st.arcSize, 20) / 200) : 0
367 shape = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" ${paint}/>`
368 }
369 }
370 if (shape) shapeParts.push(`<g${opacity}${rotate}>${shape}</g>`)
371 const text = labelText(cell.value, isHtml) || (st.shape === 'image' ? 'image' : '')
372 if (text) labels.push(textLines(wrapLabel(text, labelBox, st, swap), labelBox, st, defaultInk))
373 }
374
375 if (extents.length === 0) {
376 return {
377 svg: `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80"><rect width="320" height="80" fill="${background}"/>` +
378 `<text x="160" y="45" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" fill="${defaultInk}">Empty page</text></svg>`,
379 vertices,
380 edges,
381 page: page.name,
382 }
383 }
384 const pad = 20
385 const minX = Math.min(...extents.map(b => b.x)) - pad
386 const minY = Math.min(...extents.map(b => b.y)) - pad
387 const width = Math.round(Math.max(...extents.map(b => b.x + b.w)) + pad - minX)
388 const height = Math.round(Math.max(...extents.map(b => b.y + b.h)) + pad - minY)
389 const defs = [...markers.entries()]
390 .map(([c, id]) =>
391 `<marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
392 `<path d="M0,0 L10,5 L0,10 z" fill="${c}"/></marker>`)
393 .join('')
394 const svg =
395 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${minX} ${minY} ${width} ${height}" ` +
396 `font-family="Helvetica, Arial, system-ui, sans-serif"><defs>${defs}</defs>` +
397 `<rect x="${minX}" y="${minY}" width="${width}" height="${height}" fill="${background === 'none' ? '#ffffff' : background}"/>` +
398 shapeParts.join('') + lineParts.join('') + labels.join('') +
399 `</svg>`
400 return { svg, vertices, edges, page: page.name }
401}
402
403/** Render one page of an uncompressed file (page index 0-based). */
404export function drawioToSvg(source: string, pageIndex = 0, options: { transpose?: boolean | 'swap' } = {}): DrawioRender & { pages: number } {
405 const pages = listPages(source)
406 const page = pages[Math.min(Math.max(0, pageIndex), pages.length - 1)]
407 if (!page) throw new Error('the file has no pages')
408 return { ...pageToSvg(page, options), pages: pages.length }
409}
410hooks/formats/excalidraw.ts 285 lines1// Obsidian Excalidraw drawing (.excalidraw.md or .excalidraw) → standalone SVG, no scripts.
2// Clean strokes; the hand-drawn (rough) style is deliberately not reproduced.
3import { esc } from '../common'
4import { decompressFromBase64 } from './lzstring'
5
6type Point = [number, number]
7
8type Element = {
9 id: string
10 type: string
11 x: number
12 y: number
13 width: number
14 height: number
15 angle?: number
16 strokeColor?: string
17 backgroundColor?: string
18 fillStyle?: string
19 strokeWidth?: number
20 strokeStyle?: 'solid' | 'dashed' | 'dotted'
21 opacity?: number
22 roundness?: { type: number } | null
23 points?: Point[]
24 startArrowhead?: string | null
25 endArrowhead?: string | null
26 text?: string
27 fontSize?: number
28 fontFamily?: number
29 textAlign?: 'left' | 'center' | 'right'
30 verticalAlign?: 'top' | 'middle' | 'bottom'
31 lineHeight?: number
32 fileId?: string | null
33 name?: string | null
34 isDeleted?: boolean
35}
36
37export type ExcalidrawRender = { svg: string; elements: number; skipped: string[]; format: string }
38
39const SANS = 'system-ui, -apple-system, Segoe UI, sans-serif'
40const MONO = 'Cascadia Code, Consolas, ui-monospace, monospace'
41
42/** Pull the scene JSON and the embedded-file names out of the drawing note. */
43export function extractScene(source: string): { json: string; format: string; files: Map<string, string> } {
44 const files = new Map<string, string>()
45 const embedded = /^#{1,2} Embedded Files\s*\n([\s\S]*?)(?=^#{1,2} |^%%|$(?![\s\S]))/m.exec(source)
46 for (const m of (embedded?.[1] ?? '').matchAll(/^([0-9a-fA-F]{8,}):\s*\[\[([^\]|]+)/gm)) {
47 if (m[1] && m[2]) files.set(m[1], m[2])
48 }
49 const trimmed = source.trimStart()
50 if (trimmed.startsWith('{')) return { json: trimmed, format: 'json', files }
51 const block = /^#{1,2} Drawing\s*\n```(json|compressed-json)\s*\n([\s\S]*?)\n```/m.exec(source)
52 if (!block || !block[1] || block[2] === undefined) throw new Error('no "## Drawing" block found')
53 if (block[1] === 'json') return { json: block[2], format: 'json', files }
54 const json = decompressFromBase64(block[2])
55 if (!json) throw new Error('the compressed drawing could not be decompressed')
56 return { json, format: 'compressed-json', files }
57}
58
59function num(value: unknown, fallback = 0): number {
60 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
61}
62
63function paint(color: string | undefined, fallback: string): string {
64 if (!color || color === 'transparent') return fallback
65 return /^#[0-9a-fA-F]{3,8}$|^[a-z]+$|^rgba?\([\d.,\s%]+\)$/.test(color) ? color : fallback
66}
67
68function strokeAttrs(el: Element): string {
69 const width = num(el.strokeWidth, 1)
70 const dash = el.strokeStyle === 'dashed' ? ` stroke-dasharray="${width * 4} ${width * 3}"`
71 : el.strokeStyle === 'dotted' ? ` stroke-dasharray="${width} ${width * 2.5}" stroke-linecap="round"` : ''
72 return `stroke="${paint(el.strokeColor, '#1e1e1e')}" stroke-width="${width}"${dash}`
73}
74
75function fillAttrs(el: Element): string {
76 const fill = paint(el.backgroundColor, 'none')
77 if (fill === 'none') return 'fill="none"'
78 // Hatched fills read lighter than solid ones; approximate with opacity.
79 return el.fillStyle === 'solid' ? `fill="${fill}"` : `fill="${fill}" fill-opacity="0.45"`
80}
81
82function wrap(el: Element, body: string): string {
83 const parts: string[] = []
84 const angle = num(el.angle)
85 if (angle) {
86 const cx = el.x + el.width / 2
87 const cy = el.y + el.height / 2
88 parts.push(`transform="rotate(${(angle * 180) / Math.PI} ${cx} ${cy})"`)
89 }
90 const opacity = num(el.opacity, 100)
91 if (opacity < 100) parts.push(`opacity="${opacity / 100}"`)
92 return parts.length ? `<g ${parts.join(' ')}>${body}</g>` : body
93}
94
95function linePoints(el: Element): Point[] {
96 const pts = Array.isArray(el.points) ? el.points : []
97 return pts.filter(p => Array.isArray(p) && p.length >= 2).map(([px, py]) => [el.x + num(px), el.y + num(py)])
98}
99
100function boundsOf(el: Element): [number, number, number, number] {
101 if (el.type === 'line' || el.type === 'arrow' || el.type === 'freedraw') {
102 const pts = linePoints(el)
103 if (pts.length) {
104 const xs = pts.map(p => p[0])
105 const ys = pts.map(p => p[1])
106 return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
107 }
108 }
109 return [el.x, el.y, el.x + el.width, el.y + el.height]
110}
111
112function drawText(el: Element): string {
113 const size = num(el.fontSize, 20)
114 const step = size * num(el.lineHeight, 1.25)
115 const lines = String(el.text ?? '').replace(/\r/g, '').split('\n')
116 const anchor = el.textAlign === 'center' ? 'middle' : el.textAlign === 'right' ? 'end' : 'start'
117 const x = el.textAlign === 'center' ? el.x + el.width / 2 : el.textAlign === 'right' ? el.x + el.width : el.x
118 const family = el.fontFamily === 3 || el.fontFamily === 8 ? MONO : SANS
119 const first = el.y + size * 0.95
120 const tspans = lines
121 .map((line, i) => `<tspan x="${x}" y="${first + i * step}">${esc(line) || ' '}</tspan>`)
122 .join('')
123 return `<text font-family="${family}" font-size="${size}" fill="${paint(el.strokeColor, '#1e1e1e')}" text-anchor="${anchor}">${tspans}</text>`
124}
125
126const LINEAR = new Set(['line', 'arrow', 'freedraw'])
127
128function median(values: number[]): number {
129 const sorted = values.filter(v => v > 0).sort((a, b) => a - b)
130 return sorted[Math.floor(sorted.length / 2)] ?? 0
131}
132
133/**
134 * Swap the layout's axes — a wide swimlane becomes a tall one — while text stays
135 * horizontal. Positions are swapped and rescaled by the typical box aspect ratio
136 * (new x = old y · w/h, new y = old x · h/w), so a box keeps its size and an arrow that
137 * met its right edge now meets its bottom edge. Long containers (lanes, separators,
138 * frames) swap their width and height with the layout, so rows become columns.
139 */
140export function transposeScene(elements: Element[], swap = false): Element[] {
141 // `swap` is the exact axis swap (every shape's width and height swap too): the fallback
142 // when keeping shape sizes does not turn the drawing to the other orientation.
143 // Typical box size, from ordinary shapes only: lanes and separators would skew it.
144 const boxes = elements.filter(e =>
145 (e.type === 'rectangle' || e.type === 'ellipse' || e.type === 'diamond') &&
146 e.width > 0 && e.height > 0 && e.width <= 3 * e.height && e.height <= 3 * e.width)
147 const mw = median(boxes.map(b => b.width))
148 const mh = median(boxes.map(b => b.height))
149 const sx = !swap && mw && mh ? mw / mh : 1
150 const sy = !swap && mw && mh ? mh / mw : 1
151 const map = (x: number, y: number): Point => [y * sx, x * sy]
152 const isLong = (e: Element) =>
153 (swap && e.type !== 'text') ||
154 e.type === 'frame' || e.type === 'magicframe' ||
155 (e.type !== 'text' && ((e.width > 3 * e.height && e.width > 3 * mw) || (e.height > 3 * e.width && e.height > 3 * mh)))
156
157 return elements.map(e => {
158 if (LINEAR.has(e.type)) {
159 const pts = linePoints(e).map(([x, y]) => map(x, y))
160 const [x0, y0] = pts[0] ?? map(e.x, e.y)
161 const rel = pts.map(([x, y]): Point => [x - x0, y - y0])
162 const xs = rel.map(p => p[0])
163 const ys = rel.map(p => p[1])
164 const width = xs.length ? Math.max(...xs) - Math.min(...xs) : 0
165 const height = ys.length ? Math.max(...ys) - Math.min(...ys) : 0
166 return { ...e, x: x0, y: y0, width, height, points: rel }
167 }
168 const [cx, cy] = map(e.x + e.width / 2, e.y + e.height / 2)
169 const [width, height] = isLong(e) ? [e.height * sx, e.width * sy] : [e.width, e.height]
170 return { ...e, x: cx - width / 2, y: cy - height / 2, width, height }
171 })
172}
173
174export function excalidrawToSvg(source: string, options: { transpose?: boolean | 'swap' } = {}): ExcalidrawRender {
175 const scene = extractScene(source)
176 const data = JSON.parse(scene.json) as { elements?: unknown; appState?: { viewBackgroundColor?: string } }
177 const parsed = (Array.isArray(data.elements) ? data.elements : [])
178 .filter((e): e is Element => !!e && typeof e === 'object' && typeof (e as Element).type === 'string')
179 .filter(e => !e.isDeleted)
180 .map(e => ({ ...e, x: num(e.x), y: num(e.y), width: num(e.width), height: num(e.height) }))
181 const all = options.transpose ? transposeScene(parsed, options.transpose === 'swap') : parsed
182 const background = paint(data.appState?.viewBackgroundColor, '#ffffff')
183 const skipped: string[] = []
184
185 if (all.length === 0) {
186 return {
187 svg: `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80">` +
188 `<rect width="320" height="80" fill="${background}"/>` +
189 `<text x="160" y="45" text-anchor="middle" font-family="${SANS}" font-size="14" fill="#59636e">Empty drawing</text></svg>`,
190 elements: 0,
191 skipped,
192 format: scene.format,
193 }
194 }
195
196 const markers = new Map<string, string>()
197 const markerFor = (color: string): string => {
198 if (!markers.has(color)) markers.set(color, `h${markers.size}`)
199 return markers.get(color) as string
200 }
201
202 const body: string[] = []
203 for (const el of all) {
204 const stroke = strokeAttrs(el)
205 switch (el.type) {
206 case 'rectangle': {
207 const rx = el.roundness ? Math.min(32, Math.min(el.width, el.height) * 0.25) : 0
208 body.push(wrap(el, `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" rx="${rx}" ${fillAttrs(el)} ${stroke}/>`))
209 break
210 }
211 case 'ellipse':
212 body.push(wrap(el, `<ellipse cx="${el.x + el.width / 2}" cy="${el.y + el.height / 2}" rx="${el.width / 2}" ry="${el.height / 2}" ${fillAttrs(el)} ${stroke}/>`))
213 break
214 case 'diamond': {
215 const cx = el.x + el.width / 2
216 const cy = el.y + el.height / 2
217 const pts = `${cx},${el.y} ${el.x + el.width},${cy} ${cx},${el.y + el.height} ${el.x},${cy}`
218 body.push(wrap(el, `<polygon points="${pts}" ${fillAttrs(el)} ${stroke}/>`))
219 break
220 }
221 case 'line':
222 case 'arrow': {
223 const pts = linePoints(el)
224 if (pts.length < 2) break
225 const color = paint(el.strokeColor, '#1e1e1e')
226 const isArrow = el.type === 'arrow'
227 const end = isArrow && (el.endArrowhead ?? 'arrow') ? ` marker-end="url(#${markerFor(color)})"` : ''
228 const start = el.startArrowhead ? ` marker-start="url(#${markerFor(color)})"` : ''
229 const d = `M${pts.map(p => p.join(',')).join(' L')}`
230 const fill = el.type === 'line' && pts.length > 2 && fillAttrs(el) !== 'fill="none"' ? fillAttrs(el) : 'fill="none"'
231 body.push(wrap(el, `<path d="${d}" ${fill} ${stroke} stroke-linejoin="round"${start}${end}/>`))
232 break
233 }
234 case 'freedraw': {
235 const pts = linePoints(el)
236 if (pts.length < 2) break
237 body.push(wrap(el, `<path d="M${pts.map(p => p.join(',')).join(' L')}" fill="none" ${stroke} stroke-linecap="round" stroke-linejoin="round"/>`))
238 break
239 }
240 case 'text':
241 body.push(wrap(el, drawText(el)))
242 break
243 case 'frame':
244 case 'magicframe':
245 body.push(
246 `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" rx="8" fill="none" stroke="#868e96" stroke-width="1.5"/>` +
247 `<text x="${el.x}" y="${el.y - 6}" font-family="${SANS}" font-size="14" fill="#495057">${esc(el.name ?? 'Frame')}</text>`,
248 )
249 break
250 case 'image':
251 case 'embeddable':
252 case 'iframe': {
253 const label = (el.fileId && scene.files.get(el.fileId)) || el.type
254 skipped.push(label)
255 body.push(wrap(el,
256 `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" fill="#f1f3f5" stroke="#adb5bd" stroke-dasharray="6 4"/>` +
257 `<text x="${el.x + el.width / 2}" y="${el.y + el.height / 2}" text-anchor="middle" font-family="${SANS}" font-size="14" fill="#495057">${esc(label)}</text>`))
258 break
259 }
260 default:
261 skipped.push(el.type)
262 }
263 }
264
265 const boxes = all.map(boundsOf)
266 const pad = 30
267 const minX = Math.min(...boxes.map(b => b[0])) - pad
268 const minY = Math.min(...boxes.map(b => b[1])) - pad - 10
269 const width = Math.round(Math.max(...boxes.map(b => b[2])) + pad - minX)
270 const height = Math.round(Math.max(...boxes.map(b => b[3])) + pad - minY)
271
272 const defs = [...markers.entries()]
273 .map(([color, id]) =>
274 `<marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">` +
275 `<path d="M0,0 L10,5 L0,10" fill="none" stroke="${color}" stroke-width="1.6"/></marker>`)
276 .join('')
277
278 const svg =
279 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${minX} ${minY} ${width} ${height}">` +
280 `<defs>${defs}</defs><rect x="${minX}" y="${minY}" width="${width}" height="${height}" fill="${background}"/>` +
281 body.join('') +
282 `</svg>`
283 return { svg, elements: all.length, skipped, format: scene.format }
284}
285hooks/xml.ts 64 lines1// A minimal, DOM-free XML reader (the mod environment has no DOMParser).
2// Same reader as drawio-preview's; each mod ships its own copy.
3
4export type XmlNode = {
5 /** Qualified name as written, e.g. `bpmn:task`. */
6 name: string
7 /** Name without its namespace prefix, e.g. `task`. */
8 local: string
9 attrs: Record<string, string>
10 children: XmlNode[]
11 text: string
12}
13
14const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
15
16export function decodeEntities(text: string): string {
17 return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, code: string) => {
18 if (code.startsWith('#x')) return String.fromCodePoint(parseInt(code.slice(2), 16))
19 if (code.startsWith('#')) return String.fromCodePoint(parseInt(code.slice(1), 10))
20 return ENTITIES[code] ?? whole
21 })
22}
23
24export function parseXml(source: string): XmlNode {
25 const root: XmlNode = { name: '#root', local: '#root', attrs: {}, children: [], text: '' }
26 const stack: XmlNode[] = [root]
27 const tag = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z_][\w:.-]*)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g
28 let last = 0
29 for (let m = tag.exec(source); m; m = tag.exec(source)) {
30 const top = stack[stack.length - 1] as XmlNode
31 top.text += source.slice(last, m.index)
32 last = tag.lastIndex
33 if (m[1] !== undefined) {
34 top.text += m[1]
35 continue
36 }
37 const name = m[3]
38 if (!name) continue // comment or declaration
39 if (m[2] === '/') {
40 if (stack.length > 1) stack.pop()
41 continue
42 }
43 const attrs: Record<string, string> = {}
44 for (const a of (m[4] ?? '').matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
45 if (a[1]) attrs[a[1]] = decodeEntities(a[2] ?? a[3] ?? '')
46 }
47 const node: XmlNode = { name, local: name.slice(name.indexOf(':') + 1), attrs, children: [], text: '' }
48 top.children.push(node)
49 if (m[5] !== '/') stack.push(node)
50 }
51 return root
52}
53
54export function walk(node: XmlNode, visit: (n: XmlNode) => void): void {
55 for (const child of node.children) {
56 visit(child)
57 walk(child, visit)
58 }
59}
60
61export function child(node: XmlNode, local: string): XmlNode | undefined {
62 return node.children.find(c => c.local === local)
63}
64hooks/formats/lzstring.ts 83 lines1// LZ-String decompressFromBase64 (pieroxy/lz-string, MIT), the codec the Obsidian
2// Excalidraw plugin uses for ```compressed-json drawing blocks. Ported to strict TS.
3
4const KEY = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
5
6export function decompressFromBase64(input: string): string | null {
7 const clean = input.replace(/\s+/g, '')
8 if (!clean) return ''
9 const index = new Map<string, number>()
10 for (let i = 0; i < KEY.length; i++) index.set(KEY.charAt(i), i)
11 return decompress(clean.length, 32, i => index.get(clean.charAt(i)) ?? 0)
12}
13
14function decompress(length: number, resetValue: number, next: (i: number) => number): string | null {
15 const dictionary: string[] = ['0', '1', '2']
16 let enlargeIn = 4
17 let dictSize = 4
18 let numBits = 3
19 const result: string[] = []
20 const data = { val: next(0), position: resetValue, index: 1 }
21
22 const readBits = (count: number): number => {
23 let bits = 0
24 const max = 2 ** count
25 let power = 1
26 while (power !== max) {
27 const bit = data.val & data.position
28 data.position >>= 1
29 if (data.position === 0) {
30 data.position = resetValue
31 data.val = next(data.index++)
32 }
33 bits |= (bit > 0 ? 1 : 0) * power
34 power <<= 1
35 }
36 return bits
37 }
38
39 let first: string
40 switch (readBits(2)) {
41 case 0:
42 first = String.fromCharCode(readBits(8))
43 break
44 case 1:
45 first = String.fromCharCode(readBits(16))
46 break
47 default:
48 return ''
49 }
50 dictionary[3] = first
51 let w = first
52 result.push(first)
53
54 for (;;) {
55 if (data.index > length) return ''
56 let code = readBits(numBits)
57 if (code === 0 || code === 1) {
58 dictionary[dictSize++] = String.fromCharCode(readBits(code === 0 ? 8 : 16))
59 code = dictSize - 1
60 enlargeIn--
61 } else if (code === 2) {
62 return result.join('')
63 }
64 if (enlargeIn === 0) {
65 enlargeIn = 2 ** numBits
66 numBits++
67 }
68 let entry: string
69 const known = dictionary[code]
70 if (known !== undefined) entry = known
71 else if (code === dictSize) entry = w + w.charAt(0)
72 else return null
73 result.push(entry)
74 dictionary[dictSize++] = w + entry.charAt(0)
75 enlargeIn--
76 w = entry
77 if (enlargeIn === 0) {
78 enlargeIn = 2 ** numBits
79 numBits++
80 }
81 }
82}
83types/index.d.ts 29 lines1export type DiagramTarget = {
2 /** Which renderer draws it; also the pane's id. */
3 format: 'canvas' | 'excalidraw' | 'drawio' | 'bpmn' | 'd2'
4 /** Vault-relative path with forward slashes. */
5 rel: string
6 /** Resolved absolute path. */
7 real: string
8 /** Obsidian vault name; absent outside a vault. */
9 vault?: string
10 /** Bumped on every refresh so the pane redraws. */
11 rev: number
12 /** Page (draw.io) or diagram (BPMN) shown, 0-based. */
13 page: number
14 /** Asked-for orientation; absent means the drawing's natural one. */
15 orientation?: 'landscape' | 'portrait'
16 /** Zoom relative to fitting the pane's width (1 = fit; an older 0 also reads as fit). */
17 zoom: number
18 /** When zoomed in: where the visible slice sits, 0 = left edge … 1 = right edge. */
19 pan?: number
20 /** draw.io only: try the draw.io CLI export first, or always the built-in renderer. */
21 prefer: 'auto' | 'builtin'
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'diagram-preview': { target: StateFamily<DiagramTarget> }
27 }
28}
29