@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,197 +0,0 @@
1
- /**
2
- * Shape-aware edge clipping utilities.
3
- *
4
- * ELK.js treats all nodes as rectangles for edge routing. For non-rectangular
5
- * shapes like diamonds, this causes edges to terminate at the bounding box
6
- * boundary instead of the actual shape vertices.
7
- *
8
- * This module provides utilities to clip edge endpoints to actual shape
9
- * boundaries after ELK layout is complete.
10
- */
11
-
12
- import type { Point, PositionedNode } from '@zombie-mermaid/core'
13
-
14
- /**
15
- * Clip an edge endpoint to the actual shape boundary of a node.
16
- *
17
- * @param points - The edge points array
18
- * @param node - The node to clip to
19
- * @param isStart - True if clipping the start point (source), false for end (target)
20
- * @returns New points array with clipped endpoint
21
- */
22
- export function clipEdgeToShape(
23
- points: Point[],
24
- node: PositionedNode,
25
- isStart: boolean,
26
- ): Point[] {
27
- if (points.length < 2) return points
28
-
29
- // Only clip non-rectangular shapes
30
- if (
31
- node.shape === 'rectangle' ||
32
- node.shape === 'rounded' ||
33
- node.shape === 'stadium'
34
- ) {
35
- return points
36
- }
37
-
38
- const result = [...points]
39
-
40
- if (node.shape === 'diamond') {
41
- if (isStart) {
42
- result[0] = clipToDiamond(points[0]!, points[1]!, node)
43
- } else {
44
- const lastIdx = points.length - 1
45
- result[lastIdx] = clipToDiamond(
46
- points[lastIdx]!,
47
- points[lastIdx - 1]!,
48
- node,
49
- )
50
- }
51
- }
52
- // Future: add clipping for hexagon, circle, etc.
53
-
54
- return result
55
- }
56
-
57
- /**
58
- * Clip a point to the diamond shape boundary using ray-polygon intersection.
59
- *
60
- * Diamond vertices are at the midpoints of the bounding box sides:
61
- * - Top: (cx, y)
62
- * - Right: (x + w, cy)
63
- * - Bottom: (cx, y + h)
64
- * - Left: (x, cy)
65
- *
66
- * For orthogonal edges, we extend the final segment as a ray and find where
67
- * it intersects the diamond boundary. This preserves orthogonality while
68
- * ensuring the edge terminates at the actual diamond shape.
69
- *
70
- * @param endpoint - The edge endpoint to clip
71
- * @param adjacent - The adjacent point (to determine ray direction)
72
- * @param node - The diamond node
73
- * @returns Clipped point on the diamond boundary
74
- */
75
- function clipToDiamond(
76
- endpoint: Point,
77
- adjacent: Point,
78
- node: PositionedNode,
79
- ): Point {
80
- const cx = node.x + node.width / 2
81
- const cy = node.y + node.height / 2
82
-
83
- // Diamond vertices
84
- const top: Point = { x: cx, y: node.y }
85
- const right: Point = { x: node.x + node.width, y: cy }
86
- const bottom: Point = { x: cx, y: node.y + node.height }
87
- const left: Point = { x: node.x, y: cy }
88
-
89
- // Determine approach direction from adjacent point
90
- const dx = endpoint.x - adjacent.x
91
- const dy = endpoint.y - adjacent.y
92
-
93
- // For orthogonal edges, one of dx or dy will be ~0
94
- const isVertical = Math.abs(dx) < Math.abs(dy)
95
-
96
- if (isVertical) {
97
- // Vertical ray at x = endpoint.x
98
- const rayX = endpoint.x
99
-
100
- if (dy > 0) {
101
- // Coming from above (moving down) → intersect with top half of diamond
102
- // Top half edges: left-top (left → top) and top-right (top → right)
103
- if (rayX <= cx) {
104
- // Intersect with left-top edge (from left vertex to top vertex)
105
- return intersectVerticalRayWithEdge(rayX, left, top) ?? top
106
- } else {
107
- // Intersect with top-right edge (from top vertex to right vertex)
108
- return intersectVerticalRayWithEdge(rayX, top, right) ?? top
109
- }
110
- } else {
111
- // Coming from below (moving up) → intersect with bottom half of diamond
112
- // Bottom half edges: left-bottom (bottom → left) and bottom-right (right → bottom)
113
- if (rayX <= cx) {
114
- // Intersect with bottom-left edge (from bottom vertex to left vertex)
115
- return intersectVerticalRayWithEdge(rayX, bottom, left) ?? bottom
116
- } else {
117
- // Intersect with bottom-right edge (from right vertex to bottom vertex)
118
- return intersectVerticalRayWithEdge(rayX, right, bottom) ?? bottom
119
- }
120
- }
121
- } else {
122
- // Horizontal ray at y = endpoint.y
123
- const rayY = endpoint.y
124
-
125
- if (dx > 0) {
126
- // Coming from left (moving right) → intersect with left half of diamond
127
- // Left half edges: top-left (top → left) and left-bottom (left → bottom)
128
- if (rayY <= cy) {
129
- // Intersect with top-left edge (from top vertex to left vertex)
130
- return intersectHorizontalRayWithEdge(rayY, top, left) ?? left
131
- } else {
132
- // Intersect with left-bottom edge (from left vertex to bottom vertex)
133
- return intersectHorizontalRayWithEdge(rayY, left, bottom) ?? left
134
- }
135
- } else {
136
- // Coming from right (moving left) → intersect with right half of diamond
137
- // Right half edges: top-right (top → right) and right-bottom (right → bottom)
138
- if (rayY <= cy) {
139
- // Intersect with top-right edge (from top vertex to right vertex)
140
- return intersectHorizontalRayWithEdge(rayY, top, right) ?? right
141
- } else {
142
- // Intersect with right-bottom edge (from right vertex to bottom vertex)
143
- return intersectHorizontalRayWithEdge(rayY, right, bottom) ?? right
144
- }
145
- }
146
- }
147
- }
148
-
149
- /**
150
- * Find intersection of a horizontal ray (y = rayY) with a line segment.
151
- * Returns the intersection point or null if no intersection.
152
- */
153
- function intersectHorizontalRayWithEdge(
154
- rayY: number,
155
- p1: Point,
156
- p2: Point,
157
- ): Point | null {
158
- const dy = p2.y - p1.y
159
- if (Math.abs(dy) < 0.001) {
160
- // Edge is horizontal, no single intersection
161
- return null
162
- }
163
-
164
- const t = (rayY - p1.y) / dy
165
- if (t < 0 || t > 1) {
166
- // Intersection outside the edge segment
167
- return null
168
- }
169
-
170
- const x = p1.x + t * (p2.x - p1.x)
171
- return { x, y: rayY }
172
- }
173
-
174
- /**
175
- * Find intersection of a vertical ray (x = rayX) with a line segment.
176
- * Returns the intersection point or null if no intersection.
177
- */
178
- function intersectVerticalRayWithEdge(
179
- rayX: number,
180
- p1: Point,
181
- p2: Point,
182
- ): Point | null {
183
- const dx = p2.x - p1.x
184
- if (Math.abs(dx) < 0.001) {
185
- // Edge is vertical, no single intersection
186
- return null
187
- }
188
-
189
- const t = (rayX - p1.x) / dx
190
- if (t < 0 || t > 1) {
191
- // Intersection outside the edge segment
192
- return null
193
- }
194
-
195
- const y = p1.y + t * (p2.y - p1.y)
196
- return { x: rayX, y }
197
- }
package/src/styles.ts DELETED
@@ -1,145 +0,0 @@
1
- // ============================================================================
2
- // Font metrics — character width estimates for Inter at different sizes.
3
- // Used to approximate text bounding boxes without DOM measurement.
4
- // These are calibrated for Inter's typical glyph widths.
5
- //
6
- // NOTE: Theme/color system has moved to packages/core/src/theme.ts. This file only
7
- // contains font metrics, spacing constants, and stroke widths.
8
- // ============================================================================
9
-
10
- import { buildStyleBlock, measureTextWidth } from '@zombie-mermaid/core'
11
- import {
12
- MONO_FONT_FAMILY as EMBEDDED_MONO_FONT_FAMILY,
13
- MONO_FONT_FACE_CSS as EMBEDDED_MONO_FONT_FACE_CSS,
14
- } from './generated/mono-font-subset.ts'
15
-
16
- /** Average character width in px at the given font size and weight (proportional font) */
17
- export function estimateTextWidth(
18
- text: string,
19
- fontSize: number,
20
- fontWeight: number,
21
- ): number {
22
- // Delegate to variable-width character measurement for better accuracy
23
- // with mixed character sets (Latin narrow/wide, CJK, emoji, etc.)
24
- return measureTextWidth(text, fontSize, fontWeight)
25
- }
26
-
27
- /** Average character width in px for monospace fonts (uniform glyph width) */
28
- export function estimateMonoTextWidth(text: string, fontSize: number): number {
29
- // Monospace fonts have uniform character width — 0.6 of fontSize matches actual
30
- // glyph widths for JetBrains Mono / SF Mono / Fira Code at small sizes (11px).
31
- // Previous value of 0.55 underestimated widths, causing class member labels to
32
- // extend beyond their box boundaries.
33
- return text.length * fontSize * 0.6
34
- }
35
-
36
- /** Monospace font family used for code-like text (class members, types) */
37
- export const MONO_FONT = "'JetBrains Mono'" as const
38
-
39
- /** Full CSS fallback chain for monospace text */
40
- export const MONO_FONT_STACK =
41
- `${MONO_FONT}, 'SF Mono', 'Fira Code', ui-monospace, monospace` as const
42
-
43
- /** Default font sizes used in the renderer (in px). Overridable via `RenderOptions.fontSizes`. */
44
- export const FONT_SIZES = {
45
- /** Node label text */
46
- nodeLabel: 13,
47
- /** Edge label text */
48
- edgeLabel: 11,
49
- /** Subgraph header text */
50
- groupHeader: 12,
51
- } as const
52
-
53
- /** Resolved font-size set — same shape as {@link FONT_SIZES} but mutable numbers. */
54
- export type FontSizes = { [K in keyof typeof FONT_SIZES]: number }
55
-
56
- /** Partial font-size overrides, as accepted by `RenderOptions.fontSizes`. */
57
- export type FontSizeOptions = Partial<FontSizes>
58
-
59
- /**
60
- * Merge user-provided font-size overrides over the {@link FONT_SIZES} defaults.
61
- * Any field left unspecified (or `undefined`) falls back to its default.
62
- */
63
- export function resolveFontSizes(overrides?: FontSizeOptions): FontSizes {
64
- return {
65
- nodeLabel: overrides?.nodeLabel ?? FONT_SIZES.nodeLabel,
66
- edgeLabel: overrides?.edgeLabel ?? FONT_SIZES.edgeLabel,
67
- groupHeader: overrides?.groupHeader ?? FONT_SIZES.groupHeader,
68
- }
69
- }
70
-
71
- /** Font weights used per element type */
72
- export const FONT_WEIGHTS = {
73
- nodeLabel: 500,
74
- edgeLabel: 400,
75
- groupHeader: 600,
76
- } as const
77
-
78
- // ============================================================================
79
- // Spacing & sizing constants
80
- // ============================================================================
81
-
82
- /** Vertical gap between a subgraph header band and the content area below it (px).
83
- * Without this, nested subgraph headers sit flush against their parent's header band.
84
- * Increased from 8 to 12 to provide more clearance for edges routing near headers. */
85
- export const GROUP_HEADER_CONTENT_PAD = 12
86
-
87
- /** Padding inside node shapes */
88
- export const NODE_PADDING = {
89
- /** Horizontal padding inside rectangles/rounded/stadium (increased from 16 for better label fit) */
90
- horizontal: 20,
91
- /** Vertical padding inside rectangles/rounded/stadium */
92
- vertical: 10,
93
- /** Extra padding for diamond shapes (they need more space due to rotation) */
94
- diamondExtra: 24,
95
- } as const
96
-
97
- /** Stroke widths per element type (in px) */
98
- export const STROKE_WIDTHS = {
99
- outerBox: 1,
100
- innerBox: 0.75,
101
- /** Edge connector stroke (increased from 0.75 for better visibility) */
102
- connector: 1,
103
- } as const
104
-
105
- /**
106
- * Vertical shift applied to all text elements for font-agnostic centering.
107
- *
108
- * Instead of relying on `dominant-baseline="central"` (which each font interprets
109
- * differently based on its own ascent/descent metrics), we use the default alphabetic
110
- * baseline and shift down by 0.35em. This places the optical center of text at the
111
- * y coordinate, regardless of font family (Inter, JetBrains Mono, system fallbacks).
112
- *
113
- * The 0.35em value approximates the distance from alphabetic baseline to visual
114
- * center of Latin text. Using `em` units ensures it scales with font size.
115
- */
116
- export const TEXT_BASELINE_SHIFT = '0.35em' as const
117
-
118
- /** Arrow head dimensions — matches spec: 8px wide × ~5px tall */
119
- export const ARROW_HEAD = {
120
- width: 8,
121
- height: 5,
122
- } as const
123
-
124
- /**
125
- * SVG `<style>` block for a diagram: core's `buildStyleBlock` plus, when
126
- * `hasMonoFont` is set, the self-hosted mono font subset for the `.mono`
127
- * rule. The subset lives here rather than in `core` because only SVG output
128
- * embeds it (#1319).
129
- */
130
- export function buildSvgStyleBlock(
131
- font: string,
132
- hasMonoFont: boolean,
133
- nonce?: string,
134
- ): string {
135
- return buildStyleBlock(
136
- font,
137
- hasMonoFont
138
- ? {
139
- family: EMBEDDED_MONO_FONT_FAMILY,
140
- faceCss: EMBEDDED_MONO_FONT_FACE_CSS,
141
- }
142
- : false,
143
- nonce,
144
- )
145
- }
package/src/title-gaps.ts DELETED
@@ -1,161 +0,0 @@
1
- /**
2
- * Break an edge where it crosses a subgraph's title text.
3
- *
4
- * Layout treats a subgraph's title bar as plain padding, so an edge that
5
- * enters a box from above can run straight over the title. mermaid.js draws
6
- * such an edge through the text as well; here the edge is simply not painted
7
- * over the text itself, and picks up again on the other side. The edge stays
8
- * one element with its geometry untouched: a `<mask>` hides the stroke inside
9
- * the text box (#1239).
10
- */
11
-
12
- import { measureMultilineText } from '@zombie-mermaid/core'
13
- import type {
14
- PositionedEdge,
15
- PositionedGroup,
16
- Point,
17
- } from '@zombie-mermaid/core'
18
- import type { FontSizes } from './styles.ts'
19
- import { FONT_WEIGHTS } from './styles.ts'
20
-
21
- /** Left inset of the title text inside the box, matching `renderGroup`. */
22
- const TITLE_TEXT_INSET = 12
23
-
24
- /** Space left clear around the title text, in px. */
25
- const GAP_PADDING = 2
26
-
27
- export interface TitleTextBox {
28
- x: number
29
- y: number
30
- width: number
31
- height: number
32
- }
33
-
34
- function flattenGroups(groups: PositionedGroup[]): PositionedGroup[] {
35
- return groups.flatMap((g) => [g, ...flattenGroups(g.children)])
36
- }
37
-
38
- /** The rectangle each subgraph's title text occupies, padded by `GAP_PADDING`. */
39
- export function titleTextBoxes(
40
- groups: PositionedGroup[],
41
- fontSizes: FontSizes,
42
- ): TitleTextBox[] {
43
- const headerHeight = fontSizes.groupHeader + 16
44
- return flattenGroups(groups)
45
- .filter((g) => g.label !== '')
46
- .map((g) => {
47
- const text = measureMultilineText(
48
- g.label,
49
- fontSizes.groupHeader,
50
- FONT_WEIGHTS.groupHeader,
51
- )
52
- return {
53
- x: g.x + TITLE_TEXT_INSET - GAP_PADDING,
54
- y: g.y + headerHeight / 2 - text.height / 2 - GAP_PADDING,
55
- width: text.width + 2 * GAP_PADDING,
56
- height: text.height + 2 * GAP_PADDING,
57
- }
58
- })
59
- }
60
-
61
- /** Whether the segment a-b touches the rectangle (Liang-Barsky clipping). */
62
- function segmentHitsBox(a: Point, b: Point, box: TitleTextBox): boolean {
63
- const dx = b.x - a.x
64
- const dy = b.y - a.y
65
- const p = [-dx, dx, -dy, dy]
66
- const q = [
67
- a.x - box.x,
68
- box.x + box.width - a.x,
69
- a.y - box.y,
70
- box.y + box.height - a.y,
71
- ]
72
- let t0 = 0
73
- let t1 = 1
74
- for (let i = 0; i < 4; i++) {
75
- const pi = p[i]!
76
- const qi = q[i]!
77
- if (pi === 0) {
78
- if (qi < 0) return false
79
- } else {
80
- const t = qi / pi
81
- if (pi < 0) t0 = Math.max(t0, t)
82
- else t1 = Math.min(t1, t)
83
- if (t0 > t1) return false
84
- }
85
- }
86
- return true
87
- }
88
-
89
- /** The box without its `GAP_PADDING` clearance: the text itself. */
90
- function textOnly(box: TitleTextBox): TitleTextBox {
91
- return {
92
- x: box.x + GAP_PADDING,
93
- y: box.y + GAP_PADDING,
94
- width: box.width - 2 * GAP_PADDING,
95
- height: box.height - 2 * GAP_PADDING,
96
- }
97
- }
98
-
99
- /**
100
- * Whether any segment of `edge` passes through the text of any of the `boxes`.
101
- * The padding only widens the hole that is cut; an edge that merely passes
102
- * through the clearance beside the text does not need a gap.
103
- */
104
- export function edgeCrossesTitle(
105
- edge: PositionedEdge,
106
- boxes: TitleTextBox[],
107
- ): boolean {
108
- const pts = edge.points
109
- const texts = boxes.map(textOnly)
110
- for (let i = 0; i + 1 < pts.length; i++) {
111
- if (texts.some((box) => segmentHitsBox(pts[i]!, pts[i + 1]!, box))) {
112
- return true
113
- }
114
- }
115
- return false
116
- }
117
-
118
- /** Small stable hash (FNV-1a) for a mask id that is the same for the same boxes. */
119
- function hashBoxes(boxes: TitleTextBox[]): string {
120
- let h = 0x811c9dc5
121
- for (const ch of boxes
122
- .map((b) => `${b.x},${b.y},${b.width},${b.height}`)
123
- .join(';')) {
124
- h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193)
125
- }
126
- return (h >>> 0).toString(36)
127
- }
128
-
129
- export interface TitleGapMask {
130
- id: string
131
- /** The `<mask>` element, for `<defs>`. */
132
- markup: string
133
- }
134
-
135
- /**
136
- * The mask that hides edge strokes inside `boxes`. The id comes from the box
137
- * positions, so two inline diagrams never share an id with different content.
138
- *
139
- * `maskUnits="userSpaceOnUse"` is required: the default bounding-box units
140
- * give a straight vertical or horizontal edge a zero-size box, which masks the
141
- * whole edge away.
142
- */
143
- export function titleGapMask(
144
- boxes: TitleTextBox[],
145
- width: number,
146
- height: number,
147
- ): TitleGapMask {
148
- const id = `zm-title-gap-${hashBoxes(boxes)}`
149
- const holes = boxes
150
- .map(
151
- (b) =>
152
- ` <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="#000" />`,
153
- )
154
- .join('\n')
155
- const markup =
156
- ` <mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}">\n` +
157
- ` <rect x="0" y="0" width="${width}" height="${height}" fill="#fff" />\n` +
158
- `${holes}\n` +
159
- ` </mask>`
160
- return { id, markup }
161
- }