@zombie-mermaid/svg-renderer 4.2.2 → 4.2.3

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 (33) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.js +13 -5
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -3
  6. package/src/index.ts +34 -1
  7. package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
  8. package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
  9. package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
  10. package/src/__tests__/c4-svg.test.ts +0 -337
  11. package/src/__tests__/c4-text-width-1210.test.ts +0 -43
  12. package/src/__tests__/class-component-order-1249.test.ts +0 -40
  13. package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
  14. package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
  15. package/src/__tests__/class-namespace-1196.test.ts +0 -165
  16. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
  17. package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
  18. package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
  19. package/src/__tests__/elk-adapter-utils.test.ts +0 -166
  20. package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
  21. package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
  22. package/src/__tests__/er-default-direction-1250.test.ts +0 -69
  23. package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
  24. package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
  25. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
  26. package/src/__tests__/pie-layout.test.ts +0 -215
  27. package/src/__tests__/pie-svg.test.ts +0 -194
  28. package/src/__tests__/render-mermaid-svg.test.ts +0 -64
  29. package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
  30. package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
  31. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
  32. package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
  33. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "4.2.2",
3
+ "version": "4.2.3",
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": {
@@ -31,13 +31,14 @@
31
31
  },
32
32
  "files": [
33
33
  "src/",
34
+ "!src/**/__tests__",
34
35
  "dist/",
35
36
  "LICENSE"
36
37
  ],
37
38
  "dependencies": {
38
39
  "elkjs": "^0.11.0",
39
40
  "entities": "^7.0.1",
40
- "@zombie-mermaid/core": "4.2.2",
41
- "@zombie-mermaid/mermaid-parser": "4.2.2"
41
+ "@zombie-mermaid/core": "4.2.3",
42
+ "@zombie-mermaid/mermaid-parser": "4.2.3"
42
43
  }
43
44
  }
package/src/index.ts CHANGED
@@ -202,12 +202,45 @@ export function renderMermaidSVG(
202
202
  text: string,
203
203
  options: RenderOptions = {},
204
204
  ): string {
205
- const svg = renderMermaidSVGRaw(text, options)
205
+ const svg = scopeSvgIds(renderMermaidSVGRaw(text, options))
206
206
  return options.resolveColors
207
207
  ? resolveCssColors(svg, buildColors(options))
208
208
  : svg
209
209
  }
210
210
 
211
+ /**
212
+ * Prefix every `id` in the SVG (and each `url(#…)`, `href="#…"` reference to
213
+ * it) with a hash of the SVG itself, so two diagrams inlined in one HTML
214
+ * document never share marker/mask ids. Without this every `url(#arrowhead)`
215
+ * resolves to the first diagram's marker, which draws nothing when that
216
+ * diagram is in a `display: none` subtree (#1397). Hashing the whole output
217
+ * (not just the source) keeps same-text renders with different options apart.
218
+ */
219
+ function scopeSvgIds(svg: string): string {
220
+ const ids = new Set(
221
+ Array.from(svg.matchAll(/\sid="([^"]+)"/g), (m) => m[1] ?? ''),
222
+ )
223
+ if (ids.size === 0) return svg
224
+ let h = 0x811c9dc5
225
+ for (let i = 0; i < svg.length; i++) {
226
+ h = Math.imul(h ^ svg.charCodeAt(i), 0x01000193)
227
+ }
228
+ const prefix = `zm${(h >>> 0).toString(36)}-`
229
+ // ids already carrying the zm- prefix (aria title/desc, title-gap mask) are unique already
230
+ const scoped = (id: string) =>
231
+ ids.has(id) && !id.startsWith('zm-') ? prefix + id : id
232
+ return svg
233
+ .replace(
234
+ /(\sid=")([^"]+)"/g,
235
+ (_, a: string, id: string) => `${a}${scoped(id)}"`,
236
+ )
237
+ .replace(/url\(#([^)]+)\)/g, (_, id: string) => `url(#${scoped(id)})`)
238
+ .replace(
239
+ /(href=")#([^"]+)"/g,
240
+ (_, a: string, id: string) => `${a}#${scoped(id)}"`,
241
+ )
242
+ }
243
+
211
244
  /** The renderer proper — `renderMermaidSVG` minus the optional `resolveColors` post-pass. */
212
245
  function renderMermaidSVGRaw(text: string, options: RenderOptions): string {
213
246
  // Decode XML entities that may leak from markdown parsers (e.g. rehype-raw).
@@ -1,112 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { splitStatements } from '@zombie-mermaid/core'
3
- import { c4RelLabelLines, parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
- import { layoutC4DiagramSync } from '../c4/layout.ts'
5
- import { C4, c4TextWidth } from '../c4/metrics.ts'
6
-
7
- // Mermaid starts a relationship label at the chord's midpoint, which can land
8
- // it on a shape. The label slides along its line to clear every shape (#1290).
9
- const layout = (src: string) =>
10
- layoutC4DiagramSync(parseC4Diagram(splitStatements(src)))
11
-
12
- // The C4 Container sample: `Reads/writes [SQL]` and `Publishes [AMQP]` both
13
- // used to sit on the Database cylinder.
14
- const container = `C4Container
15
- Person(user, "User")
16
- System_Boundary(sys, "Shop") {
17
- Container(web, "Web App", "React", "Storefront")
18
- Container(api, "API", "Node.js", "Business logic")
19
- ContainerDb(db, "Database", "PostgreSQL", "Orders")
20
- ContainerQueue(q, "Events", "Kafka")
21
- }
22
- System_Ext(pay, "Payments")
23
- Rel(user, web, "Browses")
24
- Rel(web, api, "Calls", "JSON/HTTPS")
25
- Rel(api, db, "Reads/writes", "SQL")
26
- BiRel(api, q, "Publishes", "AMQP")
27
- Rel(api, pay, "Charges cards")`
28
-
29
- function labelRects(d: ReturnType<typeof layout>) {
30
- return d.relationships.flatMap((rel) => {
31
- if (!rel.labelPosition) return []
32
- const lines = c4RelLabelLines(rel)
33
- return lines.map((line, i) => {
34
- const isTech = rel.technology !== undefined && i === lines.length - 1
35
- const w = c4TextWidth(line, C4.messageSize, 400)
36
- const cx =
37
- isTech && rel.technologyX !== undefined
38
- ? rel.technologyX
39
- : rel.labelPosition!.x
40
- const cy = rel.labelPosition!.y + (i === 0 ? 0 : C4.messageSize + 5)
41
- return {
42
- rel: `${rel.from}->${rel.to}`,
43
- x0: cx - w / 2,
44
- x1: cx + w / 2,
45
- y0: cy - C4.messageSize / 2,
46
- y1: cy + C4.messageSize / 2,
47
- }
48
- })
49
- })
50
- }
51
-
52
- function overlapping(d: ReturnType<typeof layout>): string[] {
53
- const hits: string[] = []
54
- for (const r of labelRects(d)) {
55
- for (const e of d.elements) {
56
- if (
57
- r.x0 < e.x + e.width &&
58
- r.x1 > e.x &&
59
- r.y0 < e.y + e.height &&
60
- r.y1 > e.y
61
- ) {
62
- hits.push(`${r.rel} label over ${e.alias}`)
63
- }
64
- }
65
- }
66
- return hits
67
- }
68
-
69
- describe('C4 relationship labels clear the shapes (#1290)', () => {
70
- it('keeps every label in the Container sample off every shape', () => {
71
- expect(overlapping(layout(container))).toEqual([])
72
- })
73
-
74
- it('keeps a label on its own line, only sliding along it', () => {
75
- const d = layout(container)
76
- const rel = d.relationships.find((r) => r.to === 'db')!
77
- const [s, e] = rel.points
78
- const dx = e!.x - s!.x
79
- const dy = e!.y - s!.y
80
- const t = rel.labelPosition!
81
- // Same distance from the chord as an unmoved label: Mermaid's label is
82
- // half its own width to the right of the midpoint, never further off.
83
- const across = Math.abs(
84
- ((t.x - s!.x) * dy - (t.y - s!.y) * dx) / Math.hypot(dx, dy),
85
- )
86
- expect(across).toBeLessThan(40)
87
- })
88
-
89
- it('leaves a label that already clears every shape where Mermaid puts it', () => {
90
- const d = layout(`C4Context
91
- System(a, "A")
92
- System(b, "B")
93
- Rel(a, b, "Uses")`)
94
- const rel = d.relationships[0]!
95
- const [s, e] = rel.points
96
- const mid = (s!.x + e!.x) / 2
97
- const width = c4TextWidth('Uses', C4.messageSize, 400)
98
- expect(rel.labelPosition!.x).toBeCloseTo(mid + width / 2, 5)
99
- })
100
-
101
- it('does not let two labels in one gap sit on each other', () => {
102
- const rects = labelRects(layout(container))
103
- for (const [i, a] of rects.entries()) {
104
- for (const b of rects.slice(i + 1)) {
105
- if (a.rel === b.rel) continue
106
- const apart =
107
- a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0
108
- expect(apart, `${a.rel} vs ${b.rel}`).toBe(true)
109
- }
110
- }
111
- })
112
- })
@@ -1,52 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { splitStatements } from '@zombie-mermaid/core'
3
- import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
- import { layoutC4DiagramSync } from '../c4/layout.ts'
5
- import { c4PersonGeometry } from '../c4/metrics.ts'
6
-
7
- // A relationship meets a cylinder, pipe or person where Mermaid's own shape
8
- // outline puts it, not at the box around the shape (#1208). The pipe and the
9
- // oblique person cases are checked against recorded Mermaid output in
10
- // c4-mermaid-reference.test.ts.
11
- const layout = (src: string) =>
12
- layoutC4DiagramSync(parseC4Diagram(splitStatements(src)))
13
-
14
- describe('C4 line ends on shaped elements (#1208)', () => {
15
- // Row 1 holds four shapes and row 2 starts under the first, so the line from
16
- // `b` down to the database arrives from the upper right.
17
- const src = `C4Context
18
- System(a, "A")
19
- System(b, "B")
20
- System(c, "C")
21
- System(e, "E")
22
- SystemDb(db, "Store")
23
- Rel(b, db, "Reads")`
24
-
25
- it('ends a slanted line on the curve of the cylinder cap, below the box top', () => {
26
- const d = layout(src)
27
- const store = d.elements.find((e) => e.alias === 'db')!
28
- const [, end] = d.relationships[0]!.points
29
- // 84.4px right of the centre the cap (rx 108, ry 15.8) has dropped
30
- // 5.9px, so the line ends 5.9px under the top of the box, not on it.
31
- expect(end!.y - store.y).toBeCloseTo(5.96, 1)
32
- })
33
-
34
- it('ends a line into the bottom middle of a cylinder at the box bottom', () => {
35
- const d = layout(`C4Context
36
- SystemDb(db, "Store")
37
- System(b, "B")
38
- System(c, "C")
39
- System(e, "E")
40
- System(f, "F")
41
- Rel(f, db, "Reads")`)
42
- const store = d.elements.find((e) => e.alias === 'db')!
43
- const [, end] = d.relationships[0]!.points
44
- // Straight below the centre the ellipse is at its lowest: no lift.
45
- expect(end!.y - (store.y + store.height)).toBeCloseTo(0, 5)
46
- })
47
-
48
- it("keeps a wide person head at Mermaid's 56px radius cap", () => {
49
- expect(c4PersonGeometry(216).headRadius).toBeCloseTo(49.68, 2)
50
- expect(c4PersonGeometry(400).headRadius).toBe(56)
51
- })
52
- })
@@ -1,124 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { readFileSync } from 'node:fs'
3
- import { splitStatements } from '@zombie-mermaid/core'
4
- import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
5
- import { layoutC4DiagramSync } from '../c4/layout.ts'
6
-
7
- // Each case is a C4 source rendered by real Mermaid 11.17.2 (headless Chrome,
8
- // screen width 1440), with the numbers read back from its SVG: the diagram
9
- // size, the centre of every shape, and each relationship's line and label.
10
- // The sources vary on purpose (shapes of every kind, wrapped names and
11
- // descriptions, boundaries nested and side by side, relationships in every
12
- // direction), so a constant tuned to one sample cannot satisfy all of them.
13
- //
14
- // Vertical numbers depend only on heights and gaps, so they must agree to a
15
- // few pixels. Horizontal ones depend on text widths, which this package sums
16
- // from Arial advances (Mermaid measures them in a browser), and relationship
17
- // ends follow each shape's own outline, as Mermaid's do.
18
- interface Reference {
19
- name: string
20
- source: string
21
- width: number
22
- height: number
23
- elements: Record<string, [number, number]>
24
- relationships: { line: number[]; label?: [number, number] }[]
25
- }
26
-
27
- const cases: Reference[] = JSON.parse(
28
- readFileSync(
29
- new URL('./fixtures/c4-mermaid-reference.json', import.meta.url),
30
- 'utf8',
31
- ),
32
- )
33
-
34
- const Y = 3
35
- const X = 2
36
- const LINE = 3
37
- // Relationships whose end this package cannot place within LINE, with the
38
- // reason. Each is a limit of the shape outline, not of the text widths.
39
- const WIDER_LINE = 9
40
- const WIDER: Record<string, number[]> = {
41
- // A line that skims along the top of a person's body, where the pill meets
42
- // the head: 0.25px of height moves where it crosses by about 8px.
43
- 'v02-person-targets': [3],
44
- }
45
- const LABEL = 10
46
- // Deliberate difference (#1209): Mermaid merges two relationships with the
47
- // same from/to (the later one wins, one line drawn). This package draws both,
48
- // so a relationship the author wrote is never dropped. Each entry is a case
49
- // where Mermaid drew `n` lines for a source with `n + 1` relationships.
50
- const DUPLICATE_PAIRS = new Set(['v07-dynamic'])
51
-
52
- describe('C4 SVG layout against Mermaid on varied diagrams', () => {
53
- it('covers a spread of diagrams', () => {
54
- expect(cases.length).toBeGreaterThanOrEqual(30)
55
- })
56
-
57
- for (const ref of cases) {
58
- it(`${ref.name} matches Mermaid`, () => {
59
- const d = layoutC4DiagramSync(parseC4Diagram(splitStatements(ref.source)))
60
- expect(Math.abs(d.height - ref.height), 'diagram height').toBeLessThan(Y)
61
- expect(Math.abs(d.width - ref.width), 'diagram width').toBeLessThan(X)
62
-
63
- for (const e of d.elements) {
64
- const r = ref.elements[e.alias]
65
- if (!r) continue
66
- const cx = e.x + e.width / 2
67
- const cy = e.y + e.height / 2
68
- expect(Math.abs(cx - r[0]), `${e.alias} centre x`).toBeLessThan(X)
69
- expect(Math.abs(cy - r[1]), `${e.alias} centre y`).toBeLessThan(Y)
70
- }
71
-
72
- // Mermaid merges duplicate pairs and this package draws both (see
73
- // DUPLICATE_PAIRS), so the lines only line up when the counts agree.
74
- // A count mismatch is only allowed for a listed case, and there it must
75
- // be exactly the duplicate that is drawn extra.
76
- if (DUPLICATE_PAIRS.has(ref.name)) {
77
- expect(d.relationships.length, 'both duplicate lines drawn').toBe(
78
- ref.relationships.length + 1,
79
- )
80
- const pairs = d.relationships.map((r) => `${r.from}>${r.to}`)
81
- expect(
82
- new Set(pairs).size,
83
- 'a pair is drawn twice, as written',
84
- ).toBeLessThan(pairs.length)
85
- return
86
- }
87
- expect(d.relationships.length, 'relationship count').toBe(
88
- ref.relationships.length,
89
- )
90
- d.relationships.forEach((rel, i) => {
91
- const r = ref.relationships[i]!
92
- const [s, e] = rel.points
93
- const got = [s!.x, s!.y, e!.x, e!.y]
94
- const limit = WIDER[ref.name]?.includes(i + 1) ? WIDER_LINE : LINE
95
- got.forEach((v, k) => {
96
- expect(
97
- Math.abs(v - r.line[k]!),
98
- `relationship ${i + 1} line[${k}]`,
99
- ).toBeLessThan(limit)
100
- })
101
- if (r.label && rel.labelPosition) {
102
- // Mermaid's label can sit on a shape; this package slides it along
103
- // the chord to clear it (#1290). That is the one allowed difference:
104
- // the label may move along the line but not off it.
105
- const dx = e!.x - s!.x
106
- const dy = e!.y - s!.y
107
- const len = Math.hypot(dx, dy)
108
- const across = (p: { x: number; y: number }): number =>
109
- ((p.x - s!.x) * dy - (p.y - s!.y) * dx) / len
110
- const ref0 = { x: r.label[0], y: r.label[1] }
111
- const moved =
112
- Math.abs(rel.labelPosition.x - ref0.x) >= LABEL ||
113
- Math.abs(rel.labelPosition.y - ref0.y) >= LABEL
114
- if (moved) {
115
- expect(
116
- Math.abs(across(rel.labelPosition) - across(ref0)),
117
- `relationship ${i + 1} label is off the chord`,
118
- ).toBeLessThan(LABEL)
119
- }
120
- }
121
- })
122
- })
123
- }
124
- })