@zombie-mermaid/core 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/LICENSE +22 -0
- package/dist/index.cjs +29 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1236 -0
- package/dist/index.d.ts +1236 -0
- package/dist/index.js +888 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/click-directive.test.ts +63 -0
- package/src/__tests__/color-utils.test.ts +186 -0
- package/src/__tests__/diagram-type.test.ts +28 -0
- package/src/__tests__/text-metrics.test.ts +486 -0
- package/src/__tests__/theme-palette-contrast.test.ts +84 -0
- package/src/__tests__/theme.test.ts +162 -0
- package/src/click-directive.ts +106 -0
- package/src/color-utils.ts +205 -0
- package/src/diagram-type.ts +35 -0
- package/src/direction-override.ts +46 -0
- package/src/direction.ts +56 -0
- package/src/generated/mono-font-subset.ts +32 -0
- package/src/index.ts +34 -0
- package/src/init-directive.ts +248 -0
- package/src/multiline-utils.ts +275 -0
- package/src/statements.ts +197 -0
- package/src/style-directives.ts +214 -0
- package/src/text-metrics.ts +520 -0
- package/src/theme.ts +712 -0
- package/src/types.ts +670 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { detectDiagramType } from '@zombie-mermaid/core'
|
|
3
|
+
|
|
4
|
+
describe('detectDiagramType', () => {
|
|
5
|
+
it('detects each diagram type from its header keyword', () => {
|
|
6
|
+
expect(detectDiagramType('flowchart TD\nA-->B')).toBe('flowchart')
|
|
7
|
+
expect(detectDiagramType('sequenceDiagram\nA->>B: Hi')).toBe('sequence')
|
|
8
|
+
expect(detectDiagramType('classDiagram\nclass Foo')).toBe('class')
|
|
9
|
+
expect(detectDiagramType('erDiagram\nFOO ||--o{ BAR : has')).toBe('er')
|
|
10
|
+
expect(detectDiagramType('xychart-beta\nx-axis [a, b]')).toBe('xychart')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('isolates the header on a semicolon-separated single line', () => {
|
|
14
|
+
expect(detectDiagramType('sequenceDiagram;A->>B: Hi')).toBe('sequence')
|
|
15
|
+
expect(detectDiagramType('classDiagram;class Foo')).toBe('class')
|
|
16
|
+
expect(detectDiagramType('erDiagram;FOO ||--o{ BAR : has')).toBe('er')
|
|
17
|
+
expect(detectDiagramType('flowchart TD;A-->B')).toBe('flowchart')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('does not match an xychart header with an unsupported suffix', () => {
|
|
21
|
+
expect(detectDiagramType('xychart-foo\nx-axis [a, b]')).toBe('flowchart')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to flowchart for unrecognized input', () => {
|
|
25
|
+
expect(detectDiagramType('')).toBe('flowchart')
|
|
26
|
+
expect(detectDiagramType('not a real header')).toBe('flowchart')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for text-metrics module — variable-width character measurement.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
getCharWidth,
|
|
7
|
+
measureTextWidth,
|
|
8
|
+
isWideChar,
|
|
9
|
+
hasMeasuredAdvance,
|
|
10
|
+
} from '@zombie-mermaid/core'
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Character width classification
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
describe('getCharWidth', () => {
|
|
17
|
+
describe('narrow characters', () => {
|
|
18
|
+
it('measures thin letters as narrow (i, l, t, f, j, I)', () => {
|
|
19
|
+
for (const ch of ['i', 'l', 't', 'f', 'j', 'I']) {
|
|
20
|
+
expect(getCharWidth(ch), ch).toBeLessThan(0.7)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('measures thin punctuation as narrow', () => {
|
|
25
|
+
for (const ch of ['!', '|', '.', ',', ':', ';', "'", '1']) {
|
|
26
|
+
expect(getCharWidth(ch), ch).toBeLessThan(0.8)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('measures r as semi-narrow', () => {
|
|
31
|
+
expect(getCharWidth('r')).toBeGreaterThan(getCharWidth('i'))
|
|
32
|
+
expect(getCharWidth('r')).toBeLessThan(getCharWidth('a'))
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('normal characters', () => {
|
|
37
|
+
it('measures average lowercase letters near 1.0', () => {
|
|
38
|
+
for (const ch of ['a', 'e', 'o', 'n', 's']) {
|
|
39
|
+
expect(getCharWidth(ch), ch).toBeGreaterThan(0.9)
|
|
40
|
+
expect(getCharWidth(ch), ch).toBeLessThan(1.15)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('measures digits near 1.0-1.2 (tabular width)', () => {
|
|
45
|
+
for (const ch of ['0', '2', '9']) {
|
|
46
|
+
expect(getCharWidth(ch), ch).toBeGreaterThan(1.0)
|
|
47
|
+
expect(getCharWidth(ch), ch).toBeLessThan(1.2)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('wide characters', () => {
|
|
53
|
+
it('measures uppercase letters wider than lowercase (except I)', () => {
|
|
54
|
+
for (const ch of ['A', 'B', 'N', 'Z']) {
|
|
55
|
+
expect(getCharWidth(ch), ch).toBeGreaterThan(1.1)
|
|
56
|
+
expect(getCharWidth(ch), ch).toBeLessThan(1.45)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('measures wide lowercase (w, m) above 1.4', () => {
|
|
61
|
+
expect(getCharWidth('w')).toBeGreaterThan(1.4)
|
|
62
|
+
expect(getCharWidth('m')).toBeGreaterThan(1.4)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('measures very wide characters (W, M) above 1.6', () => {
|
|
66
|
+
expect(getCharWidth('W')).toBeGreaterThan(1.6)
|
|
67
|
+
expect(getCharWidth('M')).toBeGreaterThan(1.6)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('space', () => {
|
|
72
|
+
it('measures space at its real Inter advance (~0.52)', () => {
|
|
73
|
+
// The old bucket value of 0.3 underestimated every word gap by ~1.3px
|
|
74
|
+
expect(getCharWidth(' ')).toBeCloseTo(0.521, 3)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('previously-omitted punctuation (measured Inter advances)', () => {
|
|
79
|
+
// Real Inter advances (canvas measureText, headless Chrome, weight 400,
|
|
80
|
+
// same method as the rest of INTER_ADVANCES), measured at 100px and
|
|
81
|
+
// normalized by the weight-400 baseRatio (0.54): ratio = px / (100 * 0.54).
|
|
82
|
+
// Before this table entry existed, these characters fell through to the
|
|
83
|
+
// old coarse bucket (`) or the 1.0 default (all others), which could
|
|
84
|
+
// undersize labels with repeated occurrences of these characters.
|
|
85
|
+
it.each([
|
|
86
|
+
['#', 1.173],
|
|
87
|
+
['$', 1.188],
|
|
88
|
+
['&', 1.193],
|
|
89
|
+
['*', 0.928],
|
|
90
|
+
['<', 1.225],
|
|
91
|
+
['=', 1.225],
|
|
92
|
+
['>', 1.225],
|
|
93
|
+
['?', 0.947],
|
|
94
|
+
['^', 0.873],
|
|
95
|
+
['`', 0.598],
|
|
96
|
+
['~', 1.225],
|
|
97
|
+
])('measures %s at its real Inter advance', (ch, expected) => {
|
|
98
|
+
expect(getCharWidth(ch)).toBeCloseTo(expected, 3)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('table completeness', () => {
|
|
103
|
+
it('has an exact measured Inter advance for every printable ASCII character (U+0020–U+007E)', () => {
|
|
104
|
+
const missing: string[] = []
|
|
105
|
+
for (let code = 0x20; code <= 0x7e; code++) {
|
|
106
|
+
const ch = String.fromCharCode(code)
|
|
107
|
+
if (!hasMeasuredAdvance(ch))
|
|
108
|
+
missing.push(`U+${code.toString(16).padStart(4, '0')} (${ch})`)
|
|
109
|
+
}
|
|
110
|
+
expect(missing).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('inherited Object.prototype keys', () => {
|
|
115
|
+
// INTER_ADVANCES is a plain object literal, so bracket access with
|
|
116
|
+
// `!== undefined` also matches inherited Object.prototype properties
|
|
117
|
+
// like 'toString' and 'constructor' — they are not real table entries,
|
|
118
|
+
// but a naive `obj[key] !== undefined` check can't tell the difference.
|
|
119
|
+
it.each(['toString', 'constructor', 'hasOwnProperty', '__proto__'])(
|
|
120
|
+
'hasMeasuredAdvance(%s) is false — not an own property of the table',
|
|
121
|
+
(key) => {
|
|
122
|
+
expect(hasMeasuredAdvance(key)).toBe(false)
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
it('getCharWidth never returns an inherited property instead of a measured width', () => {
|
|
127
|
+
// Without an own-property check, `INTER_ADVANCES['toString'] !== undefined`
|
|
128
|
+
// is true (it resolves Object.prototype.toString), so getCharWidth('toString')
|
|
129
|
+
// would return that function instead of a number. With the own-property
|
|
130
|
+
// check in place, the direct-table lookup correctly misses, and the
|
|
131
|
+
// multi-character string instead falls through to the (unrelated,
|
|
132
|
+
// NFD-based) accented-Latin branch, which matches on its first
|
|
133
|
+
// normalized character ('t') — still a real measured number, never a
|
|
134
|
+
// function.
|
|
135
|
+
const result = getCharWidth('toString')
|
|
136
|
+
expect(typeof result).toBe('number')
|
|
137
|
+
expect(result).toBe(getCharWidth('t'))
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('combining marks (zero-width)', () => {
|
|
142
|
+
it('returns 0 for combining diacritical marks', () => {
|
|
143
|
+
// U+0301 = combining acute accent
|
|
144
|
+
expect(getCharWidth('\u0301')).toBe(0)
|
|
145
|
+
// U+0308 = combining diaeresis
|
|
146
|
+
expect(getCharWidth('\u0308')).toBe(0)
|
|
147
|
+
// U+0327 = combining cedilla
|
|
148
|
+
expect(getCharWidth('\u0327')).toBe(0)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('accented characters (precomposed)', () => {
|
|
153
|
+
it('returns the base letter width for precomposed accented letters', () => {
|
|
154
|
+
// Accents don't change the advance — é measures the same as e
|
|
155
|
+
expect(getCharWidth('é')).toBe(getCharWidth('e')) // U+00E9
|
|
156
|
+
expect(getCharWidth('ñ')).toBe(getCharWidth('n')) // U+00F1
|
|
157
|
+
expect(getCharWidth('ü')).toBe(getCharWidth('u')) // U+00FC
|
|
158
|
+
expect(getCharWidth('ç')).toBe(getCharWidth('c')) // U+00E7
|
|
159
|
+
expect(getCharWidth('ö')).toBe(getCharWidth('o')) // U+00F6
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('falls through to the default character-class logic when the NFD base is not itself a measured ASCII letter', () => {
|
|
163
|
+
// U+0439 (й, Cyrillic "short i") NFD-decomposes to U+0438 (и) + a
|
|
164
|
+
// combining breve. The base "и" differs from the char, but it is a
|
|
165
|
+
// Cyrillic letter, not one of the printable-ASCII keys in
|
|
166
|
+
// INTER_ADVANCES — so the lookup misses and getCharWidth must fall
|
|
167
|
+
// through to the coarse character-class buckets below instead of
|
|
168
|
+
// returning `undefined`-derived garbage.
|
|
169
|
+
const char = 'й'
|
|
170
|
+
const base = char.normalize('NFD')[0]
|
|
171
|
+
if (base === undefined)
|
|
172
|
+
throw new Error(
|
|
173
|
+
'unreachable: normalize() of a non-empty string always has index 0',
|
|
174
|
+
)
|
|
175
|
+
expect(base).not.toBe(char) // sanity: this char does decompose
|
|
176
|
+
expect(hasMeasuredAdvance(base)).toBe(false) // sanity: base has no exact advance
|
|
177
|
+
|
|
178
|
+
// None of the ASCII character-class buckets match a Cyrillic letter,
|
|
179
|
+
// and its code point (0x0439) is outside both the A-Z and 0-9 ranges,
|
|
180
|
+
// so it lands on the final default width.
|
|
181
|
+
expect(getCharWidth(char)).toBe(1.0)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('CJK characters (fullwidth)', () => {
|
|
186
|
+
it('returns 2.0 for CJK ideographs', () => {
|
|
187
|
+
expect(getCharWidth('中')).toBe(2.0) // U+4E2D
|
|
188
|
+
expect(getCharWidth('国')).toBe(2.0) // U+56FD
|
|
189
|
+
expect(getCharWidth('字')).toBe(2.0) // U+5B57
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('returns 2.0 for Japanese hiragana/katakana', () => {
|
|
193
|
+
expect(getCharWidth('あ')).toBe(2.0) // Hiragana
|
|
194
|
+
expect(getCharWidth('ア')).toBe(2.0) // Katakana
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('returns 2.0 for Korean hangul', () => {
|
|
198
|
+
expect(getCharWidth('한')).toBe(2.0) // U+D55C
|
|
199
|
+
expect(getCharWidth('글')).toBe(2.0) // U+AE00
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('emoji (fullwidth)', () => {
|
|
204
|
+
it('returns 2.0 for common emoji', () => {
|
|
205
|
+
expect(getCharWidth('😀')).toBe(2.0)
|
|
206
|
+
expect(getCharWidth('🚀')).toBe(2.0)
|
|
207
|
+
expect(getCharWidth('❤')).toBe(2.0)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('edge cases', () => {
|
|
212
|
+
it('returns 0 for empty string', () => {
|
|
213
|
+
expect(getCharWidth('')).toBe(0)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// Text width measurement
|
|
220
|
+
// ============================================================================
|
|
221
|
+
|
|
222
|
+
describe('measureTextWidth', () => {
|
|
223
|
+
const fontSize = 13
|
|
224
|
+
const fontWeight = 500
|
|
225
|
+
const baseRatio = 0.57 // weight 500 (was 0.55, increased for edge truncation safety)
|
|
226
|
+
const minPadding = fontSize * 0.15 // minimum padding added to prevent truncation (increased for label separation)
|
|
227
|
+
|
|
228
|
+
it('returns minPadding for empty text', () => {
|
|
229
|
+
// Empty text still gets minimum padding to prevent edge truncation
|
|
230
|
+
expect(measureTextWidth('', fontSize, fontWeight)).toBeCloseTo(
|
|
231
|
+
minPadding,
|
|
232
|
+
1,
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Sum of per-char ratios — verifies the formula without pinning calibration
|
|
237
|
+
const ratioSum = (text: string) =>
|
|
238
|
+
[...text].reduce((s, ch) => s + getCharWidth(ch), 0)
|
|
239
|
+
|
|
240
|
+
it('handles lowercase text with narrow letters', () => {
|
|
241
|
+
const width = measureTextWidth('hello', fontSize, fontWeight)
|
|
242
|
+
expect(width).toBeCloseTo(
|
|
243
|
+
ratioSum('hello') * fontSize * baseRatio + minPadding,
|
|
244
|
+
1,
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('narrow text is narrower than uniform estimate', () => {
|
|
249
|
+
// "illiterate" has many narrow chars (i, l, t)
|
|
250
|
+
const narrow = measureTextWidth('illicit', fontSize, fontWeight)
|
|
251
|
+
const uniform = 'illicit'.length * fontSize * baseRatio
|
|
252
|
+
expect(narrow).toBeLessThan(uniform)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('wide text is wider than uniform estimate', () => {
|
|
256
|
+
// "MAMMOTH" has wide chars (M, A, O)
|
|
257
|
+
const wide = measureTextWidth('MAMMOTH', fontSize, fontWeight)
|
|
258
|
+
const uniform = 'MAMMOTH'.length * fontSize * baseRatio
|
|
259
|
+
expect(wide).toBeGreaterThan(uniform)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('handles mixed Latin text', () => {
|
|
263
|
+
const width = measureTextWidth('Will', fontSize, fontWeight)
|
|
264
|
+
expect(width).toBeCloseTo(
|
|
265
|
+
ratioSum('Will') * fontSize * baseRatio + minPadding,
|
|
266
|
+
1,
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('handles spaces correctly', () => {
|
|
271
|
+
const width = measureTextWidth('a b', fontSize, fontWeight)
|
|
272
|
+
expect(width).toBeCloseTo(
|
|
273
|
+
ratioSum('a b') * fontSize * baseRatio + minPadding,
|
|
274
|
+
1,
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('handles decomposed accents (base + combining mark)', () => {
|
|
279
|
+
// "café" with decomposed é = c + a + f + e + combining accent
|
|
280
|
+
// Should be same width as "cafe" since combining mark is zero-width
|
|
281
|
+
const decomposed = 'cafe\u0301' // e + combining acute
|
|
282
|
+
const precomposed = 'café'
|
|
283
|
+
const widthDecomposed = measureTextWidth(decomposed, fontSize, fontWeight)
|
|
284
|
+
const widthPrecomposed = measureTextWidth(precomposed, fontSize, fontWeight)
|
|
285
|
+
expect(widthDecomposed).toBeCloseTo(widthPrecomposed, 1)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('handles CJK text', () => {
|
|
289
|
+
// "中国" = 2 chars × 2.0 width = 4.0
|
|
290
|
+
const width = measureTextWidth('中国', fontSize, fontWeight)
|
|
291
|
+
expect(width).toBeCloseTo(4.0 * fontSize * baseRatio + minPadding, 1)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('handles mixed Latin and CJK', () => {
|
|
295
|
+
const width = measureTextWidth('Hello中国', fontSize, fontWeight)
|
|
296
|
+
expect(width).toBeCloseTo(
|
|
297
|
+
ratioSum('Hello中国') * fontSize * baseRatio + minPadding,
|
|
298
|
+
1,
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('heavier weights produce wider estimates', () => {
|
|
303
|
+
const regular = measureTextWidth('Test', fontSize, 400)
|
|
304
|
+
const medium = measureTextWidth('Test', fontSize, 500)
|
|
305
|
+
const bold = measureTextWidth('Test', fontSize, 600)
|
|
306
|
+
|
|
307
|
+
expect(medium).toBeGreaterThan(regular)
|
|
308
|
+
expect(bold).toBeGreaterThan(medium)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('scales with font size', () => {
|
|
312
|
+
const small = measureTextWidth('Test', 11, fontWeight)
|
|
313
|
+
const large = measureTextWidth('Test', 16, fontWeight)
|
|
314
|
+
|
|
315
|
+
expect(large).toBeGreaterThan(small)
|
|
316
|
+
expect(large / small).toBeCloseTo(16 / 11, 1)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// ============================================================================
|
|
321
|
+
// Calibration against real Inter metrics
|
|
322
|
+
// ============================================================================
|
|
323
|
+
//
|
|
324
|
+
// Fixtures measured with canvas measureText in headless Chrome 138 with the
|
|
325
|
+
// real Inter font loaded (400 weight, 11px — the edge-label spec). If the
|
|
326
|
+
// estimator underestimates these, edge-label text overflows its 8px-padded
|
|
327
|
+
// background rect; if it grossly overestimates, layout gets bloated.
|
|
328
|
+
|
|
329
|
+
describe('calibration against real Inter widths (11px, weight 400)', () => {
|
|
330
|
+
const REAL_INTER_WIDTHS: Record<string, number> = {
|
|
331
|
+
yes: 18.19,
|
|
332
|
+
no: 13.09,
|
|
333
|
+
'on failure': 48.47,
|
|
334
|
+
'validates credentials': 106.56,
|
|
335
|
+
'sends confirmation email': 130.6,
|
|
336
|
+
'asynchronous message processing': 184.1,
|
|
337
|
+
'returns HTTP 401 Unauthorized response': 215.65,
|
|
338
|
+
'WRITES TO DATABASE': 119.44,
|
|
339
|
+
'user_id + session_token validation': 177.51,
|
|
340
|
+
'retry with exponential backoff (max 5)': 197.49,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
it('never underestimates real width by more than 3%', () => {
|
|
344
|
+
for (const [text, real] of Object.entries(REAL_INTER_WIDTHS)) {
|
|
345
|
+
const est = measureTextWidth(text, 11, 400)
|
|
346
|
+
expect(
|
|
347
|
+
est,
|
|
348
|
+
`"${text}" est ${est.toFixed(1)} vs real ${real}`,
|
|
349
|
+
).toBeGreaterThanOrEqual(real * 0.97)
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('never overestimates real width by more than 12% + 3px', () => {
|
|
354
|
+
for (const [text, real] of Object.entries(REAL_INTER_WIDTHS)) {
|
|
355
|
+
const est = measureTextWidth(text, 11, 400)
|
|
356
|
+
expect(
|
|
357
|
+
est,
|
|
358
|
+
`"${text}" est ${est.toFixed(1)} vs real ${real}`,
|
|
359
|
+
).toBeLessThanOrEqual(real * 1.12 + 3)
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('preserves at least 6px of the nominal 8px edge-label padding per side', () => {
|
|
364
|
+
// Edge-label background rect = estimated width + 8px padding each side.
|
|
365
|
+
// The visible gap between glyphs and rect wall must stay close to 8px.
|
|
366
|
+
const EDGE_LABEL_PADDING = 8
|
|
367
|
+
for (const [text, real] of Object.entries(REAL_INTER_WIDTHS)) {
|
|
368
|
+
const est = measureTextWidth(text, 11, 400)
|
|
369
|
+
const gapPerSide = (est + EDGE_LABEL_PADDING * 2 - real) / 2
|
|
370
|
+
expect(
|
|
371
|
+
gapPerSide,
|
|
372
|
+
`"${text}" gap ${gapPerSide.toFixed(1)}px`,
|
|
373
|
+
).toBeGreaterThanOrEqual(6)
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// ============================================================================
|
|
379
|
+
// Real-world examples
|
|
380
|
+
// ============================================================================
|
|
381
|
+
|
|
382
|
+
describe('real-world text examples', () => {
|
|
383
|
+
const fontSize = 13
|
|
384
|
+
const fontWeight = 500
|
|
385
|
+
|
|
386
|
+
it('handles typical node labels', () => {
|
|
387
|
+
const labels = ['User', 'Database', 'API Gateway', 'Load Balancer']
|
|
388
|
+
for (const label of labels) {
|
|
389
|
+
const width = measureTextWidth(label, fontSize, fontWeight)
|
|
390
|
+
expect(width).toBeGreaterThan(0)
|
|
391
|
+
// Width should be reasonable (not too small or too large)
|
|
392
|
+
expect(width).toBeGreaterThan(label.length * 3)
|
|
393
|
+
expect(width).toBeLessThan(label.length * 15)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('handles Japanese labels', () => {
|
|
398
|
+
const width = measureTextWidth('データベース', fontSize, fontWeight)
|
|
399
|
+
// 6 CJK chars × 2.0 × 13 × 0.57 + minPadding
|
|
400
|
+
const baseRatio = 0.57
|
|
401
|
+
const minPadding = fontSize * 0.15
|
|
402
|
+
expect(width).toBeCloseTo(6 * 2.0 * fontSize * baseRatio + minPadding, 1)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('handles Hungarian text with accents', () => {
|
|
406
|
+
const width = measureTextWidth('Üdvözöljük', fontSize, fontWeight)
|
|
407
|
+
expect(width).toBeGreaterThan(0)
|
|
408
|
+
// Should be similar to unaccented version (within 5% difference)
|
|
409
|
+
const unaccented = measureTextWidth('Udvozoljuk', fontSize, fontWeight)
|
|
410
|
+
const percentDiff = Math.abs(width - unaccented) / unaccented
|
|
411
|
+
expect(percentDiff).toBeLessThan(0.05)
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// ============================================================================
|
|
416
|
+
// isWideChar — shared wide-character detection (used by the ASCII grid's
|
|
417
|
+
// display-width helpers in src/ascii/display-width.ts)
|
|
418
|
+
// ============================================================================
|
|
419
|
+
|
|
420
|
+
describe('isWideChar', () => {
|
|
421
|
+
it('returns true for CJK ideographs, kana, and hangul', () => {
|
|
422
|
+
expect(isWideChar('日')).toBe(true)
|
|
423
|
+
expect(isWideChar('本')).toBe(true)
|
|
424
|
+
expect(isWideChar('語')).toBe(true)
|
|
425
|
+
expect(isWideChar('テ')).toBe(true) // katakana
|
|
426
|
+
expect(isWideChar('あ')).toBe(true) // hiragana
|
|
427
|
+
expect(isWideChar('한')).toBe(true) // hangul syllable
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('returns true for fullwidth-form characters', () => {
|
|
431
|
+
expect(isWideChar('A')).toBe(true) // fullwidth Latin A (U+FF21)
|
|
432
|
+
expect(isWideChar('1')).toBe(true) // fullwidth digit 1 (U+FF11)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('returns true for emoji', () => {
|
|
436
|
+
expect(isWideChar('😀')).toBe(true)
|
|
437
|
+
expect(isWideChar('🎉')).toBe(true)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('returns false for ASCII letters, digits, and punctuation', () => {
|
|
441
|
+
expect(isWideChar('A')).toBe(false)
|
|
442
|
+
expect(isWideChar('a')).toBe(false)
|
|
443
|
+
expect(isWideChar('1')).toBe(false)
|
|
444
|
+
expect(isWideChar(' ')).toBe(false)
|
|
445
|
+
expect(isWideChar('.')).toBe(false)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('returns false for accented Latin characters', () => {
|
|
449
|
+
expect(isWideChar('Ü')).toBe(false)
|
|
450
|
+
expect(isWideChar('é')).toBe(false)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('returns false for arrowhead and box-drawing glyphs used by the ASCII renderer', () => {
|
|
454
|
+
// These are Extended_Pictographic (emoji-capable) but have no default
|
|
455
|
+
// Emoji_Presentation and render as a single narrow column in every real
|
|
456
|
+
// terminal. Regression test for the ▶ (U+25B6) misclassification —
|
|
457
|
+
// used as the sequence-diagram arrowhead in src/ascii/sequence.ts.
|
|
458
|
+
expect(isWideChar('▶')).toBe(false) // solid ->> / -->> arrowhead
|
|
459
|
+
expect(isWideChar('◀')).toBe(false) // solid self-message loop arrowhead
|
|
460
|
+
expect(isWideChar('▼')).toBe(false)
|
|
461
|
+
expect(isWideChar('▲')).toBe(false)
|
|
462
|
+
expect(isWideChar('◄')).toBe(false)
|
|
463
|
+
expect(isWideChar('►')).toBe(false)
|
|
464
|
+
expect(isWideChar('◁')).toBe(false)
|
|
465
|
+
expect(isWideChar('▷')).toBe(false)
|
|
466
|
+
expect(isWideChar('◢')).toBe(false)
|
|
467
|
+
expect(isWideChar('◣')).toBe(false)
|
|
468
|
+
expect(isWideChar('◤')).toBe(false)
|
|
469
|
+
expect(isWideChar('◥')).toBe(false)
|
|
470
|
+
expect(isWideChar('○')).toBe(false)
|
|
471
|
+
expect(isWideChar('✕')).toBe(false)
|
|
472
|
+
// Diagonal edge-routing arrowheads (draw-arrows.ts's drawArrowHead) as
|
|
473
|
+
// of issue #1062 — JetBrains Mono NL has no glyph for the ◢◣◤◥ set
|
|
474
|
+
// above, so these Arrows-block glyphs are drawn instead.
|
|
475
|
+
expect(isWideChar('↖')).toBe(false)
|
|
476
|
+
expect(isWideChar('↗')).toBe(false)
|
|
477
|
+
expect(isWideChar('↘')).toBe(false)
|
|
478
|
+
expect(isWideChar('↙')).toBe(false)
|
|
479
|
+
// Same Arrows-block misclassification, not drawn by the renderer itself
|
|
480
|
+
// but reachable if typed into node/edge/note label text.
|
|
481
|
+
expect(isWideChar('↔')).toBe(false)
|
|
482
|
+
expect(isWideChar('↕')).toBe(false)
|
|
483
|
+
expect(isWideChar('↩')).toBe(false)
|
|
484
|
+
expect(isWideChar('↪')).toBe(false)
|
|
485
|
+
})
|
|
486
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the "legible, readable contrast" acceptance criterion the #684
|
|
3
|
+
* theme-selector restoration's 16 per-theme verification sub-issues
|
|
4
|
+
* (#691-706) each individually ask for, systematically across every
|
|
5
|
+
* built-in theme at once rather than eyeballing 16 screenshots one at a
|
|
6
|
+
* time — see docs/decisions/theme-selector-shared-state.md and this
|
|
7
|
+
* repo's CLAUDE.md's "favor tooling over manual scanning" convention.
|
|
8
|
+
*
|
|
9
|
+
* Computes the actual WCAG 2.x contrast ratio (relative luminance, per
|
|
10
|
+
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance) between each
|
|
11
|
+
* theme's `bg`/`fg` pair — not a hand-picked pass/fail per theme, so a
|
|
12
|
+
* future THEMES addition with a genuinely illegible pair fails this test
|
|
13
|
+
* automatically instead of only being caught by a human noticing a
|
|
14
|
+
* screenshot looks wrong.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { THEMES } from '@zombie-mermaid/core'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The threshold every theme must clear. WCAG AA's own thresholds are 4.5:1
|
|
21
|
+
* for normal text and 3:1 for large/bold text; diagram node labels here
|
|
22
|
+
* are typically medium, semi-bold text, closer to the "large text"
|
|
23
|
+
* category than dense body copy. 4.0 sits between the two -- strict enough
|
|
24
|
+
* to catch a genuinely illegible pair, loose enough not to fail
|
|
25
|
+
* `solarized-light` (4.13:1), whose muted bg/fg pair matches Ethan
|
|
26
|
+
* Schoonover's actual published Solarized values rather than being a
|
|
27
|
+
* mistake in this repo's THEMES data -- #684's own non-goals rule out
|
|
28
|
+
* changing THEMES' colors to "fix" a curated editor-theme's real values.
|
|
29
|
+
*/
|
|
30
|
+
const MIN_CONTRAST_RATIO = 4.0
|
|
31
|
+
|
|
32
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
33
|
+
const clean = hex.replace('#', '')
|
|
34
|
+
return [
|
|
35
|
+
parseInt(clean.slice(0, 2), 16),
|
|
36
|
+
parseInt(clean.slice(2, 4), 16),
|
|
37
|
+
parseInt(clean.slice(4, 6), 16),
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** sRGB channel -> linear-light value, the WCAG relative-luminance step. */
|
|
42
|
+
function linearize(channel: number): number {
|
|
43
|
+
const c = channel / 255
|
|
44
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function relativeLuminance(hex: string): number {
|
|
48
|
+
const [r, g, b] = hexToRgb(hex)
|
|
49
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** WCAG contrast ratio between two colors, always >= 1 (order-independent). */
|
|
53
|
+
function contrastRatio(hexA: string, hexB: string): number {
|
|
54
|
+
const lumA = relativeLuminance(hexA)
|
|
55
|
+
const lumB = relativeLuminance(hexB)
|
|
56
|
+
const lighter = Math.max(lumA, lumB)
|
|
57
|
+
const darker = Math.min(lumA, lumB)
|
|
58
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('every built-in theme has a legible bg/fg contrast ratio', () => {
|
|
62
|
+
for (const [themeKey, colors] of Object.entries(THEMES)) {
|
|
63
|
+
it(`${themeKey}: bg/fg contrast is at least ${MIN_CONTRAST_RATIO}:1`, () => {
|
|
64
|
+
const ratio = contrastRatio(colors.bg, colors.fg)
|
|
65
|
+
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST_RATIO)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('contrastRatio() itself', () => {
|
|
71
|
+
it('returns 1 for identical colors', () => {
|
|
72
|
+
expect(contrastRatio('#808080', '#808080')).toBeCloseTo(1, 5)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns 21 for pure black vs pure white (the WCAG maximum)', () => {
|
|
76
|
+
expect(contrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is symmetric regardless of argument order', () => {
|
|
80
|
+
const a = contrastRatio('#1a1b26', '#a9b1d6')
|
|
81
|
+
const b = contrastRatio('#a9b1d6', '#1a1b26')
|
|
82
|
+
expect(a).toBeCloseTo(b, 10)
|
|
83
|
+
})
|
|
84
|
+
})
|