@zombie-mermaid/svg-renderer 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1485 @@
1
+ import type {
2
+ PositionedGraph,
3
+ PositionedNode,
4
+ PositionedEdge,
5
+ PositionedGroup,
6
+ Point,
7
+ DiagramColors,
8
+ SvgEmitOptions,
9
+ CurveStyle,
10
+ } from '@zombie-mermaid/core'
11
+ import {
12
+ svgOpenTag,
13
+ buildStyleBlock,
14
+ styleOpenTag,
15
+ getReadableTextColor,
16
+ measureMultilineText,
17
+ renderMultilineText,
18
+ renderMultilineTextWithBackground,
19
+ escapeXml,
20
+ escapeAttr,
21
+ safeHref,
22
+ sanitizeClassName,
23
+ } from '@zombie-mermaid/core'
24
+ import type { FontSizes } from './styles.ts'
25
+ import {
26
+ FONT_SIZES,
27
+ FONT_WEIGHTS,
28
+ STROKE_WIDTHS,
29
+ ARROW_HEAD,
30
+ } from './styles.ts'
31
+ import { pointsToPath } from './edge-curves.ts'
32
+
33
+ // ============================================================================
34
+ // SVG renderer — converts a PositionedGraph into an SVG string.
35
+ //
36
+ // Pure string concatenation, no DOM manipulation.
37
+ // Renders back-to-front: groups → edges → arrow heads → edge labels → nodes → node labels.
38
+ //
39
+ // All colors are referenced via CSS custom properties (var(--_xxx)) defined
40
+ // in the <style> block. The caller provides bg/fg (+ optional enrichment
41
+ // colors) via DiagramColors, which are set as inline CSS variables on the
42
+ // <svg> tag. See packages/core/src/theme.ts for the full variable system.
43
+ //
44
+ // Style spec:
45
+ // - All corners rx=0 ry=0 (sharp)
46
+ // - Stroke widths: outer box 1px, inner box 0.75px, connectors 0.75px
47
+ // - Arrow heads: filled triangles, 8px wide × 4.8px tall
48
+ // - Dashed edges: stroke-dasharray="4 4"
49
+ // - Font: Inter with weight per element type
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Render a positioned graph as an SVG string.
54
+ *
55
+ * @param colors - DiagramColors with bg/fg and optional enrichment variables.
56
+ * These are set as CSS custom properties on the <svg> tag.
57
+ * All element colors reference derived --_xxx variables.
58
+ * @param transparent - If true, renders with transparent background.
59
+ * @param embedSource - Original diagram source to stamp onto the root `<svg>`
60
+ * as `data-src` (from `options.embedSource`). Omitted
61
+ * when the option is off.
62
+ * @param animationEnabled - Whether `e1@{ animate: true }` edges actually
63
+ * animate (from `options.interactivity === 'full'`,
64
+ * see `resolveAnimationEnabled` in
65
+ * src/diagram-registry.ts).
66
+ * Default true — preserves the previously-ungated
67
+ * behavior for callers who don't pass it.
68
+ * @param linksEnabled - Whether `click`-based `<a href>` links and `<title>`
69
+ * tooltips render (from
70
+ * `options.interactivity !== 'none'`, see
71
+ * `resolveLinksEnabled` in src/diagram-registry.ts). Default true
72
+ * — preserves the previously-ungated behavior for
73
+ * callers who don't pass it.
74
+ * @param title - Accessible name (from `options.title`). See svgOpenTag() in
75
+ * packages/core/src/theme.ts.
76
+ * @param decorative - Marks the SVG decorative (from `options.decorative`).
77
+ * @param emit - Strict-CSP controls (from `options.nonce` /
78
+ * `options.styleAttribute`, see #216): a `nonce` for every
79
+ * `<style>` element, and whether the root `style="…"`
80
+ * attribute is emitted at all. Default: no nonce, attribute on.
81
+ */
82
+ export function renderSvg(
83
+ graph: PositionedGraph,
84
+ colors: DiagramColors,
85
+ font: string = 'Inter',
86
+ transparent: boolean = false,
87
+ fontSizes: FontSizes = FONT_SIZES,
88
+ curve: CurveStyle = 'linear',
89
+ embedSource?: string,
90
+ animationEnabled: boolean = true,
91
+ linksEnabled: boolean = true,
92
+ title?: string,
93
+ decorative?: boolean,
94
+ emit: SvgEmitOptions = {},
95
+ ): string {
96
+ const parts: string[] = []
97
+
98
+ // See #239: a click-based link renders as a focusable <a href> inside the
99
+ // SVG, which role="img"/aria-hidden would hide from assistive tech while
100
+ // leaving it Tab-reachable — svgOpenTag forces no root role in that case.
101
+ // Uses the same safeHref() check renderNode uses to decide whether an <a>
102
+ // is actually emitted — a rejected scheme or control character means no
103
+ // link renders, so it shouldn't affect the SVG's accessibility semantics.
104
+ // Gated by linksEnabled too: `interactivity: 'none'` strips the <a> below,
105
+ // so it shouldn't count toward this decision either.
106
+ const hasInteractiveLinks =
107
+ linksEnabled &&
108
+ graph.nodes.some((n) => Boolean(safeHref(n.interaction?.href)))
109
+
110
+ // SVG root with CSS variables + style block + defs
111
+ parts.push(
112
+ withDataSrc(
113
+ svgOpenTag(
114
+ graph.width,
115
+ graph.height,
116
+ colors,
117
+ transparent,
118
+ title,
119
+ decorative,
120
+ hasInteractiveLinks,
121
+ emit.styleAttribute,
122
+ ),
123
+ embedSource,
124
+ ),
125
+ )
126
+ parts.push(buildStyleBlock(font, false, emit.nonce))
127
+ // Keyframes for animated edges (`e1@{ animate: true }`). Emitted only when
128
+ // an animated edge exists and animation is enabled, so an ordinary diagram
129
+ // (or one rendered with `interactivity: 'none'`) gains no extra markup.
130
+ if (animationEnabled && graph.edges.some((e) => e.animate)) {
131
+ parts.push(edgeAnimationStyle(emit.nonce))
132
+ }
133
+ parts.push('<defs>')
134
+ parts.push(arrowMarkerDefs())
135
+ // Per-color arrow markers for edges with custom stroke via linkStyle
136
+ const customStrokeColors = new Set<string>()
137
+ for (const edge of graph.edges) {
138
+ if (edge.inlineStyle?.stroke) {
139
+ customStrokeColors.add(edge.inlineStyle.stroke)
140
+ }
141
+ }
142
+ for (const color of customStrokeColors) {
143
+ parts.push(arrowMarkerDefsForColor(color))
144
+ }
145
+ parts.push('</defs>')
146
+
147
+ // 1. Subgraph backgrounds (group rectangles with header bands)
148
+ for (const group of graph.groups) {
149
+ parts.push(renderGroup(group, font, fontSizes))
150
+ }
151
+
152
+ // 2. Edges (paths — rendered behind nodes)
153
+ // Each edge is a <path> with semantic data-* attributes
154
+ for (const edge of graph.edges) {
155
+ parts.push(renderEdge(edge, curve, animationEnabled))
156
+ }
157
+
158
+ // 3. Edge labels (positioned at midpoint of edge)
159
+ // Each label is wrapped in <g class="edge-label">
160
+ for (const edge of graph.edges) {
161
+ if (edge.label) {
162
+ parts.push(renderEdgeLabel(edge, font, fontSizes))
163
+ }
164
+ }
165
+
166
+ // 4. Nodes (shape + label wrapped in <g class="node">)
167
+ for (const node of graph.nodes) {
168
+ parts.push(renderNode(node, font, fontSizes, linksEnabled))
169
+ }
170
+
171
+ parts.push('</svg>')
172
+
173
+ return parts.join('\n')
174
+ }
175
+
176
+ // ============================================================================
177
+ // Arrow marker definitions
178
+ // ============================================================================
179
+
180
+ /**
181
+ * Keyframes for `e1@{ animate: true }` edges — a marching-ants dash.
182
+ *
183
+ * CSS animation rather than SMIL: SMIL is deprecated in browsers, while a
184
+ * CSS `@keyframes` block inside the SVG animates in browsers and is simply
185
+ * ignored by static rasterizers, which render the first frame. The
186
+ * `prefers-reduced-motion` guard stops the animation for users who have asked
187
+ * the system for less movement — the edge still renders, just still.
188
+ *
189
+ * A second `<style>` element, so it takes the same `nonce` as the theme
190
+ * block (see `styleOpenTag`) — under a nonce-based CSP it would otherwise be
191
+ * the one un-nonced element on the page and get silently dropped.
192
+ */
193
+ function edgeAnimationStyle(nonce?: string): string {
194
+ return [
195
+ styleOpenTag(nonce),
196
+ ' @keyframes zm-edge-dash { to { stroke-dashoffset: -28; } }',
197
+ ' .edge-animated { animation: zm-edge-dash 1s linear infinite; }',
198
+ ' @media (prefers-reduced-motion: reduce) {',
199
+ ' .edge-animated { animation: none; }',
200
+ ' }',
201
+ '</style>',
202
+ ].join('\n')
203
+ }
204
+
205
+ /**
206
+ * Reusable arrow head markers — both forward (end) and reverse (start) variants.
207
+ * Arrow color uses the var(--_arrow) CSS variable.
208
+ */
209
+ function arrowMarkerDefs(): string {
210
+ return arrowMarkerPair('var(--_arrow)', '')
211
+ }
212
+
213
+ /**
214
+ * Arrow markers tinted to a specific color (for linkStyle stroke overrides).
215
+ * IDs are suffixed with a sanitized color string to avoid collisions.
216
+ */
217
+ function arrowMarkerDefsForColor(color: string): string {
218
+ return arrowMarkerPair(escapeAttr(color), `-${markerSuffix(color)}`)
219
+ }
220
+
221
+ /**
222
+ * Build the forward (marker-end) + reverse (marker-start) arrow-head marker pair.
223
+ *
224
+ * Both heads share one polygon. The reverse head differs only by
225
+ * orient="auto-start-reverse", which flips it 180° to point back out of
226
+ * the start node — so the polygon must NOT be pre-reversed. Reversing both is
227
+ * a double reversal: the head points into the line and vanishes in
228
+ * librsvg/Inkscape/browsers.
229
+ */
230
+ function arrowMarkerPair(color: string, idSuffix: string): string {
231
+ const w = ARROW_HEAD.width
232
+ const h = ARROW_HEAD.height
233
+ // Pull arrowhead back slightly (refX = w - 1) to prevent clipping at node boundaries.
234
+ const refX = w - 1
235
+ // Both fill and a thin stroke for better definition at small sizes.
236
+ const style = `fill="${color}" stroke="${color}" stroke-width="0.75" stroke-linejoin="round"`
237
+ const polygon = `<polygon points="0 0, ${w} ${h / 2}, 0 ${h}" ${style} />`
238
+ const marker = (id: string, orient: string) =>
239
+ ` <marker id="${id}" markerWidth="${w}" markerHeight="${h}" refX="${refX}" refY="${h / 2}" orient="${orient}">` +
240
+ `\n ${polygon}` +
241
+ `\n </marker>`
242
+ return (
243
+ marker(`arrowhead${idSuffix}`, 'auto') +
244
+ '\n' +
245
+ marker(`arrowhead-start${idSuffix}`, 'auto-start-reverse')
246
+ )
247
+ }
248
+
249
+ /** Sanitize a color value into a collision-free SVG ID suffix.
250
+ * Non-alphanumeric chars are hex-encoded so distinct inputs never collapse
251
+ * (e.g. "var(--line-1)" → "var28--line2d129", "var(--line1)" → "var28--line129"). */
252
+ function markerSuffix(color: string): string {
253
+ return color.replace(/[^a-zA-Z0-9]/g, (ch) => ch.charCodeAt(0).toString(16))
254
+ }
255
+
256
+ // ============================================================================
257
+ // Group rendering (subgraph backgrounds)
258
+ // ============================================================================
259
+
260
+ function renderGroup(
261
+ group: PositionedGroup,
262
+ font: string,
263
+ fontSizes: FontSizes,
264
+ ): string {
265
+ const headerHeight = fontSizes.groupHeader + 16
266
+ const parts: string[] = []
267
+
268
+ // Opening <g> with semantic attributes for subgraph identification
269
+ // data-id: original Mermaid subgraph ID
270
+ // data-label: display label (may differ from ID)
271
+ parts.push(
272
+ `<g class="subgraph" data-id="${escapeAttr(group.id)}" data-label="${escapeAttr(group.label)}">`,
273
+ )
274
+
275
+ // Outer rectangle
276
+ parts.push(
277
+ ` <rect x="${group.x}" y="${group.y}" width="${group.width}" height="${group.height}" ` +
278
+ `rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
279
+ )
280
+
281
+ // Header band
282
+ parts.push(
283
+ ` <rect x="${group.x}" y="${group.y}" width="${group.width}" height="${headerHeight}" ` +
284
+ `rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
285
+ )
286
+
287
+ // Header label (supports multi-line via <br> tags)
288
+ parts.push(
289
+ ' ' +
290
+ renderMultilineText(
291
+ group.label,
292
+ group.x + 12,
293
+ group.y + headerHeight / 2,
294
+ fontSizes.groupHeader,
295
+ `font-size="${fontSizes.groupHeader}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
296
+ ),
297
+ )
298
+
299
+ // Render nested groups recursively (inside this group)
300
+ for (const child of group.children) {
301
+ parts.push(renderGroup(child, font, fontSizes))
302
+ }
303
+
304
+ parts.push('</g>')
305
+
306
+ return parts.join('\n')
307
+ }
308
+
309
+ // ============================================================================
310
+ // Edge rendering
311
+ // ============================================================================
312
+
313
+ function renderEdge(
314
+ edge: PositionedEdge,
315
+ curve: CurveStyle,
316
+ animationEnabled: boolean = true,
317
+ ): string {
318
+ if (edge.points.length < 2) return ''
319
+
320
+ // `interactivity: 'none'` strips motion: the edge still renders (id,
321
+ // data-* attributes, arrowheads) but never gets the animated class or dash.
322
+ const animate = animationEnabled && (edge.animate ?? false)
323
+
324
+ /*
325
+ * A curved edge must be a <path>; only a path can express the
326
+ * interpolations Mermaid's `flowchart.curve` selects.
327
+ *
328
+ * The default (`linear`) deliberately keeps emitting <polyline>. A path of
329
+ * straight `L` segments would be geometrically identical, but changing the
330
+ * element for every diagram would break any consumer selecting
331
+ * `polyline.edge` — for no benefit to a diagram that asked for no curve.
332
+ * So the element changes only when the author opts into a curve.
333
+ */
334
+ const curved = curve !== 'linear'
335
+
336
+ /*
337
+ * An invisible link (`A ~~~ B`) still occupies its layout slot — that is
338
+ * the whole point of the syntax — so the element is emitted with its data
339
+ * attributes intact and only its paint suppressed. Omitting the element
340
+ * entirely would lose it from DOM inspection and from `data-style` queries.
341
+ */
342
+ const invisible = edge.style === 'invisible'
343
+
344
+ const dashArray = edge.style === 'dotted' ? ' stroke-dasharray="4 4"' : ''
345
+ const baseStrokeWidth =
346
+ edge.style === 'thick'
347
+ ? STROKE_WIDTHS.connector * 2
348
+ : STROKE_WIDTHS.connector
349
+ const strokeColor = invisible
350
+ ? 'none'
351
+ : escapeAttr(edge.inlineStyle?.stroke ?? 'var(--_line)')
352
+ const strokeWidth = escapeAttr(
353
+ edge.inlineStyle?.['stroke-width'] ?? String(baseStrokeWidth),
354
+ )
355
+
356
+ // Build marker attributes based on arrow direction flags
357
+ // Use color-specific markers when edge has a custom stroke from linkStyle
358
+ const suffix = edge.inlineStyle?.stroke
359
+ ? `-${markerSuffix(edge.inlineStyle.stroke)}`
360
+ : ''
361
+ let markers = ''
362
+ if (!invisible) {
363
+ if (edge.hasArrowEnd) markers += ` marker-end="url(#arrowhead${suffix})"`
364
+ if (edge.hasArrowStart)
365
+ markers += ` marker-start="url(#arrowhead-start${suffix})"`
366
+ }
367
+
368
+ // Semantic data attributes for edge identification and inspection:
369
+ // - class="edge": CSS targeting and type identification
370
+ // - data-from/data-to: source and target node IDs
371
+ // - data-style: edge style (solid, dotted, thick)
372
+ // - data-arrow-start/end: arrow presence flags
373
+ // - data-label: edge label if present (for quick lookup without traversing DOM)
374
+ const dataAttrs = [
375
+ // An animated edge (`e1@{ animate: true }`, with animation enabled)
376
+ // carries an extra class; its keyframes live in the shared style block.
377
+ `class="edge${animate ? ' edge-animated' : ''}"`,
378
+ `data-from="${escapeAttr(edge.source)}"`,
379
+ `data-to="${escapeAttr(edge.target)}"`,
380
+ `data-style="${edge.style}"`,
381
+ `data-arrow-start="${edge.hasArrowStart}"`,
382
+ `data-arrow-end="${edge.hasArrowEnd}"`,
383
+ ]
384
+ if (edge.label) {
385
+ dataAttrs.push(`data-label="${escapeAttr(edge.label)}"`)
386
+ }
387
+ if (edge.id) {
388
+ // Mermaid edge id (`A e1@--> B`), used to target the edge from CSS and
389
+ // to attach the animation below.
390
+ dataAttrs.push(`data-id="${escapeAttr(edge.id)}"`)
391
+ }
392
+
393
+ // Marching ants need a dash pattern to march; supply one only when the
394
+ // edge's own style hasn't already set stroke-dasharray.
395
+ const animatedDash = animate && !dashArray ? ' stroke-dasharray="8 6"' : ''
396
+
397
+ const geometry = curved
398
+ ? `d="${pointsToPath(edge.points, curve)}"`
399
+ : `points="${pointsToPolylinePath(edge.points)}"`
400
+
401
+ return (
402
+ `<${curved ? 'path' : 'polyline'} ${dataAttrs.join(' ')} ${geometry} ` +
403
+ `fill="none" stroke="${strokeColor}" ` +
404
+ `stroke-width="${strokeWidth}"${dashArray}${animatedDash}${markers} />`
405
+ )
406
+ }
407
+
408
+ /** Convert points to SVG polyline points attribute: "x1,y1 x2,y2 ..." */
409
+ function pointsToPolylinePath(points: Point[]): string {
410
+ return points.map((p) => `${p.x},${p.y}`).join(' ')
411
+ }
412
+
413
+ // `_font` isn't read here but is kept to match the `(entity, font)` signature
414
+ // threaded through the rest of the render* functions in this file.
415
+ function renderEdgeLabel(
416
+ edge: PositionedEdge,
417
+ _font: string,
418
+ fontSizes: FontSizes,
419
+ ): string {
420
+ // Only called when edge.label is set (see call site), but narrow it here
421
+ // too rather than trusting that invariant across the function boundary.
422
+ if (!edge.label) return ''
423
+ const label = edge.label
424
+
425
+ // Use layout-computed label position when available (layout-aware, avoids collisions).
426
+ // Fall back to geometric midpoint of the edge polyline.
427
+ const mid = edge.labelPosition ?? edgeMidpoint(edge.points)
428
+ const padding = 8
429
+
430
+ // Measure text (works for both single and multi-line)
431
+ const metrics = measureMultilineText(
432
+ label,
433
+ fontSizes.edgeLabel,
434
+ FONT_WEIGHTS.edgeLabel,
435
+ )
436
+
437
+ // Wrap in <g class="edge-label"> with reference to the edge it belongs to
438
+ const content = renderMultilineTextWithBackground(
439
+ label,
440
+ mid.x,
441
+ mid.y,
442
+ metrics.width,
443
+ metrics.height,
444
+ fontSizes.edgeLabel,
445
+ padding,
446
+ // Use --_text-sec for better contrast (was --_text-muted)
447
+ `text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-sec)"`,
448
+ // Increased stroke width from 0.5 to 1 for better label separation from edges
449
+ `rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1"`,
450
+ )
451
+
452
+ // Semantic wrapper: links label to its edge via data-from/data-to
453
+ return (
454
+ `<g class="edge-label" data-from="${escapeAttr(edge.source)}" data-to="${escapeAttr(edge.target)}" data-label="${escapeAttr(label)}">\n` +
455
+ ` ${content.replace(/\n/g, '\n ')}\n` +
456
+ `</g>`
457
+ )
458
+ }
459
+
460
+ /**
461
+ * Get the point at `index`, throwing instead of silently producing
462
+ * `undefined` (and downstream `NaN`s). The loops in `edgeMidpoint` only ever
463
+ * pass indices derived from `points.length`, so this should never actually
464
+ * throw — it exists because `noUncheckedIndexedAccess` can't prove that from
465
+ * the loop bounds alone, and a loud failure here is far easier to diagnose
466
+ * than silently corrupted coordinates.
467
+ */
468
+ function requirePoint(points: Point[], index: number): Point {
469
+ const point = points[index]
470
+ if (!point) {
471
+ // Unreachable — both call sites in edgeMidpoint only ever pass indices
472
+ // in [0, points.length - 1].
473
+ /* v8 ignore next */
474
+ throw new Error(`edgeMidpoint: missing point at index ${index}`)
475
+ }
476
+ return point
477
+ }
478
+
479
+ /** Get the midpoint of a polyline (by walking segments) */
480
+ function edgeMidpoint(points: Point[]): Point {
481
+ if (points.length === 0) return { x: 0, y: 0 }
482
+ if (points.length === 1) return points[0]!
483
+
484
+ // Calculate total length
485
+ let totalLength = 0
486
+ for (let i = 1; i < points.length; i++) {
487
+ totalLength += dist(requirePoint(points, i - 1), requirePoint(points, i))
488
+ }
489
+
490
+ // Walk to the halfway point
491
+ let remaining = totalLength / 2
492
+ for (let i = 1; i < points.length; i++) {
493
+ const prev = requirePoint(points, i - 1)
494
+ const curr = requirePoint(points, i)
495
+ const segLen = dist(prev, curr)
496
+ if (remaining <= segLen) {
497
+ const t = remaining / segLen
498
+ return {
499
+ x: prev.x + t * (curr.x - prev.x),
500
+ y: prev.y + t * (curr.y - prev.y),
501
+ }
502
+ }
503
+ remaining -= segLen
504
+ }
505
+
506
+ return points[points.length - 1]!
507
+ }
508
+
509
+ function dist(a: Point, b: Point): number {
510
+ return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
511
+ }
512
+
513
+ // ============================================================================
514
+ // Node rendering
515
+ // ============================================================================
516
+
517
+ /**
518
+ * Render a complete node: shape + label wrapped in a semantic <g> element.
519
+ *
520
+ * The group includes data attributes for:
521
+ * - data-id: original Mermaid node ID (for edge matching)
522
+ * - data-label: display label text
523
+ * - data-shape: shape type (rectangle, diamond, circle, etc.)
524
+ *
525
+ * @param linksEnabled - Whether a `click`-based `<a href>` link and `<title>`
526
+ * tooltip render (from `options.interactivity !==
527
+ * 'none'`). Default true.
528
+ */
529
+ function renderNode(
530
+ node: PositionedNode,
531
+ font: string,
532
+ fontSizes: FontSizes,
533
+ linksEnabled: boolean = true,
534
+ ): string {
535
+ const shape = renderNodeShape(node)
536
+ const label = renderNodeLabel(node, font, fontSizes)
537
+
538
+ // Combine shape and label inside a semantic group
539
+ // This enables reliable node identification without heuristics
540
+ const parts: string[] = []
541
+ const safeClassName = sanitizeClassName(node.className)
542
+ const classAttr = safeClassName ? `node ${safeClassName}` : 'node'
543
+
544
+ const interaction = node.interaction
545
+ const groupAttrs = [
546
+ `class="${classAttr}"`,
547
+ `data-id="${escapeAttr(node.id)}"`,
548
+ `data-label="${escapeAttr(node.label)}"`,
549
+ `data-shape="${node.shape}"`,
550
+ ]
551
+ /*
552
+ * `click A call fn()` is deliberately absent from the markup. This renderer
553
+ * produces a static SVG string and executes nothing a diagram supplies —
554
+ * running diagram-authored script would make every rendered diagram an
555
+ * execution vector. The binding is exposed as data instead, on the
556
+ * `interactions` map `parseMermaid()` returns, and the `data-id` attribute
557
+ * above is the hook a host binds it to. The inert `data-click-callback`
558
+ * attribute once emitted here was removed in #216 — see
559
+ * docs/decisions/no-script-interactivity.md.
560
+ */
561
+ parts.push(`<g ${groupAttrs.join(' ')}>`)
562
+
563
+ // An href becomes a real SVG link, which needs no script to work.
564
+ // `interactivity: 'none'` strips it — a link is meaningless in
565
+ // print/rasterized output, the target that level is meant for.
566
+ const href = linksEnabled ? safeHref(interaction?.href) : undefined
567
+ const indent = href ? ' ' : ' '
568
+ if (href) {
569
+ const targetAttr = interaction?.target
570
+ ? ` target="${escapeAttr(interaction.target)}"`
571
+ : ''
572
+ parts.push(` <a href="${escapeAttr(href)}"${targetAttr}>`)
573
+ }
574
+
575
+ if (linksEnabled && interaction?.tooltip) {
576
+ // <title> is SVG's native tooltip — no script, no CSS. Gated by
577
+ // linksEnabled alongside href above: both come from the same `click`
578
+ // statement, and 'none' strips both.
579
+ parts.push(`${indent}<title>${escapeXml(interaction.tooltip)}</title>`)
580
+ }
581
+
582
+ parts.push(`${indent}${shape.replace(/\n/g, `\n${indent}`)}`)
583
+ if (label) {
584
+ parts.push(`${indent}${label.replace(/\n/g, `\n${indent}`)}`)
585
+ }
586
+
587
+ if (href) parts.push(' </a>')
588
+ parts.push('</g>')
589
+
590
+ return parts.join('\n')
591
+ }
592
+
593
+ function renderNodeShape(node: PositionedNode): string {
594
+ const { x, y, width, height, shape, inlineStyle } = node
595
+
596
+ // Resolve fill and stroke — inline styles (from mermaid `style` directives)
597
+ // override the CSS variable defaults. When no inline style is present, the
598
+ // CSS variable handles theming automatically via color-mix() derivation.
599
+ const fill = escapeAttr(inlineStyle?.fill ?? 'var(--_node-fill)')
600
+ const stroke = escapeAttr(inlineStyle?.stroke ?? 'var(--_node-stroke)')
601
+ const sw = escapeAttr(
602
+ inlineStyle?.['stroke-width'] ?? String(STROKE_WIDTHS.innerBox),
603
+ )
604
+
605
+ switch (shape) {
606
+ case 'diamond':
607
+ return renderDiamond(x, y, width, height, fill, stroke, sw)
608
+ case 'rounded':
609
+ return renderRoundedRect(x, y, width, height, fill, stroke, sw)
610
+ case 'stadium':
611
+ return renderStadium(x, y, width, height, fill, stroke, sw)
612
+ case 'circle':
613
+ return renderCircle(x, y, width, height, fill, stroke, sw)
614
+ case 'subroutine':
615
+ return renderSubroutine(x, y, width, height, fill, stroke, sw)
616
+ case 'doublecircle':
617
+ return renderDoubleCircle(x, y, width, height, fill, stroke, sw)
618
+ case 'hexagon':
619
+ return renderHexagon(x, y, width, height, fill, stroke, sw)
620
+ case 'cylinder':
621
+ return renderCylinder(x, y, width, height, fill, stroke, sw)
622
+ case 'asymmetric':
623
+ return renderAsymmetric(x, y, width, height, fill, stroke, sw)
624
+ case 'trapezoid':
625
+ return renderTrapezoid(x, y, width, height, fill, stroke, sw)
626
+ case 'trapezoid-alt':
627
+ return renderTrapezoidAlt(x, y, width, height, fill, stroke, sw)
628
+ case 'parallelogram':
629
+ return renderParallelogram(x, y, width, height, fill, stroke, sw)
630
+ case 'parallelogram-alt':
631
+ return renderParallelogramAlt(x, y, width, height, fill, stroke, sw)
632
+ case 'state-start':
633
+ return renderStateStart(x, y, width, height)
634
+ case 'state-end':
635
+ return renderStateEnd(x, y, width, height)
636
+
637
+ // --- Expanded-syntax shapes (`A@{ shape: ... }`) ---
638
+ case 'document':
639
+ return renderDocument(x, y, width, height, fill, stroke, sw)
640
+ case 'stacked-document':
641
+ return renderStacked(x, y, width, height, fill, stroke, sw, true)
642
+ case 'stacked-process':
643
+ return renderStacked(x, y, width, height, fill, stroke, sw, false)
644
+ case 'card':
645
+ return renderCard(x, y, width, height, fill, stroke, sw)
646
+ case 'lined-process':
647
+ return renderLinedProcess(x, y, width, height, fill, stroke, sw)
648
+ case 'divided-process':
649
+ return renderDividedProcess(x, y, width, height, fill, stroke, sw)
650
+ case 'window-pane':
651
+ return renderWindowPane(x, y, width, height, fill, stroke, sw)
652
+ case 'triangle':
653
+ return renderTriangle(x, y, width, height, fill, stroke, sw, false)
654
+ case 'flipped-triangle':
655
+ return renderTriangle(x, y, width, height, fill, stroke, sw, true)
656
+ case 'filled-circle':
657
+ return renderFilledCircle(x, y, width, height, stroke)
658
+ case 'crossed-circle':
659
+ return renderCrossedCircle(x, y, width, height, fill, stroke, sw)
660
+ case 'fork-join':
661
+ return renderForkJoin(x, y, width, height, stroke)
662
+ case 'notched-pentagon':
663
+ return renderNotchedPentagon(x, y, width, height, fill, stroke, sw)
664
+ case 'sloped-rectangle':
665
+ return renderSlopedRectangle(x, y, width, height, fill, stroke, sw)
666
+ case 'flag':
667
+ return renderFlag(x, y, width, height, fill, stroke, sw)
668
+ case 'bow-tie-rectangle':
669
+ return renderBowTie(x, y, width, height, fill, stroke, sw)
670
+ case 'half-rounded-rectangle':
671
+ return renderHalfRounded(x, y, width, height, fill, stroke, sw)
672
+ case 'brace':
673
+ return renderBraces(x, y, width, height, fill, stroke, sw, 'left')
674
+ case 'brace-right':
675
+ return renderBraces(x, y, width, height, fill, stroke, sw, 'right')
676
+ case 'braces':
677
+ return renderBraces(x, y, width, height, fill, stroke, sw, 'both')
678
+ case 'bolt':
679
+ return renderBolt(x, y, width, height, fill, stroke, sw)
680
+ case 'text':
681
+ case 'anchor':
682
+ // No outline — the label (rendered separately) is the whole node.
683
+ return ''
684
+
685
+ case 'rectangle':
686
+ default:
687
+ return renderRect(x, y, width, height, fill, stroke, sw)
688
+ }
689
+ }
690
+
691
+ // --- Basic shapes ---
692
+
693
+ function renderRect(
694
+ x: number,
695
+ y: number,
696
+ w: number,
697
+ h: number,
698
+ fill: string,
699
+ stroke: string,
700
+ sw: string,
701
+ ): string {
702
+ return (
703
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
704
+ `rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
705
+ )
706
+ }
707
+
708
+ function renderRoundedRect(
709
+ x: number,
710
+ y: number,
711
+ w: number,
712
+ h: number,
713
+ fill: string,
714
+ stroke: string,
715
+ sw: string,
716
+ ): string {
717
+ return (
718
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
719
+ `rx="6" ry="6" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
720
+ )
721
+ }
722
+
723
+ function renderStadium(
724
+ x: number,
725
+ y: number,
726
+ w: number,
727
+ h: number,
728
+ fill: string,
729
+ stroke: string,
730
+ sw: string,
731
+ ): string {
732
+ const r = h / 2
733
+ return (
734
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
735
+ `rx="${r}" ry="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
736
+ )
737
+ }
738
+
739
+ function renderCircle(
740
+ x: number,
741
+ y: number,
742
+ w: number,
743
+ h: number,
744
+ fill: string,
745
+ stroke: string,
746
+ sw: string,
747
+ ): string {
748
+ const cx = x + w / 2
749
+ const cy = y + h / 2
750
+ const r = Math.min(w, h) / 2
751
+ return (
752
+ `<circle cx="${cx}" cy="${cy}" r="${r}" ` +
753
+ `fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
754
+ )
755
+ }
756
+
757
+ function renderDiamond(
758
+ x: number,
759
+ y: number,
760
+ w: number,
761
+ h: number,
762
+ fill: string,
763
+ stroke: string,
764
+ sw: string,
765
+ ): string {
766
+ const cx = x + w / 2
767
+ const cy = y + h / 2
768
+ const hw = w / 2
769
+ const hh = h / 2
770
+ const points = [
771
+ `${cx},${cy - hh}`, // top
772
+ `${cx + hw},${cy}`, // right
773
+ `${cx},${cy + hh}`, // bottom
774
+ `${cx - hw},${cy}`, // left
775
+ ].join(' ')
776
+
777
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
778
+ }
779
+
780
+ // --- Batch 1 shapes ---
781
+
782
+ /** Subroutine: rectangle with double vertical borders on left and right */
783
+ function renderSubroutine(
784
+ x: number,
785
+ y: number,
786
+ w: number,
787
+ h: number,
788
+ fill: string,
789
+ stroke: string,
790
+ sw: string,
791
+ ): string {
792
+ const inset = 8 // distance from edge to inner vertical line
793
+ return (
794
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
795
+ `rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
796
+ `\n<line x1="${x + inset}" y1="${y}" x2="${x + inset}" y2="${y + h}" ` +
797
+ `stroke="${stroke}" stroke-width="${sw}" />` +
798
+ `\n<line x1="${x + w - inset}" y1="${y}" x2="${x + w - inset}" y2="${y + h}" ` +
799
+ `stroke="${stroke}" stroke-width="${sw}" />`
800
+ )
801
+ }
802
+
803
+ /** Double circle: two concentric circles with a gap between them */
804
+ function renderDoubleCircle(
805
+ x: number,
806
+ y: number,
807
+ w: number,
808
+ h: number,
809
+ fill: string,
810
+ stroke: string,
811
+ sw: string,
812
+ ): string {
813
+ const cx = x + w / 2
814
+ const cy = y + h / 2
815
+ const outerR = Math.min(w, h) / 2
816
+ const innerR = outerR - 5 // 5px gap between rings
817
+ return (
818
+ `<circle cx="${cx}" cy="${cy}" r="${outerR}" ` +
819
+ `fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
820
+ `\n<circle cx="${cx}" cy="${cy}" r="${innerR}" ` +
821
+ `fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
822
+ )
823
+ }
824
+
825
+ /** Hexagon: 6-point polygon with flat top/bottom and angled sides */
826
+ function renderHexagon(
827
+ x: number,
828
+ y: number,
829
+ w: number,
830
+ h: number,
831
+ fill: string,
832
+ stroke: string,
833
+ sw: string,
834
+ ): string {
835
+ const inset = h / 4 // horizontal inset for the angled sides
836
+ const points = [
837
+ `${x + inset},${y}`, // top-left
838
+ `${x + w - inset},${y}`, // top-right
839
+ `${x + w},${y + h / 2}`, // mid-right
840
+ `${x + w - inset},${y + h}`, // bottom-right
841
+ `${x + inset},${y + h}`, // bottom-left
842
+ `${x},${y + h / 2}`, // mid-left
843
+ ].join(' ')
844
+
845
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
846
+ }
847
+
848
+ // --- Batch 2 shapes ---
849
+
850
+ /** Cylinder / database: top ellipse cap + body rect + bottom ellipse */
851
+ function renderCylinder(
852
+ x: number,
853
+ y: number,
854
+ w: number,
855
+ h: number,
856
+ fill: string,
857
+ stroke: string,
858
+ sw: string,
859
+ ): string {
860
+ const ry = 7 // ellipse vertical radius for the cap
861
+ const cx = x + w / 2
862
+ const bodyTop = y + ry
863
+ const bodyH = h - 2 * ry
864
+
865
+ return (
866
+ // Body rectangle (no top border — covered by top ellipse)
867
+ `<rect x="${x}" y="${bodyTop}" width="${w}" height="${bodyH}" ` +
868
+ `fill="${fill}" stroke="none" />` +
869
+ // Left and right body borders
870
+ `\n<line x1="${x}" y1="${bodyTop}" x2="${x}" y2="${bodyTop + bodyH}" stroke="${stroke}" stroke-width="${sw}" />` +
871
+ `\n<line x1="${x + w}" y1="${bodyTop}" x2="${x + w}" y2="${bodyTop + bodyH}" stroke="${stroke}" stroke-width="${sw}" />` +
872
+ // Bottom ellipse (half visible)
873
+ `\n<ellipse cx="${cx}" cy="${y + h - ry}" rx="${w / 2}" ry="${ry}" ` +
874
+ `fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
875
+ // Top ellipse (full, on top)
876
+ `\n<ellipse cx="${cx}" cy="${bodyTop}" rx="${w / 2}" ry="${ry}" ` +
877
+ `fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
878
+ )
879
+ }
880
+
881
+ /** Asymmetric / flag: rectangle with a pointed left edge */
882
+ function renderAsymmetric(
883
+ x: number,
884
+ y: number,
885
+ w: number,
886
+ h: number,
887
+ fill: string,
888
+ stroke: string,
889
+ sw: string,
890
+ ): string {
891
+ const indent = 12 // how far the point indents
892
+ const points = [
893
+ `${x + indent},${y}`, // top-left (indented)
894
+ `${x + w},${y}`, // top-right
895
+ `${x + w},${y + h}`, // bottom-right
896
+ `${x + indent},${y + h}`, // bottom-left (indented)
897
+ `${x},${y + h / 2}`, // left point
898
+ ].join(' ')
899
+
900
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
901
+ }
902
+
903
+ /** Trapezoid [/text\]: wider bottom, narrower top */
904
+ function renderTrapezoid(
905
+ x: number,
906
+ y: number,
907
+ w: number,
908
+ h: number,
909
+ fill: string,
910
+ stroke: string,
911
+ sw: string,
912
+ ): string {
913
+ const inset = w * 0.15 // top edge is narrower by this amount on each side
914
+ const points = [
915
+ `${x + inset},${y}`, // top-left (indented)
916
+ `${x + w - inset},${y}`, // top-right (indented)
917
+ `${x + w},${y + h}`, // bottom-right (full width)
918
+ `${x},${y + h}`, // bottom-left (full width)
919
+ ].join(' ')
920
+
921
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
922
+ }
923
+
924
+ /** Trapezoid-alt [\text/]: wider top, narrower bottom */
925
+ function renderTrapezoidAlt(
926
+ x: number,
927
+ y: number,
928
+ w: number,
929
+ h: number,
930
+ fill: string,
931
+ stroke: string,
932
+ sw: string,
933
+ ): string {
934
+ const inset = w * 0.15 // bottom edge is narrower
935
+ const points = [
936
+ `${x},${y}`, // top-left (full width)
937
+ `${x + w},${y}`, // top-right (full width)
938
+ `${x + w - inset},${y + h}`, // bottom-right (indented)
939
+ `${x + inset},${y + h}`, // bottom-left (indented)
940
+ ].join(' ')
941
+
942
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
943
+ }
944
+
945
+ /**
946
+ * Parallelogram [/text/]: leans right.
947
+ *
948
+ * Unlike the trapezoids, both sloped sides run the same direction — the top
949
+ * edge shifts right by `inset` and the bottom edge shifts left by the same
950
+ * amount, so opposite sides stay parallel.
951
+ */
952
+ function renderParallelogram(
953
+ x: number,
954
+ y: number,
955
+ w: number,
956
+ h: number,
957
+ fill: string,
958
+ stroke: string,
959
+ sw: string,
960
+ ): string {
961
+ const inset = w * 0.15
962
+ const points = [
963
+ `${x + inset},${y}`, // top-left (shifted right)
964
+ `${x + w},${y}`, // top-right
965
+ `${x + w - inset},${y + h}`, // bottom-right (shifted left)
966
+ `${x},${y + h}`, // bottom-left
967
+ ].join(' ')
968
+
969
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
970
+ }
971
+
972
+ /** Parallelogram-alt [\text\]: leans left — the mirror of renderParallelogram. */
973
+ function renderParallelogramAlt(
974
+ x: number,
975
+ y: number,
976
+ w: number,
977
+ h: number,
978
+ fill: string,
979
+ stroke: string,
980
+ sw: string,
981
+ ): string {
982
+ const inset = w * 0.15
983
+ const points = [
984
+ `${x},${y}`, // top-left
985
+ `${x + w - inset},${y}`, // top-right (shifted left)
986
+ `${x + w},${y + h}`, // bottom-right
987
+ `${x + inset},${y + h}`, // bottom-left (shifted right)
988
+ ].join(' ')
989
+
990
+ return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
991
+ }
992
+
993
+ // --- Expanded-syntax shapes (`A@{ shape: ... }`) ---
994
+ //
995
+ // Each of these is reachable only through the expanded metadata syntax; the
996
+ // classic bracket forms have no spelling for them. See
997
+ // packages/mermaid-parser/src/expanded-shapes.ts for the semantic-name →
998
+ // geometry alias table.
999
+
1000
+ /** Shared attribute string for a filled, stroked path. */
1001
+ function shapeAttrs(fill: string, stroke: string, sw: string): string {
1002
+ return `fill="${fill}" stroke="${stroke}" stroke-width="${sw}"`
1003
+ }
1004
+
1005
+ /**
1006
+ * Document: rectangle with a wavy bottom edge.
1007
+ *
1008
+ * The wave is two cubic segments — down then up — so the edge returns to its
1009
+ * starting height at the right corner and the shape tiles cleanly when
1010
+ * stacked (see renderStacked).
1011
+ */
1012
+ function renderDocument(
1013
+ x: number,
1014
+ y: number,
1015
+ w: number,
1016
+ h: number,
1017
+ fill: string,
1018
+ stroke: string,
1019
+ sw: string,
1020
+ ): string {
1021
+ const waveH = Math.min(10, h * 0.16)
1022
+ const base = y + h - waveH
1023
+ // Both ends land on `base`, so the left and right sides are the same
1024
+ // height. Ending the curve anywhere else makes every document node
1025
+ // visibly lopsided.
1026
+ const d =
1027
+ `M ${x} ${y} L ${x + w} ${y} L ${x + w} ${base} ` +
1028
+ `C ${x + w * 0.75} ${base + waveH * 1.8} ${x + w * 0.25} ${base - waveH * 0.8} ${x} ${base} Z`
1029
+ return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
1030
+ }
1031
+
1032
+ /** Stacked document / process: offset copies behind the front shape. */
1033
+ function renderStacked(
1034
+ x: number,
1035
+ y: number,
1036
+ w: number,
1037
+ h: number,
1038
+ fill: string,
1039
+ stroke: string,
1040
+ sw: string,
1041
+ isDocument: boolean,
1042
+ ): string {
1043
+ const offset = 5
1044
+ const frontW = w - offset * 2
1045
+ const frontH = h - offset * 2
1046
+ const parts: string[] = []
1047
+
1048
+ // Two offset copies behind, back to front.
1049
+ for (let i = 2; i >= 1; i--) {
1050
+ parts.push(
1051
+ `<rect x="${x + offset * i}" y="${y + offset * (2 - i)}" width="${frontW}" height="${frontH}" ` +
1052
+ `${shapeAttrs(fill, stroke, sw)} />`,
1053
+ )
1054
+ }
1055
+
1056
+ parts.push(
1057
+ isDocument
1058
+ ? renderDocument(x, y + offset * 2, frontW, frontH, fill, stroke, sw)
1059
+ : `<rect x="${x}" y="${y + offset * 2}" width="${frontW}" height="${frontH}" ${shapeAttrs(fill, stroke, sw)} />`,
1060
+ )
1061
+
1062
+ return parts.join('\n')
1063
+ }
1064
+
1065
+ /** Card / notched rectangle: top-left corner clipped. */
1066
+ function renderCard(
1067
+ x: number,
1068
+ y: number,
1069
+ w: number,
1070
+ h: number,
1071
+ fill: string,
1072
+ stroke: string,
1073
+ sw: string,
1074
+ ): string {
1075
+ const notch = Math.min(14, w * 0.12, h * 0.3)
1076
+ const points = [
1077
+ `${x + notch},${y}`,
1078
+ `${x + w},${y}`,
1079
+ `${x + w},${y + h}`,
1080
+ `${x},${y + h}`,
1081
+ `${x},${y + notch}`,
1082
+ ].join(' ')
1083
+ return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
1084
+ }
1085
+
1086
+ /** Lined process: rectangle with a vertical rule inset from the left edge. */
1087
+ function renderLinedProcess(
1088
+ x: number,
1089
+ y: number,
1090
+ w: number,
1091
+ h: number,
1092
+ fill: string,
1093
+ stroke: string,
1094
+ sw: string,
1095
+ ): string {
1096
+ const inset = Math.min(12, w * 0.12)
1097
+ return (
1098
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${shapeAttrs(fill, stroke, sw)} />\n` +
1099
+ `<line x1="${x + inset}" y1="${y}" x2="${x + inset}" y2="${y + h}" stroke="${stroke}" stroke-width="${sw}" />`
1100
+ )
1101
+ }
1102
+
1103
+ /** Divided process: rectangle split by a horizontal rule near the top. */
1104
+ function renderDividedProcess(
1105
+ x: number,
1106
+ y: number,
1107
+ w: number,
1108
+ h: number,
1109
+ fill: string,
1110
+ stroke: string,
1111
+ sw: string,
1112
+ ): string {
1113
+ const split = y + Math.min(16, h * 0.3)
1114
+ return (
1115
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${shapeAttrs(fill, stroke, sw)} />\n` +
1116
+ `<line x1="${x}" y1="${split}" x2="${x + w}" y2="${split}" stroke="${stroke}" stroke-width="${sw}" />`
1117
+ )
1118
+ }
1119
+
1120
+ /** Window pane / internal storage: rectangle quartered by a cross. */
1121
+ function renderWindowPane(
1122
+ x: number,
1123
+ y: number,
1124
+ w: number,
1125
+ h: number,
1126
+ fill: string,
1127
+ stroke: string,
1128
+ sw: string,
1129
+ ): string {
1130
+ const vx = x + Math.min(16, w * 0.16)
1131
+ const hy = y + Math.min(14, h * 0.28)
1132
+ return (
1133
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${shapeAttrs(fill, stroke, sw)} />\n` +
1134
+ `<line x1="${vx}" y1="${y}" x2="${vx}" y2="${y + h}" stroke="${stroke}" stroke-width="${sw}" />\n` +
1135
+ `<line x1="${x}" y1="${hy}" x2="${x + w}" y2="${hy}" stroke="${stroke}" stroke-width="${sw}" />`
1136
+ )
1137
+ }
1138
+
1139
+ /** Triangle, apex up (extract) or apex down (manual file). */
1140
+ function renderTriangle(
1141
+ x: number,
1142
+ y: number,
1143
+ w: number,
1144
+ h: number,
1145
+ fill: string,
1146
+ stroke: string,
1147
+ sw: string,
1148
+ flipped: boolean,
1149
+ ): string {
1150
+ const points = flipped
1151
+ ? [`${x},${y}`, `${x + w},${y}`, `${x + w / 2},${y + h}`]
1152
+ : [`${x + w / 2},${y}`, `${x + w},${y + h}`, `${x},${y + h}`]
1153
+ return `<polygon points="${points.join(' ')}" ${shapeAttrs(fill, stroke, sw)} />`
1154
+ }
1155
+
1156
+ /** Filled circle (junction): a solid dot, so it takes the stroke color as fill. */
1157
+ function renderFilledCircle(
1158
+ x: number,
1159
+ y: number,
1160
+ w: number,
1161
+ h: number,
1162
+ stroke: string,
1163
+ ): string {
1164
+ const r = Math.min(w, h) / 2 - 2
1165
+ return `<circle cx="${x + w / 2}" cy="${y + h / 2}" r="${r}" fill="${stroke}" stroke="${stroke}" />`
1166
+ }
1167
+
1168
+ /** Crossed circle (summary): circle with an X through it. */
1169
+ function renderCrossedCircle(
1170
+ x: number,
1171
+ y: number,
1172
+ w: number,
1173
+ h: number,
1174
+ fill: string,
1175
+ stroke: string,
1176
+ sw: string,
1177
+ ): string {
1178
+ const cx = x + w / 2
1179
+ const cy = y + h / 2
1180
+ const r = Math.min(w, h) / 2 - 2
1181
+ // Cross arms meet the circumference at 45°, so offset by r/√2.
1182
+ const d = r / Math.SQRT2
1183
+ return (
1184
+ `<circle cx="${cx}" cy="${cy}" r="${r}" ${shapeAttrs(fill, stroke, sw)} />\n` +
1185
+ `<line x1="${cx - d}" y1="${cy - d}" x2="${cx + d}" y2="${cy + d}" stroke="${stroke}" stroke-width="${sw}" />\n` +
1186
+ `<line x1="${cx + d}" y1="${cy - d}" x2="${cx - d}" y2="${cy + d}" stroke="${stroke}" stroke-width="${sw}" />`
1187
+ )
1188
+ }
1189
+
1190
+ /** Fork/join: a solid bar, drawn in the stroke color. */
1191
+ function renderForkJoin(
1192
+ x: number,
1193
+ y: number,
1194
+ w: number,
1195
+ h: number,
1196
+ stroke: string,
1197
+ ): string {
1198
+ const barH = Math.min(8, h)
1199
+ return `<rect x="${x}" y="${y + (h - barH) / 2}" width="${w}" height="${barH}" fill="${stroke}" stroke="${stroke}" />`
1200
+ }
1201
+
1202
+ /** Notched pentagon (loop limit): both top corners clipped. */
1203
+ function renderNotchedPentagon(
1204
+ x: number,
1205
+ y: number,
1206
+ w: number,
1207
+ h: number,
1208
+ fill: string,
1209
+ stroke: string,
1210
+ sw: string,
1211
+ ): string {
1212
+ const notch = Math.min(14, w * 0.12, h * 0.3)
1213
+ const points = [
1214
+ `${x + notch},${y}`,
1215
+ `${x + w - notch},${y}`,
1216
+ `${x + w},${y + notch}`,
1217
+ `${x + w},${y + h}`,
1218
+ `${x},${y + h}`,
1219
+ `${x},${y + notch}`,
1220
+ ].join(' ')
1221
+ return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
1222
+ }
1223
+
1224
+ /** Sloped rectangle (manual input): top edge slopes up to the right. */
1225
+ function renderSlopedRectangle(
1226
+ x: number,
1227
+ y: number,
1228
+ w: number,
1229
+ h: number,
1230
+ fill: string,
1231
+ stroke: string,
1232
+ sw: string,
1233
+ ): string {
1234
+ const slope = Math.min(12, h * 0.3)
1235
+ const points = [
1236
+ `${x},${y + slope}`,
1237
+ `${x + w},${y}`,
1238
+ `${x + w},${y + h}`,
1239
+ `${x},${y + h}`,
1240
+ ].join(' ')
1241
+ return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
1242
+ }
1243
+
1244
+ /** Flag / paper tape: wavy top and bottom edges. */
1245
+ function renderFlag(
1246
+ x: number,
1247
+ y: number,
1248
+ w: number,
1249
+ h: number,
1250
+ fill: string,
1251
+ stroke: string,
1252
+ sw: string,
1253
+ ): string {
1254
+ const waveH = Math.min(8, h * 0.14)
1255
+ const d =
1256
+ `M ${x} ${y + waveH} ` +
1257
+ `C ${x + w * 0.25} ${y - waveH} ${x + w * 0.75} ${y + waveH * 2} ${x + w} ${y + waveH} ` +
1258
+ `L ${x + w} ${y + h - waveH} ` +
1259
+ `C ${x + w * 0.75} ${y + h + waveH} ${x + w * 0.25} ${y + h - waveH * 2} ${x} ${y + h - waveH} Z`
1260
+ return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
1261
+ }
1262
+
1263
+ /** Bow-tie rectangle (stored data): left and right edges curve inward. */
1264
+ function renderBowTie(
1265
+ x: number,
1266
+ y: number,
1267
+ w: number,
1268
+ h: number,
1269
+ fill: string,
1270
+ stroke: string,
1271
+ sw: string,
1272
+ ): string {
1273
+ const bow = Math.min(14, w * 0.12)
1274
+ const d =
1275
+ `M ${x + bow} ${y} L ${x + w} ${y} ` +
1276
+ `Q ${x + w - bow * 1.4} ${y + h / 2} ${x + w} ${y + h} ` +
1277
+ `L ${x + bow} ${y + h} ` +
1278
+ `Q ${x + bow * 1.4} ${y + h / 2} ${x + bow} ${y} Z`
1279
+ return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
1280
+ }
1281
+
1282
+ /** Delay / half-rounded rectangle: the right end is a semicircle. */
1283
+ function renderHalfRounded(
1284
+ x: number,
1285
+ y: number,
1286
+ w: number,
1287
+ h: number,
1288
+ fill: string,
1289
+ stroke: string,
1290
+ sw: string,
1291
+ ): string {
1292
+ const r = h / 2
1293
+ const straight = Math.max(0, w - r)
1294
+ const d =
1295
+ `M ${x} ${y} L ${x + straight} ${y} ` +
1296
+ `A ${r} ${r} 0 0 1 ${x + straight} ${y + h} ` +
1297
+ `L ${x} ${y + h} Z`
1298
+ return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
1299
+ }
1300
+
1301
+ /**
1302
+ * Brace shapes: a rectangle body flanked by curly braces.
1303
+ *
1304
+ * Mermaid's `brace`/`comment` is a left brace, `brace-r` a right brace, and
1305
+ * `braces` both. The body itself is unfilled so the braces read as annotation
1306
+ * marks rather than as a container.
1307
+ */
1308
+ function renderBraces(
1309
+ x: number,
1310
+ y: number,
1311
+ w: number,
1312
+ h: number,
1313
+ _fill: string,
1314
+ stroke: string,
1315
+ sw: string,
1316
+ side: 'left' | 'right' | 'both',
1317
+ ): string {
1318
+ const armW = Math.min(10, w * 0.12)
1319
+ // Unfilled: a filled body reads as a container, which is the opposite of
1320
+ // what a brace annotation means. The rect stays only to reserve the
1321
+ // label area; `_fill` is deliberately unused.
1322
+ const parts = [
1323
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="none" />`,
1324
+ ]
1325
+
1326
+ const leftBrace =
1327
+ `M ${x + armW} ${y} Q ${x} ${y} ${x} ${y + h * 0.25} ` +
1328
+ `Q ${x} ${y + h / 2} ${x - armW * 0.4} ${y + h / 2} ` +
1329
+ `Q ${x} ${y + h / 2} ${x} ${y + h * 0.75} ` +
1330
+ `Q ${x} ${y + h} ${x + armW} ${y + h}`
1331
+ const rightBrace =
1332
+ `M ${x + w - armW} ${y} Q ${x + w} ${y} ${x + w} ${y + h * 0.25} ` +
1333
+ `Q ${x + w} ${y + h / 2} ${x + w + armW * 0.4} ${y + h / 2} ` +
1334
+ `Q ${x + w} ${y + h / 2} ${x + w} ${y + h * 0.75} ` +
1335
+ `Q ${x + w} ${y + h} ${x + w - armW} ${y + h}`
1336
+
1337
+ if (side === 'left' || side === 'both') {
1338
+ parts.push(
1339
+ `<path d="${leftBrace}" fill="none" stroke="${stroke}" stroke-width="${sw}" />`,
1340
+ )
1341
+ }
1342
+ if (side === 'right' || side === 'both') {
1343
+ parts.push(
1344
+ `<path d="${rightBrace}" fill="none" stroke="${stroke}" stroke-width="${sw}" />`,
1345
+ )
1346
+ }
1347
+
1348
+ return parts.join('\n')
1349
+ }
1350
+
1351
+ /** Lightning bolt (communication link). */
1352
+ function renderBolt(
1353
+ x: number,
1354
+ y: number,
1355
+ w: number,
1356
+ h: number,
1357
+ fill: string,
1358
+ stroke: string,
1359
+ sw: string,
1360
+ ): string {
1361
+ const points = [
1362
+ `${x + w * 0.42},${y}`,
1363
+ `${x + w},${y}`,
1364
+ `${x + w * 0.62},${y + h * 0.42}`,
1365
+ `${x + w},${y + h * 0.42}`,
1366
+ `${x + w * 0.3},${y + h}`,
1367
+ `${x + w * 0.5},${y + h * 0.55}`,
1368
+ `${x},${y + h * 0.55}`,
1369
+ ].join(' ')
1370
+ return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
1371
+ }
1372
+
1373
+ // --- Batch 3: State diagram pseudostates ---
1374
+
1375
+ /** State start: small filled circle using primary text color */
1376
+ function renderStateStart(x: number, y: number, w: number, h: number): string {
1377
+ const cx = x + w / 2
1378
+ const cy = y + h / 2
1379
+ const r = Math.min(w, h) / 2 - 2
1380
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="var(--_text)" stroke="none" />`
1381
+ }
1382
+
1383
+ /** State end: bullseye — outer ring + inner filled circle using primary text color */
1384
+ function renderStateEnd(x: number, y: number, w: number, h: number): string {
1385
+ const cx = x + w / 2
1386
+ const cy = y + h / 2
1387
+ const outerR = Math.min(w, h) / 2 - 2
1388
+ const innerR = outerR - 4
1389
+ return (
1390
+ `<circle cx="${cx}" cy="${cy}" r="${outerR}" ` +
1391
+ `fill="none" stroke="var(--_text)" stroke-width="${STROKE_WIDTHS.innerBox * 2}" />` +
1392
+ `\n<circle cx="${cx}" cy="${cy}" r="${innerR}" fill="var(--_text)" stroke="none" />`
1393
+ )
1394
+ }
1395
+
1396
+ // ============================================================================
1397
+ // Node label rendering
1398
+ // ============================================================================
1399
+
1400
+ // `_font` isn't read here but is kept to match the `(entity, font)` signature
1401
+ // threaded through the rest of the render* functions in this file.
1402
+ function renderNodeLabel(
1403
+ node: PositionedNode,
1404
+ _font: string,
1405
+ fontSizes: FontSizes,
1406
+ ): string {
1407
+ // State pseudostates have no label
1408
+ if (node.shape === 'state-start' || node.shape === 'state-end') {
1409
+ if (!node.label) return ''
1410
+ }
1411
+
1412
+ const cx = node.x + node.width / 2
1413
+ const cy = node.y + node.height / 2
1414
+
1415
+ // Resolve text color — inline styles can override the CSS variable default.
1416
+ // When there's no explicit `color` but there IS a concrete, resolvable
1417
+ // `fill` (e.g. from classDef/style), compute a readable black/white text
1418
+ // color from the fill's luminance instead of defaulting to the ambient
1419
+ // theme foreground, which can be unreadable against a custom fill (e.g.
1420
+ // white theme text on a light pastel fill in dark mode). See issue #55.
1421
+ const textColor = escapeAttr(
1422
+ node.inlineStyle?.color ??
1423
+ getReadableTextColor(node.inlineStyle?.fill, 'var(--_text)'),
1424
+ )
1425
+
1426
+ let attrs = `text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="${textColor}"`
1427
+
1428
+ // Per-node font-family override (from `style A font-family:...` or
1429
+ // classDef/class). Emitted as an inline `style` attribute rather than a
1430
+ // `font-family` presentation attribute: the global font is applied via a
1431
+ // `text { font-family: ... }` rule in the embedded <style> block (see
1432
+ // theme.ts buildStyleBlock), and a presentation attribute always loses to
1433
+ // any stylesheet rule regardless of selector specificity. An inline `style`
1434
+ // attribute has the highest priority in the cascade, so it reliably
1435
+ // overrides the global rule for just this node while every other node
1436
+ // keeps falling back to the global font stack. See issue #57.
1437
+ const fontFamily = node.inlineStyle?.['font-family']
1438
+ if (fontFamily) {
1439
+ attrs += ` style="font-family: ${escapeAttr(fontFamily)};"`
1440
+ }
1441
+
1442
+ return renderMultilineText(node.label, cx, cy, fontSizes.nodeLabel, attrs)
1443
+ }
1444
+
1445
+ // ============================================================================
1446
+ // Utilities
1447
+ // ============================================================================
1448
+
1449
+ /**
1450
+ * Escape a string for embedding as a *multiline-safe* XML attribute value —
1451
+ * `escapeAttr()` plus tab/LF/CR as numeric character references.
1452
+ *
1453
+ * A strict XML parser applies attribute-value normalization on the way in:
1454
+ * any literal tab, LF, or CR in the value is collapsed to a single space.
1455
+ * (This is why a standalone .svg file opened directly, or any output run
1456
+ * through `DOMParser` with an XML/`image/svg+xml` mimetype, would silently
1457
+ * flatten a multiline `data-src` back to one line.) Character references
1458
+ * are exempt from that normalization — `&#10;` round-trips as an actual
1459
+ * newline — so diagram source, which is virtually always multiline, needs
1460
+ * this rather than `escapeAttr()` alone to survive the trip intact.
1461
+ */
1462
+ function escapeMultilineAttr(value: string): string {
1463
+ return escapeAttr(value)
1464
+ .replace(/\r/g, '&#13;')
1465
+ .replace(/\n/g, '&#10;')
1466
+ .replace(/\t/g, '&#9;')
1467
+ }
1468
+
1469
+ /**
1470
+ * Splice a `data-src` attribute (the original diagram source, escaped) onto
1471
+ * an already-built root `<svg ...>` opening tag, e.g. from
1472
+ * `embedSource: true`. Applied to the string `svgOpenTag()` returns rather
1473
+ * than the diagram source's own markup, so it always lands on the root
1474
+ * element regardless of diagram type. No-op when `source` is undefined.
1475
+ */
1476
+ export function withDataSrc(
1477
+ svgTag: string,
1478
+ source: string | undefined,
1479
+ ): string {
1480
+ if (source === undefined) return svgTag
1481
+ return svgTag.replace(
1482
+ '<svg ',
1483
+ `<svg data-src="${escapeMultilineAttr(source)}" `,
1484
+ )
1485
+ }