@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -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
|
+
}
|