@wxxb789/anc 0.0.1 → 0.0.3

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,1699 @@
1
+ /**
2
+ * The static, self-contained SVG subset: a namespace-aware XML walk plus the CSS
3
+ * policy, and the measurements a rasterizer needs from it.
4
+ *
5
+ * This module is **policy only**. It reads bytes, never a filesystem path, and it
6
+ * never renders. `svg-render.ts` turns an accepted document into WebP; keeping
7
+ * the two apart means the security gate is tested without a native binding and
8
+ * the renderer cannot be reached by a document this file refused.
9
+ *
10
+ * ## What is accepted
11
+ *
12
+ * A single `<svg>` root in the SVG namespace, drawing elements from the static
13
+ * subset (shapes, paths, groups, `defs`/`symbol`/`use`, gradients, patterns, clip
14
+ * paths, masks, markers, static filters, and text), styled by attributes, a
15
+ * `style` attribute or a `<style>` sheet, and referencing only **fragments of the
16
+ * same document** (`href="#id"`, `xlink:href="#id"`, `url(#grad)`). No external
17
+ * resource can be named, so the renderer has nothing to resolve even before the
18
+ * byte-buffer boundary denies it a base URI.
19
+ *
20
+ * ## What is refused, and why by construct rather than by text
21
+ *
22
+ * - A `DOCTYPE` event or an entity reference beyond XML's predefined five. The
23
+ * saxes parser is configured without a DTD resolver and reports an undefined
24
+ * entity as an error; a DTD is refused before it can declare one.
25
+ * - `XInclude`, `script`, `foreignObject`, animation elements, `on*` handlers,
26
+ * `xmlns`-relative foreign namespaces, `xml:base`, and processing instructions.
27
+ * - Every `href`/`xlink:href` and every CSS `url()` that is not a fragment,
28
+ * classified by the **decoded** value (see `svg-css.ts`), so an escaped
29
+ * `\6a avascript:` is the script URL it resolves to.
30
+ * - Every `data:` payload, with its own diagnostic. That is the design's bounded
31
+ * choice rather than an unimplemented embedding path.
32
+ * - Documents and declared surfaces beyond finite budgets, before a pixel is
33
+ * allocated: element count, nesting depth, `use` count, text volume, filter
34
+ * primitive count, standard deviations, filter regions and convolution
35
+ * kernels.
36
+ *
37
+ * ## What it returns
38
+ *
39
+ * The logical size to rasterize — explicit absolute `width`/`height` when both are
40
+ * usable, otherwise the `viewBox` — and the visible text runs, which the renderer
41
+ * probes against the host's fonts. A `viewBox`-only document has no author-chosen
42
+ * CSS size, so the renderer derives one from the article layout; this module
43
+ * reports what the author wrote and leaves that product decision where the
44
+ * measurement is.
45
+ */
46
+ import { SaxesParser } from 'saxes';
47
+ import { analyzeStyleSheet, declarationReferences, inspectPresentationAttribute, inspectReference, inspectStyleAttribute, presentationDeclaration, } from "./svg-css.js";
48
+ /** The SVG namespace every accepted element must be in. */
49
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
50
+ /** The legacy link namespace, accepted for `href`, `title` and `role`. */
51
+ const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
52
+ /** The XInclude namespace, refused with its own diagnostic. */
53
+ const XINCLUDE_NAMESPACE = 'http://www.w3.org/2001/XInclude';
54
+ /** XML's own namespace, accepted only for `xml:lang` and `xml:space`. */
55
+ const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace';
56
+ /** Namespace declarations themselves; saxes reports them as attributes. */
57
+ const XMLNS_NAMESPACE = 'http://www.w3.org/2000/xmlns/';
58
+ /**
59
+ * Chosen to admit an ordinary diagram export (tens to low hundreds of elements,
60
+ * a handful of filters) while refusing a synthetic flood long before libvips is
61
+ * asked for a surface. The render-budget half lives in `svg-render.ts`, because
62
+ * it is about pixels rather than syntax.
63
+ */
64
+ export const SVG_POLICY_LIMITS = Object.freeze({
65
+ maxElements: 20_000,
66
+ maxDepth: 128,
67
+ maxUses: 500,
68
+ maxUseInstances: 16_384,
69
+ maxTextCharacters: 100_000,
70
+ maxStyleCharacters: 1_000_000,
71
+ maxDistinctTextCharacters: 4_096,
72
+ maxFilterPrimitives: 100,
73
+ maxFilterRegion: 20_000,
74
+ maxFilterRegionPercent: 500,
75
+ maxStdDeviation: 1_000,
76
+ maxKernelEntries: 100,
77
+ maxTurbulenceOctaves: 16,
78
+ });
79
+ /* ------------------------------------------------------------------------- *
80
+ * Element and attribute policy
81
+ * ------------------------------------------------------------------------- */
82
+ /**
83
+ * Elements this subset draws.
84
+ *
85
+ * An allowlist, not a denylist, for the reason the design gives: a feature the
86
+ * policy does not understand must fail rather than render as a misleading
87
+ * partial diagram. `feImage` is absent because it names a resource; `metadata` is
88
+ * absent because this build does not render it and will not walk arbitrary
89
+ * foreign XML to prove that.
90
+ */
91
+ const ALLOWED_ELEMENTS = {
92
+ svg: true,
93
+ g: true,
94
+ defs: true,
95
+ desc: true,
96
+ title: true,
97
+ style: true,
98
+ symbol: true,
99
+ use: true,
100
+ view: true,
101
+ rect: true,
102
+ circle: true,
103
+ ellipse: true,
104
+ line: true,
105
+ polyline: true,
106
+ polygon: true,
107
+ path: true,
108
+ text: true,
109
+ tspan: true,
110
+ textPath: true,
111
+ a: true,
112
+ linearGradient: true,
113
+ radialGradient: true,
114
+ stop: true,
115
+ pattern: true,
116
+ clipPath: true,
117
+ mask: true,
118
+ marker: true,
119
+ filter: true,
120
+ feBlend: true,
121
+ feColorMatrix: true,
122
+ feComponentTransfer: true,
123
+ feComposite: true,
124
+ feConvolveMatrix: true,
125
+ feDiffuseLighting: true,
126
+ feDisplacementMap: true,
127
+ feDropShadow: true,
128
+ feFlood: true,
129
+ feFuncA: true,
130
+ feFuncB: true,
131
+ feFuncG: true,
132
+ feFuncR: true,
133
+ feGaussianBlur: true,
134
+ feMerge: true,
135
+ feMergeNode: true,
136
+ feMorphology: true,
137
+ feOffset: true,
138
+ fePointLight: true,
139
+ feSpecularLighting: true,
140
+ feSpotLight: true,
141
+ feTile: true,
142
+ feTurbulence: true,
143
+ };
144
+ /**
145
+ * Attributes with no resource-loading meaning.
146
+ *
147
+ * Grouped by what they do rather than alphabetised, so a new entry lands in a
148
+ * reviewable place. `href`/`xlink:href` are absent because they get their own
149
+ * fragment-only check; `style` is absent because it is parsed.
150
+ */
151
+ const ALLOWED_ATTRIBUTES = {
152
+ // Core, accessibility and styling hooks.
153
+ id: true,
154
+ class: true,
155
+ role: true,
156
+ tabindex: true,
157
+ lang: true,
158
+ // Geometry.
159
+ x: true,
160
+ y: true,
161
+ z: true,
162
+ x1: true,
163
+ y1: true,
164
+ x2: true,
165
+ y2: true,
166
+ width: true,
167
+ height: true,
168
+ rx: true,
169
+ ry: true,
170
+ r: true,
171
+ cx: true,
172
+ cy: true,
173
+ fx: true,
174
+ fy: true,
175
+ fr: true,
176
+ d: true,
177
+ points: true,
178
+ pathLength: true,
179
+ viewBox: true,
180
+ preserveAspectRatio: true,
181
+ transform: true,
182
+ 'transform-origin': true,
183
+ version: true,
184
+ baseProfile: true,
185
+ // Paint and compositing.
186
+ fill: true,
187
+ 'fill-opacity': true,
188
+ 'fill-rule': true,
189
+ stroke: true,
190
+ 'stroke-width': true,
191
+ 'stroke-opacity': true,
192
+ 'stroke-linecap': true,
193
+ 'stroke-linejoin': true,
194
+ 'stroke-miterlimit': true,
195
+ 'stroke-dasharray': true,
196
+ 'stroke-dashoffset': true,
197
+ 'paint-order': true,
198
+ color: true,
199
+ opacity: true,
200
+ display: true,
201
+ visibility: true,
202
+ overflow: true,
203
+ clip: true,
204
+ 'clip-path': true,
205
+ 'clip-rule': true,
206
+ mask: true,
207
+ filter: true,
208
+ 'mix-blend-mode': true,
209
+ isolation: true,
210
+ 'vector-effect': true,
211
+ 'shape-rendering': true,
212
+ 'text-rendering': true,
213
+ 'image-rendering': true,
214
+ 'color-interpolation': true,
215
+ 'color-interpolation-filters': true,
216
+ 'color-rendering': true,
217
+ 'pointer-events': true,
218
+ 'marker-start': true,
219
+ 'marker-mid': true,
220
+ 'marker-end': true,
221
+ // Text.
222
+ 'font-family': true,
223
+ 'font-size': true,
224
+ 'font-size-adjust': true,
225
+ 'font-stretch': true,
226
+ 'font-style': true,
227
+ 'font-variant': true,
228
+ 'font-weight': true,
229
+ 'letter-spacing': true,
230
+ 'word-spacing': true,
231
+ kerning: true,
232
+ 'text-anchor': true,
233
+ 'text-decoration': true,
234
+ 'writing-mode': true,
235
+ direction: true,
236
+ 'unicode-bidi': true,
237
+ 'dominant-baseline': true,
238
+ 'alignment-baseline': true,
239
+ 'baseline-shift': true,
240
+ 'white-space': true,
241
+ textLength: true,
242
+ lengthAdjust: true,
243
+ startOffset: true,
244
+ method: true,
245
+ spacing: true,
246
+ // Gradients and patterns.
247
+ offset: true,
248
+ 'stop-color': true,
249
+ 'stop-opacity': true,
250
+ 'flood-color': true,
251
+ 'flood-opacity': true,
252
+ 'lighting-color': true,
253
+ gradientUnits: true,
254
+ gradientTransform: true,
255
+ spreadMethod: true,
256
+ patternUnits: true,
257
+ patternContentUnits: true,
258
+ patternTransform: true,
259
+ // Clip paths, masks and markers.
260
+ clipPathUnits: true,
261
+ maskUnits: true,
262
+ maskContentUnits: true,
263
+ markerWidth: true,
264
+ markerHeight: true,
265
+ markerUnits: true,
266
+ refX: true,
267
+ refY: true,
268
+ orient: true,
269
+ // Filters.
270
+ filterUnits: true,
271
+ primitiveUnits: true,
272
+ in: true,
273
+ in2: true,
274
+ result: true,
275
+ mode: true,
276
+ operator: true,
277
+ type: true,
278
+ values: true,
279
+ tableValues: true,
280
+ slope: true,
281
+ intercept: true,
282
+ amplitude: true,
283
+ exponent: true,
284
+ scale: true,
285
+ baseFrequency: true,
286
+ numOctaves: true,
287
+ seed: true,
288
+ stitchTiles: true,
289
+ xChannelSelector: true,
290
+ yChannelSelector: true,
291
+ radius: true,
292
+ stdDeviation: true,
293
+ dx: true,
294
+ dy: true,
295
+ rotate: true,
296
+ order: true,
297
+ kernelMatrix: true,
298
+ divisor: true,
299
+ bias: true,
300
+ targetX: true,
301
+ targetY: true,
302
+ edgeMode: true,
303
+ preserveAlpha: true,
304
+ surfaceScale: true,
305
+ diffuseConstant: true,
306
+ specularConstant: true,
307
+ specularExponent: true,
308
+ limitingConeAngle: true,
309
+ pointsAtX: true,
310
+ pointsAtY: true,
311
+ pointsAtZ: true,
312
+ k1: true,
313
+ k2: true,
314
+ k3: true,
315
+ k4: true,
316
+ };
317
+ /**
318
+ * Presentation attributes that accept `url(...)`.
319
+ *
320
+ * Every one is parsed as a CSS value, not scanned for the text `url(`. `cursor`
321
+ * is absent with the attribute from the allowlist: it is a pointer-interaction
322
+ * property, and an SVG served as an image has no pointer to style.
323
+ */
324
+ const URL_ATTRIBUTES = {
325
+ fill: true,
326
+ stroke: true,
327
+ filter: true,
328
+ 'clip-path': true,
329
+ mask: true,
330
+ 'marker-start': true,
331
+ 'marker-mid': true,
332
+ 'marker-end': true,
333
+ };
334
+ /**
335
+ * The URL-bearing properties whose declaration is inherited by descendants.
336
+ *
337
+ * This is the **property table** the expansion count uses. A declaration in one
338
+ * of these slots applies to the nearest painted descendant for which no nearer
339
+ * declaration exists, so one `url(#p)` on a `<g>` is the same render work as one
340
+ * on each drawn child. `marker` is the shorthand for the three marker slots and
341
+ * is expanded when declared. `cursor` is inherited too; it cannot paint with a
342
+ * document fragment in an SVG image, but following it costs only budget and
343
+ * keeps the table closed against an inheritance route this policy forgot.
344
+ *
345
+ * Every other URL-bearing property in this subset is **local**, counted on the
346
+ * element that declares it and not inherited: `filter`, `clip-path` and `mask`
347
+ * (the three non-inherited reference properties SVG defines), `href` (a
348
+ * template edge, always applied to the declaring definition), and any other CSS
349
+ * property a `style` attribute or sheet names. A property outside this table
350
+ * cannot multiply a `<use>` through inheritance: the reference properties
351
+ * librsvg inherits and paints with are exactly `fill`, `stroke`, the marker
352
+ * slots and `cursor`; an unknown property is still counted where it is declared,
353
+ * so a reference cannot disappear from the graph.
354
+ *
355
+ * **Suppression invariant.** A declaration in one of these slots may override
356
+ * the reference inherited from an ancestor only when its value provably paints
357
+ * no resource — the whitelist in `svg-css.ts`. Every other value (`bogus`,
358
+ * `url(#…)`, a bare gradient name, an escaped or unknown keyword, a variable,
359
+ * anything unparseable) keeps the ancestor reference: an invalid paint falls
360
+ * back to the inherited value in the renderer, so treating it as an override
361
+ * would lose work. Over-counting is the accepted direction; the count is an
362
+ * upper bound by design.
363
+ */
364
+ const INHERITED_REFERENCE_SLOTS = {
365
+ fill: 0,
366
+ stroke: 1,
367
+ 'marker-start': 2,
368
+ 'marker-mid': 3,
369
+ 'marker-end': 4,
370
+ cursor: 5,
371
+ };
372
+ /** Slot order for {@link INHERITED_REFERENCE_SLOTS}. */
373
+ const INHERITED_REFERENCE_PROPERTIES = [
374
+ 'fill',
375
+ 'stroke',
376
+ 'marker-start',
377
+ 'marker-mid',
378
+ 'marker-end',
379
+ 'cursor',
380
+ ];
381
+ /** The three properties the `marker` shorthand declares. */
382
+ const MARKER_PROPERTIES = ['marker-start', 'marker-mid', 'marker-end'];
383
+ /**
384
+ * Elements that paint with an inherited paint property.
385
+ *
386
+ * A container (`g`, `a`, `svg`, `symbol`, `defs`, `pattern`, `mask`, `clipPath`,
387
+ * a gradient, `marker`, `filter`) holds no geometry: its `fill` or `stroke`
388
+ * applies only through descent, so its own declaration is not counted and its
389
+ * painted descendants are. `use` is excluded for the same reason: its paint
390
+ * properties apply to the instantiated content, which the expansion walk counts
391
+ * by propagating the use's effective slots into that subtree. Text content
392
+ * elements are painters even though one `<text>` run may share a paint server;
393
+ * counting each inherited text element is an upper bound, never an omission.
394
+ */
395
+ const PAINTED_ELEMENTS = {
396
+ rect: true,
397
+ circle: true,
398
+ ellipse: true,
399
+ line: true,
400
+ polyline: true,
401
+ polygon: true,
402
+ path: true,
403
+ text: true,
404
+ tspan: true,
405
+ textPath: true,
406
+ };
407
+ /** SVG's number grammar, shared by every budget argument below. */
408
+ const NUMBER_PATTERN = String.raw `[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`;
409
+ const LENGTH_PATTERN = new RegExp(String.raw `^\s*(${NUMBER_PATTERN})\s*([A-Za-z]*)\s*$`);
410
+ const PERCENT_PATTERN = new RegExp(String.raw `^\s*(${NUMBER_PATTERN})\s*%\s*$`);
411
+ const NUMBER_PATTERN_ONLY = new RegExp(String.raw `^\s*(${NUMBER_PATTERN})\s*$`);
412
+ /**
413
+ * CSS pixel size of one absolute SVG length unit, and whether librsvg
414
+ * re-evaluates it at the requested DPI.
415
+ *
416
+ * Measured rather than assumed: see {@link SvgSource.sizeScale}. One table, so
417
+ * adding a unit cannot silently miss its density class.
418
+ */
419
+ const LENGTH_UNITS = {
420
+ px: { px: 1, physical: false },
421
+ pt: { px: 4 / 3, physical: true },
422
+ pc: { px: 16, physical: true },
423
+ mm: { px: 96 / 25.4, physical: true },
424
+ cm: { px: 96 / 2.54, physical: true },
425
+ in: { px: 96, physical: true },
426
+ };
427
+ /** A finite, positive number, or `undefined`. */
428
+ function positive(value) {
429
+ return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined;
430
+ }
431
+ /** Parse a signed SVG length; `undefined` for a unit this subset does not know. */
432
+ function parseSignedLength(raw) {
433
+ if (raw === undefined)
434
+ return undefined;
435
+ const match = LENGTH_PATTERN.exec(raw);
436
+ if (match === null)
437
+ return undefined;
438
+ const unit = (match[2] ?? '').toLowerCase();
439
+ const scale = unit === '' ? { px: 1, physical: false } : LENGTH_UNITS[unit];
440
+ if (scale === undefined)
441
+ return undefined;
442
+ const px = Number(match[1]) * scale.px;
443
+ return Number.isFinite(px) ? { px, physical: scale.physical } : undefined;
444
+ }
445
+ /**
446
+ * Parse an SVG length attribute to CSS pixels and classify its scaling.
447
+ *
448
+ * A percentage, `em`, `ex` or `ch` depends on a viewport or a font, so it is
449
+ * treated as absent rather than guessed. The renderer then falls back to the
450
+ * document's measured natural size, which is the size librsvg itself chose.
451
+ */
452
+ function parseLength(raw) {
453
+ const parsed = parseSignedLength(raw);
454
+ return parsed !== undefined && parsed.px > 0 ? parsed : undefined;
455
+ }
456
+ /** Parse `viewBox` into four finite numbers with a positive size. */
457
+ function parseViewBox(raw) {
458
+ if (raw === undefined)
459
+ return undefined;
460
+ const parts = raw.trim().split(/[\s,]+/);
461
+ if (parts.length !== 4)
462
+ return undefined;
463
+ const [x, y, width, height] = parts.map(Number);
464
+ if (![x, y, width, height].every((value) => Number.isFinite(value)))
465
+ return undefined;
466
+ if (!positive(width) || !positive(height))
467
+ return undefined;
468
+ return { x: x, y: y, width: width, height: height };
469
+ }
470
+ /**
471
+ * Decode the document's bytes as UTF-8.
472
+ *
473
+ * The producer's `looksLikeSvg` signature is byte-based and selects an SVG only
474
+ * from UTF-8 text, so the policy is UTF-8-only too. A UTF-16 document is
475
+ * reported as an unsupported file by the producer rather than parsed here.
476
+ */
477
+ function decodeDocument(bytes) {
478
+ try {
479
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
480
+ }
481
+ catch {
482
+ return undefined;
483
+ }
484
+ }
485
+ /** A painted element markers do not apply to. */
486
+ const NO_MARKER_PLACEMENTS = Object.freeze({ start: 0, mid: 0, end: 0 });
487
+ /** SVG's number grammar as a global matcher; `match` resets `lastIndex`. */
488
+ const NUMBER_TOKEN_PATTERN = new RegExp(NUMBER_PATTERN, 'g');
489
+ /** A path-data token: one command letter or one number. */
490
+ const PATH_TOKEN_PATTERN = new RegExp(`[MmZzLlHhVvCcSsQqTtAa]|${NUMBER_PATTERN}`, 'g');
491
+ /** Whether one path-data token is a command letter rather than a number. */
492
+ const PATH_COMMAND_PATTERN = /^[MmZzLlHhVvCcSsQqTtAa]$/;
493
+ /** The number of numeric tokens in a geometry attribute. */
494
+ function numericTokens(raw) {
495
+ if (raw === undefined)
496
+ return 0;
497
+ return raw.match(NUMBER_TOKEN_PATTERN)?.length ?? 0;
498
+ }
499
+ /**
500
+ * Split path data into command letters and numbers, or `undefined` when it
501
+ * contains anything else.
502
+ *
503
+ * The split is deliberately lexical: this is a placement bound, not a geometry
504
+ * model, and the caller only needs to know how many vertices and subpaths the
505
+ * data can produce. A character the tokenizer cannot classify makes the bound
506
+ * fall back to the numeric-token estimate rather than silently ignoring it.
507
+ */
508
+ function tokenizePathData(pathData) {
509
+ const tokens = [];
510
+ let consumed = 0;
511
+ for (const match of pathData.matchAll(PATH_TOKEN_PATTERN)) {
512
+ const index = match.index;
513
+ if (!/^[\s,]*$/.test(pathData.slice(consumed, index)))
514
+ return undefined;
515
+ tokens.push(match[0]);
516
+ consumed = index + match[0].length;
517
+ }
518
+ if (!/^[\s,]*$/.test(pathData.slice(consumed)))
519
+ return undefined;
520
+ return tokens;
521
+ }
522
+ /** The documented fallback when path data cannot be tokenized. */
523
+ function fallbackPathPlacements(pathData) {
524
+ // Every numeric token could be a vertex, and every vertex could be interior.
525
+ // Over-counting is the accepted direction; falling back to one placement is
526
+ // what would under-count a path whose data this policy cannot parse.
527
+ const vertices = Math.max(1, numericTokens(pathData));
528
+ const subpaths = Math.max(1, (pathData.match(/[Mm]/g) ?? []).length);
529
+ return { start: subpaths, mid: vertices, end: subpaths };
530
+ }
531
+ /** Marker placements from a path's `d` data. */
532
+ function pathMarkerPlacements(pathData) {
533
+ if (pathData === undefined)
534
+ return { start: 1, mid: 0, end: 1 };
535
+ const tokens = tokenizePathData(pathData);
536
+ if (tokens === undefined)
537
+ return fallbackPathPlacements(pathData);
538
+ if (tokens.length === 0)
539
+ return { start: 1, mid: 0, end: 1 };
540
+ let subpaths = 0;
541
+ let interior = 0;
542
+ let currentVertices = 0;
543
+ const finishSubpath = (closed) => {
544
+ // A one-vertex subpath has no interior vertex; subtracting a flat
545
+ // `2 × subpaths` from the total would under-count a path that mixes a
546
+ // degenerate subpath with a long one.
547
+ //
548
+ // A closepath adds the initial point as the subpath's last vertex. When
549
+ // the subpath has at least two vertices, its previous endpoint moves from
550
+ // last to interior: measured against the pinned librsvg, `M0 0 L10 10 Z`
551
+ // renders a mid marker at (10, 10).
552
+ if (currentVertices > 0) {
553
+ interior += Math.max(0, currentVertices + (closed ? 1 : 0) - 2);
554
+ }
555
+ currentVertices = 0;
556
+ };
557
+ const beginSubpath = () => {
558
+ finishSubpath(false);
559
+ subpaths += 1;
560
+ };
561
+ /**
562
+ * The upper bound on the vertices one command group's numbers produce.
563
+ *
564
+ * Every command has one lexical number per grammar value, so its arity
565
+ * divides the group exactly and a partial group rounds up. `A`/`a` is the
566
+ * exception: arc flags are single characters that need no separator, so
567
+ * `0110` is the flags `0 1` followed by the x coordinate `10`, and dividing
568
+ * a compact group by the seven-value arity under-counts its arcs. Every arc
569
+ * still consumes at least `rx ry rotation`, so `ceil(numbers / 3)` bounds
570
+ * the arcs in both the compact and the spaced spelling. The flags are not
571
+ * lexed: this is a placement bound, and over-counting is its safe direction.
572
+ */
573
+ const segmentBound = (letter, numbers) => {
574
+ switch (letter) {
575
+ case 'A':
576
+ case 'a':
577
+ return Math.ceil(numbers / 3);
578
+ case 'H':
579
+ case 'h':
580
+ case 'V':
581
+ case 'v':
582
+ return numbers;
583
+ case 'C':
584
+ case 'c':
585
+ return Math.ceil(numbers / 6);
586
+ case 'S':
587
+ case 's':
588
+ case 'Q':
589
+ case 'q':
590
+ return Math.ceil(numbers / 4);
591
+ default:
592
+ return Math.ceil(numbers / 2);
593
+ }
594
+ };
595
+ let at = 0;
596
+ while (at < tokens.length) {
597
+ const letter = tokens[at];
598
+ at += 1;
599
+ if (!PATH_COMMAND_PATTERN.test(letter))
600
+ return fallbackPathPlacements(pathData);
601
+ if (letter === 'Z' || letter === 'z') {
602
+ finishSubpath(true);
603
+ continue;
604
+ }
605
+ const first = at;
606
+ while (at < tokens.length && !PATH_COMMAND_PATTERN.test(tokens[at]))
607
+ at += 1;
608
+ const numbers = at - first;
609
+ if (letter === 'M' || letter === 'm') {
610
+ beginSubpath();
611
+ // The first pair is the moveto; every additional pair is an implicit
612
+ // lineto and therefore another vertex.
613
+ currentVertices += Math.max(1, Math.ceil(numbers / 2));
614
+ }
615
+ else {
616
+ if (currentVertices === 0) {
617
+ beginSubpath();
618
+ // A drawing command that follows a closepath starts a new subpath at
619
+ // the closed subpath's initial point, which is a vertex without its
620
+ // own argument.
621
+ currentVertices = 1;
622
+ }
623
+ currentVertices += Math.max(1, segmentBound(letter, numbers));
624
+ }
625
+ }
626
+ finishSubpath(false);
627
+ if (subpaths === 0)
628
+ subpaths = 1;
629
+ return { start: subpaths, mid: interior, end: subpaths };
630
+ }
631
+ /** Marker placements for one shape, or `undefined` when markers do not apply. */
632
+ function markerPlacementsFor(element, points, pathData) {
633
+ switch (element) {
634
+ case 'line':
635
+ // Two vertices, one subpath: start and end render once, mid never.
636
+ return { start: 1, mid: 0, end: 1 };
637
+ case 'polyline': {
638
+ const numbers = numericTokens(points);
639
+ if (numbers === 0)
640
+ return { start: 1, mid: 0, end: 1 };
641
+ // Each vertex is one coordinate pair; an odd trailing number is counted
642
+ // as another vertex rather than dropped.
643
+ const vertices = Math.ceil(numbers / 2);
644
+ return { start: 1, mid: Math.max(0, vertices - 2), end: 1 };
645
+ }
646
+ case 'polygon': {
647
+ const numbers = numericTokens(points);
648
+ if (numbers === 0)
649
+ return { start: 1, mid: 0, end: 1 };
650
+ const vertices = Math.ceil(numbers / 2);
651
+ // A polygon closes back to its first point, and the pinned librsvg
652
+ // places marker-end at that closing point: every listed point after the
653
+ // first is interior. Measured with `points="20,20 50,20 50,50"`:
654
+ // marker-mid renders at both listed points, marker-end at the first.
655
+ return { start: 1, mid: Math.max(0, vertices - 1), end: 1 };
656
+ }
657
+ case 'path':
658
+ return pathMarkerPlacements(pathData);
659
+ default:
660
+ return undefined;
661
+ }
662
+ }
663
+ /**
664
+ * Instances produced by expanding `<use>` references, with a DP over the id
665
+ * graph. A literal `<use>` counts once, plus the rendered subtree of its target;
666
+ * the total is the work librsvg would perform even though the XML is small.
667
+ *
668
+ * An unreferenced definition container (`defs`, `symbol`, `mask`, `pattern`,
669
+ * `clipPath`, `filter`, `marker`, a gradient) is not drawn on its own and
670
+ * contributes nothing. It is drawn when a rendered element reaches it, either as
671
+ * a `<use>` target or through a same-document reference written as `url(#id)` in
672
+ * an attribute, a `style` attribute or a `<style>` sheet, or as an
673
+ * `href`/`xlink:href` fragment on anything but `<use>` — a template edge
674
+ * (`pattern`, `linearGradient`, `radialGradient`, `filter`, `marker`), a
675
+ * `textPath` path, or the conservative reading of an `<a>` navigation. A
676
+ * container reached that way follows its own references, so a mask inside
677
+ * `<defs>` and a pattern inherited from another pattern are counted only when
678
+ * something drawn reaches the first link. Nested definition containers are still
679
+ * skipped as ordinary children of an element.
680
+ *
681
+ * **Inheritance is the second half of the graph.** `fill`, `stroke`, the marker
682
+ * slots and `cursor` are inherited properties: one declaration on a `<g>` is the
683
+ * work of one declaration on every painted descendant, so a `url(#p)` on an
684
+ * ancestor is multiplied by the painted descendants it reaches while a nearer
685
+ * declaration whose value provably paints no resource overrides it, exactly as
686
+ * the property system works for a valid non-reference paint. A nearer value
687
+ * that only parses is not an override: an invalid paint falls back to the
688
+ * inherited one, so it keeps the ancestor reference and is counted there too.
689
+ *
690
+ * **Markers are placed by geometry, not by element.** `marker-start` and
691
+ * `marker-end` render once per subpath and `marker-mid` once per interior
692
+ * vertex, so a marker reference is multiplied by the placements its shape can
693
+ * produce. The placement bound comes from `points` (one vertex per coordinate
694
+ * pair), `d` (a lexical command/argument count) and `line` (two vertices, one
695
+ * subpath); unparseable data falls back to its numeric-token count, never to a
696
+ * single placement. A closepath or a polygon closes onto its first point, which
697
+ * is the last vertex for marker placement, so the point before it becomes
698
+ * interior; that is measured against the pinned librsvg rather than assumed.
699
+ * This applies equally to a local declaration, an inherited one, and one a
700
+ * `<use>` propagates into cloned content.
701
+ *
702
+ * The slots are resolved once per element in a parent-before-child pass
703
+ * (one array per node, six entries), so attribution is O(elements × properties)
704
+ * rather than an ancestor walk per element. `filter`, `clip-path`, `mask` and
705
+ * `href` do not inherit and stay attached to the element that declares them. A
706
+ * `<use>`'s own effective slots are inherited by the content it instantiates
707
+ * (SVG's shadow-tree inheritance), so they are added once per painted element
708
+ * the target expansion produces — and once per marker placement for a marker
709
+ * slot; that is an upper bound when a target element overrides the inherited
710
+ * value, never an omission.
711
+ *
712
+ * The count is deliberately a conservative upper bound: every reference is
713
+ * counted once per render it produces, even if two properties point at the same
714
+ * definition, and every use counts, including one under a hidden branch. A
715
+ * `<style>` rule's declarations are attributed to every element its selector
716
+ * matches, so the same picture counts the same whether it is written as a
717
+ * presentation attribute, a `style` attribute or a stylesheet; a rule that
718
+ * matches nothing contributes nothing, and a selector form this policy cannot
719
+ * decide is refused rather than attributed to the root or silently ignored.
720
+ * Each node's subtree is memoized, so a chain of definitions that reference the
721
+ * next one repeatedly is linear rather than exponential. A cycle in either the
722
+ * `<use>` graph or the reference graph is infinity, because the work is
723
+ * unbounded; the budget exists to bound work, not to prove a picture's exact
724
+ * shape.
725
+ *
726
+ * Matching propagates a set left to right instead of backtracking per element:
727
+ * the leftmost compound marks every element it matches, and each compound to
728
+ * its right keeps the elements whose parent (`>`) or any ancestor (descendant)
729
+ * is in the previous set. One step is O(elements × compound width), using a
730
+ * parent-before-child pass to carry "an ancestor matches" down the tree once,
731
+ * so one selector costs O(length × elements) and one rule
732
+ * O(selectors × length × elements). The old
733
+ * recursive form explored every ancestor path for each descendant step, which
734
+ * is C(depth, steps) for a selector like `missing g g … rect`; that is what
735
+ * stalled the build.
736
+ *
737
+ * The ceiling is the polynomial bound itself, deliberately without a work
738
+ * counter: `steps` and `selectors` are capped by `maxStyleCharacters` and
739
+ * `elements` by `maxElements`, while a counter would add a new refusal for a
740
+ * document the earlier policy accepted, and this change must not alter a
741
+ * verdict. A future selector form must evaluate the same way — one pass per
742
+ * compound over the element set — to keep the bound; a form that cannot be
743
+ * decided by set propagation is refused by the CSS compiler instead.
744
+ */
745
+ function markSelectorMatches(selector, names, ids, classes, parents, into) {
746
+ const elements = names.length;
747
+ const matchesCompound = (compound, at) => {
748
+ if (compound.name !== undefined && names[at] !== compound.name)
749
+ return false;
750
+ if (compound.id !== undefined && ids[at] !== compound.id)
751
+ return false;
752
+ for (const name of compound.classes) {
753
+ if (classes[at]?.includes(name) !== true)
754
+ return false;
755
+ }
756
+ return true;
757
+ };
758
+ const last = selector.length - 1;
759
+ let matched = new Uint8Array(elements);
760
+ for (let at = 0; at < elements; at += 1) {
761
+ if (matchesCompound(selector[0], at))
762
+ matched[at] = 1;
763
+ }
764
+ for (let step = 1; step <= last; step += 1) {
765
+ const next = new Uint8Array(elements);
766
+ const compound = selector[step];
767
+ if (compound.combinator === '>') {
768
+ for (let at = 0; at < elements; at += 1) {
769
+ const parent = parents[at];
770
+ if (parent !== undefined && matched[parent] === 1 && matchesCompound(compound, at))
771
+ next[at] = 1;
772
+ }
773
+ }
774
+ else {
775
+ // A parent is recorded before its children, so one ascending pass carries
776
+ // "some ancestor matches the preceding compound" without walking an
777
+ // ancestor chain per element. `matched[parent]` is the parent itself.
778
+ const ancestorMatched = new Uint8Array(elements);
779
+ for (let at = 0; at < elements; at += 1) {
780
+ const parent = parents[at];
781
+ const inherited = parent !== undefined && (matched[parent] === 1 || ancestorMatched[parent] === 1);
782
+ ancestorMatched[at] = inherited ? 1 : 0;
783
+ if (inherited && matchesCompound(compound, at))
784
+ next[at] = 1;
785
+ }
786
+ }
787
+ matched = next;
788
+ }
789
+ for (let at = 0; at < elements; at += 1) {
790
+ if (matched[at] === 1)
791
+ into[at] = 1;
792
+ }
793
+ }
794
+ function expandedUseInstances(nodes, useTargets, children, declarations, placements, parents, idNodes, root) {
795
+ const nonRendered = {
796
+ defs: true,
797
+ symbol: true,
798
+ clipPath: true,
799
+ mask: true,
800
+ pattern: true,
801
+ linearGradient: true,
802
+ radialGradient: true,
803
+ filter: true,
804
+ marker: true,
805
+ style: true,
806
+ title: true,
807
+ desc: true,
808
+ view: true,
809
+ };
810
+ const slotsCount = INHERITED_REFERENCE_PROPERTIES.length;
811
+ const emptySlots = [];
812
+ const slots = [];
813
+ for (let index = 0; index < nodes.length; index += 1) {
814
+ const parent = parents[index];
815
+ const inherited = parent === undefined ? emptySlots : slots[parent];
816
+ const own = declarations[index];
817
+ let context = inherited;
818
+ if (own !== undefined) {
819
+ let copy;
820
+ for (const [property, slot] of Object.entries(INHERITED_REFERENCE_SLOTS)) {
821
+ const declared = own.get(property);
822
+ if (declared === undefined)
823
+ continue;
824
+ copy ??= [...inherited];
825
+ // A value that does not provably paint without a resource may resolve
826
+ // to the parent's value (a CSS-wide keyword) or fall back to it (an
827
+ // invalid paint), so the descendant context keeps both the declared
828
+ // references and the inherited ones. Over-counting is the safe
829
+ // direction; treating every non-deferring declaration as an override
830
+ // is what let `fill="bogus"` erase an inherited pattern reference.
831
+ copy[slot] = declared.suppresses
832
+ ? declared.references
833
+ : [...(inherited[slot] ?? []), ...declared.references];
834
+ }
835
+ if (copy !== undefined)
836
+ context = copy;
837
+ }
838
+ slots.push(context);
839
+ }
840
+ const paints = (index) => PAINTED_ELEMENTS[nodes[index] ?? ''] === true;
841
+ /**
842
+ * How many times one marker slot renders on this element.
843
+ *
844
+ * A marker reference is work per placement, not per declaration: start and
845
+ * end once per subpath, mid once per interior vertex. A painted element
846
+ * markers do not apply to (`text`) has no placement, so a marker reference
847
+ * there contributes nothing. Non-marker slots keep the old per-element count.
848
+ */
849
+ const markerSlotPlacements = (index, slot) => {
850
+ if (slot !== 2 && slot !== 3 && slot !== 4)
851
+ return 1;
852
+ const marker = placements[index];
853
+ if (marker === undefined)
854
+ return 0;
855
+ return slot === 2 ? marker.start : slot === 3 ? marker.mid : marker.end;
856
+ };
857
+ /** A node's own references, excluding inherited slots on a non-painting container. */
858
+ const ownReferences = (index) => {
859
+ const own = declarations[index];
860
+ if (own === undefined)
861
+ return [];
862
+ const painted = paints(index);
863
+ const accumulated = [];
864
+ for (const [property, declared] of own) {
865
+ if (declared.references.length === 0)
866
+ continue;
867
+ const slot = INHERITED_REFERENCE_SLOTS[property];
868
+ if (!painted && slot !== undefined)
869
+ continue;
870
+ const multiplier = slot === undefined ? 1 : markerSlotPlacements(index, slot);
871
+ if (multiplier === 0)
872
+ continue;
873
+ for (const reference of declared.references)
874
+ accumulated.push({ reference, multiplier });
875
+ }
876
+ return accumulated;
877
+ };
878
+ /**
879
+ * The nearest declaration of each inherited property that still applies.
880
+ *
881
+ * A declaration that provably paints without a resource replaces the
882
+ * ancestor's reference; every other declaration keeps it, whether it defers
883
+ * to the parent or might be invalid and fall back there. The ancestor context
884
+ * is the parent's, not this node's merged context, so a painter that declares
885
+ * a reference and inherits one cannot count its own reference twice through
886
+ * the slot. Marker slots are multiplied by this element's placements, so an
887
+ * inherited `marker-mid` on a `<g>` costs one expansion per interior vertex
888
+ * of each painted descendant, not one per descendant.
889
+ */
890
+ const inheritedReferences = (index) => {
891
+ if (!paints(index))
892
+ return [];
893
+ const own = declarations[index];
894
+ const parent = parents[index];
895
+ const inherited = parent === undefined ? emptySlots : slots[parent];
896
+ const accumulated = [];
897
+ for (let slot = 0; slot < slotsCount; slot += 1) {
898
+ const declared = own?.get(INHERITED_REFERENCE_PROPERTIES[slot]);
899
+ if (declared !== undefined && declared.suppresses)
900
+ continue;
901
+ const references = inherited[slot];
902
+ if (references === undefined || references.length === 0)
903
+ continue;
904
+ const multiplier = markerSlotPlacements(index, slot);
905
+ if (multiplier === 0)
906
+ continue;
907
+ for (const reference of references)
908
+ accumulated.push({ reference, multiplier });
909
+ }
910
+ return accumulated;
911
+ };
912
+ const paintedSummaries = new Map();
913
+ const painting = new Set();
914
+ const paintedSummary = (index) => {
915
+ const memo = paintedSummaries.get(index);
916
+ if (memo !== undefined)
917
+ return memo;
918
+ if (painting.has(index)) {
919
+ return {
920
+ painted: Number.POSITIVE_INFINITY,
921
+ start: Number.POSITIVE_INFINITY,
922
+ mid: Number.POSITIVE_INFINITY,
923
+ end: Number.POSITIVE_INFINITY,
924
+ };
925
+ }
926
+ painting.add(index);
927
+ const marker = placements[index] ?? NO_MARKER_PLACEMENTS;
928
+ let painted = paints(index) ? 1 : 0;
929
+ let start = marker.start;
930
+ let mid = marker.mid;
931
+ let end = marker.end;
932
+ const add = (summary) => {
933
+ painted += summary.painted;
934
+ start += summary.start;
935
+ mid += summary.mid;
936
+ end += summary.end;
937
+ };
938
+ for (const child of children[index] ?? []) {
939
+ if (nonRendered[nodes[child] ?? ''] === true)
940
+ continue;
941
+ add(paintedSummary(child));
942
+ }
943
+ const target = useTargets[index];
944
+ if (target !== undefined) {
945
+ const targetIndex = idNodes.get(target);
946
+ if (targetIndex !== undefined)
947
+ add(paintedSummary(targetIndex));
948
+ }
949
+ painting.delete(index);
950
+ const summary = { painted, start, mid, end };
951
+ paintedSummaries.set(index, summary);
952
+ return summary;
953
+ };
954
+ const expansion = new Map();
955
+ const counted = new Map();
956
+ const visiting = new Set();
957
+ const countRendered = (index) => {
958
+ const memo = counted.get(index);
959
+ if (memo !== undefined)
960
+ return memo;
961
+ if (visiting.has(index))
962
+ return Number.POSITIVE_INFINITY;
963
+ visiting.add(index);
964
+ let total = 0;
965
+ const target = useTargets[index];
966
+ if (target !== undefined) {
967
+ total = 1 + expand(target);
968
+ // SVG inherits a `<use>`'s computed slots into the content it clones, so
969
+ // one declaration on the use is one declaration per painted element the
970
+ // target produces; a marker slot is one per placement that element
971
+ // produces. Overridden values make this an upper bound.
972
+ const targetIndex = idNodes.get(target);
973
+ const summary = targetIndex === undefined
974
+ ? { painted: 0, start: 0, mid: 0, end: 0 }
975
+ : paintedSummary(targetIndex);
976
+ let propagated = 0;
977
+ for (let slot = 0; slot < slotsCount; slot += 1) {
978
+ const references = slots[index][slot];
979
+ if (references === undefined)
980
+ continue;
981
+ const multiplier = slot === 2 ? summary.start : slot === 3 ? summary.mid : slot === 4 ? summary.end : summary.painted;
982
+ if (multiplier === 0)
983
+ continue;
984
+ for (const reference of references)
985
+ propagated += expand(reference) * multiplier;
986
+ }
987
+ total += propagated;
988
+ }
989
+ for (const child of children[index] ?? []) {
990
+ if (nonRendered[nodes[child] ?? ''] === true)
991
+ continue;
992
+ total += countRendered(child);
993
+ }
994
+ for (const { reference, multiplier } of ownReferences(index)) {
995
+ total += expand(reference) * multiplier;
996
+ }
997
+ for (const { reference, multiplier } of inheritedReferences(index)) {
998
+ total += expand(reference) * multiplier;
999
+ }
1000
+ visiting.delete(index);
1001
+ counted.set(index, total);
1002
+ return total;
1003
+ };
1004
+ const expand = (id) => {
1005
+ const index = idNodes.get(id);
1006
+ if (index === undefined)
1007
+ return 0;
1008
+ const memo = expansion.get(index);
1009
+ if (memo !== undefined)
1010
+ return memo;
1011
+ const total = countRendered(index);
1012
+ expansion.set(index, total);
1013
+ return total;
1014
+ };
1015
+ return countRendered(root);
1016
+ }
1017
+ /** The reason a tag name or attribute has no place in this subset. */
1018
+ function namespaceFault(uri, name) {
1019
+ if (uri === XINCLUDE_NAMESPACE) {
1020
+ return { reason: 'svg-xinclude', detail: `${name}: XInclude is not part of the static subset` };
1021
+ }
1022
+ return { reason: 'svg-foreign', detail: `${name}: unsupported namespace ${JSON.stringify(uri)}` };
1023
+ }
1024
+ /**
1025
+ * The value of an attribute identified by its namespace URI and local name.
1026
+ *
1027
+ * XML attribute identity is `(URI, local name)`; the prefix is only a spelling
1028
+ * alias and is never consulted for a policy decision. `namespaces` is ordered
1029
+ * only to preserve SVG's `href` precedence (the unprefixed attribute wins over
1030
+ * the legacy XLink one); within one namespace an expanded name is unique.
1031
+ */
1032
+ function attributeValue(tag, local, namespaces) {
1033
+ for (const namespace of namespaces) {
1034
+ for (const attribute of Object.values(tag.attributes)) {
1035
+ if (attribute.local === local && attribute.uri === namespace)
1036
+ return attribute.value;
1037
+ }
1038
+ }
1039
+ return undefined;
1040
+ }
1041
+ /**
1042
+ * Inspect one SVG candidate: the single security decision of Goal 0012.
1043
+ *
1044
+ * @param bytes The already-authorized source bytes; never a path.
1045
+ * @param limits Finite work limits. Tests inject smaller ones.
1046
+ */
1047
+ export function inspectSvg(bytes, limits = SVG_POLICY_LIMITS) {
1048
+ const text = decodeDocument(bytes);
1049
+ if (text === undefined) {
1050
+ return { ok: false, refusal: { reason: 'svg-invalid', detail: 'not a UTF-8 XML document' } };
1051
+ }
1052
+ let refusal;
1053
+ let rootSeen = false;
1054
+ let elements = 0;
1055
+ let depth = 0;
1056
+ let uses = 0;
1057
+ let filterPrimitives = 0;
1058
+ let textDepth = 0;
1059
+ let styleDepth = 0;
1060
+ let titleDescDepth = 0;
1061
+ const textBuffers = [];
1062
+ let styleBuffer = '';
1063
+ const textRuns = [];
1064
+ let textCharacters = 0;
1065
+ let rootWidth;
1066
+ let rootHeight;
1067
+ let rootViewBox;
1068
+ const nodes = [];
1069
+ const useTargets = [];
1070
+ const markerGeometry = [];
1071
+ const childNodes = [];
1072
+ const elementParents = [];
1073
+ const elementIds = [];
1074
+ const elementClasses = [];
1075
+ const declarations = [];
1076
+ const styleRules = [];
1077
+ const filterPrimitiveBounding = [];
1078
+ const elementStack = [];
1079
+ const idNodes = new Map();
1080
+ /**
1081
+ * Record one declaration, merging repeated declarations of one property.
1082
+ *
1083
+ * Presence is the fact that matters to inheritance: a declaration with no
1084
+ * `url()` (`fill="red"`) must still be visible as the declaration that
1085
+ * overrides an ancestor's `fill="url(#p)"`. A deferring keyword sets the
1086
+ * property but keeps the ancestor's reference reachable. The `marker`
1087
+ * shorthand declares the three marker slots, as the property system expands
1088
+ * it.
1089
+ */
1090
+ const declare = (index, declaration) => {
1091
+ const names = declaration.property === 'marker' ? MARKER_PROPERTIES : [declaration.property];
1092
+ const map = (declarations[index] ??= new Map());
1093
+ for (const name of names) {
1094
+ const existing = map.get(name);
1095
+ if (existing === undefined) {
1096
+ map.set(name, {
1097
+ references: [...declaration.references],
1098
+ defers: declaration.defers,
1099
+ suppresses: declaration.suppresses,
1100
+ });
1101
+ }
1102
+ else {
1103
+ existing.references.push(...declaration.references);
1104
+ existing.defers ||= declaration.defers;
1105
+ // One declaration that might paint keeps the inherited reference
1106
+ // reachable; the cascade order between them is not modelled here.
1107
+ existing.suppresses &&= declaration.suppresses;
1108
+ }
1109
+ }
1110
+ };
1111
+ /** Whether the nearest enclosing `<filter>` resolves primitive regions in bbox units. */
1112
+ const filterRegionUnits = () => {
1113
+ for (let at = elementStack.length - 1; at >= 0; at -= 1) {
1114
+ const ancestor = elementStack[at];
1115
+ if (nodes[ancestor] === 'filter')
1116
+ return filterPrimitiveBounding[ancestor] === true ? 'bounding' : 'user';
1117
+ }
1118
+ return 'user';
1119
+ };
1120
+ const fail = (fault) => {
1121
+ if (refusal === undefined)
1122
+ refusal = fault;
1123
+ };
1124
+ const parser = new SaxesParser({ xmlns: true, position: true });
1125
+ parser.on('error', (error) => {
1126
+ const entity = /entit/i.test(error.message);
1127
+ fail({
1128
+ reason: entity ? 'svg-doctype' : 'svg-invalid',
1129
+ detail: error.message.split('\n')[0] ?? 'XML parse error',
1130
+ });
1131
+ });
1132
+ parser.on('doctype', () => {
1133
+ fail({ reason: 'svg-doctype', detail: 'a DOCTYPE declaration; DTDs and entity declarations are refused' });
1134
+ });
1135
+ parser.on('processinginstruction', (instruction) => {
1136
+ fail(instruction.target.toLowerCase() === 'xml-stylesheet'
1137
+ ? { reason: 'svg-external', detail: 'an xml-stylesheet processing instruction' }
1138
+ : { reason: 'svg-unsafe', detail: `processing instruction <?${instruction.target}?>` });
1139
+ });
1140
+ parser.on('opentag', (tag) => {
1141
+ if (refusal !== undefined)
1142
+ return;
1143
+ elements += 1;
1144
+ depth += 1;
1145
+ if (elements > limits.maxElements) {
1146
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxElements} elements` });
1147
+ return;
1148
+ }
1149
+ if (depth > limits.maxDepth) {
1150
+ fail({ reason: 'svg-workload', detail: `nesting deeper than ${limits.maxDepth} elements` });
1151
+ return;
1152
+ }
1153
+ const local = tag.local;
1154
+ if (!rootSeen) {
1155
+ rootSeen = true;
1156
+ if (local !== 'svg') {
1157
+ fail({ reason: 'svg-invalid', detail: `root element is <${tag.name}>, not <svg>` });
1158
+ return;
1159
+ }
1160
+ if (tag.uri === '') {
1161
+ fail({
1162
+ reason: 'svg-unsafe',
1163
+ detail: 'the root <svg> has no namespace; add xmlns="http://www.w3.org/2000/svg"',
1164
+ });
1165
+ return;
1166
+ }
1167
+ if (tag.uri !== SVG_NAMESPACE) {
1168
+ fail(namespaceFault(tag.uri, `<${tag.name}>`));
1169
+ return;
1170
+ }
1171
+ rootViewBox = parseViewBox(attributeValue(tag, 'viewBox', ['']));
1172
+ rootWidth = parseLength(attributeValue(tag, 'width', ['']));
1173
+ rootHeight = parseLength(attributeValue(tag, 'height', ['']));
1174
+ }
1175
+ else if (tag.uri !== SVG_NAMESPACE) {
1176
+ fail(namespaceFault(tag.uri, `<${tag.name}>`));
1177
+ return;
1178
+ }
1179
+ // Active-content elements are refused by name before any attribute is read:
1180
+ // their attributes are not part of the subset either, and "unsupported
1181
+ // attribute `attributeName`" would send an author to edit the wrong thing.
1182
+ if (local === 'script') {
1183
+ fail({ reason: 'svg-script', detail: '<script> is active content' });
1184
+ return;
1185
+ }
1186
+ if (local === 'foreignObject') {
1187
+ fail({ reason: 'svg-foreign', detail: '<foreignObject> is not part of the static subset' });
1188
+ return;
1189
+ }
1190
+ if (local.startsWith('animate') || local === 'set' || local === 'discard') {
1191
+ fail({ reason: 'svg-animation', detail: `<${local}> animates the drawing` });
1192
+ return;
1193
+ }
1194
+ if (local === 'metadata') {
1195
+ fail({ reason: 'svg-unsafe', detail: '<metadata> is not drawn; delete the metadata block' });
1196
+ return;
1197
+ }
1198
+ if (local === 'style' && styleDepth > 0) {
1199
+ fail({
1200
+ reason: 'svg-css',
1201
+ detail: '<style> inside <style> is not part of the static subset; nested stylesheets are refused',
1202
+ });
1203
+ return;
1204
+ }
1205
+ // Attributes are checked **before** the element allowlist, so a construct the
1206
+ // design names gets its own reason: `<image href="data:…">` reports an
1207
+ // embedded payload, `<image xlink:href="file:…">` an external reference, and
1208
+ // only an `<image>` with no offending reference falls through to "this element
1209
+ // is not in the subset". A policy that named the element first would report
1210
+ // every embedded asset as a generic unsupported element.
1211
+ //
1212
+ // A filter region is read in the coordinate system its element declares:
1213
+ // `<filter filterUnits>` for the filter's own sides, and the parent filter's
1214
+ // `primitiveUnits` for an `fe*` subregion.
1215
+ const regionUnits = local === 'filter'
1216
+ ? (attributeValue(tag, 'filterUnits', ['']) ?? '').trim() === 'userSpaceOnUse'
1217
+ ? 'user'
1218
+ : 'bounding'
1219
+ : local.startsWith('fe')
1220
+ ? filterRegionUnits()
1221
+ : 'user';
1222
+ for (const attribute of Object.values(tag.attributes)) {
1223
+ const attributeRefusal = checkAttribute(local, attribute, limits, regionUnits);
1224
+ if (attributeRefusal !== undefined) {
1225
+ fail(attributeRefusal);
1226
+ return;
1227
+ }
1228
+ }
1229
+ if (local === 'image' || local === 'feImage') {
1230
+ fail({
1231
+ reason: 'svg-external',
1232
+ detail: `<${local}> loads a resource into the drawing; embed pictures with ordinary Markdown instead`,
1233
+ });
1234
+ return;
1235
+ }
1236
+ if (ALLOWED_ELEMENTS[local] !== true) {
1237
+ fail({ reason: 'svg-unsafe', detail: `<${local}> is not part of the static subset` });
1238
+ return;
1239
+ }
1240
+ if (local === 'use') {
1241
+ uses += 1;
1242
+ if (uses > limits.maxUses) {
1243
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxUses} <use> references` });
1244
+ return;
1245
+ }
1246
+ }
1247
+ if (local.startsWith('fe'))
1248
+ filterPrimitives += 1;
1249
+ if (filterPrimitives > limits.maxFilterPrimitives) {
1250
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxFilterPrimitives} filter primitives` });
1251
+ return;
1252
+ }
1253
+ // Record the accepted tree for the `<use>` expansion count. The element
1254
+ // stack mirrors XML nesting; ids are first-wins here as in a renderer, and a
1255
+ // same-document fragment is the only reference shape the policy admits.
1256
+ const nodeIndex = nodes.length;
1257
+ nodes.push(local);
1258
+ useTargets.push(undefined);
1259
+ markerGeometry.push(local === 'path' || local === 'polyline' || local === 'polygon' || local === 'line'
1260
+ ? { points: attributeValue(tag, 'points', ['']), d: attributeValue(tag, 'd', ['']) }
1261
+ : undefined);
1262
+ childNodes.push([]);
1263
+ const parentNode = elementStack.at(-1);
1264
+ if (parentNode !== undefined)
1265
+ childNodes[parentNode].push(nodeIndex);
1266
+ elementStack.push(nodeIndex);
1267
+ elementParents.push(parentNode);
1268
+ const elementId = attributeValue(tag, 'id', ['']);
1269
+ elementIds.push(elementId);
1270
+ const elementClass = attributeValue(tag, 'class', ['']);
1271
+ elementClasses.push(elementClass === undefined
1272
+ ? undefined
1273
+ : elementClass.split(/[\t\n\f\r ]+/).filter((name) => name !== ''));
1274
+ if (elementId !== undefined && !idNodes.has(elementId))
1275
+ idNodes.set(elementId, nodeIndex);
1276
+ const href = attributeValue(tag, 'href', ['', SVG_NAMESPACE, XLINK_NAMESPACE]);
1277
+ if (local === 'use') {
1278
+ if (href !== undefined && href.startsWith('#') && href.length > 1) {
1279
+ useTargets[nodeIndex] = href.slice(1);
1280
+ }
1281
+ }
1282
+ else if (href !== undefined && href.startsWith('#') && href.length > 1) {
1283
+ // A template edge: `pattern`/gradient/filter/marker inherits the
1284
+ // referenced definition's content, `textPath` names its path, and `<a>`
1285
+ // navigation is followed conservatively. `<use>` is excluded because its
1286
+ // href is already the use edge, counted with the use instance itself;
1287
+ // recording it twice would change every existing count. Any other element
1288
+ // the allowlist admits an href on gets the same edge, so an element this
1289
+ // table forgot cannot become an unfollowed template.
1290
+ declare(nodeIndex, { property: 'href', references: [href.slice(1)], defers: false, suppresses: false });
1291
+ }
1292
+ filterPrimitiveBounding.push(local === 'filter' &&
1293
+ (attributeValue(tag, 'primitiveUnits', ['']) ?? '').trim() === 'objectBoundingBox');
1294
+ // A rendered element reaches a definition container through these references,
1295
+ // so the expansion count follows them. The value was already accepted by the
1296
+ // CSS policy, which admits only same-document fragments; a style attribute or
1297
+ // sheet can name the same containers as the presentation attributes. The
1298
+ // property name is kept because `fill`/`stroke`/marker inheritance is
1299
+ // resolved per property, while `filter`/`clip-path`/`mask` stay local.
1300
+ for (const attribute of Object.values(tag.attributes)) {
1301
+ if (attribute.uri !== '' && attribute.uri !== SVG_NAMESPACE)
1302
+ continue;
1303
+ if (URL_ATTRIBUTES[attribute.local] === true) {
1304
+ declare(nodeIndex, presentationDeclaration(attribute.local, attribute.value));
1305
+ }
1306
+ else if (attribute.local === 'style') {
1307
+ for (const declaration of declarationReferences(attribute.value)) {
1308
+ declare(nodeIndex, declaration);
1309
+ }
1310
+ }
1311
+ }
1312
+ if (local === 'style') {
1313
+ styleDepth += 1;
1314
+ styleBuffer = '';
1315
+ }
1316
+ if (local === 'title' || local === 'desc')
1317
+ titleDescDepth += 1;
1318
+ if (local === 'text') {
1319
+ textDepth += 1;
1320
+ textBuffers.push('');
1321
+ }
1322
+ });
1323
+ parser.on('text', (value) => {
1324
+ if (refusal !== undefined)
1325
+ return;
1326
+ if (styleDepth > 0) {
1327
+ // A `<style>` is parsed as one sheet when it closes. saxes may split its
1328
+ // text at an entity or CDATA boundary, and parsing each fragment would
1329
+ // treat a valid sheet as two malformed ones.
1330
+ styleBuffer += value;
1331
+ if (styleBuffer.length > limits.maxStyleCharacters) {
1332
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxStyleCharacters} characters of CSS` });
1333
+ }
1334
+ return;
1335
+ }
1336
+ if (textDepth > 0 && titleDescDepth === 0) {
1337
+ const index = textBuffers.length - 1;
1338
+ if (index >= 0)
1339
+ textBuffers[index] = (textBuffers[index] ?? '') + value;
1340
+ textCharacters += value.length;
1341
+ if (textCharacters > limits.maxTextCharacters) {
1342
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxTextCharacters} characters of text` });
1343
+ }
1344
+ }
1345
+ });
1346
+ parser.on('cdata', (value) => {
1347
+ if (refusal !== undefined)
1348
+ return;
1349
+ if (styleDepth > 0) {
1350
+ // A `<style>` is parsed as one sheet when it closes. saxes may split its
1351
+ // text at an entity or CDATA boundary, and parsing each fragment would
1352
+ // treat a valid sheet as two malformed ones.
1353
+ styleBuffer += value;
1354
+ if (styleBuffer.length > limits.maxStyleCharacters) {
1355
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxStyleCharacters} characters of CSS` });
1356
+ }
1357
+ return;
1358
+ }
1359
+ if (textDepth > 0 && titleDescDepth === 0) {
1360
+ const index = textBuffers.length - 1;
1361
+ if (index >= 0)
1362
+ textBuffers[index] = (textBuffers[index] ?? '') + value;
1363
+ textCharacters += value.length;
1364
+ if (textCharacters > limits.maxTextCharacters) {
1365
+ fail({ reason: 'svg-workload', detail: `more than ${limits.maxTextCharacters} characters of text` });
1366
+ }
1367
+ }
1368
+ });
1369
+ parser.on('closetag', (tag) => {
1370
+ if (depth > 0)
1371
+ depth -= 1;
1372
+ if (elementStack.length > 0)
1373
+ elementStack.pop();
1374
+ if (refusal !== undefined) {
1375
+ // Keep depth coherent for the remaining events, but stop inspecting.
1376
+ if (tag.local === 'style' && styleDepth > 0) {
1377
+ styleDepth -= 1;
1378
+ styleBuffer = '';
1379
+ }
1380
+ if ((tag.local === 'title' || tag.local === 'desc') && titleDescDepth > 0)
1381
+ titleDescDepth -= 1;
1382
+ if (tag.local === 'text' && textDepth > 0) {
1383
+ textDepth -= 1;
1384
+ textBuffers.pop();
1385
+ }
1386
+ return;
1387
+ }
1388
+ if (tag.local === 'style' && styleDepth > 0) {
1389
+ styleDepth -= 1;
1390
+ const analysis = analyzeStyleSheet(styleBuffer);
1391
+ if (!analysis.ok) {
1392
+ fail(analysis.refusal);
1393
+ }
1394
+ else {
1395
+ // A sheet applies document-wide, but its references are counted per
1396
+ // element its selectors match. Matching waits until the whole document
1397
+ // is recorded, because a rule may name elements that appear after the
1398
+ // sheet.
1399
+ styleRules.push(...analysis.rules);
1400
+ }
1401
+ styleBuffer = '';
1402
+ }
1403
+ if ((tag.local === 'title' || tag.local === 'desc') && titleDescDepth > 0)
1404
+ titleDescDepth -= 1;
1405
+ if (tag.local === 'text' && textDepth > 0) {
1406
+ textDepth -= 1;
1407
+ const run = textBuffers.pop() ?? '';
1408
+ if (run !== '') {
1409
+ const parent = textBuffers.length - 1;
1410
+ if (parent >= 0)
1411
+ textBuffers[parent] = (textBuffers[parent] ?? '') + run;
1412
+ else
1413
+ textRuns.push(run);
1414
+ }
1415
+ }
1416
+ });
1417
+ try {
1418
+ parser.write(text).close();
1419
+ }
1420
+ catch (error) {
1421
+ fail({
1422
+ reason: 'svg-invalid',
1423
+ detail: error instanceof Error ? error.message.split('\n')[0] : String(error),
1424
+ });
1425
+ }
1426
+ if (refusal !== undefined)
1427
+ return { ok: false, refusal };
1428
+ if (!rootSeen)
1429
+ return { ok: false, refusal: { reason: 'svg-invalid', detail: 'the document has no root element' } };
1430
+ if (uses > 0) {
1431
+ // A stylesheet declaration belongs to every element its selector matches,
1432
+ // once per matching rule. Two selectors in one list that both match an
1433
+ // element still apply that rule's declarations once, so each declaration is
1434
+ // pushed once. A declaration with no URL is attributed when it sets an
1435
+ // inherited slot, because it is what overrides an ancestor's reference.
1436
+ // Each selector is matched once over the whole tree rather than once per
1437
+ // element, which is what keeps attribution at O(selectors × steps × elements).
1438
+ const attributable = (declaration) => declaration.references.length > 0 ||
1439
+ declaration.defers ||
1440
+ declaration.property === 'marker' ||
1441
+ INHERITED_REFERENCE_SLOTS[declaration.property] !== undefined;
1442
+ const matched = new Uint8Array(nodes.length);
1443
+ for (const rule of styleRules) {
1444
+ const declarationsToApply = rule.declarations.filter(attributable);
1445
+ if (declarationsToApply.length === 0)
1446
+ continue;
1447
+ matched.fill(0);
1448
+ for (const selector of rule.selectors) {
1449
+ markSelectorMatches(selector, nodes, elementIds, elementClasses, elementParents, matched);
1450
+ }
1451
+ for (let index = 0; index < nodes.length; index += 1) {
1452
+ if (matched[index] !== 1)
1453
+ continue;
1454
+ for (const declaration of declarationsToApply) {
1455
+ declare(index, declaration);
1456
+ }
1457
+ }
1458
+ }
1459
+ // Placement parsing is deferred to this branch: a document without a
1460
+ // `<use>` never asks the expansion count a question, so it should not pay
1461
+ // for tokenizing every path's `d`.
1462
+ const markerPlacements = markerGeometry.map((geometry, index) => geometry === undefined
1463
+ ? undefined
1464
+ : markerPlacementsFor(nodes[index], geometry.points, geometry.d));
1465
+ const instances = expandedUseInstances(nodes, useTargets, childNodes, declarations, markerPlacements, elementParents, idNodes, 0);
1466
+ if (!Number.isFinite(instances) || instances > limits.maxUseInstances) {
1467
+ return {
1468
+ ok: false,
1469
+ refusal: {
1470
+ reason: 'svg-workload',
1471
+ detail: Number.isFinite(instances)
1472
+ ? `expanding <use> creates ${instances} instances, more than ${limits.maxUseInstances}; reduce references or replace them with drawn geometry`
1473
+ : `the <use> graph never terminates or exceeds the instance budget of ${limits.maxUseInstances}; break the cycle`,
1474
+ },
1475
+ };
1476
+ }
1477
+ }
1478
+ // A width without a height (or the reverse) is only usable when a viewBox
1479
+ // supplies the other side; otherwise the document declares no shape at all.
1480
+ let width = rootWidth?.px;
1481
+ let height = rootHeight?.px;
1482
+ if (rootViewBox !== undefined) {
1483
+ const ratio = rootViewBox.height / rootViewBox.width;
1484
+ if (width === undefined && height === undefined) {
1485
+ // The viewBox is the whole size. librsvg uses its units as CSS pixels when
1486
+ // no CSS size is written, which is what the renderer measures anyway.
1487
+ width = rootViewBox.width;
1488
+ height = rootViewBox.height;
1489
+ }
1490
+ else if (width !== undefined && height === undefined) {
1491
+ height = width * ratio;
1492
+ }
1493
+ else if (height !== undefined && width === undefined) {
1494
+ width = height / ratio;
1495
+ }
1496
+ }
1497
+ if (width === undefined || height === undefined) {
1498
+ return {
1499
+ ok: false,
1500
+ refusal: {
1501
+ reason: 'svg-invalid',
1502
+ detail: 'no usable size: give both absolute width and height, or a viewBox',
1503
+ },
1504
+ };
1505
+ }
1506
+ // A physical width with a unitless height changes the picture's aspect ratio as
1507
+ // the render density rises, because the two branches scale differently. There
1508
+ // is no density at which that document has the aspect ratio its author wrote
1509
+ // down, so refuse it rather than publish a distorted diagram.
1510
+ if (rootWidth !== undefined &&
1511
+ rootHeight !== undefined &&
1512
+ rootWidth.physical !== rootHeight.physical) {
1513
+ return {
1514
+ ok: false,
1515
+ refusal: {
1516
+ reason: 'svg-invalid',
1517
+ detail: 'width and height mix physical units with px/unitless units; use one unit for both',
1518
+ },
1519
+ };
1520
+ }
1521
+ return {
1522
+ ok: true,
1523
+ source: {
1524
+ width,
1525
+ height,
1526
+ declaredSize: rootWidth !== undefined || rootHeight !== undefined,
1527
+ sizeScale: rootWidth?.physical === true || rootHeight?.physical === true ? 'physical' : 'linear',
1528
+ text: textRuns,
1529
+ },
1530
+ };
1531
+ }
1532
+ /**
1533
+ * The distinct characters that need a host glyph, filtered and deduplicated.
1534
+ *
1535
+ * Whitespace, combining marks and Unicode format characters (zero-width
1536
+ * joiners, variation selectors, soft hyphen, BOM and bidi controls) are excluded
1537
+ * because they are not standalone glyphs: a lone mark combines with a preceding
1538
+ * base, and a format character draws nothing by definition. The caller maps
1539
+ * these characters to code points and asks `font-coverage.ts`.
1540
+ */
1541
+ export function requiredCharacters(source, limits = SVG_POLICY_LIMITS) {
1542
+ const seen = new Set();
1543
+ const characters = [];
1544
+ for (const run of source.text) {
1545
+ for (const character of run) {
1546
+ if (/\s/u.test(character) || /\p{M}/u.test(character) || /\p{Cf}/u.test(character))
1547
+ continue;
1548
+ if (seen.has(character))
1549
+ continue;
1550
+ seen.add(character);
1551
+ characters.push(character);
1552
+ if (characters.length > limits.maxDistinctTextCharacters) {
1553
+ return {
1554
+ reason: 'svg-workload',
1555
+ detail: `more than ${limits.maxDistinctTextCharacters} distinct text characters`,
1556
+ };
1557
+ }
1558
+ }
1559
+ }
1560
+ return characters;
1561
+ }
1562
+ /** One attribute's policy check. */
1563
+ function checkAttribute(element, attribute, limits, regionUnits = 'user') {
1564
+ const { local, uri } = attribute;
1565
+ const value = attribute.value;
1566
+ // The prefix is retained only for the human-readable diagnostic. Every
1567
+ // decision below is made from the namespace URI and local name, so an
1568
+ // alternate prefix bound to the same namespace follows the same path.
1569
+ const where = `${element}@${attribute.prefix === '' ? local : `${attribute.prefix}:${local}`}`;
1570
+ if (uri === XMLNS_NAMESPACE)
1571
+ return undefined;
1572
+ // An event handler is active regardless of the namespace spelling it chose.
1573
+ if (/^on[a-z]+$/i.test(local)) {
1574
+ return { reason: 'svg-script', detail: `${where}: event handler attribute` };
1575
+ }
1576
+ if (uri === XLINK_NAMESPACE) {
1577
+ if (local === 'href')
1578
+ return inspectReference(value, where);
1579
+ if (local === 'title' || local === 'role')
1580
+ return undefined;
1581
+ return { reason: 'svg-foreign', detail: `${where}: unsupported attribute namespace ${JSON.stringify(uri)}` };
1582
+ }
1583
+ if (uri === XML_NAMESPACE) {
1584
+ if (local === 'lang' || local === 'space')
1585
+ return undefined;
1586
+ if (local === 'base') {
1587
+ return { reason: 'svg-external', detail: `${where}: xml:base changes how references resolve` };
1588
+ }
1589
+ return { reason: 'svg-unsafe', detail: `${where}: unsupported xml attribute` };
1590
+ }
1591
+ if (uri !== '' && uri !== SVG_NAMESPACE) {
1592
+ return { reason: 'svg-foreign', detail: `${where}: unsupported attribute namespace ${JSON.stringify(uri)}` };
1593
+ }
1594
+ if (local === 'href')
1595
+ return inspectReference(value, where);
1596
+ if (local === 'style') {
1597
+ if (value.length > limits.maxStyleCharacters) {
1598
+ return {
1599
+ reason: 'svg-workload',
1600
+ detail: `${where}: more than ${limits.maxStyleCharacters} characters of CSS`,
1601
+ };
1602
+ }
1603
+ return inspectStyleAttribute(value);
1604
+ }
1605
+ if (local.startsWith('data-') || local.startsWith('aria-'))
1606
+ return undefined;
1607
+ if (ALLOWED_ATTRIBUTES[local] !== true) {
1608
+ return { reason: 'svg-unsafe', detail: `${where}: attribute is not part of the static subset` };
1609
+ }
1610
+ if (URL_ATTRIBUTES[local] === true) {
1611
+ const refusal = inspectPresentationAttribute(local, value, where);
1612
+ if (refusal !== undefined)
1613
+ return refusal;
1614
+ }
1615
+ if (local === 'type' && element === 'style') {
1616
+ const type = value.trim().toLowerCase();
1617
+ if (type !== '' && type !== 'text/css')
1618
+ return { reason: 'svg-css', detail: `${where}: ${JSON.stringify(value)}` };
1619
+ }
1620
+ if (local === 'stdDeviation') {
1621
+ for (const part of value.trim().split(/[\s,]+/)) {
1622
+ const number = Number(part);
1623
+ if (!Number.isFinite(number) || number < 0) {
1624
+ return { reason: 'svg-invalid', detail: `${where}: ${JSON.stringify(value)}` };
1625
+ }
1626
+ if (number > limits.maxStdDeviation) {
1627
+ return { reason: 'svg-workload', detail: `${where}: ${number} exceeds ${limits.maxStdDeviation}` };
1628
+ }
1629
+ }
1630
+ }
1631
+ if (local === 'order' && element === 'feConvolveMatrix') {
1632
+ const order = value.trim().split(/[\s,]+/).map(Number);
1633
+ if (order.length < 1 || order.length > 2 || order.some((part) => !Number.isInteger(part) || part < 1)) {
1634
+ return { reason: 'svg-invalid', detail: `${where}: ${JSON.stringify(value)}` };
1635
+ }
1636
+ const entries = order.length === 1 ? order[0] * order[0] : order[0] * order[1];
1637
+ if (entries > limits.maxKernelEntries) {
1638
+ return { reason: 'svg-workload', detail: `${where}: ${entries} kernel entries exceeds ${limits.maxKernelEntries}` };
1639
+ }
1640
+ }
1641
+ if (local === 'kernelMatrix' && value.trim().split(/[\s,]+/).length > limits.maxKernelEntries) {
1642
+ return { reason: 'svg-workload', detail: `${where}: a kernel larger than ${limits.maxKernelEntries} entries` };
1643
+ }
1644
+ if (local === 'numOctaves') {
1645
+ const match = NUMBER_PATTERN_ONLY.exec(value);
1646
+ const octaves = match === null ? Number.NaN : Number(match[1]);
1647
+ if (!Number.isInteger(octaves) || octaves < 0) {
1648
+ return { reason: 'svg-invalid', detail: `${where}: ${JSON.stringify(value)} is not a non-negative integer` };
1649
+ }
1650
+ if (octaves > limits.maxTurbulenceOctaves) {
1651
+ return { reason: 'svg-workload', detail: `${where}: ${octaves} exceeds ${limits.maxTurbulenceOctaves} octaves` };
1652
+ }
1653
+ }
1654
+ // A filter region is a surface libvips allocates. A percentage resolves
1655
+ // against the object's bounding box in either coordinate system, so it is
1656
+ // bounded directly. A unitless number is not: `filterUnits` decides whether it
1657
+ // is a user unit or a fraction of the box, where `6` is 600% — the hostile
1658
+ // shape a user-unit bound would miss. Primitive subregions follow the parent
1659
+ // filter's `primitiveUnits`. A unit-bearing length has no canonical meaning
1660
+ // under `objectBoundingBox`, so it is checked under both readings and the
1661
+ // larger surface wins.
1662
+ const regionAttribute = local === 'x' || local === 'y' || local === 'width' || local === 'height';
1663
+ const regionElement = element === 'filter' ||
1664
+ (element.startsWith('fe') && element !== 'fePointLight' && element !== 'feSpotLight');
1665
+ if (regionElement && regionAttribute) {
1666
+ const size = local === 'width' || local === 'height';
1667
+ const percent = PERCENT_PATTERN.exec(value);
1668
+ if (percent !== null) {
1669
+ const number = Number(percent[1]);
1670
+ if (!Number.isFinite(number)) {
1671
+ return { reason: 'svg-workload', detail: `${where}: ${JSON.stringify(value)} exceeds the filter-region budget` };
1672
+ }
1673
+ if (size && number < 0) {
1674
+ return { reason: 'svg-invalid', detail: `${where}: ${JSON.stringify(value)} is negative` };
1675
+ }
1676
+ if (Math.abs(number) > limits.maxFilterRegionPercent) {
1677
+ return { reason: 'svg-workload', detail: `${where}: ${JSON.stringify(value)} exceeds the filter-region budget` };
1678
+ }
1679
+ }
1680
+ else {
1681
+ const absolute = parseSignedLength(value);
1682
+ if (absolute === undefined) {
1683
+ return { reason: 'svg-invalid', detail: `${where}: ${JSON.stringify(value)} is not a filter-region length` };
1684
+ }
1685
+ if (size && absolute.px < 0) {
1686
+ return { reason: 'svg-invalid', detail: `${where}: ${JSON.stringify(value)} is negative` };
1687
+ }
1688
+ if (regionUnits === 'bounding' && Math.abs(absolute.px) * 100 > limits.maxFilterRegionPercent) {
1689
+ return { reason: 'svg-workload', detail: `${where}: ${JSON.stringify(value)} exceeds the filter-region budget` };
1690
+ }
1691
+ // The user-unit ceiling is refused on the ceiling itself, so the largest
1692
+ // admitted side is strictly below `maxFilterRegion`.
1693
+ if (Math.abs(absolute.px) >= limits.maxFilterRegion) {
1694
+ return { reason: 'svg-workload', detail: `${where}: ${JSON.stringify(value)} exceeds the filter-region budget` };
1695
+ }
1696
+ }
1697
+ }
1698
+ return undefined;
1699
+ }