@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/src/theme.ts ADDED
@@ -0,0 +1,712 @@
1
+ // ============================================================================
2
+ // Theme system — CSS custom property-based theming for mermaid SVG diagrams.
3
+ //
4
+ // Architecture:
5
+ // - Two required variables: --bg (background) and --fg (foreground)
6
+ // - Five optional enrichment variables: --line, --accent, --muted, --surface, --border
7
+ // - Unset optionals fall back to color-mix() derivations from bg + fg
8
+ // - All derived values computed in a <style> block inside the SVG
9
+ //
10
+ // This means the SVG is a function of its CSS variables. The caller provides
11
+ // colors, and the SVG adapts. No light/dark mode detection needed.
12
+ // ============================================================================
13
+
14
+ import { escapeXml, escapeAttr } from './multiline-utils.ts'
15
+ import { parseHexColor } from './color-utils.ts'
16
+ import {
17
+ MONO_FONT_FAMILY as SVG_MONO_FONT_FAMILY,
18
+ MONO_FONT_FACE_CSS as SVG_MONO_FONT_FACE_CSS,
19
+ } from './generated/mono-font-subset.ts'
20
+
21
+ // ============================================================================
22
+ // Types
23
+ // ============================================================================
24
+
25
+ /**
26
+ * How the renderers emit their two inline-style surfaces — the `<style>`
27
+ * element(s) and the root `<svg style="--bg: …">` attribute. Both exist so a
28
+ * host page with a strict `Content-Security-Policy` (`style-src` without
29
+ * `'unsafe-inline'`) can still render diagrams with their colours intact;
30
+ * see `RenderOptions.nonce` / `RenderOptions.styleAttribute` in packages/core/src/types.ts
31
+ * and GitHub issue #216. Threaded as one object through every diagram
32
+ * renderer so the two options can't drift apart per diagram type.
33
+ */
34
+ export interface SvgEmitOptions {
35
+ /** Value for a `nonce` attribute on every emitted `<style>` element. */
36
+ nonce?: string
37
+ /** Emit the root `style="--bg: …"` attribute. Default: true. */
38
+ styleAttribute?: boolean
39
+ }
40
+
41
+ /**
42
+ * Diagram color configuration.
43
+ *
44
+ * Required: bg + fg give you a clean mono diagram.
45
+ * Optional: line, accent, muted, surface, border bring in richer color
46
+ * from Shiki themes or custom palettes. Each falls back to a color-mix()
47
+ * derivation from bg + fg if not set.
48
+ */
49
+ export interface DiagramColors {
50
+ /** Background color → CSS variable --bg */
51
+ bg: string
52
+ /** Foreground / primary text color → CSS variable --fg */
53
+ fg: string
54
+
55
+ // -- Optional enrichment (each falls back to color-mix from bg+fg) --
56
+
57
+ /** Edge/connector color → CSS variable --line */
58
+ line?: string
59
+ /** Arrow heads, highlights, special nodes → CSS variable --accent */
60
+ accent?: string
61
+ /** Secondary text, edge labels → CSS variable --muted */
62
+ muted?: string
63
+ /** Node/box fill tint → CSS variable --surface */
64
+ surface?: string
65
+ /** Node/group stroke color → CSS variable --border */
66
+ border?: string
67
+ }
68
+
69
+ // ============================================================================
70
+ // Defaults
71
+ // ============================================================================
72
+
73
+ /** Default bg/fg when no colors are provided (zinc light) */
74
+ export const DEFAULTS: Readonly<{ bg: string; fg: string }> = {
75
+ bg: '#FFFFFF',
76
+ fg: '#27272A',
77
+ } as const
78
+
79
+ // ============================================================================
80
+ // color-mix() weights for derived CSS variables
81
+ //
82
+ // When an optional enrichment variable is NOT set, we compute the derived
83
+ // value by mixing --fg into --bg at these percentages. This produces a
84
+ // coherent mono hierarchy on any bg/fg combination.
85
+ // ============================================================================
86
+
87
+ export const MIX = {
88
+ /** Primary text: near-full fg */
89
+ text: 100, // just use --fg directly
90
+ /** Secondary text (group headers): fg mixed at 60% */
91
+ textSec: 60,
92
+ /** Muted text (edge labels, notes): fg mixed at 40% */
93
+ textMuted: 40,
94
+ /** Faint text (de-emphasized): fg mixed at 25% */
95
+ textFaint: 25,
96
+ /** Edge/connector lines: fg mixed at 50% for clear visibility */
97
+ line: 50,
98
+ /** Arrow head fill: fg mixed at 85% for clear visibility */
99
+ arrow: 85,
100
+ /** Node fill tint: fg mixed at 3% */
101
+ nodeFill: 3,
102
+ /** Node/group stroke: fg mixed at 20% */
103
+ nodeStroke: 20,
104
+ /** Group header band tint: fg mixed at 5% */
105
+ groupHeader: 5,
106
+ /** Inner divider strokes: fg mixed at 12% */
107
+ innerStroke: 12,
108
+ /** Key badge background opacity (ER diagrams) */
109
+ keyBadge: 10,
110
+ } as const
111
+
112
+ // ============================================================================
113
+ // Well-known theme palettes
114
+ //
115
+ // Curated bg/fg pairs (+ optional enrichment) for popular editor themes.
116
+ // Users can also extract from Shiki theme objects via fromShikiTheme().
117
+ // ============================================================================
118
+
119
+ export const THEMES: Record<string, DiagramColors> = {
120
+ 'zinc-light': {
121
+ bg: '#FFFFFF',
122
+ fg: '#27272A',
123
+ },
124
+ 'zinc-dark': {
125
+ bg: '#18181B',
126
+ fg: '#FAFAFA',
127
+ },
128
+ 'tokyo-night': {
129
+ bg: '#1a1b26',
130
+ fg: '#a9b1d6',
131
+ line: '#3d59a1',
132
+ accent: '#7aa2f7',
133
+ muted: '#565f89',
134
+ },
135
+ 'tokyo-night-storm': {
136
+ bg: '#24283b',
137
+ fg: '#a9b1d6',
138
+ line: '#3d59a1',
139
+ accent: '#7aa2f7',
140
+ muted: '#565f89',
141
+ },
142
+ 'tokyo-night-light': {
143
+ bg: '#d5d6db',
144
+ fg: '#343b58',
145
+ line: '#34548a',
146
+ accent: '#34548a',
147
+ muted: '#9699a3',
148
+ },
149
+ 'catppuccin-mocha': {
150
+ bg: '#1e1e2e',
151
+ fg: '#cdd6f4',
152
+ line: '#585b70',
153
+ accent: '#cba6f7',
154
+ muted: '#6c7086',
155
+ },
156
+ 'catppuccin-latte': {
157
+ bg: '#eff1f5',
158
+ fg: '#4c4f69',
159
+ line: '#9ca0b0',
160
+ accent: '#8839ef',
161
+ muted: '#9ca0b0',
162
+ },
163
+ nord: {
164
+ bg: '#2e3440',
165
+ fg: '#d8dee9',
166
+ line: '#4c566a',
167
+ accent: '#88c0d0',
168
+ muted: '#616e88',
169
+ },
170
+ 'nord-light': {
171
+ bg: '#eceff4',
172
+ fg: '#2e3440',
173
+ line: '#aab1c0',
174
+ accent: '#5e81ac',
175
+ muted: '#7b88a1',
176
+ },
177
+ dracula: {
178
+ bg: '#282a36',
179
+ fg: '#f8f8f2',
180
+ line: '#6272a4',
181
+ accent: '#bd93f9',
182
+ muted: '#6272a4',
183
+ },
184
+ 'github-light': {
185
+ bg: '#ffffff',
186
+ fg: '#1f2328',
187
+ line: '#d1d9e0',
188
+ accent: '#0969da',
189
+ muted: '#59636e',
190
+ },
191
+ 'github-dark': {
192
+ bg: '#0d1117',
193
+ fg: '#e6edf3',
194
+ line: '#3d444d',
195
+ accent: '#4493f8',
196
+ muted: '#9198a1',
197
+ },
198
+ 'solarized-light': {
199
+ bg: '#fdf6e3',
200
+ fg: '#657b83',
201
+ line: '#93a1a1',
202
+ accent: '#268bd2',
203
+ muted: '#93a1a1',
204
+ },
205
+ 'solarized-dark': {
206
+ bg: '#002b36',
207
+ fg: '#839496',
208
+ line: '#586e75',
209
+ accent: '#268bd2',
210
+ muted: '#586e75',
211
+ },
212
+ 'one-dark': {
213
+ bg: '#282c34',
214
+ fg: '#abb2bf',
215
+ line: '#4b5263',
216
+ accent: '#c678dd',
217
+ muted: '#5c6370',
218
+ },
219
+ } as const
220
+
221
+ export type ThemeName = keyof typeof THEMES
222
+
223
+ // ============================================================================
224
+ // Shiki theme extraction
225
+ //
226
+ // Extracts DiagramColors from a Shiki ThemeRegistrationResolved object.
227
+ // This provides native compatibility with any VS Code / TextMate theme.
228
+ // ============================================================================
229
+
230
+ /**
231
+ * Minimal subset of Shiki's ThemeRegistrationResolved that we need.
232
+ * We don't import from shiki to avoid a hard dependency.
233
+ */
234
+ interface ShikiThemeLike {
235
+ type?: string
236
+ colors?: Record<string, string>
237
+ tokenColors?: Array<{
238
+ scope?: string | string[]
239
+ settings?: { foreground?: string }
240
+ }>
241
+ }
242
+
243
+ /**
244
+ * Extract diagram colors from a Shiki theme object.
245
+ * Works with any VS Code / TextMate theme loaded by Shiki.
246
+ *
247
+ * Maps editor UI colors to diagram roles:
248
+ * editor.background → bg
249
+ * editor.foreground → fg
250
+ * editorLineNumber.fg → line (optional)
251
+ * focusBorder / keyword → accent (optional)
252
+ * comment token → muted (optional)
253
+ * editor.selectionBackground→ surface (optional)
254
+ * editorWidget.border → border (optional)
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * import { getSingletonHighlighter } from 'shiki'
259
+ * import { fromShikiTheme } from 'zombie-mermaid'
260
+ *
261
+ * const hl = await getSingletonHighlighter({ themes: ['tokyo-night'] })
262
+ * const colors = fromShikiTheme(hl.getTheme('tokyo-night'))
263
+ * const svg = renderMermaidSVG(code, colors)
264
+ * ```
265
+ */
266
+ export function fromShikiTheme(theme: ShikiThemeLike): DiagramColors {
267
+ const c = theme.colors ?? {}
268
+ const dark = theme.type === 'dark'
269
+
270
+ // Helper: find a token color by scope name
271
+ const tokenColor = (scope: string): string | undefined =>
272
+ theme.tokenColors?.find((t) =>
273
+ Array.isArray(t.scope) ? t.scope.includes(scope) : t.scope === scope,
274
+ )?.settings?.foreground
275
+
276
+ return {
277
+ bg: c['editor.background'] ?? (dark ? '#1e1e1e' : '#ffffff'),
278
+ fg: c['editor.foreground'] ?? (dark ? '#d4d4d4' : '#333333'),
279
+ line: c['editorLineNumber.foreground'] ?? undefined,
280
+ accent: c['focusBorder'] ?? tokenColor('keyword') ?? undefined,
281
+ muted:
282
+ tokenColor('comment') ?? c['editorLineNumber.foreground'] ?? undefined,
283
+ surface: c['editor.selectionBackground'] ?? undefined,
284
+ border: c['editorWidget.border'] ?? undefined,
285
+ }
286
+ }
287
+
288
+ // ============================================================================
289
+ // SVG style block — the CSS variable derivation system
290
+ //
291
+ // Generates the <style> content that maps user-facing variables (--bg, --fg,
292
+ // --line, etc.) to internal derived variables (--_text, --_line, etc.) using
293
+ // color-mix() fallbacks.
294
+ // ============================================================================
295
+
296
+ /**
297
+ * Characters that must never appear in a `font` value once it's embedded in
298
+ * the generated `<style>` block — whether it's treated as a CSS `var()`
299
+ * reference or as a literal font name. Angle brackets would let the value
300
+ * break out of the `<style>...</style>` element (e.g. a literal `</style>`
301
+ * or an injected `<script>`); semicolons/braces would let it terminate the
302
+ * current CSS declaration/rule and inject new ones.
303
+ */
304
+ const UNSAFE_FONT_CHARS_RE = /[<>{};]/
305
+
306
+ /**
307
+ * Detect a syntactically well-formed CSS `var()` reference, e.g.
308
+ * `var(--font-family-body)` or `var(--font, 'Fallback Font')` (a `var()`
309
+ * with a quoted fallback argument). Requires balanced parens and rejects
310
+ * any of `UNSAFE_FONT_CHARS_RE`.
311
+ *
312
+ * `font.startsWith('var(')` alone isn't enough: a caller could pass
313
+ * something that merely starts with `var(` but isn't actually one (e.g.
314
+ * `var(--x); } .evil{...}`). Anything that fails this stricter check is
315
+ * treated as an untrusted/malformed value and falls back to being quoted
316
+ * as a literal font name below — which neutralizes it, since a quoted
317
+ * string isn't parsed as a `var()` call by CSS.
318
+ */
319
+ function isSafeCssVarReference(font: string): boolean {
320
+ const trimmed = font.trim()
321
+ if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) return false
322
+ if (UNSAFE_FONT_CHARS_RE.test(trimmed)) return false
323
+ let depth = 0
324
+ for (const ch of trimmed) {
325
+ if (ch === '(') depth++
326
+ else if (ch === ')') {
327
+ depth--
328
+ if (depth < 0) return false
329
+ }
330
+ }
331
+ return depth === 0
332
+ }
333
+
334
+ /**
335
+ * CSS generic font-family keywords. These are resolved by the browser/user
336
+ * agent to a locally available font and are never real font names a Google
337
+ * Fonts `@import` could fetch.
338
+ */
339
+ const GENERIC_FONT_FAMILIES = new Set([
340
+ 'sans-serif',
341
+ 'serif',
342
+ 'monospace',
343
+ 'system-ui',
344
+ 'ui-sans-serif',
345
+ 'ui-serif',
346
+ 'ui-monospace',
347
+ 'ui-rounded',
348
+ 'cursive',
349
+ 'fantasy',
350
+ 'math',
351
+ 'emoji',
352
+ 'fangsong',
353
+ ])
354
+
355
+ /**
356
+ * Detect a `font` value that names a font *stack* (comma-separated list,
357
+ * e.g. `"ui-sans-serif, system-ui, sans-serif"`) or a single CSS generic
358
+ * family keyword (e.g. `"system-ui"`) rather than a single concrete font
359
+ * name. Neither is something a Google Fonts `@import` could ever
360
+ * successfully fetch: a stack has no single `family=` value to request, and
361
+ * a generic keyword is resolved locally by the browser, not hosted by
362
+ * Google Fonts. Skipping the `@import` for these avoids baking a dead,
363
+ * always-404 request into the SVG (see #223).
364
+ */
365
+ function isFontStackOrGenericFamily(font: string): boolean {
366
+ const trimmed = font.trim()
367
+ if (trimmed.includes(',')) return true
368
+ return GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())
369
+ }
370
+
371
+ /**
372
+ * The opening `<style>` tag every renderer uses — with a `nonce` attribute
373
+ * when the caller supplied one (see `RenderOptions.nonce`, #216).
374
+ *
375
+ * All `<style>` emission points go through this one function so a nonce
376
+ * can't be missed on one of them: under a nonce-based CSP a single
377
+ * un-nonced `<style>` is silently dropped by the browser, which for the
378
+ * theme block means an unstyled diagram with no console hint as to why.
379
+ *
380
+ * An empty/whitespace-only nonce is treated as unset — `nonce=""` would
381
+ * authorise nothing and only mislead a reader into thinking it did.
382
+ * The value is attribute-escaped; a real nonce is base64 and never needs
383
+ * escaping, but the option is a plain string and this keeps a stray `"`
384
+ * from breaking out of the attribute.
385
+ */
386
+ export function styleOpenTag(nonce?: string): string {
387
+ if (nonce === undefined || nonce.trim() === '') return '<style>'
388
+ return `<style nonce="${escapeAttr(nonce)}">`
389
+ }
390
+
391
+ /**
392
+ * Build the CSS variable derivation rules for the SVG <style> block.
393
+ *
394
+ * When an optional variable (--line, --accent, etc.) is set on the SVG or
395
+ * a parent element, it's used directly. When unset, the fallback computes
396
+ * a blended value from --fg and --bg using color-mix().
397
+ *
398
+ * `nonce`, when given, lands on the `<style>` element as a `nonce`
399
+ * attribute — see `styleOpenTag()`.
400
+ *
401
+ * `font` is user-supplied input. When it's a CSS `var(...)` reference (e.g.
402
+ * `var(--font-family-body)`, letting the SVG inherit a host page's
403
+ * design-system font), the Google Fonts `@import` is skipped — it would
404
+ * otherwise URL-encode the literal `var(...)` string into a `family=` query
405
+ * param and produce a guaranteed no-op request — and the value is emitted
406
+ * unquoted, since a quoted string isn't parsed as a `var()` call by CSS.
407
+ *
408
+ * The `@import` is likewise skipped when `font` is a font *stack* (a
409
+ * comma-separated list, e.g. `"ui-sans-serif, system-ui, sans-serif"`) or a
410
+ * single CSS generic family keyword (e.g. `"system-ui"`) — see
411
+ * `isFontStackOrGenericFamily`. Neither names a single concrete font that
412
+ * Google Fonts could host, so importing one always produces a dead,
413
+ * always-404 request (#223). This is additive to the `var()` skip above,
414
+ * and only affects the `@import` decision — the `text { font-family: ... }`
415
+ * rule still renders the literal value exactly as it does today.
416
+ *
417
+ * Any other value is treated as a literal font name: sanitized and quoted
418
+ * exactly as before.
419
+ *
420
+ * `hasMonoFont`'s `.mono` rule (class-diagram method signatures, ER-diagram
421
+ * attribute types) never reaches out to Google Fonts at all — it used to
422
+ * `@import` 'JetBrains Mono' from Google's CDN, a third-party network
423
+ * dependency a published library's default SVG output had no business
424
+ * making (#1061, filed as a follow-up from #1059's ASCII-side self-hosting
425
+ * fix). It now embeds {@link SVG_MONO_FONT_FACE_CSS} — a small, subsetted
426
+ * (Basic Latin + Latin-1 Supplement), self-hosted `@font-face` as a base64
427
+ * `woff2` data URI, generated by scripts/build-mono-font-subset.ts into
428
+ * ./generated/mono-font-subset.ts — directly in the SVG's own `<style>`
429
+ * block. Unlike `font` above, this isn't caller-configurable: it's the same
430
+ * embed for every consumer, on by default, with no network request either
431
+ * way — a strict improvement over the dead-or-alive CDN fetch it replaces,
432
+ * and the only way a *standalone* SVG (no surrounding host page providing
433
+ * its own copy of the font) renders `.mono` text correctly out of the box.
434
+ */
435
+ export function buildStyleBlock(
436
+ font: string,
437
+ hasMonoFont: boolean,
438
+ nonce?: string,
439
+ ): string {
440
+ const isVarReference = isSafeCssVarReference(font)
441
+ // Literal font names are sanitized before quoting (strip characters that
442
+ // could break out of the CSS string or the <style> block). A validated
443
+ // var() reference is passed through as-is so `var(--x, 'Fallback')` keeps
444
+ // its own internal quotes intact.
445
+ const safeFont = isVarReference ? font.trim() : font.replace(/[<>{};'"]/g, '')
446
+ const skipImport = isVarReference || isFontStackOrGenericFamily(font)
447
+
448
+ const fontImports = skipImport
449
+ ? []
450
+ : [
451
+ `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(safeFont)}:wght@400;500;600;700&amp;display=swap');`,
452
+ ]
453
+
454
+ // Derived CSS variables: use override if set, else mix from bg+fg.
455
+ // The --_ prefix signals "private/derived" — not meant for external override.
456
+ const derivedVars = `
457
+ /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */
458
+ --_text: var(--fg);
459
+ --_text-sec: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textSec}%, var(--bg)));
460
+ --_text-muted: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textMuted}%, var(--bg)));
461
+ --_text-faint: color-mix(in srgb, var(--fg) ${MIX.textFaint}%, var(--bg));
462
+ --_line: var(--line, color-mix(in srgb, var(--fg) ${MIX.line}%, var(--bg)));
463
+ --_arrow: var(--accent, color-mix(in srgb, var(--fg) ${MIX.arrow}%, var(--bg)));
464
+ --_node-fill: var(--surface, color-mix(in srgb, var(--fg) ${MIX.nodeFill}%, var(--bg)));
465
+ --_node-stroke: var(--border, color-mix(in srgb, var(--fg) ${MIX.nodeStroke}%, var(--bg)));
466
+ --_group-fill: var(--bg);
467
+ --_group-hdr: color-mix(in srgb, var(--fg) ${MIX.groupHeader}%, var(--bg));
468
+ --_inner-stroke: color-mix(in srgb, var(--fg) ${MIX.innerStroke}%, var(--bg));
469
+ --_key-badge: color-mix(in srgb, var(--fg) ${MIX.keyBadge}%, var(--bg));`
470
+
471
+ return [
472
+ styleOpenTag(nonce),
473
+ ...(fontImports.length > 0 ? [` ${fontImports.join('\n ')}`] : []),
474
+ // Self-hosted, embedded @font-face for `.mono` text — see the doc
475
+ // comment above for why this never goes through the CDN-import/skip
476
+ // logic `font` above does.
477
+ ...(hasMonoFont ? [` ${SVG_MONO_FONT_FACE_CSS}`] : []),
478
+ ` text { font-family: ${isVarReference ? safeFont : `'${safeFont}'`}, system-ui, sans-serif; }`,
479
+ ...(hasMonoFont
480
+ ? [
481
+ ` .mono { font-family: '${SVG_MONO_FONT_FAMILY}', 'SF Mono', 'Fira Code', ui-monospace, monospace; }`,
482
+ ]
483
+ : []),
484
+ ` svg {${derivedVars}`,
485
+ ` }`,
486
+ '</style>',
487
+ ].join('\n')
488
+ }
489
+
490
+ // ============================================================================
491
+ // Fill → text color contrast
492
+ //
493
+ // When a node has a custom `fill` (from classDef/style) but no explicit
494
+ // `color`, defaulting text to the ambient theme foreground can be unreadable
495
+ // (e.g. white text on a light pastel fill in dark mode). If the fill is a
496
+ // concrete, resolvable color (hex), compute a readable black/white text
497
+ // color from its perceptual luminance. Anything else (CSS variable
498
+ // references, named colors, malformed values) is left alone — the caller
499
+ // falls back to the theme foreground.
500
+ // ============================================================================
501
+
502
+ /**
503
+ * Perceptual luminance of an sRGB color on a 0-1 scale (0 = black, 1 =
504
+ * white), using the ITU-R BT.601 luma weights (0.299/0.587/0.114).
505
+ *
506
+ * This is the simple, non-gamma-corrected "perceived brightness" formula
507
+ * (as opposed to the gamma-corrected WCAG relative-luminance formula used
508
+ * for contrast-ratio math). It's the right tool here: we just need a
509
+ * black-vs-white pick, and BT.601 luma crosses black/white legibility at
510
+ * ~0.5 on its own 0-1 scale, so the threshold in getReadableTextColor()
511
+ * lines up directly with the formula instead of needing a separately
512
+ * derived crossover constant. (True WCAG relative luminance would need a
513
+ * ~0.18 threshold, not 0.5, to give the same black/white picks — see
514
+ * https://www.w3.org/TR/WCAG20/#relativeluminancedef.)
515
+ */
516
+ function perceptualLuminance(r: number, g: number, b: number): number {
517
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
518
+ }
519
+
520
+ /**
521
+ * Pick a readable text color for a given fill color.
522
+ *
523
+ * If `fill` is a concrete, resolvable hex color, returns pure black or
524
+ * white depending on the fill's perceptual luminance (threshold 0.5: light
525
+ * fills get dark text, dark fills get light text). If `fill` is undefined
526
+ * or isn't a resolvable color (a `var(...)` reference, a named CSS color,
527
+ * or malformed input), returns `fallback` unchanged so callers don't guess
528
+ * at colors they can't actually evaluate.
529
+ */
530
+ export function getReadableTextColor(
531
+ fill: string | undefined,
532
+ fallback: string,
533
+ ): string {
534
+ if (!fill) return fallback
535
+ const rgb = parseHexColor(fill)
536
+ if (!rgb) return fallback
537
+ const luminance = perceptualLuminance(...rgb)
538
+ return luminance > 0.5 ? '#000000' : '#FFFFFF'
539
+ }
540
+
541
+ /**
542
+ * Page-unique suffix generator for the root accessible-name `<title>` id.
543
+ *
544
+ * Mirrors `markerSuffix()` in packages/svg-renderer/src/renderer.ts, which solves the same
545
+ * "multiple SVGs inlined into one HTML page share a single id namespace"
546
+ * problem for arrow-marker ids — a marker's id is sanitized from its stroke
547
+ * color, since two markers with the same color are meant to share one
548
+ * definition. A title has no such natural per-call input (two diagrams can
549
+ * easily have the same title text and must still get distinct ids), so this
550
+ * increments a module-level counter instead: every `svgOpenTag()` call that
551
+ * renders a title gets a fresh, never-repeated id for the lifetime of the
552
+ * process, which is exactly what's needed for `aria-labelledby` to resolve
553
+ * correctly when several rendered diagrams are concatenated into one page.
554
+ */
555
+ let titleIdCounter = 0
556
+
557
+ function nextTitleId(): string {
558
+ titleIdCounter += 1
559
+ return `zm-title-${titleIdCounter}`
560
+ }
561
+
562
+ /**
563
+ * Test-only: reset the title-id counter so id assertions in tests don't
564
+ * depend on how many titled diagrams earlier tests in the same file rendered.
565
+ * Not part of the public API (not re-exported from src/index.ts).
566
+ */
567
+ export function __resetSvgTitleIdCounterForTests(): void {
568
+ titleIdCounter = 0
569
+ }
570
+
571
+ /**
572
+ * The exact CSS declaration list the root `<svg style="…">` attribute
573
+ * carries: the theme custom properties (`--bg`, `--fg`, plus whichever
574
+ * enrichment variables were actually provided — unset ones fall back to the
575
+ * color-mix() derivations in the `<style>` block) and, unless `transparent`,
576
+ * a `background: var(--bg)`.
577
+ *
578
+ * Exposed (via `themeCssVariables()` in src/index.ts) so a host that turns
579
+ * the attribute off with `styleAttribute: false` — because its
580
+ * Content-Security-Policy forbids `style=` attributes, which a nonce can
581
+ * never authorise (#216) — can put the very same declarations in its own
582
+ * stylesheet on a wrapper instead. Single source of truth for the variable
583
+ * list: `svgOpenTag()` and the helper both call this, so the two can't
584
+ * drift.
585
+ *
586
+ * Emitted compactly (`--bg:#fff;--fg:#000;background:var(--bg)`) rather
587
+ * than pretty-printed: it's the same string the attribute has always
588
+ * carried, so default output stays byte-identical. Colour values are the
589
+ * caller's own `RenderOptions` and are not escaped here — same as before.
590
+ */
591
+ export function themeStyleDeclarations(
592
+ colors: DiagramColors,
593
+ transparent?: boolean,
594
+ ): string {
595
+ const vars = [
596
+ `--bg:${colors.bg}`,
597
+ `--fg:${colors.fg}`,
598
+ colors.line ? `--line:${colors.line}` : '',
599
+ colors.accent ? `--accent:${colors.accent}` : '',
600
+ colors.muted ? `--muted:${colors.muted}` : '',
601
+ colors.surface ? `--surface:${colors.surface}` : '',
602
+ colors.border ? `--border:${colors.border}` : '',
603
+ ]
604
+ .filter(Boolean)
605
+ .join(';')
606
+
607
+ const bgStyle = transparent ? '' : ';background:var(--bg)'
608
+ return `${vars}${bgStyle}`
609
+ }
610
+
611
+ /**
612
+ * Build the SVG opening tag with CSS variables set as inline styles.
613
+ * Only includes optional variables that are actually provided — unset ones
614
+ * will fall back to the color-mix() derivations in the <style> block.
615
+ *
616
+ * `styleAttribute: false` drops the `style="…"` attribute entirely (the
617
+ * host supplies those declarations from its own stylesheet — see
618
+ * `themeStyleDeclarations()` and `RenderOptions.styleAttribute`, #216).
619
+ * Every other attribute — `xmlns`, `viewBox`, `width`/`height`, the
620
+ * `role`/`aria-*` accessibility attributes, and anything a caller splices
621
+ * on afterwards (`data-src`, `data-xychart-colors`) — is unaffected.
622
+ *
623
+ * Also handles the SVG's accessible name (see `RenderOptions.title` /
624
+ * `RenderOptions.decorative` in packages/core/src/types.ts, and GitHub issue #215):
625
+ *
626
+ * - `decorative: true` → `aria-hidden="true"` on the root, no `role` or
627
+ * name. Use for a diagram already described in surrounding prose; `title`
628
+ * is ignored when this is set.
629
+ * - `title` given (and not decorative) → `role="img"` +
630
+ * `aria-labelledby="zm-title-N"` on the root, plus a `<title id="zm-title-N">`
631
+ * as the SVG's first child holding the escaped text. This is the standard
632
+ * SVG/WAI-ARIA "accessible name via a referenced title element" technique
633
+ * (see MDN "SVG accessibility" and the WAI-ARIA `img` role: an element
634
+ * with `role="img"` needs a computed accessible name to be meaningful to
635
+ * assistive tech; `aria-labelledby` pointing at a `<title>` is the
636
+ * documented way to supply one for inline SVG).
637
+ * - Neither given → `role="img"` only, no name. This still stops assistive
638
+ * tech from treating the SVG as a plain group (which announces every
639
+ * node/edge label individually, out of reading order — the core bug in
640
+ * #215), without fabricating a name the library can't honestly claim. It
641
+ * reads the same as an `<img>` with no `alt`: present as a single image,
642
+ * unlabeled.
643
+ *
644
+ * `hasInteractiveLinks` overrides all of the above (see #239): a `click A
645
+ * "url"` statement renders a real, focusable `<a href>` inside the SVG, and
646
+ * both `role="img"` and `aria-hidden="true"` are unsafe on an ancestor of a
647
+ * focusable element — `role="img"` tells assistive tech to stop descending
648
+ * into children (the link becomes unreachable by screen reader while still
649
+ * Tab-focusable), and `aria-hidden="true"` on a focusable descendant is an
650
+ * explicit WAI-ARIA violation (the link vanishes from the accessibility tree
651
+ * but not from the tab order). When any node has a link, the root gets no
652
+ * `role` at all — `decorative` is silently overridden rather than honored,
653
+ * since aria-hiding a diagram that contains an actionable link is not a safe
654
+ * request to fulfill — but `title`/`aria-labelledby` still apply if given:
655
+ * an accessible name is still valid on an element with no explicit role.
656
+ *
657
+ * The per-node/per-point `<title>` tooltips added by earlier work (nested
658
+ * inside `<g>` elements, with no `id` attribute) are unaffected: they never
659
+ * collide with the root title's generated id, and a root `<title>` alongside
660
+ * unrelated descendant `<title>` elements is valid SVG.
661
+ *
662
+ * @param transparent - If true, omits the background style for transparent SVGs
663
+ * @param title - Accessible name text. Ignored when `decorative` is true
664
+ * and there are no interactive links (see `hasInteractiveLinks`).
665
+ * @param decorative - Marks the SVG as decorative (`aria-hidden="true"`).
666
+ * Overridden when `hasInteractiveLinks` is true.
667
+ * @param hasInteractiveLinks - True if any node has a `click`-based `href`.
668
+ * See #239 — forces no root `role` so the link
669
+ * stays reachable, regardless of `decorative`.
670
+ * @param styleAttribute - Emit the root `style="…"` attribute. Default true.
671
+ */
672
+ export function svgOpenTag(
673
+ width: number,
674
+ height: number,
675
+ colors: DiagramColors,
676
+ transparent?: boolean,
677
+ title?: string,
678
+ decorative?: boolean,
679
+ hasInteractiveLinks?: boolean,
680
+ styleAttribute: boolean = true,
681
+ ): string {
682
+ const styleAttr = styleAttribute
683
+ ? ` style="${themeStyleDeclarations(colors, transparent)}"`
684
+ : ''
685
+
686
+ let a11yAttrs = ''
687
+ let titleEl = ''
688
+ if (hasInteractiveLinks) {
689
+ // No root role: role="img" or aria-hidden would hide a real, focusable
690
+ // <a href> descendant from assistive tech while leaving it Tab-reachable.
691
+ if (title) {
692
+ const id = nextTitleId()
693
+ a11yAttrs = ` aria-labelledby="${id}"`
694
+ titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
695
+ }
696
+ } else if (decorative) {
697
+ a11yAttrs = ' aria-hidden="true"'
698
+ } else {
699
+ a11yAttrs = ' role="img"'
700
+ if (title) {
701
+ const id = nextTitleId()
702
+ a11yAttrs += ` aria-labelledby="${id}"`
703
+ titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
704
+ }
705
+ }
706
+
707
+ return (
708
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" ` +
709
+ `width="${width}" height="${height}"${a11yAttrs}${styleAttr}>` +
710
+ titleEl
711
+ )
712
+ }