@zombie-mermaid/svg-renderer 3.2.0 → 4.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 (45) hide show
  1. package/dist/index.cjs +45 -41
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +34 -4
  4. package/dist/index.d.ts +34 -4
  5. package/dist/index.js +2310 -1115
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
  9. package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
  10. package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
  11. package/src/__tests__/c4-text-width-1210.test.ts +43 -0
  12. package/src/__tests__/class-component-order-1249.test.ts +40 -0
  13. package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
  14. package/src/__tests__/class-namespace-1196.test.ts +165 -0
  15. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
  16. package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
  17. package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
  18. package/src/__tests__/er-default-direction-1250.test.ts +69 -0
  19. package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
  20. package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
  21. package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
  22. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
  23. package/src/c4/arial-widths.ts +59 -0
  24. package/src/c4/layout.ts +234 -44
  25. package/src/c4/metrics.ts +56 -30
  26. package/src/c4/renderer.ts +1 -1
  27. package/src/class/layout.ts +178 -13
  28. package/src/class/renderer.ts +31 -2
  29. package/src/er/layout.ts +20 -4
  30. package/src/er/renderer.ts +10 -1
  31. package/src/layout-engine/back-edges.ts +73 -0
  32. package/src/layout-engine/compound-flat.ts +916 -0
  33. package/src/layout-engine/elk-graph-builder.ts +5 -4
  34. package/src/layout-engine/from-elk.ts +7 -2
  35. package/src/layout-engine/inner-edges.ts +329 -0
  36. package/src/layout-engine/layout-hints.ts +27 -0
  37. package/src/layout-engine/to-elk.ts +170 -20
  38. package/src/layout-engine.ts +38 -1
  39. package/src/registry.ts +2 -2
  40. package/src/renderer.ts +26 -2
  41. package/src/sequence/layout.ts +72 -4
  42. package/src/sequence/renderer.ts +155 -15
  43. package/src/title-gaps.ts +146 -0
  44. package/src/xychart/layout.ts +31 -2
  45. package/src/xychart/renderer.ts +1 -0
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderMermaidSVG } from '../../../../src/index.ts'
3
+
4
+ // The marker used to share the faint colour and regular weight of the colon,
5
+ // so `~` read as `-` at 1x (#1237). It now gets the member-name colour and a
6
+ // bold weight, in its own tspan.
7
+ describe('class diagram visibility markers (#1237)', () => {
8
+ const svg = renderMermaidSVG(`classDiagram
9
+ class User {
10
+ +String a
11
+ -String b
12
+ #String c
13
+ ~String d
14
+ }`)
15
+
16
+ for (const marker of ['+', '-', '#', '~']) {
17
+ it(`draws ${marker} in a bold, non-faint tspan`, () => {
18
+ expect(svg).toContain(
19
+ `<tspan fill="var(--_text-sec)" font-weight="700">${marker}</tspan>`,
20
+ )
21
+ })
22
+ }
23
+
24
+ it('keeps a faint spacer between marker and name', () => {
25
+ expect(svg).toContain('<tspan fill="var(--_text-faint)"> </tspan>')
26
+ })
27
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderMermaidSVG } from '../../../../src/index.ts'
3
+
4
+ // Mono glyphs advance 0.6em (6.6px at the 11px attribute size). Chromium
5
+ // without subpixel glyph positioning (headless Linux) rounds each advance to a
6
+ // whole pixel, so the worst-case drawn width is chars * ceil(6.6) = chars * 7.
7
+ // A box sized from the unrounded estimate let the type run into the
8
+ // right-aligned name (#1262, same cause as #1238).
9
+ const WORST_CASE_ADVANCE = 7
10
+ // The row text puts two spaces between type and name, so at worst-case
11
+ // advances the gap must not shrink below two whole-pixel glyphs.
12
+ const MIN_GAP_BETWEEN_TYPE_AND_NAME = 2 * WORST_CASE_ADVANCE
13
+
14
+ const SRC = `erDiagram
15
+ CUSTOMER {
16
+ varchar(255) emailAddressPrimary
17
+ string name
18
+ }`
19
+
20
+ const attr = (tag: string, name: string) => {
21
+ const start = tag.indexOf(`${name}="`) + name.length + 2
22
+ return Number(tag.slice(start, tag.indexOf('"', start)))
23
+ }
24
+
25
+ describe('ER attribute row padding (#1262)', () => {
26
+ const svg = renderMermaidSVG(SRC)
27
+ const texts = svg.split('<text')
28
+ const typeText = texts.find((t) => t.includes('varchar(255)')) as string
29
+ const nameText = texts.find((t) =>
30
+ t.includes('emailAddressPrimary'),
31
+ ) as string
32
+
33
+ it('finds the type and name text', () => {
34
+ expect(typeText).toBeDefined()
35
+ expect(nameText).toBeDefined()
36
+ })
37
+
38
+ it('leaves a gap between type and name even with whole-pixel glyph advances', () => {
39
+ const typeRight =
40
+ attr(typeText, 'x') + 'varchar(255)'.length * WORST_CASE_ADVANCE
41
+ const nameLeft =
42
+ attr(nameText, 'x') - 'emailAddressPrimary'.length * WORST_CASE_ADVANCE
43
+ expect(nameLeft - typeRight).toBeGreaterThanOrEqual(
44
+ MIN_GAP_BETWEEN_TYPE_AND_NAME,
45
+ )
46
+ })
47
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * #1250: an ER diagram with no `direction` statement laid out left-to-right,
3
+ * while official Mermaid lays it out top-to-bottom. The relationship line
4
+ * between two entities is the layout axis: vertical when stacked, horizontal
5
+ * when side by side.
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { renderMermaidSVG } from '@zombie-mermaid/svg-renderer'
9
+
10
+ const SOURCE = 'ORDER ||--|{ LINE_ITEM : contains'
11
+
12
+ function endpoints(svg: string): { dx: number; dy: number } {
13
+ const points = svg.match(/class="er-relationship"[^>]*points="([^"]+)"/)?.[1]
14
+ expect(points).toBeDefined()
15
+ const pts = (points ?? '').split(' ').map((p) => p.split(',').map(Number))
16
+ const first = pts[0] ?? []
17
+ const last = pts[pts.length - 1] ?? []
18
+ return {
19
+ dx: Math.abs((last[0] ?? 0) - (first[0] ?? 0)),
20
+ dy: Math.abs((last[1] ?? 0) - (first[1] ?? 0)),
21
+ }
22
+ }
23
+
24
+ describe('ER default layout direction (#1250)', () => {
25
+ it('stacks entities top-to-bottom when the source has no direction', () => {
26
+ const { dx, dy } = endpoints(renderMermaidSVG(`erDiagram\n ${SOURCE}`))
27
+ expect(dy).toBeGreaterThan(dx)
28
+ })
29
+
30
+ it('still honors an explicit `direction LR` in the source', () => {
31
+ const { dx, dy } = endpoints(
32
+ renderMermaidSVG(`erDiagram\n direction LR\n ${SOURCE}`),
33
+ )
34
+ expect(dx).toBeGreaterThan(dy)
35
+ })
36
+
37
+ it('still honors the direction render option', () => {
38
+ const { dx, dy } = endpoints(
39
+ renderMermaidSVG(`erDiagram\n ${SOURCE}`, { direction: 'LR' }),
40
+ )
41
+ expect(dx).toBeGreaterThan(dy)
42
+ })
43
+
44
+ it('keeps declaration order within a layer and centers a shared child', () => {
45
+ const svg = renderMermaidSVG(`erDiagram
46
+ CUSTOMER ||--o{ ORDER : places
47
+ ORDER ||--|{ LINE_ITEM : contains
48
+ PRODUCT ||--o{ LINE_ITEM : includes`)
49
+ const box = (id: string): { left: number; right: number } => {
50
+ const start = svg.indexOf(`data-id="${id}"`)
51
+ expect(start).toBeGreaterThanOrEqual(0)
52
+ const m = svg
53
+ .slice(start)
54
+ .match(/>\s*<rect x="([\d.]+)"[^>]*width="([\d.]+)"/)
55
+ expect(m).not.toBeNull()
56
+ const x = Number(m?.[1])
57
+ return { left: x, right: x + Number(m?.[2]) }
58
+ }
59
+ const order = box('ORDER')
60
+ const product = box('PRODUCT')
61
+ const lineItem = box('LINE_ITEM')
62
+ // ORDER is declared before PRODUCT, so it sits to its left.
63
+ expect(order.left).toBeLessThan(product.left)
64
+ // LINE_ITEM sits between its two parents, not off to one side.
65
+ const mid = (lineItem.left + lineItem.right) / 2
66
+ expect(mid).toBeGreaterThan(order.left)
67
+ expect(mid).toBeLessThan(product.right)
68
+ })
69
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * #1244: ER edge labels were drawn in `--_text-muted` (fg mixed at 40% into
3
+ * bg), ~2.4:1 on the default white background. Read the mix the renderer
4
+ * actually emits and assert it clears WCAG AA for text (4.5:1) against the
5
+ * default theme.
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { DEFAULTS, MIX } from '@zombie-mermaid/core'
9
+ import { renderMermaidSVG } from '@zombie-mermaid/svg-renderer'
10
+
11
+ type Rgb = readonly [number, number, number]
12
+
13
+ function channels(hex: string): Rgb {
14
+ const c = (i: number) => parseInt(hex.slice(i, i + 2), 16)
15
+ return [c(1), c(3), c(5)]
16
+ }
17
+
18
+ function luminance([r, g, b]: Rgb): number {
19
+ const lin = (c: number) => {
20
+ const v = c / 255
21
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
22
+ }
23
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
24
+ }
25
+
26
+ /** Contrast of `color-mix(in srgb, fg pct%, bg)` against bg. */
27
+ function mixContrast(pct: number): number {
28
+ const [fr, fgc, fb] = channels(DEFAULTS.fg)
29
+ const [br, bgc, bb] = channels(DEFAULTS.bg)
30
+ const mix = (f: number, b: number) => (f * pct + b * (100 - pct)) / 100
31
+ const mixed: Rgb = [mix(fr, br), mix(fgc, bgc), mix(fb, bb)]
32
+ const l1 = luminance(mixed)
33
+ const l2 = luminance([br, bgc, bb])
34
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
35
+ }
36
+
37
+ describe('ER edge label contrast (#1244)', () => {
38
+ const svg = renderMermaidSVG('erDiagram\n ORDER ||--|{ LINE_ITEM : contains')
39
+ const label = svg.match(/<text[^>]*>contains<\/text>/)?.[0] ?? ''
40
+ const percent = Number(
41
+ label.match(
42
+ /fill="color-mix\(in srgb, var\(--fg\) (\d+)%, var\(--bg\)\)"/,
43
+ )?.[1],
44
+ )
45
+
46
+ it('mixes the relationship label color from --fg and --bg', () => {
47
+ expect(Number.isFinite(percent)).toBe(true)
48
+ })
49
+
50
+ it('clears WCAG AA (4.5:1) with the default theme', () => {
51
+ expect(mixContrast(percent)).toBeGreaterThanOrEqual(4.5)
52
+ })
53
+
54
+ it('is darker than the shared muted/secondary text tokens that failed AA', () => {
55
+ expect(mixContrast(MIX.textMuted)).toBeLessThan(4.5)
56
+ expect(mixContrast(MIX.textSec)).toBeLessThan(4.5)
57
+ })
58
+ })
@@ -0,0 +1,162 @@
1
+ /**
2
+ * #1242: lifelines were drawn straight through message labels and block
3
+ * titles, and a stick-figure actor's label sat on the first message arrow.
4
+ * The visual suite's tolerance can't see either, so assert the geometry.
5
+ */
6
+ import { describe, it, expect } from 'vitest'
7
+ import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
8
+ import { renderMermaidSVG } from '../../../../src/index.ts'
9
+ import { layoutSequenceDiagram } from '../sequence/layout.ts'
10
+
11
+ function lifelineSegments(
12
+ svg: string,
13
+ actor: string,
14
+ ): Array<{ x: number; y1: number; y2: number }> {
15
+ const out: Array<{ x: number; y1: number; y2: number }> = []
16
+ const re =
17
+ /<line class="lifeline" data-actor="([^"]*)" x1="([\d.]+)" y1="([\d.]+)" x2="[\d.]+" y2="([\d.]+)"/g
18
+ for (const m of svg.matchAll(re)) {
19
+ if (m[1] !== actor) continue
20
+ out.push({ x: Number(m[2]), y1: Number(m[3]), y2: Number(m[4]) })
21
+ }
22
+ return out
23
+ }
24
+
25
+ /** Centre y of the `<text>` whose content is `label`. */
26
+ function textY(svg: string, label: string): number {
27
+ for (const m of svg.matchAll(
28
+ /<text [^>]*y="([\d.]+)"[^>]*>([^<]*)<\/text>/g,
29
+ )) {
30
+ if (m[2] === label) return Number(m[1])
31
+ }
32
+ return NaN
33
+ }
34
+
35
+ /** True when no lifeline segment of `actor` covers height `y`. */
36
+ function lifelineClearAt(svg: string, actor: string, y: number): boolean {
37
+ return lifelineSegments(svg, actor).every((s) => y < s.y1 || y > s.y2)
38
+ }
39
+
40
+ describe('lifelines stay out of message labels (#1242)', () => {
41
+ const svg = renderMermaidSVG(`sequenceDiagram
42
+ participant A
43
+ participant B
44
+ participant C
45
+ A->>B: first
46
+ A->>C: label crossing the middle lifeline
47
+ A->>B: next`)
48
+
49
+ it('cuts the crossed lifeline around the label instead of running through it', () => {
50
+ const labelY = textY(svg, 'label crossing the middle lifeline')
51
+ expect(lifelineSegments(svg, 'B').length).toBeGreaterThan(1)
52
+ expect(lifelineClearAt(svg, 'B', labelY)).toBe(true)
53
+ })
54
+
55
+ it('leaves a lifeline whole where no label crosses it', () => {
56
+ expect(lifelineSegments(svg, 'A')).toHaveLength(1)
57
+ })
58
+
59
+ it('cuts lifelines around a block title tab', () => {
60
+ const blockSvg = renderMermaidSVG(`sequenceDiagram
61
+ participant A
62
+ participant B
63
+ A->>B: hi
64
+ alt Multiple panels
65
+ B->>A: yes
66
+ end`)
67
+ // The tab sits at the block's top-left; the lifeline under it is cut.
68
+ const rect = blockSvg.match(
69
+ /<g class="block"[^>]*>\s*<rect x="([\d.]+)" y="([\d.]+)"/,
70
+ )
71
+ const blockY = Number(rect?.[2])
72
+ expect(lifelineSegments(blockSvg, 'A').length).toBeGreaterThan(1)
73
+ expect(lifelineClearAt(blockSvg, 'A', blockY + 9)).toBe(true)
74
+ })
75
+
76
+ it('cuts a lifeline beside a self-message label', () => {
77
+ const selfSvg = renderMermaidSVG(`sequenceDiagram
78
+ participant A
79
+ participant B
80
+ A->>A: a long self message label crossing B
81
+ A->>B: after`)
82
+ const y = textY(selfSvg, 'a long self message label crossing B')
83
+ expect(lifelineSegments(selfSvg, 'B').length).toBeGreaterThan(1)
84
+ expect(lifelineClearAt(selfSvg, 'B', y)).toBe(true)
85
+ })
86
+
87
+ it('cuts lifelines around a bare divider label', () => {
88
+ const divSvg = renderMermaidSVG(`sequenceDiagram
89
+ participant A
90
+ participant B
91
+ alt first
92
+ A->>B: x
93
+ else other case
94
+ B->>A: y
95
+ end`)
96
+ const y = textY(divSvg, '[other case]')
97
+ expect(lifelineSegments(divSvg, 'A').length).toBeGreaterThan(1)
98
+ expect(lifelineClearAt(divSvg, 'A', y)).toBe(true)
99
+ })
100
+
101
+ it('ignores unlabeled blocks and dividers', () => {
102
+ const bareSvg = renderMermaidSVG(`sequenceDiagram
103
+ participant A
104
+ participant B
105
+ loop
106
+ B->>A: ping
107
+ else
108
+ A->>B: pong
109
+ end`)
110
+ expect(lifelineSegments(bareSvg, 'A').length).toBeGreaterThan(0)
111
+ expect(lifelineSegments(bareSvg, 'B').length).toBeGreaterThan(0)
112
+ })
113
+
114
+ it('never drops a lifeline entirely, even under a label taller than it', () => {
115
+ const lines = Array.from({ length: 8 }, (_, i) => `l${i}`).join('<br/>')
116
+ const tall = renderMermaidSVG(`sequenceDiagram
117
+ participant A
118
+ participant B
119
+ participant C
120
+ A->>C: ${lines}`)
121
+ // The label spans B's whole lifeline; B keeps one uncut line.
122
+ expect(lifelineSegments(tall, 'B')).toHaveLength(1)
123
+ })
124
+ })
125
+
126
+ describe('stick-figure actor label clears the first message (#1242)', () => {
127
+ const src = `sequenceDiagram
128
+ actor U as User
129
+ participant S as System
130
+ U->>S: Click button`
131
+ const svg = renderMermaidSVG(src)
132
+ const layout = layoutSequenceDiagram(
133
+ parseSequenceDiagram(
134
+ src
135
+ .split('\n')
136
+ .slice(1)
137
+ .map((text, line) => ({ text: text.trim(), line })),
138
+ ),
139
+ )
140
+
141
+ it('starts the actor lifeline below its label', () => {
142
+ const [seg] = lifelineSegments(svg, 'U')
143
+ expect(seg!.y1).toBeGreaterThan(textY(svg, 'User') + 6)
144
+ })
145
+
146
+ it('places the first message arrow clear of the label', () => {
147
+ const first = layout.messages[0]!
148
+ // label is 13px-ish text centred at textY; arrow must be well below it
149
+ expect(first.y).toBeGreaterThan(textY(svg, 'User') + 16)
150
+ })
151
+
152
+ it('keeps the original compact header when no stick figure is used', () => {
153
+ const plain = layoutSequenceDiagram(
154
+ parseSequenceDiagram([
155
+ { text: 'participant A', line: 0 },
156
+ { text: 'participant B', line: 1 },
157
+ { text: 'A->>B: hi', line: 2 },
158
+ ]),
159
+ )
160
+ expect(plain.messages[0]!.y - (plain.actors[0]!.y + 40)).toBe(20)
161
+ })
162
+ })
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseSequenceDiagram } from '@zombie-mermaid/mermaid-parser'
3
+ import { renderMermaidSVG } from '../../../../src/index.ts'
4
+ import { layoutSequenceDiagram } from '../sequence/layout.ts'
5
+
6
+ const SRC = [
7
+ 'participant C as Client',
8
+ 'participant S as Server',
9
+ 'C->>+S: Request',
10
+ 'S->>+S: Process',
11
+ 'S->>-S: Done',
12
+ 'S-->>-C: Response',
13
+ ]
14
+
15
+ describe('nested self-message activation (#1241)', () => {
16
+ const layout = layoutSequenceDiagram(
17
+ parseSequenceDiagram(SRC.map((text, line) => ({ text, line }))),
18
+ )
19
+ const server = layout.activations.filter((a) => a.actorId === 'S')
20
+
21
+ it('yields two activations on the actor with depths 0 and 1', () => {
22
+ expect(server.map((a) => a.depth)).toEqual([0, 1])
23
+ })
24
+
25
+ it('offsets the nested bar right by half a bar, inside the parent span', () => {
26
+ const outer = server.find((a) => a.depth === 0)!
27
+ const inner = server.find((a) => a.depth === 1)!
28
+ expect(inner.x - outer.x).toBe(outer.width / 2)
29
+ expect(inner.topY).toBeGreaterThan(outer.topY)
30
+ expect(inner.bottomY).toBeLessThan(outer.bottomY)
31
+ })
32
+
33
+ it('draws both bars, outer first so the nested one is painted on top', () => {
34
+ const svg = renderMermaidSVG(`sequenceDiagram\n ${SRC.join('\n ')}`)
35
+ expect(svg.match(/class="activation"/g)).toHaveLength(2)
36
+ })
37
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * #1243: with many categories the x-axis labels touched. The visual suite's
3
+ * tolerance can't see labels moving a few pixels, so assert on the layout.
4
+ */
5
+ import { describe, it, expect } from 'vitest'
6
+ import { parseXYChart } from '@zombie-mermaid/mermaid-parser'
7
+ import { splitStatements } from '@zombie-mermaid/core'
8
+ import { layoutXYChart } from '../xychart/layout.ts'
9
+ import { estimateTextWidth } from '../styles.ts'
10
+
11
+ function layout(src: string) {
12
+ return layoutXYChart(parseXYChart(splitStatements(src)))
13
+ }
14
+
15
+ function shown(src: string) {
16
+ return layout(src).xAxis.ticks.filter((t) => t.label !== '')
17
+ }
18
+
19
+ function minGap(ticks: ReturnType<typeof shown>): number {
20
+ let gap = Infinity
21
+ for (let i = 1; i < ticks.length; i++) {
22
+ const a = ticks[i - 1]!
23
+ const b = ticks[i]!
24
+ const half =
25
+ (estimateTextWidth(a.label, 14, 400) +
26
+ estimateTextWidth(b.label, 14, 400)) /
27
+ 2
28
+ gap = Math.min(gap, b.labelX - a.labelX - half)
29
+ }
30
+ return gap
31
+ }
32
+
33
+ const QUARTERS = `xychart-beta
34
+ x-axis [21Q1, 21Q2, 21Q3, 21Q4, 22Q1, 22Q2, 22Q3, 22Q4, 23Q1, 23Q2, 23Q3, 23Q4, 24Q1, 24Q2, 24Q3, 24Q4]
35
+ bar [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]`
36
+
37
+ describe('xychart x-axis label spacing (#1243)', () => {
38
+ it('drops labels so adjacent shown labels do not touch', () => {
39
+ const ticks = shown(QUARTERS)
40
+ expect(ticks.length).toBeLessThan(16)
41
+ expect(ticks.length).toBeGreaterThan(1)
42
+ expect(minGap(ticks)).toBeGreaterThan(0)
43
+ })
44
+
45
+ it('keeps every label when they all fit', () => {
46
+ const src = `xychart-beta
47
+ x-axis [Q1, Q2, Q3, Q4]
48
+ bar [1, 2, 3, 4]`
49
+ expect(shown(src).map((t) => t.label)).toEqual(['Q1', 'Q2', 'Q3', 'Q4'])
50
+ })
51
+
52
+ it('does not thin a roomy axis because of one long label', () => {
53
+ const src = `xychart-beta
54
+ x-axis [A, B, "A Rather Long Category", D, E]
55
+ bar [1, 2, 3, 4, 5]`
56
+ expect(shown(src)).toHaveLength(5)
57
+ })
58
+
59
+ it('keeps the tick marks of dropped labels', () => {
60
+ expect(layout(QUARTERS).xAxis.ticks).toHaveLength(16)
61
+ })
62
+ })
@@ -0,0 +1,59 @@
1
+ // Advance widths, in 1/2048 em, of Arial (the metrics Liberation Sans and
2
+ // Helvetica share) for the printable ASCII range, regular and bold. Mermaid's
3
+ // C4 renderer measures its text in the browser, and the 32 diagrams recorded
4
+ // in `__tests__/fixtures/c4-mermaid-reference.json` are laid out to within a
5
+ // fraction of a pixel by summing these advances, which a scaled copy of this
6
+ // package's own Inter-based estimate could not do (see `c4TextWidth`).
7
+ const UNITS_PER_EM = 2048
8
+
9
+ const REGULAR =
10
+ // Indexed from U+0020: ` !"#$%&'()*+,-./`
11
+ [
12
+ 569, 569, 727, 1139, 1139, 1821, 1366, 391, 682, 682, 797, 1196, 569, 682,
13
+ 569, 569,
14
+ // 0-9
15
+ 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139,
16
+ // :;<=>?@
17
+ 569, 569, 1196, 1196, 1196, 1139, 2079,
18
+ // A-Z
19
+ 1366, 1366, 1479, 1479, 1366, 1251, 1593, 1479, 569, 1024, 1366, 1139, 1706,
20
+ 1479, 1593, 1366, 1593, 1479, 1366, 1251, 1479, 1366, 1933, 1366, 1366,
21
+ 1251,
22
+ // [\]^_`
23
+ 569, 569, 569, 961, 1139, 682,
24
+ // a-z
25
+ 1139, 1139, 1024, 1139, 1139, 569, 1139, 1139, 455, 455, 1024, 455, 1706,
26
+ 1139, 1139, 1139, 1139, 682, 1024, 569, 1139, 1024, 1479, 1024, 1024, 1024,
27
+ // {|}~
28
+ 684, 532, 684, 1196,
29
+ ]
30
+
31
+ const BOLD = [
32
+ // ` !"#$%&'()*+,-./`
33
+ 569, 682, 971, 1139, 1139, 1821, 1479, 487, 682, 682, 797, 1196, 569, 682,
34
+ 569, 569,
35
+ // 0-9
36
+ 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139, 1139,
37
+ // :;<=>?@
38
+ 682, 682, 1196, 1196, 1196, 1251, 1992,
39
+ // A-Z
40
+ 1479, 1479, 1479, 1479, 1366, 1251, 1593, 1479, 569, 1139, 1479, 1251, 1706,
41
+ 1479, 1593, 1366, 1593, 1479, 1366, 1251, 1479, 1366, 1933, 1366, 1366, 1251,
42
+ // [\]^_`
43
+ 682, 569, 682, 1196, 1139, 682,
44
+ // a-z
45
+ 1139, 1251, 1139, 1251, 1139, 682, 1251, 1251, 569, 569, 1139, 569, 1821,
46
+ 1251, 1251, 1251, 1251, 797, 1139, 682, 1251, 1139, 1593, 1139, 1139, 1024,
47
+ // {|}~
48
+ 801, 573, 801, 1196,
49
+ ]
50
+
51
+ /**
52
+ * Width in em of `ch`, or `undefined` outside printable ASCII (the caller
53
+ * falls back to its own estimate).
54
+ */
55
+ export function arialAdvance(ch: string, bold: boolean): number | undefined {
56
+ const code = ch.codePointAt(0)!
57
+ if (code < 0x20 || code > 0x7e) return undefined
58
+ return (bold ? BOLD : REGULAR)[code - 0x20]! / UNITS_PER_EM
59
+ }