@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.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@zombie-mermaid/svg-renderer",
3
+ "version": "2.2.1",
4
+ "license": "MIT",
5
+ "description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "dist/index.cjs",
9
+ "module": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.cts",
19
+ "default": "./dist/index.cjs"
20
+ }
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "files": [
28
+ "src/",
29
+ "dist/",
30
+ "LICENSE"
31
+ ],
32
+ "dependencies": {
33
+ "@zombie-mermaid/core": "workspace:*",
34
+ "@zombie-mermaid/mermaid-parser": "workspace:*",
35
+ "elkjs": "^0.11.0"
36
+ }
37
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Unit tests for the shared ELK edge-geometry extraction helpers
3
+ * (`extractEdgePoints` / `extractEdgeLabelPosition`).
4
+ *
5
+ * `from-elk.ts`, `class/layout.ts`, and `er/layout.ts` all exercise this
6
+ * module indirectly through full-diagram integration tests, but those
7
+ * only ever hit the "happy path" (an edge with sections and, sometimes,
8
+ * a placed label). These tests cover the defensive branches directly —
9
+ * missing/empty sections, missing/empty labels, an unplaced label, and
10
+ * missing bend points/label dimensions — so every branch has direct
11
+ * coverage rather than relying on an integration test to happen to hit it.
12
+ */
13
+ import { describe, it, expect } from 'vitest'
14
+ import type { ElkExtendedEdge } from 'elkjs'
15
+ import {
16
+ extractEdgePoints,
17
+ extractEdgeLabelPosition,
18
+ } from '@zombie-mermaid/svg-renderer'
19
+
20
+ function edge(partial: Partial<ElkExtendedEdge>): ElkExtendedEdge {
21
+ return { id: 'e0', sources: ['a'], targets: ['b'], ...partial }
22
+ }
23
+
24
+ describe('extractEdgePoints', () => {
25
+ it('returns an empty array when the edge has no sections property', () => {
26
+ expect(extractEdgePoints(edge({}))).toEqual([])
27
+ })
28
+
29
+ it('returns an empty array when sections is an empty array', () => {
30
+ expect(extractEdgePoints(edge({ sections: [] }))).toEqual([])
31
+ })
32
+
33
+ it('walks start -> end with no bend points', () => {
34
+ const e = edge({
35
+ sections: [
36
+ {
37
+ id: 's0',
38
+ startPoint: { x: 10, y: 20 },
39
+ endPoint: { x: 30, y: 40 },
40
+ },
41
+ ],
42
+ })
43
+ expect(extractEdgePoints(e)).toEqual([
44
+ { x: 10, y: 20 },
45
+ { x: 30, y: 40 },
46
+ ])
47
+ })
48
+
49
+ it('walks start -> bendPoints -> end when bend points are present', () => {
50
+ const e = edge({
51
+ sections: [
52
+ {
53
+ id: 's0',
54
+ startPoint: { x: 0, y: 0 },
55
+ endPoint: { x: 100, y: 0 },
56
+ bendPoints: [
57
+ { x: 25, y: 50 },
58
+ { x: 75, y: 50 },
59
+ ],
60
+ },
61
+ ],
62
+ })
63
+ expect(extractEdgePoints(e)).toEqual([
64
+ { x: 0, y: 0 },
65
+ { x: 25, y: 50 },
66
+ { x: 75, y: 50 },
67
+ { x: 100, y: 0 },
68
+ ])
69
+ })
70
+
71
+ it('translates every point by offsetX/offsetY when provided', () => {
72
+ const e = edge({
73
+ sections: [
74
+ {
75
+ id: 's0',
76
+ startPoint: { x: 0, y: 0 },
77
+ endPoint: { x: 10, y: 10 },
78
+ bendPoints: [{ x: 5, y: 5 }],
79
+ },
80
+ ],
81
+ })
82
+ expect(extractEdgePoints(e, 100, 200)).toEqual([
83
+ { x: 100, y: 200 },
84
+ { x: 105, y: 205 },
85
+ { x: 110, y: 210 },
86
+ ])
87
+ })
88
+
89
+ it('only reads the first section when multiple are present', () => {
90
+ const e = edge({
91
+ sections: [
92
+ { id: 's0', startPoint: { x: 0, y: 0 }, endPoint: { x: 1, y: 1 } },
93
+ { id: 's1', startPoint: { x: 9, y: 9 }, endPoint: { x: 8, y: 8 } },
94
+ ],
95
+ })
96
+ expect(extractEdgePoints(e)).toEqual([
97
+ { x: 0, y: 0 },
98
+ { x: 1, y: 1 },
99
+ ])
100
+ })
101
+ })
102
+
103
+ describe('extractEdgeLabelPosition', () => {
104
+ it('returns undefined when the edge has no labels property', () => {
105
+ expect(extractEdgeLabelPosition(edge({}))).toBeUndefined()
106
+ })
107
+
108
+ it('returns undefined when labels is an empty array', () => {
109
+ expect(extractEdgeLabelPosition(edge({ labels: [] }))).toBeUndefined()
110
+ })
111
+
112
+ it('returns undefined when the label has no x/y (ELK left it unplaced)', () => {
113
+ expect(
114
+ extractEdgeLabelPosition(edge({ labels: [{ text: 'foo' }] })),
115
+ ).toBeUndefined()
116
+ })
117
+
118
+ it('returns undefined when only x is set (y still unplaced)', () => {
119
+ expect(
120
+ extractEdgeLabelPosition(edge({ labels: [{ text: 'foo', x: 5 }] })),
121
+ ).toBeUndefined()
122
+ })
123
+
124
+ it('computes the label center, defaulting missing width/height to 0', () => {
125
+ expect(
126
+ extractEdgeLabelPosition(
127
+ edge({ labels: [{ text: 'foo', x: 10, y: 20 }] }),
128
+ ),
129
+ ).toEqual({ x: 10, y: 20 })
130
+ })
131
+
132
+ it('computes the label center from x/y/width/height', () => {
133
+ expect(
134
+ extractEdgeLabelPosition(
135
+ edge({
136
+ labels: [{ text: 'foo', x: 10, y: 20, width: 40, height: 10 }],
137
+ }),
138
+ ),
139
+ ).toEqual({ x: 30, y: 25 })
140
+ })
141
+
142
+ it('translates the computed center by offsetX/offsetY when provided', () => {
143
+ expect(
144
+ extractEdgeLabelPosition(
145
+ edge({
146
+ labels: [{ text: 'foo', x: 10, y: 20, width: 40, height: 10 }],
147
+ }),
148
+ 100,
149
+ 200,
150
+ ),
151
+ ).toEqual({ x: 130, y: 225 })
152
+ })
153
+
154
+ it('only reads the first label when multiple are present', () => {
155
+ expect(
156
+ extractEdgeLabelPosition(
157
+ edge({
158
+ labels: [
159
+ { text: 'first', x: 0, y: 0 },
160
+ { text: 'second', x: 100, y: 100 },
161
+ ],
162
+ }),
163
+ ),
164
+ ).toEqual({ x: 0, y: 0 })
165
+ })
166
+ })
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Class diagram layout engine (ELK.js).
3
+ *
4
+ * Each class box has 3 compartments:
5
+ * 1. Header (class name + optional annotation)
6
+ * 2. Attributes section
7
+ * 3. Methods section
8
+ */
9
+
10
+ import type { ElkNode, ElkExtendedEdge } from 'elkjs'
11
+ import type {
12
+ ClassDiagram,
13
+ ClassNode,
14
+ ClassMember,
15
+ PositionedClassDiagram,
16
+ PositionedClassNode,
17
+ PositionedClassNote,
18
+ PositionedClassRelationship,
19
+ } from '@zombie-mermaid/mermaid-parser'
20
+ import { formatClassMember } from '@zombie-mermaid/mermaid-parser'
21
+ import type { ClassRenderOptions } from '@zombie-mermaid/core'
22
+ import {
23
+ estimateTextWidth,
24
+ estimateMonoTextWidth,
25
+ FONT_WEIGHTS,
26
+ resolveFontSizes,
27
+ } from '../styles.ts'
28
+ import { elkLayoutSync } from '../elk-instance.ts'
29
+ import {
30
+ extractEdgePoints,
31
+ extractEdgeLabelPosition,
32
+ } from '../layout-engine/elk-adapter-utils.ts'
33
+ import {
34
+ ELK_DIRECTION_FALLBACK,
35
+ baseElkLayoutOptions,
36
+ buildElkEdge,
37
+ buildElkLeafNode,
38
+ directionToElk,
39
+ } from '../layout-engine/elk-graph-builder.ts'
40
+ import { measureMultilineText, resolveNodeStyle } from '@zombie-mermaid/core'
41
+
42
+ /** Layout constants for class diagrams */
43
+ export const CLS = {
44
+ padding: 40,
45
+ boxPadX: 8,
46
+ headerBaseHeight: 32,
47
+ annotationHeight: 16,
48
+ memberRowHeight: 20,
49
+ sectionPadY: 8,
50
+ emptySectionHeight: 8,
51
+ minWidth: 120,
52
+ memberFontSize: 11,
53
+ memberFontWeight: 400,
54
+ nodeSpacing: 40,
55
+ layerSpacing: 60,
56
+ /** Horizontal / vertical padding inside a note box, around its text */
57
+ notePadX: 10,
58
+ notePadY: 6,
59
+ } as const
60
+
61
+ /**
62
+ * Layout id for the i-th note. A class id is a run of non-whitespace
63
+ * (`\S+` in the parser), so an id containing a space can never collide
64
+ * with one; ELK treats ids as opaque strings.
65
+ */
66
+ export function classNoteId(index: number): string {
67
+ return `note ${index}`
68
+ }
69
+
70
+ /** Layout id for the dotted link joining note `noteId` to its class. */
71
+ function classNoteLinkId(noteId: string): string {
72
+ return `${noteId} link`
73
+ }
74
+
75
+ type ClassSizeMap = Map<
76
+ string,
77
+ {
78
+ width: number
79
+ height: number
80
+ headerHeight: number
81
+ attrHeight: number
82
+ methodHeight: number
83
+ }
84
+ >
85
+
86
+ /** Size of each note box, keyed by its layout id. */
87
+ type NoteSizeMap = Map<string, { width: number; height: number }>
88
+
89
+ /** Build ELK graph and size map from a class diagram. */
90
+ function buildClassElkGraph(
91
+ diagram: ClassDiagram,
92
+ options: ClassRenderOptions,
93
+ ): { elkGraph: ElkNode; classSizes: ClassSizeMap; noteSizes: NoteSizeMap } {
94
+ const classSizes: ClassSizeMap = new Map()
95
+ const noteSizes: NoteSizeMap = new Map()
96
+ const fontSizes = resolveFontSizes(options.fontSizes)
97
+
98
+ for (const cls of diagram.classes) {
99
+ const headerHeight = cls.annotation
100
+ ? CLS.headerBaseHeight + CLS.annotationHeight
101
+ : CLS.headerBaseHeight
102
+
103
+ const attrHeight =
104
+ cls.attributes.length > 0
105
+ ? cls.attributes.length * CLS.memberRowHeight + CLS.sectionPadY
106
+ : CLS.emptySectionHeight
107
+
108
+ const methodHeight =
109
+ cls.methods.length > 0
110
+ ? cls.methods.length * CLS.memberRowHeight + CLS.sectionPadY
111
+ : CLS.emptySectionHeight
112
+
113
+ const headerTextW = estimateTextWidth(
114
+ cls.label,
115
+ fontSizes.nodeLabel,
116
+ FONT_WEIGHTS.nodeLabel,
117
+ )
118
+ const maxAttrW = maxMemberWidth(cls.attributes)
119
+ const maxMethodW = maxMemberWidth(cls.methods)
120
+ const width = Math.max(
121
+ CLS.minWidth,
122
+ headerTextW + CLS.boxPadX * 2,
123
+ maxAttrW + CLS.boxPadX * 2,
124
+ maxMethodW + CLS.boxPadX * 2,
125
+ )
126
+ const height = headerHeight + attrHeight + methodHeight
127
+
128
+ classSizes.set(cls.id, {
129
+ width,
130
+ height,
131
+ headerHeight,
132
+ attrHeight,
133
+ methodHeight,
134
+ })
135
+ }
136
+
137
+ // Iterate classSizes directly (populated above, in diagram.classes order)
138
+ // rather than looking each class back up by id — sidesteps needing an
139
+ // assertion or invariant check for a lookup that can't actually miss.
140
+ const children: ElkNode[] = []
141
+ for (const [id, size] of classSizes) {
142
+ children.push(buildElkLeafNode(id, size))
143
+ }
144
+
145
+ // Class edge labels carry no per-label layout options — placement is set
146
+ // once on the root graph below (`elk.edgeLabels.placement: CENTER`).
147
+ const labelStyle = { fontSize: fontSizes.edgeLabel }
148
+
149
+ const edges: ElkExtendedEdge[] = []
150
+ for (const [i, rel] of diagram.relationships.entries()) {
151
+ edges.push(
152
+ buildElkEdge({
153
+ id: `e${i}`,
154
+ source: rel.from,
155
+ target: rel.to,
156
+ label: rel.label,
157
+ labelStyle,
158
+ }),
159
+ )
160
+ }
161
+
162
+ // Notes. Mirrors Mermaid's classDb.getData(): every note is a node of its
163
+ // own, and `note for X` adds an arrowless dotted edge note→class so the
164
+ // layout keeps the two adjacent (with a DOWN layout the note lands above
165
+ // its class, as it does in Mermaid's TB rendering). The link edges go
166
+ // after the relationship edges so relationship indices stay positional.
167
+ const classIds = new Set(classSizes.keys())
168
+ for (const [i, note] of diagram.notes.entries()) {
169
+ const id = classNoteId(i)
170
+ const metrics = measureMultilineText(
171
+ note.text,
172
+ fontSizes.edgeLabel,
173
+ FONT_WEIGHTS.edgeLabel,
174
+ )
175
+ const size = {
176
+ width: metrics.width + CLS.notePadX * 2,
177
+ height: metrics.height + CLS.notePadY * 2,
178
+ }
179
+ noteSizes.set(id, size)
180
+ children.push(buildElkLeafNode(id, size))
181
+ if (note.forClass !== undefined && classIds.has(note.forClass)) {
182
+ edges.push(
183
+ buildElkEdge({
184
+ id: classNoteLinkId(id),
185
+ source: id,
186
+ target: note.forClass,
187
+ labelStyle,
188
+ }),
189
+ )
190
+ }
191
+ }
192
+
193
+ const elkGraph: ElkNode = {
194
+ id: 'root',
195
+ layoutOptions: {
196
+ ...baseElkLayoutOptions({
197
+ // Class diagrams have no `direction` concept — they always lay out
198
+ // top-down. See ELK_DIRECTION_FALLBACK for why that default is
199
+ // per-diagram-type rather than shared with ER's.
200
+ direction: directionToElk(undefined, ELK_DIRECTION_FALLBACK.class),
201
+ nodeSpacing: CLS.nodeSpacing,
202
+ layerSpacing: CLS.layerSpacing,
203
+ padding: CLS.padding,
204
+ }),
205
+ 'elk.edgeLabels.placement': 'CENTER',
206
+ },
207
+ children,
208
+ edges,
209
+ }
210
+
211
+ return { elkGraph, classSizes, noteSizes }
212
+ }
213
+
214
+ /** Extract positioned classes, relationships, and notes from ELK result. */
215
+ function extractClassLayout(
216
+ result: ElkNode,
217
+ diagram: ClassDiagram,
218
+ classSizes: ClassSizeMap,
219
+ noteSizes: NoteSizeMap,
220
+ ): PositionedClassDiagram {
221
+ const classLookup = new Map<string, ClassNode>()
222
+ for (const cls of diagram.classes) classLookup.set(cls.id, cls)
223
+
224
+ const positionedClasses: PositionedClassNode[] = []
225
+ for (const child of result.children ?? []) {
226
+ const cls = classLookup.get(child.id)
227
+ if (cls) {
228
+ const size = classSizes.get(cls.id)
229
+ if (!size) {
230
+ // Unreachable — classSizes is populated for every diagram.classes
231
+ // entry, and classLookup/cls.id come from that same list.
232
+ /* v8 ignore next */
233
+ throw new Error(`Missing computed size for class "${cls.id}"`)
234
+ }
235
+ positionedClasses.push({
236
+ id: cls.id,
237
+ label: cls.label,
238
+ annotation: cls.annotation,
239
+ attributes: cls.attributes,
240
+ methods: cls.methods,
241
+ x: child.x ?? 0,
242
+ y: child.y ?? 0,
243
+ width: child.width ?? size.width,
244
+ height: child.height ?? size.height,
245
+ headerHeight: size.headerHeight,
246
+ attrHeight: size.attrHeight,
247
+ methodHeight: size.methodHeight,
248
+ interaction: diagram.interactions.get(cls.id),
249
+ // Same cascade the flowchart layout applies (src/layout-engine/
250
+ // from-elk.ts): classDef default → assigned class → `style`.
251
+ inlineStyle: resolveNodeStyle(cls.id, diagram),
252
+ // Kept separately from inlineStyle so the class name still reaches
253
+ // the SVG `class` attribute when it has no matching classDef.
254
+ className: diagram.classAssignments.get(cls.id),
255
+ })
256
+ }
257
+ }
258
+
259
+ const relationships: PositionedClassRelationship[] = []
260
+ const elkEdges = result.edges ?? []
261
+ // The first diagram.relationships.length edges are the relationships, in
262
+ // order — buildClassElkGraph creates exactly one ELK edge per relationship
263
+ // before appending any note links.
264
+ for (const [i, rel] of diagram.relationships.entries()) {
265
+ const elkEdge = elkEdges[i]
266
+ if (!elkEdge) {
267
+ // Unreachable — ELK returns every edge it was given.
268
+ /* v8 ignore next */
269
+ throw new Error(`Missing ELK edge for relationship ${i}`)
270
+ }
271
+
272
+ const points = extractEdgePoints(elkEdge)
273
+ const labelPosition = extractEdgeLabelPosition(elkEdge)
274
+
275
+ relationships.push({
276
+ from: rel.from,
277
+ to: rel.to,
278
+ type: rel.type,
279
+ markerAt: rel.markerAt,
280
+ label: rel.label,
281
+ fromCardinality: rel.fromCardinality,
282
+ toCardinality: rel.toCardinality,
283
+ points,
284
+ labelPosition,
285
+ })
286
+ }
287
+
288
+ // Note links are matched by id rather than position — only notes whose
289
+ // class exists got an edge, so their count isn't derivable from the note list.
290
+ const linkEdges = new Map<string, ElkExtendedEdge>()
291
+ for (const elkEdge of elkEdges.slice(diagram.relationships.length)) {
292
+ linkEdges.set(elkEdge.id, elkEdge)
293
+ }
294
+ const childById = new Map<string, ElkNode>()
295
+ for (const child of result.children ?? []) childById.set(child.id, child)
296
+
297
+ const notes: PositionedClassNote[] = []
298
+ for (const [i, note] of diagram.notes.entries()) {
299
+ const id = classNoteId(i)
300
+ const child = childById.get(id)
301
+ const size = noteSizes.get(id)
302
+ if (!child || !size) {
303
+ // Unreachable — a child and a size are recorded for every note.
304
+ /* v8 ignore next */
305
+ throw new Error(`Missing layout for note ${i}`)
306
+ }
307
+ const link = linkEdges.get(classNoteLinkId(id))
308
+ notes.push({
309
+ id,
310
+ text: note.text,
311
+ ...(link && note.forClass !== undefined
312
+ ? { forClass: note.forClass }
313
+ : {}),
314
+ x: child.x ?? 0,
315
+ y: child.y ?? 0,
316
+ width: child.width ?? size.width,
317
+ height: child.height ?? size.height,
318
+ ...(link ? { linkPoints: extractEdgePoints(link) } : {}),
319
+ })
320
+ }
321
+
322
+ return {
323
+ width: result.width ?? 600,
324
+ height: result.height ?? 400,
325
+ classes: positionedClasses,
326
+ relationships,
327
+ notes,
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Lay out a parsed class diagram using ELK.js (synchronous).
333
+ */
334
+ export function layoutClassDiagramSync(
335
+ diagram: ClassDiagram,
336
+ options: ClassRenderOptions = {},
337
+ ): PositionedClassDiagram {
338
+ if (diagram.classes.length === 0 && diagram.notes.length === 0) {
339
+ return { width: 0, height: 0, classes: [], relationships: [], notes: [] }
340
+ }
341
+
342
+ const { elkGraph, classSizes, noteSizes } = buildClassElkGraph(
343
+ diagram,
344
+ options,
345
+ )
346
+ const result = elkLayoutSync(elkGraph, options.layoutCache)
347
+ return extractClassLayout(result, diagram, classSizes, noteSizes)
348
+ }
349
+
350
+ /** Calculate the max width of a list of class members (uses mono metrics) */
351
+ function maxMemberWidth(members: ClassMember[]): number {
352
+ if (members.length === 0) return 0
353
+ let maxW = 0
354
+ for (const m of members) {
355
+ const text = formatClassMember(m)
356
+ const w = estimateMonoTextWidth(text, CLS.memberFontSize)
357
+ if (w > maxW) maxW = w
358
+ }
359
+ return maxW
360
+ }