@wxxb789/anc 0.0.2 → 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.
- package/README.md +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
|
@@ -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
|
+
}
|