@gajae-code/tui 0.18.10 → 0.18.12

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,837 @@
1
+ /**
2
+ * Source-level handling of ```svg fences in assistant Markdown: splitting
3
+ * prose around them ({@link splitSvgFences}), turning a fence that is still
4
+ * streaming into a document an SVG parser accepts ({@link closePartialSvg}),
5
+ * and resolving the theme tokens figures color themselves with
6
+ * ({@link prepareSvg}).
7
+ *
8
+ * Source: oh-my-pi (https://github.com/can1357/oh-my-pi)
9
+ */
10
+ import { Marked } from "marked";
11
+
12
+ /** One run of an assistant text block: prose, or the body of a ```svg fence. */
13
+ export type FigureSegment =
14
+ | { readonly kind: "markdown"; readonly text: string }
15
+ /** `closed` once the fence's closing line arrived. */
16
+ | { readonly kind: "svg"; readonly source: string; readonly closed: boolean };
17
+
18
+ /** A top-level (≤3-space indent) fenced-code opener. */
19
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
20
+ const SVG_TEXT = /svg/i;
21
+ const MAX_SVG_SOURCE_BYTES = 1024 * 1024;
22
+ const MAX_SVG_ELEMENTS = 20_000;
23
+ const MAX_SVG_NESTING_DEPTH = 256;
24
+ const MAX_SVG_ATTRIBUTES_PER_ELEMENT = 64;
25
+ const MAX_CSS_FUNCTION_DEPTH = 32;
26
+ const svgMarkdownParser = new Marked();
27
+
28
+ /** Whether `markdown` holds a ```svg fence {@link splitSvgFences} would lift. */
29
+ export function hasSvgFence(markdown: string): boolean {
30
+ if (!SVG_TEXT.test(markdown)) return false;
31
+ let fence: string | undefined;
32
+ let lineStart = 0;
33
+ while (lineStart <= markdown.length) {
34
+ const newline = markdown.indexOf("\n", lineStart);
35
+ const lineEnd = newline < 0 ? markdown.length : newline;
36
+ const next = newline < 0 ? markdown.length + 1 : newline + 1;
37
+ const line = markdown.slice(lineStart, lineEnd);
38
+ if (fence) {
39
+ if (closesFence(line, fence)) fence = undefined;
40
+ } else {
41
+ const open = FENCE_OPEN.exec(line);
42
+ // A backtick fence's info string cannot hold backticks (CommonMark).
43
+ if (open && !(open[1]![0] === "`" && open[2]!.includes("`"))) {
44
+ if (isSvgFenceInfo(open[2]!)) {
45
+ return svgMarkdownParser
46
+ .lexer(markdown)
47
+ .some(token => token.type === "code" && isSvgFenceInfo(token.lang));
48
+ }
49
+ fence = open[1]!;
50
+ }
51
+ }
52
+ lineStart = next;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ /**
58
+ * Split `markdown` into prose and ```svg fence bodies, in order. Only
59
+ * top-level fences lift; an svg fence nested in another fence, a list's
60
+ * indented block or a blockquote stays prose. Blank prose runs are dropped.
61
+ * An svg fence still open at the end is the last segment, `closed: false`.
62
+ */
63
+ export function splitSvgFences(markdown: string): FigureSegment[] {
64
+ const segments: FigureSegment[] = [];
65
+ let proseStart = 0;
66
+ for (const token of svgMarkdownParser.lexer(markdown)) {
67
+ if (token.type !== "code" || !isSvgFenceInfo(token.lang)) continue;
68
+ const start = markdown.indexOf(token.raw, proseStart);
69
+ if (start < 0) continue;
70
+ const end = start + token.raw.length;
71
+ const prose = markdown.slice(proseStart, start);
72
+ if (prose.trim()) segments.push({ kind: "markdown", text: prose });
73
+ segments.push({ kind: "svg", ...splitFenceBody(token.raw) });
74
+ proseStart = end;
75
+ }
76
+ const trailingProse = markdown.slice(proseStart);
77
+ if (trailingProse.trim()) segments.push({ kind: "markdown", text: trailingProse });
78
+ return segments;
79
+ }
80
+
81
+ /** Whether a Marked SVG code token's raw source contains its closing fence. */
82
+ export function isClosedSvgFence(raw: string): boolean {
83
+ return splitFenceBody(raw).closed;
84
+ }
85
+
86
+ function isSvgFenceInfo(info: string | undefined): boolean {
87
+ return info?.trim().split(/[ \t]/, 1)[0]?.toLowerCase() === "svg";
88
+ }
89
+
90
+ function splitFenceBody(raw: string): { source: string; closed: boolean } {
91
+ const openerEnd = raw.indexOf("\n");
92
+ const opener = raw.slice(0, openerEnd < 0 ? raw.length : openerEnd);
93
+ const marker = FENCE_OPEN.exec(opener)?.[1];
94
+ const bodyStart = openerEnd < 0 ? raw.length : openerEnd + 1;
95
+ if (!marker) return { source: raw.slice(bodyStart), closed: false };
96
+ let lineStart = bodyStart;
97
+ while (lineStart <= raw.length) {
98
+ const newline = raw.indexOf("\n", lineStart);
99
+ const lineEnd = newline < 0 ? raw.length : newline;
100
+ if (closesFence(raw.slice(lineStart, lineEnd), marker)) {
101
+ return { source: raw.slice(bodyStart, lineStart), closed: true };
102
+ }
103
+ if (newline < 0) break;
104
+ lineStart = newline + 1;
105
+ }
106
+ return { source: raw.slice(bodyStart), closed: false };
107
+ }
108
+
109
+ /** Whether `line` closes a fence opened by `marker`: same character, at least as long, nothing after. */
110
+ function closesFence(line: string, marker: string): boolean {
111
+ let index = 0;
112
+ while (index < 3 && line[index] === " ") index++;
113
+ let run = 0;
114
+ while (line[index + run] === marker[0]) run++;
115
+ return run >= marker.length && line.slice(index + run).trim() === "";
116
+ }
117
+
118
+ /**
119
+ * Turn a possibly truncated SVG source into a well-formed document: cut the
120
+ * trailing construct still being written (a tag without its `>`, an
121
+ * unterminated comment, CDATA section, processing instruction or declaration,
122
+ * a dangling `&entity`) and close every element left open, innermost first.
123
+ * A complete document passes through unchanged. Returns null until the root
124
+ * `<svg …>` start tag is complete — before that there is nothing to draw.
125
+ */
126
+ export function closePartialSvg(source: string): string | null {
127
+ if (source.length > MAX_SVG_SOURCE_BYTES) return null;
128
+ if (Buffer.byteLength(source, "utf8") > MAX_SVG_SOURCE_BYTES) return null;
129
+ const open: string[] = [];
130
+ let elements = 0;
131
+ let rootSeen = false;
132
+ let end = 0;
133
+ let index = 0;
134
+ for (;;) {
135
+ const lt = source.indexOf("<", index);
136
+ if (lt < 0) {
137
+ // Trailing text: an entity reference still being written would not parse.
138
+ const amp = source.lastIndexOf("&");
139
+ end = amp >= index && !source.includes(";", amp) ? amp : source.length;
140
+ break;
141
+ }
142
+ end = lt;
143
+ const close = constructEnd(source, lt);
144
+ if (close < 0) break;
145
+ if (source.startsWith("</", lt)) {
146
+ const name = source.slice(lt + 2, close - 1).trim();
147
+ const at = open.lastIndexOf(name);
148
+ if (at >= 0) open.length = at;
149
+ } else if (source[lt + 1] !== "!" && source[lt + 1] !== "?") {
150
+ const tag = source.slice(lt, close);
151
+ const name = /^<([^\s/>]+)/.exec(tag)?.[1];
152
+ if (name !== undefined) {
153
+ elements++;
154
+ if (elements > MAX_SVG_ELEMENTS || countSvgAttributes(tag) > MAX_SVG_ATTRIBUTES_PER_ELEMENT) {
155
+ return null;
156
+ }
157
+ if (!isSelfClosingSvgTag(tag)) {
158
+ if (open.length >= MAX_SVG_NESTING_DEPTH) return null;
159
+ open.push(name);
160
+ }
161
+ if (!rootSeen && (name === "svg" || name.endsWith(":svg"))) rootSeen = true;
162
+ }
163
+ }
164
+ index = close;
165
+ end = close;
166
+ }
167
+ if (!rootSeen) return null;
168
+ let document = source.slice(0, end);
169
+ for (let at = open.length - 1; at >= 0; at--) document += `</${open[at]}>`;
170
+ return document;
171
+ }
172
+
173
+ /** CSS `var()` references are parsed with balanced parentheses below. */
174
+ const CSS_VALUE_ATTRIBUTES = new Set([
175
+ "alignment-baseline",
176
+ "baseline-shift",
177
+ "clip",
178
+ "clip-path",
179
+ "clip-rule",
180
+ "color",
181
+ "color-interpolation",
182
+ "color-interpolation-filters",
183
+ "color-rendering",
184
+ "cursor",
185
+ "direction",
186
+ "display",
187
+ "dominant-baseline",
188
+ "fill",
189
+ "fill-opacity",
190
+ "fill-rule",
191
+ "filter",
192
+ "flood-color",
193
+ "flood-opacity",
194
+ "font-family",
195
+ "font-size",
196
+ "font-size-adjust",
197
+ "font-stretch",
198
+ "font-style",
199
+ "font-variant",
200
+ "font-weight",
201
+ "glyph-orientation-horizontal",
202
+ "glyph-orientation-vertical",
203
+ "image-rendering",
204
+ "letter-spacing",
205
+ "lighting-color",
206
+ "marker-end",
207
+ "marker-mid",
208
+ "marker-start",
209
+ "mask",
210
+ "opacity",
211
+ "overflow",
212
+ "paint-order",
213
+ "pointer-events",
214
+ "shape-rendering",
215
+ "stop-color",
216
+ "stop-opacity",
217
+ "stroke",
218
+ "stroke-dasharray",
219
+ "stroke-dashoffset",
220
+ "stroke-linecap",
221
+ "stroke-linejoin",
222
+ "stroke-miterlimit",
223
+ "stroke-opacity",
224
+ "stroke-width",
225
+ "text-anchor",
226
+ "text-decoration",
227
+ "text-rendering",
228
+ "unicode-bidi",
229
+ "vector-effect",
230
+ "visibility",
231
+ "word-spacing",
232
+ "writing-mode",
233
+ ]);
234
+
235
+ /**
236
+ * Make a figure's source what an SVG rasterizer draws as written, in the
237
+ * reader's theme. Rasterizers resolve neither CSS custom properties nor an
238
+ * inherited text color, and reject a root without the SVG namespace, so:
239
+ * - reserved `--gjc-${name}` variables in `<style>` contents, `style`
240
+ * declarations, and presentation attributes become `palette[name]` (an
241
+ * unknown GJC token takes its fallback, else `palette.fg`); author-defined
242
+ * CSS custom properties stay intact, and nested fallbacks are resolved;
243
+ * SVG text and unrelated attributes stay literal;
244
+ * - a root `<svg>` lacking them gets `color` = `palette.fg` (so
245
+ * `currentColor` follows the theme), a sans-serif `font-family` (instead
246
+ * of the rasterizer's Times), `xmlns`, and `xmlns:xlink` when the source
247
+ * uses `xlink:` attributes.
248
+ */
249
+ /**
250
+ * Reject SVGs with <image> or <feImage> elements to prevent unbounded
251
+ * memory allocation during rasterization. Image elements can reference
252
+ * data-URIs with extreme dimensions (e.g., 1×2^31 height) and trigger
253
+ * multi-gigabyte buffer allocation before frame decode, causing OOM/abort
254
+ * of the local process. Parsing is structural to reject all bypass
255
+ * spellings (whitespace around attributes, xlink:href, entity-encoded data:,
256
+ * mixed case, etc.) regardless of attribute format.
257
+ */
258
+ function rejectEmbeddedRasterImages(svg: string): void {
259
+ // Scan for <image or <feImage elements by finding markup constructs and
260
+ // checking their tag names. This approach ignores attribute spelling and
261
+ // captures all raster image references regardless of how they are written.
262
+ let index = 0;
263
+ while (index < svg.length) {
264
+ const lt = svg.indexOf("<", index);
265
+ if (lt < 0) break;
266
+
267
+ const close = constructEnd(svg, lt);
268
+ if (close < 0) {
269
+ // The remainder is part of this incomplete construct, not independent
270
+ // markup. Retrying at each later `<` would rescan overlapping suffixes.
271
+ break;
272
+ }
273
+
274
+ // Skip non-element constructs (comments, CDATA, processing instructions).
275
+ if (svg[lt + 1] === "!" || svg[lt + 1] === "?") {
276
+ index = close;
277
+ continue;
278
+ }
279
+
280
+ // The namespace prefix may contain any XML name character, including
281
+ // non-ASCII letters. Compare the local name without validating the prefix.
282
+ const tag = svg.slice(lt, close);
283
+ const nameMatch = /^<\s*([^\s/>]+)/.exec(tag);
284
+ if (!nameMatch) {
285
+ index = close;
286
+ continue;
287
+ }
288
+
289
+ const tagName = nameMatch[1]!.split(":").at(-1)!.toLowerCase();
290
+ if (tagName === "image" || tagName === "feimage") {
291
+ throw new Error("SVG contains <image> or <feImage> elements, which are not supported for this renderer");
292
+ }
293
+
294
+ index = close;
295
+ }
296
+ }
297
+
298
+ /** Bound work performed synchronously while preparing assistant-controlled SVG. */
299
+ function validateSvgSource(svg: string): void {
300
+ if (svg.length > MAX_SVG_SOURCE_BYTES || Buffer.byteLength(svg, "utf8") > MAX_SVG_SOURCE_BYTES) {
301
+ throw new Error(`SVG source exceeds the ${MAX_SVG_SOURCE_BYTES}-byte limit`);
302
+ }
303
+
304
+ let elements = 0;
305
+ let depth = 0;
306
+ let index = 0;
307
+ while (index < svg.length) {
308
+ const lt = svg.indexOf("<", index);
309
+ if (lt < 0) break;
310
+
311
+ // Comments, CDATA, and processing instructions are not declarations or elements.
312
+ if (svg.startsWith("<!--", lt) || svg.startsWith("<![CDATA[", lt) || svg.startsWith("<?", lt)) {
313
+ const end = constructEnd(svg, lt);
314
+ if (end < 0) break;
315
+ index = end;
316
+ continue;
317
+ }
318
+ // DTDs and entity declarations can introduce image elements after this scan.
319
+ if (svg.startsWith("<!", lt)) {
320
+ throw new Error("SVG DTD and entity declarations are not supported");
321
+ }
322
+
323
+ const end = constructEnd(svg, lt);
324
+ if (end < 0) break;
325
+ const tag = svg.slice(lt, end);
326
+ const closing = /^<\s*\//.test(tag);
327
+ const startTag = !closing && /^<\s*[^!?/\s>]/.test(tag);
328
+ if (startTag) {
329
+ elements++;
330
+ if (elements > MAX_SVG_ELEMENTS) {
331
+ throw new Error(`SVG source exceeds the ${MAX_SVG_ELEMENTS}-element limit`);
332
+ }
333
+ const attributes = countSvgAttributes(tag);
334
+ if (attributes > MAX_SVG_ATTRIBUTES_PER_ELEMENT) {
335
+ throw new Error(`SVG elements may not have more than ${MAX_SVG_ATTRIBUTES_PER_ELEMENT} attributes`);
336
+ }
337
+ if (!/\/\s*>$/.test(tag)) {
338
+ depth++;
339
+ if (depth > MAX_SVG_NESTING_DEPTH) {
340
+ throw new Error(`SVG source exceeds the ${MAX_SVG_NESTING_DEPTH}-element nesting limit`);
341
+ }
342
+ }
343
+ } else if (closing) {
344
+ depth = Math.max(0, depth - 1);
345
+ }
346
+ index = end;
347
+ }
348
+ }
349
+
350
+ function countSvgAttributes(tag: string): number {
351
+ let quote: string | undefined;
352
+ let count = 0;
353
+ for (const char of tag) {
354
+ if (quote !== undefined) {
355
+ if (char === quote) quote = undefined;
356
+ } else if (char === '"' || char === "'") {
357
+ quote = char;
358
+ } else if (char === "=") {
359
+ count++;
360
+ }
361
+ }
362
+ return count;
363
+ }
364
+
365
+ export function prepareSvg(svg: string, palette: Readonly<Record<string, string>>): string {
366
+ validateSvgSource(svg);
367
+ rejectEmbeddedRasterImages(svg);
368
+ const fg = palette.fg ?? "currentColor";
369
+ const resolved = resolveSvgCssVariables(svg, palette, fg);
370
+ const usesXlink = resolved.includes("xlink:");
371
+ const root = findRootSvgTag(resolved);
372
+ if (!root) return resolved;
373
+ let added = "";
374
+ const replacements: Array<{ start: number; end: number; value: string }> = [];
375
+ let rootColor = false;
376
+ forEachSvgAttribute(root.tag, (name, value, start, end) => {
377
+ if (name === "color") {
378
+ rootColor = true;
379
+ if (value.trim().toLowerCase() === "currentcolor") replacements.push({ start, end, value: fg });
380
+ } else if (name === "style") {
381
+ const style = resolveRootStyleColor(value, fg);
382
+ if (style !== value) replacements.push({ start, end, value: style });
383
+ }
384
+ });
385
+ if (!rootColor) {
386
+ added += ` color="${fg}"`;
387
+ }
388
+ if (!hasSvgAttribute(root.tag, "font-family")) added += ` font-family="sans-serif"`;
389
+ if (!hasSvgAttribute(root.tag, "xmlns")) added += ` xmlns="http://www.w3.org/2000/svg"`;
390
+ if (usesXlink && !hasSvgAttribute(root.tag, "xmlns:xlink")) {
391
+ added += ` xmlns:xlink="http://www.w3.org/1999/xlink"`;
392
+ }
393
+ let rootTag = root.tag;
394
+ for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
395
+ rootTag = `${rootTag.slice(0, replacement.start)}${replacement.value}${rootTag.slice(replacement.end)}`;
396
+ }
397
+ if (!added && rootTag === root.tag) return resolved;
398
+ const tag = rootTag.replace(/^<[^\s/>]+/, `$&${added}`);
399
+ return `${resolved.slice(0, root.start)}${tag}${resolved.slice(root.end)}`;
400
+ }
401
+
402
+ /** Resolve only root inline `color: currentColor`; descendants retain inherited CSS semantics. */
403
+ function resolveRootStyleColor(style: string, fg: string): string {
404
+ const replacements: Array<{ start: number; end: number }> = [];
405
+ let declarationStart = 0;
406
+ let quote: string | undefined;
407
+ let depth = 0;
408
+ for (let index = 0; index <= style.length; index++) {
409
+ if (index === style.length || (style[index] === ";" && quote === undefined && depth === 0)) {
410
+ const declaration = style.slice(declarationStart, index);
411
+ const colon = findCssDeclarationColon(declaration);
412
+ const property =
413
+ colon < 0
414
+ ? ""
415
+ : declaration
416
+ .slice(0, colon)
417
+ .replace(/\/\*[\s\S]*?\*\//g, "")
418
+ .trim()
419
+ .toLowerCase();
420
+ if (property === "color") {
421
+ const value = declaration.slice(colon + 1);
422
+ const token = rootCurrentColorToken(value);
423
+ if (token) {
424
+ replacements.push({
425
+ start: declarationStart + colon + 1 + token.start,
426
+ end: declarationStart + colon + 1 + token.end,
427
+ });
428
+ }
429
+ }
430
+ declarationStart = index + 1;
431
+ continue;
432
+ }
433
+ if (style.startsWith("/*", index) && quote === undefined) {
434
+ const commentEnd = style.indexOf("*/", index + 2);
435
+ if (commentEnd < 0) break;
436
+ index = commentEnd + 1;
437
+ continue;
438
+ }
439
+ const char = style[index];
440
+ if (quote !== undefined) {
441
+ if (char === "\\") index++;
442
+ else if (char === quote) quote = undefined;
443
+ } else if (char === '"' || char === "'") {
444
+ quote = char;
445
+ } else if (char === "(") {
446
+ depth++;
447
+ } else if (char === ")") {
448
+ depth = Math.max(0, depth - 1);
449
+ }
450
+ }
451
+ if (replacements.length === 0) return style;
452
+ let output = "";
453
+ let cursor = 0;
454
+ for (const replacement of replacements) {
455
+ output += style.slice(cursor, replacement.start) + fg;
456
+ cursor = replacement.end;
457
+ }
458
+ return output + style.slice(cursor);
459
+ }
460
+
461
+ /** Locate a declaration's property/value separator outside strings and comments. */
462
+ function findCssDeclarationColon(declaration: string): number {
463
+ let quote: string | undefined;
464
+ let depth = 0;
465
+ for (let index = 0; index < declaration.length; index++) {
466
+ if (declaration.startsWith("/*", index) && quote === undefined) {
467
+ const commentEnd = declaration.indexOf("*/", index + 2);
468
+ if (commentEnd < 0) return -1;
469
+ index = commentEnd + 1;
470
+ continue;
471
+ }
472
+ const char = declaration[index];
473
+ if (quote !== undefined) {
474
+ if (char === "\\") index++;
475
+ else if (char === quote) quote = undefined;
476
+ } else if (char === '"' || char === "'") {
477
+ quote = char;
478
+ } else if (char === "(") {
479
+ depth++;
480
+ } else if (char === ")") {
481
+ depth = Math.max(0, depth - 1);
482
+ } else if (char === ":" && depth === 0) {
483
+ return index;
484
+ }
485
+ }
486
+ return -1;
487
+ }
488
+
489
+ /** Return the exact currentColor token only when it is the whole color value. */
490
+ function rootCurrentColorToken(value: string): { start: number; end: number } | undefined {
491
+ const skipTrivia = (from: number): number => {
492
+ let index = from;
493
+ while (index < value.length) {
494
+ if (/\s/.test(value[index] ?? "")) {
495
+ index++;
496
+ } else if (value.startsWith("/*", index)) {
497
+ const commentEnd = value.indexOf("*/", index + 2);
498
+ if (commentEnd < 0) return value.length;
499
+ index = commentEnd + 2;
500
+ } else {
501
+ break;
502
+ }
503
+ }
504
+ return index;
505
+ };
506
+ const start = skipTrivia(0);
507
+ const end = start + "currentcolor".length;
508
+ if (value.slice(start, end).toLowerCase() !== "currentcolor") return undefined;
509
+ let remaining = skipTrivia(end);
510
+ if (value[remaining] === "!") {
511
+ remaining = skipTrivia(remaining + 1);
512
+ if (value.slice(remaining, remaining + "important".length).toLowerCase() !== "important") return undefined;
513
+ remaining = skipTrivia(remaining + "important".length);
514
+ }
515
+ return remaining === value.length ? { start, end } : undefined;
516
+ }
517
+
518
+ /** Resolve CSS variables only in stylesheet text and CSS-bearing XML attributes. */
519
+ function resolveSvgCssVariables(source: string, palette: Readonly<Record<string, string>>, fg: string): string {
520
+ let output = "";
521
+ let cursor = 0;
522
+ while (cursor < source.length) {
523
+ const start = source.indexOf("<", cursor);
524
+ if (start < 0) return output + source.slice(cursor);
525
+ output += source.slice(cursor, start);
526
+ const end = constructEnd(source, start);
527
+ if (end < 0) return output + source.slice(start);
528
+ const tag = source.slice(start, end);
529
+ const styleTagName = svgElementName(tag);
530
+ if (styleTagName?.split(":").at(-1)?.toLowerCase() === "style" && !isSelfClosingSvgTag(tag)) {
531
+ output += resolveCssAttributes(tag, palette, fg);
532
+ const closingTag = findStyleClosingTag(source, end, styleTagName);
533
+ if (closingTag < 0) return output + resolveCssValue(source.slice(end), palette, fg);
534
+ output += resolveCssValue(source.slice(end, closingTag), palette, fg);
535
+ cursor = closingTag;
536
+ continue;
537
+ }
538
+ output += resolveCssAttributes(tag, palette, fg);
539
+ cursor = end;
540
+ }
541
+ return output;
542
+ }
543
+
544
+ /** Whether a complete XML start tag is self-closing. */
545
+ function isSelfClosingSvgTag(tag: string): boolean {
546
+ return /\/\s*>$/.test(tag);
547
+ }
548
+
549
+ /** Find the matching closing style tag; `<` cannot appear literally in XML style text. */
550
+ function findStyleClosingTag(source: string, start: number, qualifiedName: string): number {
551
+ const lowered = source.toLowerCase();
552
+ const closingTag = `</${qualifiedName}`.toLowerCase();
553
+ let index = source.indexOf("<", start);
554
+ while (index >= 0) {
555
+ if (lowered.startsWith(closingTag, index)) {
556
+ const next = source[index + closingTag.length];
557
+ if (next === ">" || (next !== undefined && /\s/.test(next))) return index;
558
+ }
559
+ const end = constructEnd(source, index);
560
+ if (end < 0) return -1;
561
+ index = source.indexOf("<", end);
562
+ }
563
+ return -1;
564
+ }
565
+
566
+ /** Resolve CSS-bearing attribute values without rewriting other XML content. */
567
+ function resolveCssAttributes(tag: string, palette: Readonly<Record<string, string>>, fg: string): string {
568
+ const replacements: Array<{ start: number; end: number; value: string }> = [];
569
+ forEachSvgAttribute(tag, (name, value, start, end) => {
570
+ if (name !== "style" && !CSS_VALUE_ATTRIBUTES.has(name)) return;
571
+ const resolved = resolveCssValue(value, palette, fg);
572
+ if (resolved !== value) replacements.push({ start, end, value: resolved });
573
+ });
574
+ if (replacements.length === 0) return tag;
575
+ let output = "";
576
+ let cursor = 0;
577
+ for (const replacement of replacements) {
578
+ output += tag.slice(cursor, replacement.start) + replacement.value;
579
+ cursor = replacement.end;
580
+ }
581
+ return output + tag.slice(cursor);
582
+ }
583
+
584
+ type SvgAttributeVisitor = (name: string, value: string, valueStart: number, valueEnd: number) => void;
585
+
586
+ /** Visit quoted attributes in a complete XML start tag with source offsets. */
587
+ function forEachSvgAttribute(tag: string, visit: SvgAttributeVisitor): void {
588
+ if (!svgElementName(tag)) return;
589
+ let index = 1;
590
+ while (index < tag.length && !/[\s/>]/.test(tag[index] ?? "")) index++;
591
+ while (index < tag.length) {
592
+ while (/\s/.test(tag[index] ?? "")) index++;
593
+ if (tag[index] === "/" || tag[index] === ">") return;
594
+ const nameStart = index;
595
+ while (index < tag.length && !/[\s=/>]/.test(tag[index] ?? "")) index++;
596
+ const name = tag.slice(nameStart, index);
597
+ while (/\s/.test(tag[index] ?? "")) index++;
598
+ if (tag[index] !== "=") continue;
599
+ index++;
600
+ while (/\s/.test(tag[index] ?? "")) index++;
601
+ const quote = tag[index];
602
+ if (quote === '"' || quote === "'") {
603
+ const valueStart = ++index;
604
+ while (index < tag.length && tag[index] !== quote) index++;
605
+ const valueEnd = index;
606
+ visit(name, tag.slice(valueStart, valueEnd), valueStart, valueEnd);
607
+ index++;
608
+ } else {
609
+ while (index < tag.length && !/[\s>]/.test(tag[index] ?? "")) index++;
610
+ }
611
+ }
612
+ }
613
+
614
+ /** Resolve variable functions outside CSS comments and quoted string literals. */
615
+ function resolveCssValue(source: string, palette: Readonly<Record<string, string>>, fg: string): string {
616
+ const frames: CssResolutionFrame[] = [{ source, cursor: 0, index: 0, output: "", prefix: "", suffix: "" }];
617
+ while (frames.length > 0) {
618
+ const frame = frames[frames.length - 1]!;
619
+ if (frame.index >= frame.source.length) {
620
+ const resolved = frame.output + frame.source.slice(frame.cursor);
621
+ frames.pop();
622
+ if (frames.length === 0) return resolved;
623
+ const parent = frames[frames.length - 1]!;
624
+ parent.output += frame.prefix + resolved + frame.suffix;
625
+ continue;
626
+ }
627
+ if (frame.source.startsWith("/*", frame.index)) {
628
+ const end = frame.source.indexOf("*/", frame.index + 2);
629
+ frame.index = end < 0 ? frame.source.length : end + 2;
630
+ continue;
631
+ }
632
+ const char = frame.source[frame.index];
633
+ if (char === '"' || char === "'") {
634
+ const quote = char;
635
+ frame.index++;
636
+ while (frame.index < frame.source.length) {
637
+ if (frame.source[frame.index] === "\\") frame.index += 2;
638
+ else if (frame.source[frame.index++] === quote) break;
639
+ }
640
+ continue;
641
+ }
642
+ if (frame.source.slice(frame.index, frame.index + 4).toLowerCase() === "var(") {
643
+ const reference = readCssVariableReference(frame.source, frame.index);
644
+ if (reference) {
645
+ const fallback = reference.fallback;
646
+ if (reference.name.startsWith("gjc-")) {
647
+ frame.output += frame.source.slice(frame.cursor, frame.index);
648
+ const themeColor = palette[reference.name.slice("gjc-".length)];
649
+ if (themeColor !== undefined) {
650
+ frame.output += themeColor;
651
+ } else if (fallback?.trim()) {
652
+ frames.push({ source: fallback.trim(), cursor: 0, index: 0, output: "", prefix: "", suffix: "" });
653
+ } else {
654
+ frame.output += fg;
655
+ }
656
+ frame.cursor = reference.end;
657
+ } else if (fallback !== undefined) {
658
+ frame.output += frame.source.slice(frame.cursor, frame.index);
659
+ frames.push({
660
+ source: fallback,
661
+ cursor: 0,
662
+ index: 0,
663
+ output: "",
664
+ prefix: frame.source.slice(frame.index, reference.fallbackStart),
665
+ suffix: frame.source.slice(reference.fallbackEnd, reference.end),
666
+ });
667
+ frame.cursor = reference.end;
668
+ }
669
+ frame.index = reference.end;
670
+ continue;
671
+ }
672
+ }
673
+ frame.index++;
674
+ }
675
+ return source;
676
+ }
677
+
678
+ interface CssResolutionFrame {
679
+ source: string;
680
+ cursor: number;
681
+ index: number;
682
+ output: string;
683
+ prefix: string;
684
+ suffix: string;
685
+ }
686
+
687
+ interface CssVariableReference {
688
+ name: string;
689
+ end: number;
690
+ fallback: string | undefined;
691
+ fallbackStart: number;
692
+ fallbackEnd: number;
693
+ }
694
+
695
+ /** Parse a CSS `var()` reference with balanced nested functions. */
696
+ function readCssVariableReference(source: string, start: number): CssVariableReference | undefined {
697
+ let depth = 1;
698
+ let quote: string | undefined;
699
+ let close = -1;
700
+ for (let index = start + 4; index < source.length; index++) {
701
+ if (source.startsWith("/*", index)) {
702
+ const commentEnd = source.indexOf("*/", index + 2);
703
+ if (commentEnd < 0) return undefined;
704
+ index = commentEnd + 1;
705
+ continue;
706
+ }
707
+ const char = source[index];
708
+ if (quote !== undefined) {
709
+ if (char === "\\") index++;
710
+ else if (char === quote) quote = undefined;
711
+ } else if (char === '"' || char === "'") {
712
+ quote = char;
713
+ } else if (char === "(") {
714
+ if (++depth > MAX_CSS_FUNCTION_DEPTH) {
715
+ throw new Error(`SVG CSS function nesting exceeds the ${MAX_CSS_FUNCTION_DEPTH}-level limit`);
716
+ }
717
+ } else if (char === ")" && --depth === 0) {
718
+ close = index;
719
+ break;
720
+ }
721
+ }
722
+ if (close < 0) return undefined;
723
+
724
+ const bodyStart = start + 4;
725
+ let nestedDepth = 0;
726
+ quote = undefined;
727
+ let comma = -1;
728
+ for (let index = bodyStart; index < close; index++) {
729
+ if (source.startsWith("/*", index)) {
730
+ const commentEnd = source.indexOf("*/", index + 2);
731
+ if (commentEnd < 0 || commentEnd >= close) return undefined;
732
+ index = commentEnd + 1;
733
+ continue;
734
+ }
735
+ const char = source[index];
736
+ if (quote !== undefined) {
737
+ if (char === "\\") index++;
738
+ else if (char === quote) quote = undefined;
739
+ } else if (char === '"' || char === "'") {
740
+ quote = char;
741
+ } else if (char === "(") {
742
+ nestedDepth++;
743
+ } else if (char === ")") {
744
+ nestedDepth--;
745
+ } else if (char === "," && nestedDepth === 0) {
746
+ comma = index;
747
+ break;
748
+ }
749
+ }
750
+ const nameEnd = comma < 0 ? close : comma;
751
+ const name = source.slice(bodyStart, nameEnd).trim();
752
+ if (!/^--[\w-]+$/.test(name)) return undefined;
753
+ const fallbackStart = comma < 0 ? close : comma + 1;
754
+ return {
755
+ name: name.slice(2),
756
+ end: close + 1,
757
+ fallback: comma < 0 ? undefined : source.slice(fallbackStart, close),
758
+ fallbackStart,
759
+ fallbackEnd: close,
760
+ };
761
+ }
762
+
763
+ /** The first quote-aware `<svg …>` start tag (namespace prefix allowed). */
764
+ function findRootSvgTag(source: string): { start: number; end: number; tag: string } | undefined {
765
+ let start = source.indexOf("<");
766
+ while (start >= 0) {
767
+ const end = constructEnd(source, start);
768
+ if (end < 0) return undefined;
769
+ const tag = source.slice(start, end);
770
+ if (svgElementName(tag)?.split(":").at(-1) === "svg") return { start, end, tag };
771
+ start = source.indexOf("<", end);
772
+ }
773
+ return undefined;
774
+ }
775
+
776
+ /** The qualified name of an XML start tag, without ASCII-only name restrictions. */
777
+ function svgElementName(tag: string): string | undefined {
778
+ return /^<([^!?/\s>]+)(?=[\s/>])/.exec(tag)?.[1];
779
+ }
780
+
781
+ /** Whether the quote-aware root tag declares `attribute` outside another value. */
782
+ function hasSvgAttribute(tag: string, attribute: string): boolean {
783
+ const elementName = svgElementName(tag);
784
+ if (elementName?.split(":").at(-1) !== "svg") return false;
785
+ let index = elementName.length + 1;
786
+ while (index < tag.length) {
787
+ while (/\s/.test(tag[index] ?? "")) index++;
788
+ if (tag[index] === "/" || tag[index] === ">") return false;
789
+ const start = index;
790
+ while (index < tag.length && !/[\s=/>]/.test(tag[index] ?? "")) index++;
791
+ const current = tag.slice(start, index);
792
+ while (/\s/.test(tag[index] ?? "")) index++;
793
+ if (tag[index] !== "=") return false;
794
+ index++;
795
+ while (/\s/.test(tag[index] ?? "")) index++;
796
+ const quote = tag[index];
797
+ if (quote === "'" || quote === '"') {
798
+ index++;
799
+ while (index < tag.length && tag[index] !== quote) index++;
800
+ index++;
801
+ } else {
802
+ while (index < tag.length && !/[\s>]/.test(tag[index] ?? "")) index++;
803
+ }
804
+ if (current === attribute) return true;
805
+ }
806
+ return false;
807
+ }
808
+
809
+ /** Index just past the markup construct starting at `<` (`lt`), or -1 when it is not complete yet. */
810
+ function constructEnd(source: string, lt: number): number {
811
+ const after = (terminator: string, from: number): number => {
812
+ const at = source.indexOf(terminator, from);
813
+ return at < 0 ? -1 : at + terminator.length;
814
+ };
815
+ if (source.startsWith("<!--", lt)) return after("-->", lt + 4);
816
+ if (source.startsWith("<![CDATA[", lt)) return after("]]>", lt + 9);
817
+ if (source.startsWith("<?", lt)) return after("?>", lt + 2);
818
+ // Tags and declarations end at the first `>` outside a quoted value; a
819
+ // doctype's internal subset (`[…]`) may hold `>` of its own.
820
+ let quote: string | undefined;
821
+ let subset = false;
822
+ for (let at = lt + 1; at < source.length; at++) {
823
+ const char = source[at];
824
+ if (quote !== undefined) {
825
+ if (char === quote) quote = undefined;
826
+ } else if (char === '"' || char === "'") {
827
+ quote = char;
828
+ } else if (char === "[" && source[lt + 1] === "!") {
829
+ subset = true;
830
+ } else if (char === "]") {
831
+ subset = false;
832
+ } else if (char === ">" && !subset) {
833
+ return at + 1;
834
+ }
835
+ }
836
+ return -1;
837
+ }