@zombie-mermaid/svg-renderer 4.2.2 → 5.0.0

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.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/index.cjs +52 -52
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -4
  5. package/dist/index.d.ts +23 -4
  6. package/dist/index.js +897 -849
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -5
  9. package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
  10. package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
  11. package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
  12. package/src/__tests__/c4-svg.test.ts +0 -337
  13. package/src/__tests__/c4-text-width-1210.test.ts +0 -43
  14. package/src/__tests__/class-component-order-1249.test.ts +0 -40
  15. package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
  16. package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
  17. package/src/__tests__/class-namespace-1196.test.ts +0 -165
  18. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
  19. package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
  20. package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
  21. package/src/__tests__/elk-adapter-utils.test.ts +0 -166
  22. package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
  23. package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
  24. package/src/__tests__/er-default-direction-1250.test.ts +0 -69
  25. package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
  26. package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
  27. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
  28. package/src/__tests__/pie-layout.test.ts +0 -215
  29. package/src/__tests__/pie-svg.test.ts +0 -194
  30. package/src/__tests__/render-mermaid-svg.test.ts +0 -64
  31. package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
  32. package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
  33. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
  34. package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
  35. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
  36. package/src/c4/arial-widths.ts +0 -59
  37. package/src/c4/layout.ts +0 -680
  38. package/src/c4/metrics.ts +0 -261
  39. package/src/c4/renderer.ts +0 -366
  40. package/src/class/layout.ts +0 -525
  41. package/src/class/renderer.ts +0 -669
  42. package/src/edge-curves.ts +0 -205
  43. package/src/elk-instance.ts +0 -292
  44. package/src/er/layout.ts +0 -216
  45. package/src/er/renderer.ts +0 -516
  46. package/src/generated/mono-font-subset.ts +0 -32
  47. package/src/index.ts +0 -277
  48. package/src/layout-engine/back-edges.ts +0 -73
  49. package/src/layout-engine/compound-flat.ts +0 -916
  50. package/src/layout-engine/constants.ts +0 -19
  51. package/src/layout-engine/edge-bundling.ts +0 -458
  52. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  53. package/src/layout-engine/elk-graph-builder.ts +0 -245
  54. package/src/layout-engine/from-elk.ts +0 -767
  55. package/src/layout-engine/inner-edges.ts +0 -329
  56. package/src/layout-engine/jog-straightening.ts +0 -52
  57. package/src/layout-engine/layer-alignment.ts +0 -174
  58. package/src/layout-engine/layout-hints.ts +0 -27
  59. package/src/layout-engine/to-elk.ts +0 -855
  60. package/src/layout-engine.ts +0 -115
  61. package/src/pie/layout.ts +0 -234
  62. package/src/pie/renderer.ts +0 -161
  63. package/src/registry.ts +0 -441
  64. package/src/renderer.ts +0 -1510
  65. package/src/resolve-colors.ts +0 -339
  66. package/src/sequence/layout.ts +0 -766
  67. package/src/sequence/renderer.ts +0 -687
  68. package/src/shape-clipping.ts +0 -197
  69. package/src/styles.ts +0 -145
  70. package/src/title-gaps.ts +0 -161
  71. package/src/xychart/layout.ts +0 -727
  72. package/src/xychart/renderer.ts +0 -676
@@ -1,669 +0,0 @@
1
- import type {
2
- PositionedClassDiagram,
3
- PositionedClassNamespace,
4
- PositionedClassNode,
5
- PositionedClassNote,
6
- PositionedClassRelationship,
7
- ClassMember,
8
- RelationshipType,
9
- } from '@zombie-mermaid/mermaid-parser'
10
- import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
11
- import {
12
- svgOpenTag,
13
- getReadableTextColor,
14
- sanitizeClassName,
15
- renderMultilineText,
16
- escapeXml as escapeXmlUtil,
17
- escapeAttr,
18
- safeHref,
19
- f,
20
- } from '@zombie-mermaid/core'
21
- import { withDataSrc } from '../renderer.ts'
22
- import {
23
- buildSvgStyleBlock,
24
- FONT_SIZES,
25
- FONT_WEIGHTS,
26
- STROKE_WIDTHS,
27
- TEXT_BASELINE_SHIFT,
28
- } from '../styles.ts'
29
- import type { FontSizes } from '../styles.ts'
30
- import { CLS } from './layout.ts'
31
-
32
- // ============================================================================
33
- // Class diagram SVG renderer
34
- //
35
- // Renders positioned class diagrams to SVG.
36
- // All colors use CSS custom properties (var(--_xxx)) from the theme system.
37
- //
38
- // Render order:
39
- // 0. Namespace frames (behind everything)
40
- // 1. Relationship lines and note links (behind boxes)
41
- // 2. Class boxes (header + attributes + methods compartments)
42
- // 3. Notes (dog-eared boxes)
43
- // 4. Relationship labels and cardinality
44
- // ============================================================================
45
-
46
- /** Font sizes specific to class diagrams */
47
- const CLS_FONT = {
48
- memberSize: 11,
49
- memberWeight: 400,
50
- annotationSize: 10,
51
- annotationWeight: 500,
52
- } as const
53
-
54
- /**
55
- * Render a positioned class diagram as an SVG string.
56
- *
57
- * @param colors - DiagramColors with bg/fg and optional enrichment 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 title - Accessible name (from `options.title`). See svgOpenTag() in
63
- * packages/core/src/theme.ts.
64
- * @param decorative - Marks the SVG decorative (from `options.decorative`).
65
- * @param linksEnabled - Whether `click`-based `<a href>` links and `<title>`
66
- * tooltips render (from `options.interactivity !==
67
- * 'none'`, see `resolveLinksEnabled` in
68
- * src/diagram-registry.ts).
69
- * Default true — matches the flowchart/state renderer.
70
- * @param emit - Strict-CSP controls (from `options.nonce` /
71
- * `options.styleAttribute`, see #216). Default: no nonce,
72
- * root `style` attribute on.
73
- */
74
- export function renderClassSvg(
75
- diagram: PositionedClassDiagram,
76
- colors: DiagramColors,
77
- font: string = 'Inter',
78
- transparent: boolean = false,
79
- fontSizes: FontSizes = FONT_SIZES,
80
- embedSource?: string,
81
- title?: string,
82
- decorative?: boolean,
83
- linksEnabled: boolean = true,
84
- emit: SvgEmitOptions = {},
85
- ): string {
86
- const parts: string[] = []
87
-
88
- // See #239 / packages/svg-renderer/src/renderer.ts's renderSvg: a click-based link renders as a
89
- // focusable <a href> inside the SVG, which role="img"/aria-hidden would
90
- // hide from assistive tech while leaving it Tab-reachable — svgOpenTag
91
- // forces no root role in that case. Gated by linksEnabled too, since
92
- // `interactivity: 'none'` strips the <a> below.
93
- const hasInteractiveLinks =
94
- linksEnabled &&
95
- diagram.classes.some((c) => Boolean(safeHref(c.interaction?.href)))
96
-
97
- // SVG root with CSS variables + style block (with mono font) + defs
98
- parts.push(
99
- withDataSrc(
100
- svgOpenTag(
101
- diagram.width,
102
- diagram.height,
103
- colors,
104
- transparent,
105
- title,
106
- decorative,
107
- hasInteractiveLinks,
108
- emit.styleAttribute,
109
- ),
110
- embedSource,
111
- ),
112
- )
113
- parts.push(buildSvgStyleBlock(font, true, emit.nonce))
114
- parts.push('<defs>')
115
- parts.push(relationshipMarkerDefs())
116
- parts.push('</defs>')
117
-
118
- // 0. Namespace frames (behind everything else)
119
- for (const ns of diagram.namespaces) {
120
- parts.push(renderNamespace(ns))
121
- }
122
-
123
- // 1. Relationship lines and note links (rendered behind boxes)
124
- for (const rel of diagram.relationships) {
125
- parts.push(renderRelationship(rel))
126
- }
127
- for (const note of diagram.notes) {
128
- parts.push(renderNoteLink(note))
129
- }
130
-
131
- // 2. Class boxes
132
- for (const cls of diagram.classes) {
133
- parts.push(renderClassBox(cls, fontSizes, linksEnabled))
134
- }
135
-
136
- // 3. Notes
137
- for (const note of diagram.notes) {
138
- parts.push(renderNote(note, fontSizes))
139
- }
140
-
141
- // 4. Relationship labels and cardinality
142
- for (const rel of diagram.relationships) {
143
- parts.push(renderRelationshipLabels(rel, fontSizes))
144
- }
145
-
146
- parts.push('</svg>')
147
- return parts.join('\n')
148
- }
149
-
150
- // ============================================================================
151
- // Namespace frames
152
- // ============================================================================
153
-
154
- /** A `namespace` block: a framed box with its name in a header band. */
155
- function renderNamespace(ns: PositionedClassNamespace): string {
156
- const { x, y, width, height } = ns
157
- const bandH = CLS.namespaceTitleHeight
158
- const sw = STROKE_WIDTHS.outerBox
159
- return [
160
- f`<g class="class-namespace" data-id="${escapeAttr(ns.name)}" data-label="${escapeAttr(ns.name)}">`,
161
- f` <rect x="${x}" y="${y}" width="${width}" height="${height}" rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="${sw}" />`,
162
- f` <rect x="${x}" y="${y}" width="${width}" height="${bandH}" rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${sw}" />`,
163
- f` <text x="${x + CLS.namespacePad}" y="${y + bandH / 2}" dy="${TEXT_BASELINE_SHIFT}" font-size="${CLS.namespaceTitleFontSize}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)">${escapeXml(ns.name)}</text>`,
164
- '</g>',
165
- ].join('\n')
166
- }
167
-
168
- // ============================================================================
169
- // Marker definitions
170
- // ============================================================================
171
-
172
- /**
173
- * Marker definitions for class relationship endpoints.
174
- * Each relationship type has a distinct marker:
175
- * - inheritance: hollow triangle
176
- * - composition: filled diamond
177
- * - aggregation: hollow diamond
178
- * - association: open arrow (simple >)
179
- * - dependency: open arrow (simple >)
180
- * - realization: hollow triangle (same as inheritance)
181
- *
182
- * Uses var(--_arrow) for fill/stroke and var(--bg) for hollow marker fills.
183
- */
184
- function relationshipMarkerDefs(): string {
185
- return (
186
- // Hollow triangle (inheritance, realization) — points at target
187
- ` <marker id="cls-inherit" markerWidth="12" markerHeight="10" refX="12" refY="5" orient="auto-start-reverse">` +
188
- `\n <polygon points="0 0, 12 5, 0 10" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="1.5" />` +
189
- `\n </marker>` +
190
- // Filled diamond (composition) — tip sits on the endpoint, body runs along the line;
191
- // the polygon is inset so the stroke isn't clipped by the marker viewport
192
- `\n <marker id="cls-composition" markerWidth="18" markerHeight="14" refX="16.5" refY="7" orient="auto-start-reverse">` +
193
- `\n <polygon points="9 1.5, 16.5 7, 9 12.5, 1.5 7" fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="1" />` +
194
- `\n </marker>` +
195
- // Hollow diamond (aggregation) — tip sits on the endpoint, body runs along the line
196
- `\n <marker id="cls-aggregation" markerWidth="18" markerHeight="14" refX="16.5" refY="7" orient="auto-start-reverse">` +
197
- `\n <polygon points="9 1.5, 16.5 7, 9 12.5, 1.5 7" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="1.5" />` +
198
- `\n </marker>` +
199
- // Open arrow (association, dependency)
200
- `\n <marker id="cls-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto-start-reverse">` +
201
- `\n <polyline points="0 0, 8 3, 0 6" fill="none" stroke="var(--_arrow)" stroke-width="1.5" />` +
202
- `\n </marker>`
203
- )
204
- }
205
-
206
- // ============================================================================
207
- // Class box rendering
208
- // ============================================================================
209
-
210
- /**
211
- * Render a class box with 3 compartments: header, attributes, methods.
212
- * Wrapped in <g class="class-node"> with semantic data attributes.
213
- *
214
- * @param linksEnabled - Whether a `click`-based `<a href>` link and `<title>`
215
- * tooltip render (from `options.interactivity !==
216
- * 'none'`). Default true. Mirrors renderNode() in
217
- * packages/svg-renderer/src/renderer.ts — see that function's docstring and
218
- * docs/decisions/no-script-interactivity.md for why a
219
- * `call`/callback binding is recorded as a data
220
- * attribute rather than ever invoked.
221
- */
222
- function renderClassBox(
223
- cls: PositionedClassNode,
224
- fontSizes: FontSizes,
225
- linksEnabled: boolean = true,
226
- ): string {
227
- const { x, y, width, height, headerHeight, attrHeight, inlineStyle } = cls
228
- const parts: string[] = []
229
-
230
- const interaction = cls.interaction
231
-
232
- // Resolve box colors — inline styles (from `classDef`/`cssClass`/`style`)
233
- // override the CSS-variable defaults the same way renderNodeShape() in
234
- // packages/svg-renderer/src/renderer.ts does for flowchart nodes. With no inline style the
235
- // variables keep deriving from the theme via color-mix(), so dark mode is
236
- // untouched; a concrete `fill` is used verbatim, exactly as Mermaid does.
237
- const fill = escapeAttr(inlineStyle?.fill ?? 'var(--_node-fill)')
238
- const stroke = escapeAttr(inlineStyle?.stroke ?? 'var(--_node-stroke)')
239
- const strokeWidth = escapeAttr(
240
- inlineStyle?.['stroke-width'] ?? String(STROKE_WIDTHS.outerBox),
241
- )
242
- // Mermaid paints the whole class box one color, so a custom fill replaces
243
- // the header band too rather than leaving the theme's band on top of it.
244
- const headerFill = inlineStyle?.fill ? fill : 'var(--_group-hdr)'
245
- // An explicit `color:` wins; otherwise a concrete custom fill gets a
246
- // black/white text color chosen for contrast (see getReadableTextColor,
247
- // issue #55). Left undefined when neither applies so the syntax-colored
248
- // member tspans keep their theme defaults.
249
- const textColor = inlineStyle?.color
250
- ? escapeAttr(inlineStyle.color)
251
- : inlineStyle?.fill
252
- ? escapeAttr(getReadableTextColor(inlineStyle.fill, 'var(--_text)'))
253
- : undefined
254
-
255
- // Semantic wrapper with class metadata
256
- // data-id: class identifier
257
- // data-label: class name
258
- // data-annotation: stereotype (interface, abstract, etc.)
259
- const annotationAttr = cls.annotation
260
- ? f` data-annotation="${escapeAttr(cls.annotation)}"`
261
- : ''
262
- // A style class from `cssClass`/`class A name`/`:::name` is emitted onto
263
- // the group so external CSS can target it, after the same allowlist the
264
- // flowchart renderer applies.
265
- const safeClassName = sanitizeClassName(cls.className)
266
- const groupAttrs = [
267
- f`class="${safeClassName ? f`class-node ${safeClassName}` : 'class-node'}"`,
268
- f`data-id="${escapeAttr(cls.id)}"`,
269
- f`data-label="${escapeAttr(cls.label)}"`,
270
- ]
271
- // `click ClassName call fn()` is parsed, never invoked, and never written
272
- // into the markup — see renderNode() in packages/svg-renderer/src/renderer.ts for the rationale.
273
- parts.push(f`<g ${groupAttrs.join(' ')}${annotationAttr}>`)
274
-
275
- // An href becomes a real SVG link, which needs no script to work.
276
- // `interactivity: 'none'` strips it — a link is meaningless in
277
- // print/rasterized output, the target that level is meant for.
278
- const href = linksEnabled ? safeHref(interaction?.href) : undefined
279
- if (href) {
280
- const targetAttr = interaction?.target
281
- ? f` target="${escapeAttr(interaction.target)}"`
282
- : ''
283
- parts.push(f` <a href="${escapeAttr(href)}"${targetAttr}>`)
284
- }
285
-
286
- if (linksEnabled && interaction?.tooltip) {
287
- // <title> is SVG's native tooltip — no script, no CSS. Gated by
288
- // linksEnabled alongside href above: both come from the same `click`
289
- // statement, and 'none' strips both.
290
- parts.push(f` <title>${escapeXml(interaction.tooltip)}</title>`)
291
- }
292
-
293
- // Outer rectangle (full box)
294
- parts.push(
295
- f` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
296
- f`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
297
- )
298
-
299
- // Header background
300
- parts.push(
301
- f` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
302
- f`rx="0" ry="0" fill="${headerFill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
303
- )
304
-
305
- // Annotation (<<interface>>, <<abstract>>, etc.)
306
- let nameY = y + headerHeight / 2
307
- if (cls.annotation) {
308
- const annotY = y + 12
309
- parts.push(
310
- f` <text x="${x + width / 2}" y="${annotY}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
311
- f`font-size="${CLS_FONT.annotationSize}" font-weight="${CLS_FONT.annotationWeight}" ` +
312
- f`font-style="italic" fill="${textColor ?? 'var(--_text-muted)'}">&lt;&lt;${escapeXml(cls.annotation)}&gt;&gt;</text>`,
313
- )
314
- nameY = y + headerHeight / 2 + 6
315
- }
316
-
317
- // Class name (supports multi-line via <br> tags)
318
- parts.push(
319
- ' ' +
320
- renderMultilineText(
321
- cls.label,
322
- x + width / 2,
323
- nameY,
324
- fontSizes.nodeLabel,
325
- f`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="${textColor ?? 'var(--_text)'}"`,
326
- ),
327
- )
328
-
329
- // Divider line between header and attributes
330
- const attrTop = y + headerHeight
331
- parts.push(
332
- f` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
333
- f`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
334
- )
335
-
336
- // Attributes
337
- const memberRowH = 20
338
- for (let i = 0; i < cls.attributes.length; i++) {
339
- const member = cls.attributes[i]!
340
- const memberY = attrTop + 4 + i * memberRowH + memberRowH / 2
341
- parts.push(' ' + renderMember(member, x + CLS.boxPadX, memberY, textColor))
342
- }
343
-
344
- // Divider line between attributes and methods
345
- const methodTop = attrTop + attrHeight
346
- parts.push(
347
- f` <line x1="${x}" y1="${methodTop}" x2="${x + width}" y2="${methodTop}" ` +
348
- f`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
349
- )
350
-
351
- // Methods
352
- for (let i = 0; i < cls.methods.length; i++) {
353
- const member = cls.methods[i]!
354
- const memberY = methodTop + 4 + i * memberRowH + memberRowH / 2
355
- parts.push(' ' + renderMember(member, x + CLS.boxPadX, memberY, textColor))
356
- }
357
-
358
- if (href) parts.push(' </a>')
359
- parts.push('</g>')
360
-
361
- return parts.join('\n')
362
- }
363
-
364
- /** Heavier weight for the visibility marker so `~` stays distinct from `-` (#1237) */
365
- const VISIBILITY_MARKER_WEIGHT = 700
366
-
367
- /**
368
- * Render a single class member with syntax highlighting.
369
- * Uses <tspan> elements to color each part of the member differently:
370
- * - visibility symbol (+/-/#/~) → textSecondary, bold (faint at weight 400
371
- * made `~` read like `-` at 1x, #1237)
372
- * - member name (incl. parens for methods) → textSecondary
373
- * - colon separator → textFaint
374
- * - type annotation → textMuted
375
- *
376
- * @param textColor - A resolved custom text color (from `style ... color:` or
377
- * derived from a custom `fill`). When given, every tspan
378
- * uses it: the per-part theme tints are tuned against the
379
- * theme's own node fill and can't be assumed readable on
380
- * an arbitrary user-chosen background.
381
- */
382
- function renderMember(
383
- member: ClassMember,
384
- x: number,
385
- y: number,
386
- textColor?: string,
387
- ): string {
388
- const fontStyle = member.isAbstract ? ' font-style="italic"' : ''
389
- const decoration = member.isStatic ? ' text-decoration="underline"' : ''
390
-
391
- const faint = textColor ?? 'var(--_text-faint)'
392
- const secondary = textColor ?? 'var(--_text-sec)'
393
- const muted = textColor ?? 'var(--_text-muted)'
394
-
395
- // Build tspan parts for syntax-highlighted member text
396
- const spans: string[] = []
397
-
398
- if (member.visibility) {
399
- spans.push(
400
- f`<tspan fill="${secondary}" font-weight="${VISIBILITY_MARKER_WEIGHT}">${escapeXml(member.visibility)}</tspan><tspan fill="${faint}"> </tspan>`,
401
- )
402
- }
403
-
404
- // Add parentheses for methods to distinguish from attributes, including parameters if present
405
- const displayName = member.isMethod
406
- ? f`${member.name}(${member.params || ''})`
407
- : member.name
408
- // False positive: displayName is passed through escapeXml() (see packages/core/src/multiline-utils.ts),
409
- // which escapes &, <, >, ", ' before interpolation, so this is not raw/unescaped HTML.
410
- spans.push(f`<tspan fill="${secondary}">${escapeXml(displayName)}</tspan>`) // nosemgrep: javascript.express.security.injection.raw-html-format.raw-html-format
411
-
412
- if (member.type) {
413
- spans.push(f`<tspan fill="${faint}">: </tspan>`)
414
- spans.push(f`<tspan fill="${muted}">${escapeXml(member.type)}</tspan>`)
415
- }
416
-
417
- return (
418
- f`<text x="${x}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
419
- f`font-size="${CLS_FONT.memberSize}" font-weight="${CLS_FONT.memberWeight}"${fontStyle}${decoration}>` +
420
- f`${spans.join('')}</text>`
421
- )
422
- }
423
-
424
- // ============================================================================
425
- // Note rendering
426
- // ============================================================================
427
-
428
- /** Size of the folded corner on a note box, in px */
429
- const NOTE_FOLD = 6
430
-
431
- /**
432
- * Render a note as a dog-eared box: a polygon with its top-right corner
433
- * clipped plus a small fold triangle — the same shape the sequence renderer
434
- * draws for its notes (../sequence/renderer.ts renderNote), so notes look
435
- * alike across diagram types. Wrapped in <g class="class-note"> with
436
- * `data-for` naming the class it's attached to, if any.
437
- */
438
- function renderNote(note: PositionedClassNote, fontSizes: FontSizes): string {
439
- const { x, y, width: w, height: h } = note
440
- const forAttr =
441
- note.forClass !== undefined
442
- ? f` data-for="${escapeAttr(note.forClass)}"`
443
- : ''
444
-
445
- // Note body: (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
446
- const bodyPoints = [
447
- f`${x},${y}`,
448
- f`${x + w - NOTE_FOLD},${y}`,
449
- f`${x + w},${y + NOTE_FOLD}`,
450
- f`${x + w},${y + h}`,
451
- f`${x},${y + h}`,
452
- ].join(' ')
453
- const foldPoints =
454
- f`${x + w - NOTE_FOLD},${y} ${x + w},${y + NOTE_FOLD} ` +
455
- f`${x + w - NOTE_FOLD},${y + NOTE_FOLD}`
456
-
457
- return (
458
- f`<g class="class-note"${forAttr}>` +
459
- f`\n <polygon points="${bodyPoints}" ` +
460
- f`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
461
- f`\n <polygon points="${foldPoints}" ` +
462
- f`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
463
- f`\n ${renderMultilineText(
464
- note.text,
465
- x + w / 2,
466
- y + h / 2,
467
- fontSizes.edgeLabel,
468
- f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
469
- )}` +
470
- `\n</g>`
471
- )
472
- }
473
-
474
- /**
475
- * Render the dotted, arrowless link from a `note for X` note to its class
476
- * (Mermaid's `pattern: 'dotted'` note edge). Empty for a free note.
477
- */
478
- function renderNoteLink(note: PositionedClassNote): string {
479
- if (!note.linkPoints || note.linkPoints.length < 2) return ''
480
- const pathData = note.linkPoints.map((p) => f`${p.x},${p.y}`).join(' ')
481
- const forAttr =
482
- note.forClass !== undefined
483
- ? f` data-for="${escapeAttr(note.forClass)}"`
484
- : ''
485
- return (
486
- f`<polyline class="class-note-link"${forAttr} points="${pathData}" ` +
487
- f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}" stroke-dasharray="2 3" />`
488
- )
489
- }
490
-
491
- // ============================================================================
492
- // Relationship rendering
493
- // ============================================================================
494
-
495
- /**
496
- * Render a relationship line with appropriate markers and semantic attributes.
497
- * Includes data-* attributes for programmatic inspection.
498
- */
499
- function renderRelationship(rel: PositionedClassRelationship): string {
500
- if (rel.points.length < 2) return ''
501
-
502
- const pathData = rel.points.map((p) => f`${p.x},${p.y}`).join(' ')
503
- const isDashed = rel.type === 'dependency' || rel.type === 'realization'
504
- const dashArray = isDashed ? ' stroke-dasharray="6 4"' : ''
505
-
506
- // Determine markers based on relationship type and which end has the marker
507
- const markers = getRelationshipMarkers(rel.type, rel.markerAt)
508
-
509
- // Build semantic data attributes for relationship inspection:
510
- // - class="class-relationship": CSS targeting
511
- // - data-from/data-to: source and target class IDs
512
- // - data-type: relationship type (inheritance, composition, etc.)
513
- // - data-marker-at: which end has the marker (from/to)
514
- // - data-from-cardinality/data-to-cardinality: multiplicity if present
515
- // - data-label: relationship label if present
516
- const dataAttrs = [
517
- 'class="class-relationship"',
518
- f`data-from="${escapeAttr(rel.from)}"`,
519
- f`data-to="${escapeAttr(rel.to)}"`,
520
- f`data-type="${rel.type}"`,
521
- f`data-marker-at="${rel.markerAt}"`,
522
- ]
523
- if (rel.label) {
524
- dataAttrs.push(f`data-label="${escapeAttr(rel.label)}"`)
525
- }
526
- if (rel.fromCardinality) {
527
- dataAttrs.push(
528
- f`data-from-cardinality="${escapeAttr(rel.fromCardinality)}"`,
529
- )
530
- }
531
- if (rel.toCardinality) {
532
- dataAttrs.push(f`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`)
533
- }
534
-
535
- return (
536
- f`<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="var(--_line)" ` +
537
- f`stroke-width="${STROKE_WIDTHS.connector}"${dashArray}${markers} />`
538
- )
539
- }
540
-
541
- /**
542
- * Get marker-start/marker-end attributes for a relationship type.
543
- * Uses `markerAt` from the parser to place the marker on the correct end:
544
- * - 'from' → marker-start (prefix arrows like `<|--`, `*--`, `o--`)
545
- * - 'to' → marker-end (suffix arrows like `..|>`, `-->`, `--*`)
546
- */
547
- function getRelationshipMarkers(
548
- type: RelationshipType,
549
- markerAt: 'from' | 'to',
550
- ): string {
551
- const markerId = getMarkerDefId(type)
552
- if (!markerId) return ''
553
-
554
- if (markerAt === 'from') {
555
- return f` marker-start="url(#${markerId})"`
556
- } else {
557
- return f` marker-end="url(#${markerId})"`
558
- }
559
- }
560
-
561
- /** Map relationship type to its SVG marker definition ID */
562
- function getMarkerDefId(type: RelationshipType): string | null {
563
- switch (type) {
564
- case 'inheritance':
565
- case 'realization':
566
- return 'cls-inherit'
567
- case 'composition':
568
- return 'cls-composition'
569
- case 'aggregation':
570
- return 'cls-aggregation'
571
- case 'association':
572
- case 'dependency':
573
- return 'cls-arrow'
574
- default:
575
- return null
576
- }
577
- }
578
-
579
- /** Render relationship labels and cardinality text (supports multi-line) */
580
- function renderRelationshipLabels(
581
- rel: PositionedClassRelationship,
582
- fontSizes: FontSizes,
583
- ): string {
584
- if (!rel.label && !rel.fromCardinality && !rel.toCardinality) return ''
585
- if (rel.points.length < 2) return ''
586
-
587
- const parts: string[] = []
588
-
589
- // Label — prefer layout-computed position (collision-aware), fall back to midpoint
590
- if (rel.label) {
591
- const pos = rel.labelPosition ?? midpoint(rel.points)
592
- parts.push(
593
- renderMultilineText(
594
- rel.label,
595
- pos.x,
596
- pos.y - 8,
597
- fontSizes.edgeLabel,
598
- f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
599
- ),
600
- )
601
- }
602
-
603
- // From cardinality (near start)
604
- if (rel.fromCardinality && rel.points.length >= 2) {
605
- const p = rel.points[0]!
606
- const next = rel.points[1]!
607
- const offset = cardinalityOffset(p, next)
608
- parts.push(
609
- renderMultilineText(
610
- rel.fromCardinality,
611
- p.x + offset.x,
612
- p.y + offset.y,
613
- fontSizes.edgeLabel,
614
- f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
615
- ),
616
- )
617
- }
618
-
619
- // To cardinality (near end)
620
- if (rel.toCardinality && rel.points.length >= 2) {
621
- const p = rel.points[rel.points.length - 1]!
622
- const prev = rel.points[rel.points.length - 2]!
623
- const offset = cardinalityOffset(p, prev)
624
- parts.push(
625
- renderMultilineText(
626
- rel.toCardinality,
627
- p.x + offset.x,
628
- p.y + offset.y,
629
- fontSizes.edgeLabel,
630
- f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
631
- ),
632
- )
633
- }
634
-
635
- return parts.join('\n')
636
- }
637
-
638
- /** Get the midpoint of a point array */
639
- function midpoint(points: Array<{ x: number; y: number }>): {
640
- x: number
641
- y: number
642
- } {
643
- if (points.length === 0) return { x: 0, y: 0 }
644
- const mid = Math.floor(points.length / 2)
645
- return points[mid]!
646
- }
647
-
648
- /** Calculate offset for cardinality label perpendicular to edge direction */
649
- function cardinalityOffset(
650
- from: { x: number; y: number },
651
- to: { x: number; y: number },
652
- ): { x: number; y: number } {
653
- const dx = to.x - from.x
654
- const dy = to.y - from.y
655
- // Place label perpendicular to the edge, 14px away
656
- if (Math.abs(dx) > Math.abs(dy)) {
657
- // Mostly horizontal — offset vertically
658
- return { x: dx > 0 ? 14 : -14, y: -10 }
659
- }
660
- // Mostly vertical — offset horizontally
661
- return { x: -14, y: dy > 0 ? 14 : -14 }
662
- }
663
-
664
- // ============================================================================
665
- // Utilities
666
- // ============================================================================
667
-
668
- // Use shared escapeXml from multiline-utils
669
- const escapeXml = escapeXmlUtil