SLOPSHOPPER

diagram-preview

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

newpaneguardcommandtoasttool
v0.2.0MITupdated 2026-10-03cbruyndoncx/claude-diagrams-renderer-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diagram-preview
│ ┃ canvas ✕ › fix the failing auth test and add an audit log call │ ┃ No Canvas diagram open. Run /diagram with a │ ┃ path. ⏺ Read(src/auth.ts) │ ┃ The terminal cannot draw SVG; use the ⎿ Read 6 lines │ ┃ Desktop Code tab, or open it in Obsidian. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diagram │ ⎿ diagram-preview: diagram-preview: give a path to a diagram (.can │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · canvas
No Canvas diagram open. Run /diagram with a path. The terminal cannot draw SVG; use the Desktop Code tab, or open it in Obsidian.
Pane · excalidraw
No Excalidraw diagram open. Run /diagram with a path. The terminal cannot draw SVG; use the Desktop Code tab, or open it in Obsidian.
Pane · drawio
No Draw.io diagram open. Run /diagram with a path. The terminal cannot draw SVG; use the Desktop Code tab, or open it in Obsidian.
Pane · bpmn
No BPMN diagram open. Run /diagram with a path. The terminal cannot draw SVG; use the Desktop Code tab, or open it in Obsidian.
Pane · d2
No D2 diagram open. Run /diagram with a path. The terminal cannot draw SVG; use the Desktop Code tab, or open it in Obsidian.
README

claude-diagrams-renderer-mod

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.

FormatExtensionsRendered by
JSON Canvas (Obsidian).canvasbuilt-in renderer
Excalidraw (Obsidian plugin).excalidraw.md, .excalidrawthe plugin's own SVG export when newer, else built-in (plain and compressed-json)
draw.io / diagrams.net.drawiothe draw.io Desktop CLI export when installed, else built-in (multi-page)
BPMN 2.0.bpmnbuilt-in, from the file's own BPMN DI layout (pools, lanes, tasks, events, gateways, audit colours)
D2.d2the d2 CLI (on the PATH, or inside WSL)

Each format opens in its own pane tab, and every tab has the same toolbar:

  • Switch to portrait / Switch to landscape — one press. A drawing has a natural orientation; the other one is drawn as its transposed layout (lanes become columns, arrows still meet box edges, text stays horizontal). D2 re-lays the diagram out with direction: down / right.
  • − · Fit · + — zoom. The pane always fits an image to its width, so zoom crops the view: zoomed in you see a slice of the drawing at full pane width (pan it with ◀ ▶); zoomed out the drawing shrinks inside the pane.
  • Refresh, and Open in Obsidian (via the Advanced URI plugin) when the file is in a vault.
  • draw.io adds a page selector and a renderer toggle; BPMN a diagram selector.

The pane redraws by itself after Claude edits the file with Write or Edit.

Use

  • Slash command: /diagram <path> [page] [--landscape|--portrait]
  • Or just ask Claude ("show me the order-to-cash BPMN in portrait"): the mod registers the model tool mcp__diagram-preview__open with path, page and orientation.

Install

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.

  1. Clone this repository.
  2. Point Claude Desktop at it with 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" } }
  1. Start a new Code-tab session. For live editing, copy the folder into a session's dev-mods folder instead (the plugin-authoring skill sets that up) — not both at once, or it loads twice.

Requirements

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:

ToolUsed forWhen 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 DesktopExact draw.io renders (its CLI export)The built-in renderer draws the file
Obsidian with the Advanced URI pluginThe Open in Obsidian button (only shown for files inside a vault)The button cannot open the file
Excalidraw plugin with auto-export SVG onExcalidraw'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.

Develop

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.

Limits

  • An SVG may be at most 131,072 characters; larger drawings show a message and the Obsidian button.
  • The built-in renderers draw clean shapes, not Excalidraw's hand-drawn style; draw.io stencil shapes (C4, P&ID, …) fall back to boxes.
  • A plugin or CLI export is a fixed picture: the non-natural orientation is always the built-in renderer.

Credits

hooks/formats/lzstring.ts ports decompressFromBase64 from lz-string by pieroxy (MIT).

License

MIT

Source 10 files
hooks/register.tsx 436 lines
1import { 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}
436
hooks/common.ts 239 lines
1// 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, '&amp;')
25    .replace(/</g, '&lt;')
26    .replace(/>/g, '&gt;')
27    .replace(/"/g, '&quot;')
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}
239
hooks/formats/index.ts 135 lines
1// 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}
135
hooks/formats/bpmn.ts 414 lines
1// 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}
414
hooks/formats/canvas.ts 265 lines
1// 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}
265
hooks/formats/drawio.ts 410 lines
1// 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}
410
hooks/formats/excalidraw.ts 285 lines
1// 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}
285
hooks/xml.ts 64 lines
1// 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}
64
hooks/formats/lzstring.ts 83 lines
1// 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}
83
types/index.d.ts 29 lines
1export 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