@zombie-mermaid/svg-renderer 4.0.0 → 4.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "license": "MIT",
5
5
  "description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "elkjs": "^0.11.0",
39
39
  "entities": "^7.0.1",
40
- "@zombie-mermaid/core": "4.0.0",
41
- "@zombie-mermaid/mermaid-parser": "4.0.0"
40
+ "@zombie-mermaid/core": "4.2.0",
41
+ "@zombie-mermaid/mermaid-parser": "4.2.0"
42
42
  }
43
43
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * #1308: edges leaving `develop` in the git branching sample were merged onto
3
+ * one trunk, and the `approved` edge returning to `develop` ended on the same
4
+ * side, on top of that trunk. A fan-out/fan-in bundle is only drawn when
5
+ * nothing else attaches to the side it uses.
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
9
+ import { layoutGraphSync } from '@zombie-mermaid/svg-renderer'
10
+
11
+ const GIT_BRANCHING = `graph LR
12
+ A[main] --> B[develop]
13
+ B --> C[feature/auth]
14
+ B --> D[feature/ui]
15
+ C --> E{PR Review}
16
+ D --> E
17
+ E -->|approved| B
18
+ B --> F[release/1.0]
19
+ F --> G{Tests?}
20
+ G -->|pass| A
21
+ G -->|fail| F`
22
+
23
+ const BUNDLED = `graph LR
24
+ B --> C[one]
25
+ B --> D[two]
26
+ B --> F[three]`
27
+
28
+ function route(source: string, from: string, to: string) {
29
+ const edge = layoutGraphSync(parseMermaid(source)).edges.find(
30
+ (e) => e.source === from && e.target === to,
31
+ )
32
+ expect(edge).toBeDefined()
33
+ return edge?.points ?? []
34
+ }
35
+
36
+ type Pt = { x: number; y: number }
37
+
38
+ /** Count orthogonal segment pairs that cross at an interior point. */
39
+ function crossings(a: Pt[], b: Pt[]): number {
40
+ let n = 0
41
+ for (let i = 0; i + 1 < a.length; i++) {
42
+ for (let j = 0; j + 1 < b.length; j++) {
43
+ const [p, q, r, s] = [a[i]!, a[i + 1]!, b[j]!, b[j + 1]!]
44
+ const pH = Math.abs(p.y - q.y) < 0.5
45
+ const rH = Math.abs(r.y - s.y) < 0.5
46
+ if (pH === rH) continue
47
+ const [h1, h2, v1, v2] = pH ? [p, q, r, s] : [r, s, p, q]
48
+ if (
49
+ v1.x > Math.min(h1.x, h2.x) + 0.5 &&
50
+ v1.x < Math.max(h1.x, h2.x) - 0.5 &&
51
+ h1.y > Math.min(v1.y, v2.y) + 0.5 &&
52
+ h1.y < Math.max(v1.y, v2.y) - 0.5
53
+ )
54
+ n++
55
+ }
56
+ }
57
+ return n
58
+ }
59
+
60
+ describe('parallel edges leaving one node (#1308)', () => {
61
+ it('gives each edge out of develop its own first run', () => {
62
+ const exits = ['C', 'D', 'F'].map((t) => route(GIT_BRANCHING, 'B', t)[1])
63
+ const ys = new Set(exits.map((p) => Math.round(p?.y ?? 0)))
64
+ // The bundle drew all three as one run along y = develop's centre line.
65
+ expect(ys.size).toBe(3)
66
+ })
67
+
68
+ it('does not cross the approved edge over the runs leaving develop', () => {
69
+ const approved = route(GIT_BRANCHING, 'E', 'B')
70
+ for (const t of ['C', 'D', 'F']) {
71
+ expect(crossings(approved, route(GIT_BRANCHING, 'B', t))).toBe(0)
72
+ }
73
+ })
74
+
75
+ it('still bundles a plain fan-out with nothing else on that side', () => {
76
+ const exits = ['C', 'D', 'F'].map((t) => route(BUNDLED, 'B', t)[1]!)
77
+ expect(new Set(exits.map((p) => Math.round(p.y))).size).toBe(1)
78
+ })
79
+ })
80
+
81
+ /** Distinct rounded values of one coordinate of each edge's chosen point. */
82
+ function distinct(
83
+ source: string,
84
+ pairs: [string, string][],
85
+ pick: (pts: Pt[]) => number,
86
+ ): number {
87
+ return new Set(
88
+ pairs.map(([from, to]) => Math.round(pick(route(source, from, to)))),
89
+ ).size
90
+ }
91
+
92
+ const first = (axis: 'x' | 'y') => (pts: Pt[]) => pts[1]![axis]
93
+ const last = (axis: 'x' | 'y') => (pts: Pt[]) => pts[pts.length - 1]![axis]
94
+
95
+ describe('bundle guard on the other orientations and sides (#1308)', () => {
96
+ const out: [string, string][] = [
97
+ ['B', 'C'],
98
+ ['B', 'D'],
99
+ ['B', 'F'],
100
+ ]
101
+ const into: [string, string][] = [
102
+ ['A', 'T'],
103
+ ['B', 'T'],
104
+ ['C', 'T'],
105
+ ]
106
+
107
+ it('top-down: keeps edges leaving a node apart when a back edge returns to it', () => {
108
+ const src = `graph TD
109
+ B --> C
110
+ B --> D
111
+ B --> F
112
+ E --> B
113
+ C --> E`
114
+ expect(distinct(src, out, first('x'))).toBe(3)
115
+ })
116
+
117
+ it('top-down: still bundles a plain fan-out', () => {
118
+ const src = `graph TD
119
+ B --> C
120
+ B --> D
121
+ B --> F`
122
+ expect(distinct(src, out, first('x'))).toBe(1)
123
+ })
124
+
125
+ it('left-to-right: keeps edges entering a node apart when one leaves it on that side', () => {
126
+ const src = `graph LR
127
+ A --> T
128
+ B --> T
129
+ C --> T
130
+ T --> A`
131
+ expect(distinct(src, into, last('y'))).toBe(3)
132
+ })
133
+
134
+ it('left-to-right: still bundles a plain fan-in', () => {
135
+ const src = `graph LR
136
+ A --> T
137
+ B --> T
138
+ C --> T`
139
+ expect(distinct(src, into, last('y'))).toBe(1)
140
+ })
141
+
142
+ it('top-down: keeps edges entering a node apart when one leaves it on that side', () => {
143
+ const src = `graph TD
144
+ A --> T
145
+ B --> T
146
+ C --> T
147
+ T --> A`
148
+ expect(distinct(src, into, last('x'))).toBe(3)
149
+ })
150
+
151
+ it('top-down: still bundles a plain fan-in', () => {
152
+ const src = `graph TD
153
+ A --> T
154
+ B --> T
155
+ C --> T`
156
+ expect(distinct(src, into, last('x'))).toBe(1)
157
+ })
158
+
159
+ it('ignores self-loops and unrelated edges when deciding to bundle', () => {
160
+ const src = `graph LR
161
+ B --> B
162
+ B --> C
163
+ B --> D
164
+ B --> F
165
+ F --> G`
166
+ expect(distinct(src, out, first('y'))).toBe(1)
167
+ })
168
+ })
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parsePieChart } from '@zombie-mermaid/mermaid-parser'
3
+ import type { PositionedPieChart } from '@zombie-mermaid/mermaid-parser'
4
+ import { layoutPieChart, PIE } from '../pie/layout.ts'
5
+ import { estimateTextWidth } from '../styles.ts'
6
+
7
+ // Geometry follows Mermaid's pieRenderer.ts (default config): a 450px frame,
8
+ // radius 185 at (225, 225), slices clockwise from 12 o'clock in source
9
+ // order, labels at 0.75r, legend 216px right of the centre, rows 22px apart.
10
+
11
+ const layout = (src: string): PositionedPieChart =>
12
+ layoutPieChart(parsePieChart(src))
13
+
14
+ const TAU = Math.PI * 2
15
+ const close = (a: number, b: number) => expect(a).toBeCloseTo(b, 6)
16
+
17
+ describe('pie layout: Mermaid frame', () => {
18
+ it('uses a 450px-tall frame with radius 185 centred at (225, 225)', () => {
19
+ const p = layout('pie\n "A" : 1\n "B" : 1')
20
+ expect(p.height).toBe(450)
21
+ expect(p.radius).toBe(185)
22
+ expect(p.cx).toBe(225)
23
+ expect(p.cy).toBe(225)
24
+ // Outline circle: radius + half the 2px outer stroke.
25
+ expect(p.outerRadius).toBe(186)
26
+ })
27
+
28
+ it('places the title centred over the pie, baseline 25px from the top', () => {
29
+ const p = layout('pie title Pets\n "A" : 1')
30
+ expect(p.title).toEqual({ text: 'Pets', x: 225, y: 25 })
31
+ })
32
+
33
+ it('omits the title when there is none', () => {
34
+ expect(layout('pie\n "A" : 1').title).toBeUndefined()
35
+ })
36
+
37
+ it('sizes the width as pie + margin + swatch + gap + longest legend text', () => {
38
+ const p = layout('pie\n "Short" : 1\n "A much longer label" : 1')
39
+ const longest = estimateTextWidth('A much longer label', 17, 400)
40
+ expect(p.width).toBe(Math.ceil(450 + 40 + 18 + 4 + longest))
41
+ })
42
+
43
+ it('widens the viewBox for a title wider than the chart, keeping the pie centred under it', () => {
44
+ const title =
45
+ 'An extremely long pie chart title that runs well past the legend'
46
+ const p = layout(`pie title ${title}\n "A" : 1`)
47
+ const titleW = estimateTextWidth(title, 25, 400)
48
+ expect(titleW / 2).toBeGreaterThan(225)
49
+ // Content shifted right by the overhang on the left.
50
+ close(p.cx, titleW / 2)
51
+ close(p.title!.x, p.cx)
52
+ expect(p.width).toBeGreaterThanOrEqual(Math.floor(titleW))
53
+ })
54
+ })
55
+
56
+ describe('pie layout: slices', () => {
57
+ it("draws slices in source order, clockwise from 12 o'clock, unsorted", () => {
58
+ const p = layout('pie\n "Small" : 10\n "Big" : 70\n "Mid" : 20')
59
+ expect(p.slices.map((s) => s.label)).toEqual(['Small', 'Big', 'Mid'])
60
+ close(p.slices[0]!.startAngle, 0)
61
+ close(p.slices[0]!.endAngle, 0.1 * TAU)
62
+ close(p.slices[1]!.startAngle, 0.1 * TAU)
63
+ close(p.slices[1]!.endAngle, 0.8 * TAU)
64
+ close(p.slices[2]!.endAngle, TAU)
65
+ // First slice starts at the top of the circle.
66
+ expect(p.slices[0]!.path.startsWith('M225,40A185,185,0,0,1,')).toBe(true)
67
+ })
68
+
69
+ it('angles always cover exactly the full circle', () => {
70
+ const p = layout('pie\n "A" : 3.3\n "B" : 7.1\n "C" : 12.9\n "D" : 0.9')
71
+ const sweep = p.slices.reduce((t, s) => t + (s.endAngle - s.startAngle), 0)
72
+ close(sweep, TAU)
73
+ })
74
+
75
+ it('labels each slice with toFixed(0) of its share, at 0.75 of the radius', () => {
76
+ const p = layout(
77
+ 'pie\n "Calcium" : 42.96\n "Potassium" : 50.05\n "Magnesium" : 10.01\n "Iron" : 5',
78
+ )
79
+ // Mermaid's own docs example renders 40% / 46% / 9% / 5%.
80
+ expect(p.slices.map((s) => s.percentText)).toEqual([
81
+ '40%',
82
+ '46%',
83
+ '9%',
84
+ '5%',
85
+ ])
86
+ for (const s of p.slices) {
87
+ const mid = (s.startAngle + s.endAngle) / 2
88
+ close(s.labelX, 225 + 185 * 0.75 * Math.sin(mid))
89
+ close(s.labelY, 225 - 185 * 0.75 * Math.cos(mid))
90
+ }
91
+ })
92
+
93
+ it('uses whole-number percentages only, even for shares like 33.3%', () => {
94
+ const p = layout('pie\n "A" : 1\n "B" : 1\n "C" : 1')
95
+ expect(p.slices.map((s) => s.percentText)).toEqual(['33%', '33%', '33%'])
96
+ })
97
+
98
+ it('draws a single slice as a full circle labelled 100%, label straight below the centre', () => {
99
+ const p = layout('pie\n "Only" : 5')
100
+ expect(p.slices).toHaveLength(1)
101
+ const s = p.slices[0]!
102
+ expect(s.percentText).toBe('100%')
103
+ expect(s.path).toBe('M225,40A185,185,0,1,1,225,410A185,185,0,1,1,225,40Z')
104
+ close(s.labelX, 225)
105
+ close(s.labelY, 225 + 185 * 0.75)
106
+ })
107
+
108
+ it('leaves out slices under 1% but keeps them in the legend; the rest fill the circle', () => {
109
+ const p = layout('pie\n "Big" : 995\n "Tiny" : 5\n "Other" : 100')
110
+ // Tiny = 5/1100 = 0.45%.
111
+ expect(p.slices.map((s) => s.label)).toEqual(['Big', 'Other'])
112
+ expect(p.legend.map((l) => l.text)).toEqual(['Big', 'Tiny', 'Other'])
113
+ // Drawn slices share the full circle between them...
114
+ close(p.slices[0]!.endAngle, (995 / 1095) * TAU)
115
+ close(p.slices[1]!.endAngle, TAU)
116
+ // ...but percentages are of the whole total, omitted slices included.
117
+ expect(p.slices.map((s) => s.percentText)).toEqual(['90%', '9%'])
118
+ })
119
+
120
+ it('keeps a slice of exactly 1%', () => {
121
+ const p = layout('pie\n "A" : 99\n "B" : 1')
122
+ expect(p.slices.map((s) => s.label)).toEqual(['A', 'B'])
123
+ expect(p.slices[1]!.percentText).toBe('1%')
124
+ })
125
+
126
+ it('does not draw zero-value slices', () => {
127
+ const p = layout('pie\n "A" : 0\n "B" : 4\n "C" : 0')
128
+ expect(p.slices.map((s) => s.label)).toEqual(['B'])
129
+ expect(p.legend).toHaveLength(3)
130
+ })
131
+
132
+ it('draws no slices when every value is zero', () => {
133
+ const p = layout('pie\n "A" : 0\n "B" : 0')
134
+ expect(p.slices).toEqual([])
135
+ expect(p.legend).toHaveLength(2)
136
+ })
137
+
138
+ it('keeps palette slots tied to source position, so an omitted slice still uses one', () => {
139
+ const p = layout('pie\n "A" : 500\n "Tiny" : 1\n "C" : 500')
140
+ expect(p.slices.map((s) => s.colorIndex)).toEqual([0, 2])
141
+ expect(p.legend.map((l) => l.colorIndex)).toEqual([0, 1, 2])
142
+ })
143
+
144
+ it('wraps colours after 12 slices', () => {
145
+ const lines = Array.from({ length: 14 }, (_, i) => ` "S${i}" : 10`)
146
+ const p = layout(`pie\n${lines.join('\n')}`)
147
+ expect(p.slices.map((s) => s.colorIndex)).toEqual([
148
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1,
149
+ ])
150
+ })
151
+ })
152
+
153
+ describe('pie layout: legend', () => {
154
+ it('lists every slice to the right of the pie, 22px apart, centred on it', () => {
155
+ const p = layout('pie\n "A" : 1\n "B" : 2\n "C" : 3\n "D" : 4')
156
+ expect(p.legend.map((l) => l.x)).toEqual([441, 441, 441, 441])
157
+ // translate(216, i * 22 - 22 * 4 / 2) from the centre.
158
+ expect(p.legend.map((l) => l.y)).toEqual([181, 203, 225, 247])
159
+ for (const l of p.legend) {
160
+ expect(l.size).toBe(18)
161
+ expect(l.textX).toBe(l.x + 22)
162
+ expect(l.textY).toBe(l.y + 14)
163
+ }
164
+ })
165
+
166
+ it('shows `label [value]` with showData, value printed as-is', () => {
167
+ const p = layout(
168
+ 'pie showData\n "A" : 1234567\n "B" : 42.50\n "C" : 0.125',
169
+ )
170
+ expect(p.legend.map((l) => l.text)).toEqual([
171
+ 'A [1234567]',
172
+ 'B [42.5]',
173
+ 'C [0.125]',
174
+ ])
175
+ })
176
+
177
+ it('shows the bare label without showData', () => {
178
+ expect(layout('pie\n "A" : 1').legend[0]!.text).toBe('A')
179
+ })
180
+
181
+ it('grows the viewBox for a legend taller than the frame instead of clipping it', () => {
182
+ const lines = Array.from({ length: 30 }, (_, i) => ` "S${i}" : 1`)
183
+ const p = layout(`pie\n${lines.join('\n')}`)
184
+ // Mermaid would start the legend at 225 - 330 = -105, above the frame.
185
+ const first = p.legend[0]!
186
+ const last = p.legend[p.legend.length - 1]!
187
+ expect(first.y).toBe(0)
188
+ expect(last.y + last.size).toBeLessThanOrEqual(p.height)
189
+ // The pie moved down by the same amount and keeps its frame.
190
+ expect(p.cy).toBe(225 + 105)
191
+ expect(p.height).toBe(30 * 22 - 4)
192
+ })
193
+ })
194
+
195
+ describe('pie layout: empty chart', () => {
196
+ it('draws only the circle and title for a bare `pie`', () => {
197
+ const p = layout('pie title Nothing yet')
198
+ expect(p.slices).toEqual([])
199
+ expect(p.legend).toEqual([])
200
+ expect(p.title?.text).toBe('Nothing yet')
201
+ expect(p.height).toBe(450)
202
+ // No legend: the pie and its right margin only, not a cropped half.
203
+ expect(p.width).toBe(PIE.height + PIE.margin)
204
+ })
205
+ })
206
+
207
+ describe('pie layout: accessibility fields', () => {
208
+ it('carries accTitle and accDescr through', () => {
209
+ const p = layout(
210
+ 'pie\n accTitle: Pets\n accDescr: Dogs vs cats\n "A" : 1',
211
+ )
212
+ expect(p.accTitle).toBe('Pets')
213
+ expect(p.accDescr).toBe('Dogs vs cats')
214
+ })
215
+ })
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { __resetSvgTitleIdCounterForTests, THEMES } from '@zombie-mermaid/core'
3
+ import { getSeriesColor } from '@zombie-mermaid/mermaid-parser'
4
+ import { renderMermaidSVG } from '../../../../src/index.ts'
5
+
6
+ beforeEach(() => {
7
+ __resetSvgTitleIdCounterForTests()
8
+ })
9
+
10
+ const BASIC = `pie title Pets adopted by volunteers
11
+ "Dogs" : 386
12
+ "Cats" : 85
13
+ "Rats" : 15`
14
+
15
+ const count = (svg: string, re: RegExp) => (svg.match(re) ?? []).length
16
+
17
+ describe('pie SVG: structure', () => {
18
+ const svg = renderMermaidSVG(BASIC)
19
+
20
+ it('draws the outline circle, one path per slice, a label each, the title and a legend row each', () => {
21
+ expect(count(svg, /class="pie-outer"/g)).toBe(1)
22
+ expect(count(svg, /<path [^>]*class="pie-slice /g)).toBe(3)
23
+ expect(count(svg, /class="pie-slice-label"/g)).toBe(3)
24
+ expect(count(svg, /class="pie-title"/g)).toBe(1)
25
+ expect(count(svg, /<g class="pie-legend">/g)).toBe(3)
26
+ })
27
+
28
+ it("paints in Mermaid's order: circle, slices, labels, title, legend", () => {
29
+ const order = [
30
+ 'class="pie-outer"',
31
+ 'class="pie-slice ',
32
+ 'class="pie-slice-label"',
33
+ 'class="pie-title"',
34
+ 'class="pie-legend"',
35
+ ].map((m) => svg.indexOf(m))
36
+ expect(order).toEqual([...order].sort((a, b) => a - b))
37
+ expect(order.every((i) => i > 0)).toBe(true)
38
+ })
39
+
40
+ it('writes whole-number percentage labels', () => {
41
+ const labels = [...svg.matchAll(/class="pie-slice-label">([^<]*)</g)].map(
42
+ (m) => m[1],
43
+ )
44
+ // 386/486, 85/486, 15/486
45
+ expect(labels).toEqual(['79%', '17%', '3%'])
46
+ })
47
+
48
+ it('draws no callout lines', () => {
49
+ expect(svg).not.toMatch(/<polyline|<line /)
50
+ })
51
+
52
+ it('tags slices with their label and value', () => {
53
+ expect(svg).toContain('data-label="Dogs" data-value="386"')
54
+ })
55
+
56
+ it('sizes the root to the layout', () => {
57
+ expect(svg).toMatch(
58
+ /^<svg [^>]*viewBox="0 0 \d+ 450" width="\d+" height="450"/,
59
+ )
60
+ })
61
+
62
+ it('escapes labels and titles', () => {
63
+ const out = renderMermaidSVG('pie title A <b> & "c"\n "x<y & \\"z\\"" : 1')
64
+ expect(out).toContain(
65
+ 'class="pie-title">A &lt;b&gt; &amp; &quot;c&quot;</text>',
66
+ )
67
+ expect(out).toContain('data-label="x&lt;y &amp; &quot;z&quot;"')
68
+ expect(out).toContain(
69
+ 'class="pie-legend-text">x&lt;y &amp; &quot;z&quot;</text>',
70
+ )
71
+ })
72
+
73
+ it('renders an empty chart as the outline circle and title only', () => {
74
+ const out = renderMermaidSVG('pie title Empty')
75
+ expect(count(out, /class="pie-outer"/g)).toBe(1)
76
+ expect(out).not.toContain('class="pie-slice')
77
+ expect(out).not.toContain('class="pie-legend"')
78
+ expect(out).toContain('class="pie-title">Empty</text>')
79
+ })
80
+
81
+ it('renders a bare `pie` with no title', () => {
82
+ const out = renderMermaidSVG('pie')
83
+ expect(out).toContain('class="pie-outer"')
84
+ expect(out).not.toContain('class="pie-title"')
85
+ })
86
+ })
87
+
88
+ describe('pie SVG: styling and theme', () => {
89
+ it("uses Mermaid's 0.7 slice opacity, 2px strokes and text sizes", () => {
90
+ const svg = renderMermaidSVG(BASIC)
91
+ expect(svg).toContain(
92
+ '.pie-slice { stroke: var(--fg); stroke-width: 2px; opacity: 0.7; }',
93
+ )
94
+ expect(svg).toContain(
95
+ '.pie-outer { fill: none; stroke: var(--fg); stroke-width: 2px; }',
96
+ )
97
+ expect(svg).toMatch(/\.pie-title \{[^}]*font-size: 25px/)
98
+ expect(svg).toMatch(/\.pie-slice-label \{[^}]*font-size: 17px/)
99
+ expect(svg).toMatch(/\.pie-legend-text \{[^}]*font-size: 17px/)
100
+ })
101
+
102
+ it('fills slot 0 with the live theme accent and later slots with series shades', () => {
103
+ const svg = renderMermaidSVG(BASIC, {
104
+ bg: '#ffffff',
105
+ fg: '#111111',
106
+ accent: '#e11d48',
107
+ })
108
+ expect(svg).toContain('--pie-color-0: var(--accent, #3b82f6);')
109
+ expect(svg).toContain(
110
+ `--pie-color-1: ${getSeriesColor(1, '#e11d48', '#ffffff')};`,
111
+ )
112
+ expect(svg).toContain(
113
+ `--pie-color-2: ${getSeriesColor(2, '#e11d48', '#ffffff')};`,
114
+ )
115
+ expect(svg).toContain('.pie-color-1 { fill: var(--pie-color-1); }')
116
+ expect(svg).toContain('rect.pie-color-1 { stroke: var(--pie-color-1); }')
117
+ })
118
+
119
+ it('adapts the series shades to a dark background', () => {
120
+ const dark = THEMES['tokyo-night']!
121
+ const svg = renderMermaidSVG(BASIC, dark)
122
+ expect(svg).toContain(
123
+ `--pie-color-1: ${getSeriesColor(1, dark.accent ?? '#3b82f6', dark.bg)};`,
124
+ )
125
+ expect(svg).toContain(`--bg:${dark.bg}`)
126
+ // Light vs dark backgrounds pick different shades for the same slot.
127
+ expect(getSeriesColor(1, '#3b82f6', '#ffffff')).not.toBe(
128
+ getSeriesColor(1, '#3b82f6', dark.bg),
129
+ )
130
+ })
131
+
132
+ it('defines colour variables only for palette slots in use, wrapping after 12', () => {
133
+ const lines = Array.from({ length: 14 }, (_, i) => ` "S${i}" : 10`)
134
+ const svg = renderMermaidSVG(`pie\n${lines.join('\n')}`)
135
+ expect(count(svg, /--pie-color-\d+:/g)).toBe(12)
136
+ expect(svg).not.toContain('--pie-color-12')
137
+ expect(count(svg, /class="pie-slice pie-color-0"/g)).toBe(2)
138
+ })
139
+
140
+ it('resolves to concrete colours with resolveColors', () => {
141
+ const svg = renderMermaidSVG(BASIC, { resolveColors: true })
142
+ expect(svg).not.toMatch(/var\(--pie-color/)
143
+ })
144
+
145
+ it('honours nonce and styleAttribute: false', () => {
146
+ const svg = renderMermaidSVG(BASIC, { nonce: 'abc', styleAttribute: false })
147
+ expect(count(svg, /<style nonce="abc">/g)).toBe(2)
148
+ expect(svg).not.toMatch(/<style>/)
149
+ expect(svg.slice(0, svg.indexOf('>'))).not.toContain('style=')
150
+ })
151
+ })
152
+
153
+ describe('pie SVG: accessibility', () => {
154
+ it('uses accTitle as the accessible name and accDescr as the description', () => {
155
+ const svg = renderMermaidSVG(
156
+ 'pie\n accTitle: Pet adoptions\n accDescr: Dogs lead by far\n "Dogs" : 3\n "Cats" : 1',
157
+ )
158
+ expect(svg).toMatch(
159
+ /^<svg [^>]*role="img" aria-labelledby="zm-title-1" aria-describedby="zm-desc-1"/,
160
+ )
161
+ expect(svg).toContain('<title id="zm-title-1">Pet adoptions</title>')
162
+ expect(svg).toContain('<desc id="zm-desc-1">Dogs lead by far</desc>')
163
+ })
164
+
165
+ it('lets options.title override accTitle', () => {
166
+ const svg = renderMermaidSVG('pie\n accTitle: From source\n "A" : 1', {
167
+ title: 'From caller',
168
+ })
169
+ expect(svg).toContain('>From caller</title>')
170
+ expect(svg).not.toContain('From source')
171
+ })
172
+
173
+ it('does not use the visible title as the accessible name', () => {
174
+ const svg = renderMermaidSVG(BASIC)
175
+ expect(svg).toMatch(/^<svg [^>]*role="img"/)
176
+ expect(svg).not.toContain('<title')
177
+ })
178
+
179
+ it('drops the name and description for decorative output', () => {
180
+ const svg = renderMermaidSVG(
181
+ 'pie\n accTitle: T\n accDescr: D\n "A" : 1',
182
+ { decorative: true },
183
+ )
184
+ expect(svg).toMatch(/^<svg [^>]*aria-hidden="true"/)
185
+ expect(svg).not.toMatch(
186
+ /<title|<desc|aria-labelledby|aria-describedby|role=/,
187
+ )
188
+ })
189
+
190
+ it('embeds the source when asked', () => {
191
+ const svg = renderMermaidSVG(BASIC, { embedSource: true })
192
+ expect(svg).toMatch(/^<svg data-src="pie title Pets/)
193
+ })
194
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * #1310 — real mermaid.js (11.17.2) draws no line for an edge between a node
3
+ * and a subgraph that contains it (the edge path is zero-length). The SVG
4
+ * renderer used to draw degenerate stubs for it; it now omits the edge, so the
5
+ * output equals the same diagram without that edge.
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { renderMermaidSVG } from '../../../../src/index.ts'
9
+
10
+ const base = 'flowchart TD; subgraph Sub; B; end; A --> B'
11
+
12
+ describe('SVG: an edge between a node and its own subgraph (#1310)', () => {
13
+ it.each([
14
+ ['member to subgraph', `${base}; B --> Sub`],
15
+ ['subgraph to member', `${base}; Sub --> B`],
16
+ [
17
+ 'nested containment',
18
+ 'flowchart TD; subgraph Outer; subgraph Inner; B; end; end; A --> B; B --> Outer; Inner --> B; Outer --> Inner',
19
+ ],
20
+ ])('renders %s identically to the diagram without the edge', (_, src) => {
21
+ const without = src.includes('Outer')
22
+ ? 'flowchart TD; subgraph Outer; subgraph Inner; B; end; end; A --> B'
23
+ : base
24
+ expect(renderMermaidSVG(src)).toBe(renderMermaidSVG(without))
25
+ })
26
+
27
+ it('still draws an edge from an outside node to the subgraph', () => {
28
+ expect(renderMermaidSVG(`${base}; X --> Sub`)).not.toBe(
29
+ renderMermaidSVG(base),
30
+ )
31
+ })
32
+ })
@@ -10,7 +10,6 @@ import type {
10
10
  import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
11
11
  import {
12
12
  svgOpenTag,
13
- buildStyleBlock,
14
13
  getReadableTextColor,
15
14
  sanitizeClassName,
16
15
  renderMultilineText,
@@ -21,6 +20,7 @@ import {
21
20
  } from '@zombie-mermaid/core'
22
21
  import { withDataSrc } from '../renderer.ts'
23
22
  import {
23
+ buildSvgStyleBlock,
24
24
  FONT_SIZES,
25
25
  FONT_WEIGHTS,
26
26
  STROKE_WIDTHS,
@@ -110,7 +110,7 @@ export function renderClassSvg(
110
110
  embedSource,
111
111
  ),
112
112
  )
113
- parts.push(buildStyleBlock(font, true, emit.nonce))
113
+ parts.push(buildSvgStyleBlock(font, true, emit.nonce))
114
114
  parts.push('<defs>')
115
115
  parts.push(relationshipMarkerDefs())
116
116
  parts.push('</defs>')
@@ -8,7 +8,6 @@ import type {
8
8
  import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
9
9
  import {
10
10
  svgOpenTag,
11
- buildStyleBlock,
12
11
  renderMultilineText,
13
12
  escapeXml as escapeXmlUtil,
14
13
  escapeAttr,
@@ -17,6 +16,7 @@ import {
17
16
  } from '@zombie-mermaid/core'
18
17
  import { withDataSrc } from '../renderer.ts'
19
18
  import {
19
+ buildSvgStyleBlock,
20
20
  FONT_SIZES,
21
21
  FONT_WEIGHTS,
22
22
  STROKE_WIDTHS,
@@ -99,7 +99,7 @@ export function renderErSvg(
99
99
  embedSource,
100
100
  ),
101
101
  )
102
- parts.push(buildStyleBlock(font, true, emit.nonce))
102
+ parts.push(buildSvgStyleBlock(font, true, emit.nonce))
103
103
  parts.push('<defs>')
104
104
  parts.push('</defs>') // No marker defs — we draw crow's foot inline
105
105