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