@zombie-mermaid/svg-renderer 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.
@@ -0,0 +1,339 @@
1
+ // ============================================================================
2
+ // `resolveColors` — substitute concrete sRGB values for the CSS `var(--…)`
3
+ // and `color-mix(…)` expressions the SVG renderer emits.
4
+ //
5
+ // Browsers evaluate both natively, which is what makes the emitted SVG a
6
+ // live function of its CSS custom properties (see packages/core/src/theme.ts and
7
+ // docs/theming.md). Every other consumer — resvg, librsvg, Inkscape,
8
+ // ImageMagick, anything headless-Chrome-free — implements neither, and
9
+ // rasterizes the whole theme to black (GitHub issue #456). This pass runs
10
+ // over the finished SVG string and evaluates each expression the way a
11
+ // browser would, against the same `<style>` declarations the renderer just
12
+ // wrote, so the MIX percentages in packages/core/src/theme.ts stay the single source of
13
+ // truth: nothing here re-states a number.
14
+ //
15
+ // Scope, deliberately narrow — this is not a CSS engine:
16
+ // - Custom properties are read from the root `<svg style="--bg:…">` and
17
+ // from `svg { --_line: … }` declarations in `<style>` blocks. The
18
+ // renderer only ever declares them on the root, so one flat scope is
19
+ // exact; no cascade/specificity is modelled.
20
+ // - `color-mix()` is evaluated only `in srgb` (the only space the
21
+ // renderer emits) and only over colors `parseCssColor` understands
22
+ // (hex, `rgb()`/`rgba()`, `transparent`). Anything else is left as-is.
23
+ // - `var(--x)` with no declaration and no fallback is left untouched —
24
+ // e.g. `font: 'var(--font-family-body)'`, a host-page variable this
25
+ // library cannot know the value of.
26
+ // - Element text content (labels, `<title>`) and the `data-src` source
27
+ // stamp are never touched: only attribute values and `<style>` blocks
28
+ // are CSS, so only those are rewritten.
29
+ // ============================================================================
30
+
31
+ import type { DiagramColors } from '@zombie-mermaid/core'
32
+ import { formatCssColor, mixSrgb, parseCssColor } from '@zombie-mermaid/core'
33
+
34
+ // ============================================================================
35
+ // Expression evaluation
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Custom-property lookup with memoization and a cycle guard. Values are
40
+ * stored raw (as declared) and evaluated on first use, so a declaration
41
+ * can reference another declared later in the block.
42
+ */
43
+ class VarScope {
44
+ private readonly raw = new Map<string, string>()
45
+ private readonly resolved = new Map<string, string | undefined>()
46
+ private readonly inProgress = new Set<string>()
47
+
48
+ declare(name: string, value: string): void {
49
+ this.raw.set(name, value)
50
+ this.resolved.delete(name)
51
+ }
52
+
53
+ /** Evaluated value of `name`, or undefined when unknown or cyclic. */
54
+ lookup(name: string): string | undefined {
55
+ if (this.resolved.has(name)) return this.resolved.get(name)
56
+ const rawValue = this.raw.get(name)
57
+ if (rawValue === undefined || this.inProgress.has(name)) return undefined
58
+ this.inProgress.add(name)
59
+ const value = evaluateExpression(rawValue, this).trim()
60
+ this.inProgress.delete(name)
61
+ // A value that still contains an unresolvable reference is treated as
62
+ // unknown, so a `var()` pointing at it keeps its own fallback instead of
63
+ // being replaced by something a rasterizer can't parse either.
64
+ const usable = !/\bvar\(|\bcolor-mix\(/.test(value)
65
+ this.resolved.set(name, usable ? value : undefined)
66
+ return usable ? value : undefined
67
+ }
68
+ }
69
+
70
+ /** Index of the `)` matching the `(` at `open`, or -1 if unbalanced. */
71
+ function matchingParen(text: string, open: number): number {
72
+ let depth = 0
73
+ for (let i = open; i < text.length; i++) {
74
+ const ch = text[i]
75
+ if (ch === '(') depth++
76
+ else if (ch === ')') {
77
+ depth--
78
+ if (depth === 0) return i
79
+ }
80
+ }
81
+ return -1
82
+ }
83
+
84
+ /** Split on top-level commas only (commas nested in parens stay put). */
85
+ function splitTopLevel(text: string): string[] {
86
+ const parts: string[] = []
87
+ let depth = 0
88
+ let start = 0
89
+ for (let i = 0; i < text.length; i++) {
90
+ const ch = text[i]
91
+ if (ch === '(') depth++
92
+ else if (ch === ')') depth--
93
+ else if (ch === ',' && depth === 0) {
94
+ parts.push(text.slice(start, i))
95
+ start = i + 1
96
+ }
97
+ }
98
+ parts.push(text.slice(start))
99
+ return parts
100
+ }
101
+
102
+ /**
103
+ * Matches the next `var(` or `color-mix(` not glued to a preceding
104
+ * identifier character. Instantiated per `evaluateExpression` call (it's
105
+ * recursive, and a shared global regex's `lastIndex` would be clobbered by
106
+ * the inner call).
107
+ */
108
+ const FUNCTION_START_SOURCE = /(?<![\w-])(var|color-mix)\(/g.source
109
+
110
+ /**
111
+ * Evaluate `var(name[, fallback])`. Returns the replacement text, or null
112
+ * when the reference can't be resolved and should be left verbatim.
113
+ */
114
+ function evaluateVar(args: string, scope: VarScope): string | null {
115
+ const [nameRaw, ...fallbackParts] = splitTopLevel(args)
116
+ const name = (nameRaw ?? '').trim()
117
+ const value = scope.lookup(name)
118
+ if (value !== undefined) return value
119
+ if (fallbackParts.length > 0) return fallbackParts.join(',').trim()
120
+ return null
121
+ }
122
+
123
+ /** Parse one `color-mix()` operand: `<color> [<percentage>]`, in either order. */
124
+ function parseMixOperand(
125
+ operand: string,
126
+ ): { color: string; pct: number | undefined } | null {
127
+ const tokens = operand.trim().split(/\s+/)
128
+ let color: string | undefined
129
+ let pct: number | undefined
130
+ for (const token of tokens) {
131
+ if (/^[\d.]+%$/.test(token)) {
132
+ if (pct !== undefined) return null
133
+ pct = Number(token.slice(0, -1))
134
+ } else {
135
+ if (color !== undefined) return null
136
+ color = token
137
+ }
138
+ }
139
+ if (color === undefined) return null
140
+ return { color, pct }
141
+ }
142
+
143
+ /**
144
+ * Evaluate `color-mix(in srgb, c1 [p1%], c2 [p2%])`. Returns the
145
+ * replacement text, or null when the expression isn't one this module
146
+ * can evaluate (a non-srgb space, an operand that isn't a concrete color).
147
+ */
148
+ function evaluateColorMix(args: string): string | null {
149
+ const parts = splitTopLevel(args)
150
+ if (parts.length !== 3) return null
151
+ const [space, op1, op2] = parts
152
+ if (
153
+ space === undefined ||
154
+ op1 === undefined ||
155
+ op2 === undefined ||
156
+ !/^\s*in\s+srgb\s*$/i.test(space)
157
+ ) {
158
+ return null
159
+ }
160
+ const a = parseMixOperand(op1)
161
+ const b = parseMixOperand(op2)
162
+ if (!a || !b) return null
163
+ const c1 = parseCssColor(a.color)
164
+ const c2 = parseCssColor(b.color)
165
+ if (!c1 || !c2) return null
166
+ const mixed = mixSrgb(c1, c2, a.pct, b.pct)
167
+ return mixed ? formatCssColor(mixed) : null
168
+ }
169
+
170
+ /**
171
+ * Replace every evaluable `var()` / `color-mix()` in `text`, innermost
172
+ * first. Expressions that can't be evaluated are kept verbatim (with their
173
+ * arguments still evaluated as far as possible), and scanning resumes after
174
+ * them, so an unknown variable can never stall the pass.
175
+ */
176
+ function evaluateExpression(text: string, scope: VarScope): string {
177
+ let out = ''
178
+ let cursor = 0
179
+ const functionStart = new RegExp(FUNCTION_START_SOURCE, 'g')
180
+ let match: RegExpExecArray | null
181
+ while ((match = functionStart.exec(text)) !== null) {
182
+ const fn = match[1]
183
+ if (fn === undefined) continue
184
+ const start = match.index
185
+ const open = start + fn.length
186
+ const close = matchingParen(text, open)
187
+ if (close === -1) break
188
+ const args = evaluateExpression(text.slice(open + 1, close), scope)
189
+ const replacement =
190
+ fn === 'var' ? evaluateVar(args, scope) : evaluateColorMix(args)
191
+ out += text.slice(cursor, start)
192
+ out += replacement ?? `${fn}(${args})`
193
+ cursor = close + 1
194
+ functionStart.lastIndex = cursor
195
+ }
196
+ return out + text.slice(cursor)
197
+ }
198
+
199
+ /**
200
+ * Evaluate one CSS value against a flat set of custom-property
201
+ * declarations — the expression engine behind `resolveCssColors`, exposed
202
+ * so it can be unit-tested (and reused) without building an SVG around it.
203
+ *
204
+ * @example
205
+ * evaluateCssColorValue('color-mix(in srgb, var(--fg) 50%, var(--bg))',
206
+ * { '--fg': '#000000', '--bg': '#ffffff' }) // → '#808080'
207
+ */
208
+ export function evaluateCssColorValue(
209
+ value: string,
210
+ declarations: Record<string, string>,
211
+ ): string {
212
+ const scope = new VarScope()
213
+ for (const [name, raw] of Object.entries(declarations)) {
214
+ scope.declare(name, raw)
215
+ }
216
+ return evaluateExpression(value, scope)
217
+ }
218
+
219
+ // ============================================================================
220
+ // SVG document pass
221
+ // ============================================================================
222
+
223
+ /** `--name: value` custom-property declarations inside a CSS rule body or a `style` attribute. */
224
+ const CUSTOM_PROPERTY_RE = /(--[\w-]+)\s*:\s*([^;{}]+)/g
225
+
226
+ /** `name="value"` / `name='value'` attribute pairs inside a start tag. */
227
+ const ATTRIBUTE_RE = /(\s)([\w:.-]+)(=)("[^"]*"|'[^']*')/g
228
+
229
+ /**
230
+ * Attributes whose values are never CSS: the `embedSource` stamp holds the
231
+ * diagram's own text, which may legitimately contain `var(` in a label.
232
+ */
233
+ const SKIP_ATTRIBUTES = new Set(['data-src'])
234
+
235
+ function collectDeclarations(css: string, scope: VarScope): void {
236
+ CUSTOM_PROPERTY_RE.lastIndex = 0
237
+ let match: RegExpExecArray | null
238
+ while ((match = CUSTOM_PROPERTY_RE.exec(css)) !== null) {
239
+ const [, name, value] = match
240
+ if (name !== undefined && value !== undefined) {
241
+ scope.declare(name, value.trim())
242
+ }
243
+ }
244
+ }
245
+
246
+ /** Rewrite the attribute values of one start tag (the text between `<` and `>`). */
247
+ function resolveTagAttributes(tag: string, scope: VarScope): string {
248
+ return tag.replace(
249
+ ATTRIBUTE_RE,
250
+ (whole, space: string, name: string, eq: string, quoted: string) => {
251
+ if (SKIP_ATTRIBUTES.has(name)) return whole
252
+ const quote = quoted[0] ?? '"'
253
+ const value = quoted.slice(1, -1)
254
+ return `${space}${name}${eq}${quote}${evaluateExpression(value, scope)}${quote}`
255
+ },
256
+ )
257
+ }
258
+
259
+ /**
260
+ * Replace every `var(--…)` and `color-mix(…)` in an SVG produced by this
261
+ * library's renderer with its computed sRGB value (`#rrggbb`, or
262
+ * `rgba(r, g, b, a)` when translucent).
263
+ *
264
+ * `colors` seeds the variable scope with the caller's palette; the SVG's
265
+ * own declarations (the root `style` attribute and any `<style>` block)
266
+ * are then read on top, so the derived `--_*` variables resolve through
267
+ * exactly the `color-mix()` rules `buildStyleBlock` emitted. See the
268
+ * module comment for what is and isn't rewritten.
269
+ */
270
+ export function resolveCssColors(svg: string, colors: DiagramColors): string {
271
+ const scope = new VarScope()
272
+ scope.declare('--bg', colors.bg)
273
+ scope.declare('--fg', colors.fg)
274
+ if (colors.line) scope.declare('--line', colors.line)
275
+ if (colors.accent) scope.declare('--accent', colors.accent)
276
+ if (colors.muted) scope.declare('--muted', colors.muted)
277
+ if (colors.surface) scope.declare('--surface', colors.surface)
278
+ if (colors.border) scope.declare('--border', colors.border)
279
+
280
+ // Pass 1: gather every custom-property declaration in the document —
281
+ // `<style>` rule bodies and `style="…"` attributes — before rewriting
282
+ // anything, so a declaration can be referenced from earlier in the text.
283
+ for (const block of svg.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
284
+ const css = block[1]
285
+ if (css !== undefined) collectDeclarations(css, scope)
286
+ }
287
+ for (const attr of svg.matchAll(/\sstyle=("[^"]*"|'[^']*')/g)) {
288
+ const quoted = attr[1]
289
+ if (quoted !== undefined) {
290
+ collectDeclarations(quoted.slice(1, -1), scope)
291
+ }
292
+ }
293
+
294
+ // Pass 2: rewrite. Walk the markup, evaluating `<style>` contents and
295
+ // start-tag attribute values; leave text nodes untouched.
296
+ let out = ''
297
+ let cursor = 0
298
+ while (cursor < svg.length) {
299
+ const lt = svg.indexOf('<', cursor)
300
+ if (lt === -1) break
301
+ out += svg.slice(cursor, lt)
302
+
303
+ if (svg.startsWith('<style', lt)) {
304
+ const openEnd = svg.indexOf('>', lt)
305
+ const closeStart = svg.indexOf('</style>', lt)
306
+ if (openEnd === -1 || closeStart === -1) break
307
+ out += svg.slice(lt, openEnd + 1)
308
+ out += evaluateExpression(svg.slice(openEnd + 1, closeStart), scope)
309
+ out += '</style>'
310
+ cursor = closeStart + '</style>'.length
311
+ continue
312
+ }
313
+
314
+ // Find the end of this tag, skipping `>` characters inside quoted
315
+ // attribute values (escapeXml encodes them, but be safe for any raw
316
+ // attribute the renderer passes through).
317
+ let gt = -1
318
+ let quote: string | null = null
319
+ for (let i = lt + 1; i < svg.length; i++) {
320
+ const ch = svg[i]
321
+ if (quote !== null) {
322
+ if (ch === quote) quote = null
323
+ } else if (ch === '"' || ch === "'") {
324
+ quote = ch
325
+ } else if (ch === '>') {
326
+ gt = i
327
+ break
328
+ }
329
+ }
330
+ if (gt === -1) break
331
+ const tag = svg.slice(lt, gt + 1)
332
+ out +=
333
+ tag.startsWith('<!') || tag.startsWith('<?') || tag.startsWith('</')
334
+ ? tag
335
+ : resolveTagAttributes(tag, scope)
336
+ cursor = gt + 1
337
+ }
338
+ return out + svg.slice(cursor)
339
+ }