@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0
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/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replaced content: `<img>`, `<canvas>`, `<video>` and inline `<svg>`.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs that look like one. The first is getting the *bytes* into the
|
|
5
|
+
* document — invariant 8 says nothing may be external, and the only routes a
|
|
6
|
+
* page gives us are `fetch` (keeps the original file, so no re-encode) and a
|
|
7
|
+
* canvas readback (re-encodes, and dies on a tainted canvas). The second is
|
|
8
|
+
* geometry: `object-fit` + `object-position` decide which part of the source is
|
|
9
|
+
* on screen and where, and that is emitted as a **crop in source pixels plus a
|
|
10
|
+
* 2×3 matrix**, never as a fit keyword — a keyword pushes the same five-case
|
|
11
|
+
* calculation onto every backend, and they will not all get it right.
|
|
12
|
+
*
|
|
13
|
+
* An SVG source is never rasterized: it is fetched (or serialized, when it is
|
|
14
|
+
* inline), its ids are namespaced per instance, and it travels as a `vector`
|
|
15
|
+
* asset. The namespacing is not cosmetic — two Illustrator icons both carrying
|
|
16
|
+
* `id="a"` and `.st0` overwrite each other's gradients with no error anywhere.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @typedef {{id:string, kind:'bitmap'|'vector', mime:string, w:number, h:number, src:string, data:string|null}} Asset */
|
|
20
|
+
/** @typedef {{assets?: Record<string, object>, uid?: (prefix: string) => string, fetchTimeout?: number, maxAssetBytes?: number}} CollectCtx */
|
|
21
|
+
|
|
22
|
+
const IMAGE_TAGS = new Set(['IMG', 'CANVAS', 'VIDEO', 'SVG'])
|
|
23
|
+
|
|
24
|
+
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
25
|
+
|
|
26
|
+
/** css-images-3 §5.1: the size a replaced element falls back to with no intrinsic one. */
|
|
27
|
+
const DEFAULT_OBJECT_SIZE = { w: 300, h: 150 }
|
|
28
|
+
|
|
29
|
+
/** Past this an asset is worth reporting; it is still embedded, because dropping it is worse. */
|
|
30
|
+
const BIG_ASSET_BYTES = 8 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
/** Baking computed style onto a huge inline SVG costs more than it buys. */
|
|
33
|
+
const MAX_BAKE_NODES = 2000
|
|
34
|
+
|
|
35
|
+
/** Raster `<image>` children pulled into an inlined SVG, per instance. */
|
|
36
|
+
const MAX_NESTED_IMAGES = 8
|
|
37
|
+
|
|
38
|
+
/** `<use href="#x">` chains can point at nodes that point at more nodes. */
|
|
39
|
+
const MAX_DEF_IMPORT_PASSES = 6
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Inherited SVG presentation properties, with their initial computed values.
|
|
43
|
+
*
|
|
44
|
+
* An inline SVG loses the page's stylesheet the moment it is serialized, and
|
|
45
|
+
* `currentColor` on an icon is the commonest way a page colours one — so these
|
|
46
|
+
* are baked back on, but only where an element differs from its parent, or every
|
|
47
|
+
* icon triples in size. The ROOT has no parent once serialized, so there it is
|
|
48
|
+
* compared against the initial value instead: `.icon svg { fill: red }` sets the
|
|
49
|
+
* same value on the root and on the `<span>` around it, and comparing those two
|
|
50
|
+
* would drop the red entirely.
|
|
51
|
+
*
|
|
52
|
+
* `null` marks a property whose initial value is the browser's own default and
|
|
53
|
+
* so cannot be written down here; those are baked on the root only when the SVG
|
|
54
|
+
* actually has text to apply them to.
|
|
55
|
+
*/
|
|
56
|
+
const INHERITED_SVG_PROPS = [
|
|
57
|
+
['fill', 'rgb(0, 0, 0)'],
|
|
58
|
+
['fill-opacity', '1'],
|
|
59
|
+
['fill-rule', 'nonzero'],
|
|
60
|
+
['stroke', 'none'],
|
|
61
|
+
['stroke-width', '1px'],
|
|
62
|
+
['stroke-opacity', '1'],
|
|
63
|
+
['stroke-linecap', 'butt'],
|
|
64
|
+
['stroke-linejoin', 'miter'],
|
|
65
|
+
['stroke-dasharray', 'none'],
|
|
66
|
+
['stroke-dashoffset', '0px'],
|
|
67
|
+
['stroke-miterlimit', '4'],
|
|
68
|
+
['color', 'rgb(0, 0, 0)'],
|
|
69
|
+
['visibility', 'visible'],
|
|
70
|
+
['clip-rule', 'nonzero'],
|
|
71
|
+
['paint-order', 'normal'],
|
|
72
|
+
['marker-start', 'none'],
|
|
73
|
+
['marker-mid', 'none'],
|
|
74
|
+
['marker-end', 'none'],
|
|
75
|
+
['text-anchor', 'start'],
|
|
76
|
+
['dominant-baseline', 'auto'],
|
|
77
|
+
['letter-spacing', 'normal'],
|
|
78
|
+
['font-family', null],
|
|
79
|
+
['font-size', null],
|
|
80
|
+
['font-weight', null],
|
|
81
|
+
['font-style', null],
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
/** Non-inherited: baked whenever they differ from the initial value, since the clone inherits nothing. */
|
|
85
|
+
const OWN_SVG_PROPS = [
|
|
86
|
+
['opacity', '1'],
|
|
87
|
+
['clip-path', 'none'],
|
|
88
|
+
['mask', 'none'],
|
|
89
|
+
['filter', 'none'],
|
|
90
|
+
['mix-blend-mode', 'normal'],
|
|
91
|
+
['stop-color', 'rgb(0, 0, 0)'],
|
|
92
|
+
['stop-opacity', '1'],
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
// ——— geometry ———
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The content box, plus the offset from the border box's origin to it, both in
|
|
99
|
+
* layout px. `client*` is used rather than `getBoundingClientRect` because it is
|
|
100
|
+
* transform-free: the node's own transform is emitted separately and must not be
|
|
101
|
+
* baked into the image paint twice.
|
|
102
|
+
*/
|
|
103
|
+
function contentBox (el, cs) {
|
|
104
|
+
const num = (v) => parseFloat(v) || 0
|
|
105
|
+
const padL = num(cs.paddingLeft), padR = num(cs.paddingRight)
|
|
106
|
+
const padT = num(cs.paddingTop), padB = num(cs.paddingBottom)
|
|
107
|
+
const insetX = num(cs.borderLeftWidth) + padL
|
|
108
|
+
const insetY = num(cs.borderTopWidth) + padT
|
|
109
|
+
const cw = el.clientWidth
|
|
110
|
+
const ch = el.clientHeight
|
|
111
|
+
if (cw > 0 || ch > 0) {
|
|
112
|
+
return { w: Math.max(0, cw - padL - padR), h: Math.max(0, ch - padT - padB), insetX, insetY }
|
|
113
|
+
}
|
|
114
|
+
// Inline replaced boxes and SVG roots can report clientWidth 0; the border box
|
|
115
|
+
// minus the same insets is the next best thing.
|
|
116
|
+
const r = el.getBoundingClientRect()
|
|
117
|
+
return {
|
|
118
|
+
w: Math.max(0, r.width - insetX - num(cs.borderRightWidth) - padR),
|
|
119
|
+
h: Math.max(0, r.height - insetY - num(cs.borderBottomWidth) - padB),
|
|
120
|
+
insetX,
|
|
121
|
+
insetY,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Splits on whitespace outside parens, so `calc(10% + 4px)` survives as one token. */
|
|
126
|
+
function tokenize (css) {
|
|
127
|
+
const out = []
|
|
128
|
+
let depth = 0
|
|
129
|
+
let cur = ''
|
|
130
|
+
for (const ch of String(css).trim()) {
|
|
131
|
+
if (ch === '(') depth++
|
|
132
|
+
else if (ch === ')') depth--
|
|
133
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
134
|
+
if (cur) { out.push(cur); cur = '' }
|
|
135
|
+
} else {
|
|
136
|
+
cur += ch
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (cur) out.push(cur)
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A CSS `<length-percentage>` as `{px, pct}`, `calc()` included — the computed
|
|
145
|
+
* value of `object-position` keeps `calc()` verbatim, and `new Function` is not
|
|
146
|
+
* an option here (it detonates under a page CSP). Only the linear `+`/`-` forms
|
|
147
|
+
* are handled; anything else returns null and the caller says so.
|
|
148
|
+
*/
|
|
149
|
+
function parseLengthPct (value) {
|
|
150
|
+
let s = String(value).trim()
|
|
151
|
+
const calc = /^(?:-webkit-)?calc\((.*)\)$/is.exec(s)
|
|
152
|
+
if (calc) s = calc[1].trim()
|
|
153
|
+
const terms = []
|
|
154
|
+
let depth = 0
|
|
155
|
+
let start = 0
|
|
156
|
+
let sign = 1
|
|
157
|
+
for (let i = 0; i < s.length; i++) {
|
|
158
|
+
const ch = s[i]
|
|
159
|
+
if (ch === '(') depth++
|
|
160
|
+
else if (ch === ')') depth--
|
|
161
|
+
// CSS requires whitespace around a top-level +/- inside calc(), which is
|
|
162
|
+
// what separates an operator from the sign of a literal.
|
|
163
|
+
else if (depth === 0 && (ch === '+' || ch === '-') && i > start && /\s/.test(s[i - 1])) {
|
|
164
|
+
terms.push({ sign, text: s.slice(start, i) })
|
|
165
|
+
sign = ch === '-' ? -1 : 1
|
|
166
|
+
start = i + 1
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
terms.push({ sign, text: s.slice(start) })
|
|
170
|
+
|
|
171
|
+
const out = { px: 0, pct: 0 }
|
|
172
|
+
for (const term of terms) {
|
|
173
|
+
const text = term.text.trim()
|
|
174
|
+
if (!text) return null
|
|
175
|
+
const n = parseFloat(text)
|
|
176
|
+
if (!Number.isFinite(n)) return null
|
|
177
|
+
if (text.endsWith('%')) out.pct += term.sign * n
|
|
178
|
+
else if (/px$/i.test(text)) out.px += term.sign * n
|
|
179
|
+
else if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) out.px += term.sign * n
|
|
180
|
+
else return null
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const resolveLengthPct = (lp, free) => lp.px + (lp.pct / 100) * free
|
|
186
|
+
|
|
187
|
+
const X_KEYWORDS = { left: 0, center: 50, right: 100 }
|
|
188
|
+
const Y_KEYWORDS = { top: 0, center: 50, bottom: 100 }
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `object-position` → the two offsets of the object box inside the content box,
|
|
192
|
+
* as functions of the free space. Handles the 1/2/3/4-token grammar including
|
|
193
|
+
* the edge-relative form (`right 10px bottom 20px`), which Chromium does keep in
|
|
194
|
+
* the computed value.
|
|
195
|
+
*
|
|
196
|
+
* @returns {{x: (free:number)=>number, y: (free:number)=>number}|null}
|
|
197
|
+
*/
|
|
198
|
+
function parseObjectPosition (css) {
|
|
199
|
+
const tokens = tokenize(css || '50% 50%')
|
|
200
|
+
if (!tokens.length || tokens.length > 4) return null
|
|
201
|
+
|
|
202
|
+
const lower = tokens.map((t) => t.toLowerCase())
|
|
203
|
+
const isX = (t) => Object.prototype.hasOwnProperty.call(X_KEYWORDS, t)
|
|
204
|
+
const isY = (t) => Object.prototype.hasOwnProperty.call(Y_KEYWORDS, t)
|
|
205
|
+
const isKeyword = (t) => isX(t) || isY(t)
|
|
206
|
+
|
|
207
|
+
let x = null
|
|
208
|
+
let y = null
|
|
209
|
+
|
|
210
|
+
const put = (edge, offset) => {
|
|
211
|
+
if (isY(edge) && edge !== 'center') y = { edge, offset }
|
|
212
|
+
else if (isX(edge) && edge !== 'center') x = { edge, offset }
|
|
213
|
+
else if (!x) x = { edge: 'start', offset }
|
|
214
|
+
else y = { edge: 'start', offset }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// One or two tokens is always the plain form — the edge-relative grammar needs
|
|
218
|
+
// both components present, so `right 10px` alone parses as x=right, y=10px.
|
|
219
|
+
if (tokens.length <= 2) {
|
|
220
|
+
// `50% 50%`, `left top`, `top left`, `10px`.
|
|
221
|
+
const values = []
|
|
222
|
+
for (const t of lower) {
|
|
223
|
+
if (isKeyword(t)) values.push({ keyword: t })
|
|
224
|
+
else {
|
|
225
|
+
const lp = parseLengthPct(t)
|
|
226
|
+
if (!lp) return null
|
|
227
|
+
values.push({ lp })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (values.length === 1) values.push({ keyword: 'center' })
|
|
231
|
+
let [a, b] = values
|
|
232
|
+
// `top left` is legal and means the same as `left top`.
|
|
233
|
+
if ((a.keyword && isY(a.keyword) && a.keyword !== 'center') ||
|
|
234
|
+
(b.keyword && isX(b.keyword) && b.keyword !== 'center')) {
|
|
235
|
+
[a, b] = [b, a]
|
|
236
|
+
}
|
|
237
|
+
const axis = (v, keywords) => (v.keyword
|
|
238
|
+
? { edge: 'start', offset: { px: 0, pct: keywords[v.keyword] } }
|
|
239
|
+
: { edge: 'start', offset: v.lp })
|
|
240
|
+
if (a.keyword && !isX(a.keyword)) return null
|
|
241
|
+
if (b.keyword && !isY(b.keyword)) return null
|
|
242
|
+
x = axis(a, X_KEYWORDS)
|
|
243
|
+
y = axis(b, Y_KEYWORDS)
|
|
244
|
+
} else {
|
|
245
|
+
// Edge-relative form: keyword, optional offset, keyword, optional offset.
|
|
246
|
+
for (let i = 0; i < lower.length;) {
|
|
247
|
+
const edge = lower[i]
|
|
248
|
+
if (!isKeyword(edge)) return null
|
|
249
|
+
let offset = { px: 0, pct: edge === 'center' ? 50 : 0 }
|
|
250
|
+
if (i + 1 < lower.length && !isKeyword(lower[i + 1])) {
|
|
251
|
+
const lp = parseLengthPct(lower[i + 1])
|
|
252
|
+
if (!lp) return null
|
|
253
|
+
offset = lp
|
|
254
|
+
i += 2
|
|
255
|
+
} else {
|
|
256
|
+
i += 1
|
|
257
|
+
}
|
|
258
|
+
put(edge, offset)
|
|
259
|
+
}
|
|
260
|
+
if (!x) x = { edge: 'start', offset: { px: 0, pct: 50 } }
|
|
261
|
+
if (!y) y = { edge: 'start', offset: { px: 0, pct: 50 } }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// `right 10px` puts the object's right edge 10px from the box's right edge.
|
|
265
|
+
const fn = (side, far) => (free) => (side.edge === far
|
|
266
|
+
? free - resolveLengthPct(side.offset, free)
|
|
267
|
+
: resolveLengthPct(side.offset, free))
|
|
268
|
+
return { x: fn(x, 'right'), y: fn(y, 'bottom') }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** css-images-3 §5.5: the concrete object size for each of the five `object-fit` values. */
|
|
272
|
+
function concreteSize (fit, nw, nh, bw, bh) {
|
|
273
|
+
if (!(nw > 0) || !(nh > 0)) return { w: bw, h: bh }
|
|
274
|
+
switch (fit) {
|
|
275
|
+
case 'none':
|
|
276
|
+
return { w: nw, h: nh }
|
|
277
|
+
case 'contain': {
|
|
278
|
+
const s = Math.min(bw / nw, bh / nh)
|
|
279
|
+
return { w: nw * s, h: nh * s }
|
|
280
|
+
}
|
|
281
|
+
case 'cover': {
|
|
282
|
+
const s = Math.max(bw / nw, bh / nh)
|
|
283
|
+
return { w: nw * s, h: nh * s }
|
|
284
|
+
}
|
|
285
|
+
case 'scale-down': {
|
|
286
|
+
// "the smaller of `none` and `contain`" — which, since both preserve the
|
|
287
|
+
// ratio, is `contain` exactly when contain's scale is below 1.
|
|
288
|
+
const s = Math.min(1, Math.min(bw / nw, bh / nh))
|
|
289
|
+
return { w: nw * s, h: nh * s }
|
|
290
|
+
}
|
|
291
|
+
case 'fill':
|
|
292
|
+
default:
|
|
293
|
+
return { w: bw, h: bh }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `object-fit` + `object-position` → the source rectangle that is actually on
|
|
299
|
+
* screen and the matrix that puts it there.
|
|
300
|
+
*
|
|
301
|
+
* @returns {{crop:{x,y,w,h}, transform:number[], visible:boolean, overflowed:boolean}}
|
|
302
|
+
* `crop` is in SOURCE pixels (the asset's own `w`/`h`); `transform` maps the
|
|
303
|
+
* unit square of that crop onto the node's BORDER box, in px.
|
|
304
|
+
*/
|
|
305
|
+
function placeObject (fit, position, natural, box) {
|
|
306
|
+
const nw = natural.w > 0 ? natural.w : box.w
|
|
307
|
+
const nh = natural.h > 0 ? natural.h : box.h
|
|
308
|
+
const size = concreteSize(fit, natural.w, natural.h, box.w, box.h)
|
|
309
|
+
const ox = position.x(box.w - size.w)
|
|
310
|
+
const oy = position.y(box.h - size.h)
|
|
311
|
+
|
|
312
|
+
// The replaced content is clipped to the content box (an `overflow` other than
|
|
313
|
+
// the default is handled by the caller's clip, not here).
|
|
314
|
+
const vx0 = Math.max(0, ox)
|
|
315
|
+
const vy0 = Math.max(0, oy)
|
|
316
|
+
const vx1 = Math.min(box.w, ox + size.w)
|
|
317
|
+
const vy1 = Math.min(box.h, oy + size.h)
|
|
318
|
+
const overflowed = ox < -0.01 || oy < -0.01 ||
|
|
319
|
+
ox + size.w > box.w + 0.01 || oy + size.h > box.h + 0.01
|
|
320
|
+
|
|
321
|
+
if (!(vx1 > vx0) || !(vy1 > vy0)) {
|
|
322
|
+
return {
|
|
323
|
+
crop: { x: 0, y: 0, w: 0, h: 0 },
|
|
324
|
+
transform: [0, 0, 0, 0, box.insetX, box.insetY],
|
|
325
|
+
visible: false,
|
|
326
|
+
overflowed,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const sx = size.w / nw
|
|
331
|
+
const sy = size.h / nh
|
|
332
|
+
return {
|
|
333
|
+
crop: {
|
|
334
|
+
x: (vx0 - ox) / sx,
|
|
335
|
+
y: (vy0 - oy) / sy,
|
|
336
|
+
w: (vx1 - vx0) / sx,
|
|
337
|
+
h: (vy1 - vy0) / sy,
|
|
338
|
+
},
|
|
339
|
+
transform: [vx1 - vx0, 0, 0, vy1 - vy0, box.insetX + vx0, box.insetY + vy0],
|
|
340
|
+
visible: true,
|
|
341
|
+
overflowed,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ——— bytes ———
|
|
346
|
+
|
|
347
|
+
function bytesToBase64 (bytes) {
|
|
348
|
+
let binary = ''
|
|
349
|
+
const CHUNK = 0x8000
|
|
350
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
351
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))
|
|
352
|
+
}
|
|
353
|
+
return btoa(binary)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function parseDataUri (url) {
|
|
357
|
+
const comma = url.indexOf(',')
|
|
358
|
+
if (comma === -1) return null
|
|
359
|
+
const head = url.slice(5, comma)
|
|
360
|
+
const isB64 = /;base64$/i.test(head)
|
|
361
|
+
const mime = ((isB64 ? head.slice(0, -7) : head).split(';')[0] || '').trim() || 'text/plain'
|
|
362
|
+
const body = url.slice(comma + 1)
|
|
363
|
+
try {
|
|
364
|
+
if (isB64) {
|
|
365
|
+
const bin = atob(body)
|
|
366
|
+
const bytes = new Uint8Array(bin.length)
|
|
367
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
368
|
+
return { bytes, mime }
|
|
369
|
+
}
|
|
370
|
+
return { bytes: new TextEncoder().encode(decodeURIComponent(body)), mime }
|
|
371
|
+
} catch {
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const MAGIC = [
|
|
377
|
+
{ mime: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
|
|
378
|
+
{ mime: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
379
|
+
{ mime: 'image/gif', test: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 },
|
|
380
|
+
{ mime: 'image/webp', test: (b) => b[0] === 0x52 && b[1] === 0x49 && b[8] === 0x57 && b[9] === 0x45 },
|
|
381
|
+
{ mime: 'image/avif', test: (b) => b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70 },
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
/** A `Content-Type` is not guaranteed, and the mime rides in the data URI. */
|
|
385
|
+
function sniffMime (bytes) {
|
|
386
|
+
if (bytes.length >= 12) {
|
|
387
|
+
for (const { mime, test } of MAGIC) if (test(bytes)) return mime
|
|
388
|
+
}
|
|
389
|
+
const head = new TextDecoder('utf-8').decode(bytes.subarray(0, 256)).trimStart()
|
|
390
|
+
if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) return 'image/svg+xml'
|
|
391
|
+
return 'application/octet-stream'
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function fetchBytes (url, timeout) {
|
|
395
|
+
if (url.startsWith('data:')) return parseDataUri(url)
|
|
396
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
397
|
+
const timer = controller && timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
|
|
398
|
+
try {
|
|
399
|
+
// force-cache: the element already downloaded this, so the HTTP cache should
|
|
400
|
+
// answer without a second network trip.
|
|
401
|
+
const res = await fetch(url, { signal: controller ? controller.signal : undefined, cache: 'force-cache' })
|
|
402
|
+
if (!res.ok) return null
|
|
403
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
404
|
+
const declared = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
405
|
+
return { bytes, mime: declared && declared !== 'application/octet-stream' ? declared : sniffMime(bytes) }
|
|
406
|
+
} catch {
|
|
407
|
+
return null
|
|
408
|
+
} finally {
|
|
409
|
+
if (timer) clearTimeout(timer)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* EXIF orientation of a JPEG, 1 when absent or unreadable.
|
|
415
|
+
*
|
|
416
|
+
* It matters because `naturalWidth/Height` are already the *oriented* dimensions
|
|
417
|
+
* — so shipping the original bytes to a consumer that ignores EXIF yields an
|
|
418
|
+
* image rotated against the geometry we just measured.
|
|
419
|
+
*/
|
|
420
|
+
function jpegOrientation (bytes) {
|
|
421
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1
|
|
422
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
423
|
+
let off = 2
|
|
424
|
+
while (off + 4 <= bytes.length) {
|
|
425
|
+
if (view.getUint8(off) !== 0xff) break
|
|
426
|
+
const marker = view.getUint8(off + 1)
|
|
427
|
+
if (marker === 0xda || marker === 0xd9) break // start of scan / end of image
|
|
428
|
+
const size = view.getUint16(off + 2)
|
|
429
|
+
if (size < 2) break
|
|
430
|
+
if (marker === 0xe1 && off + 10 <= bytes.length && view.getUint32(off + 4) === 0x45786966) {
|
|
431
|
+
const tiff = off + 10
|
|
432
|
+
if (tiff + 8 > bytes.length) return 1
|
|
433
|
+
const le = view.getUint16(tiff) === 0x4949
|
|
434
|
+
if (view.getUint16(tiff + 2, le) !== 42) return 1
|
|
435
|
+
const ifd = tiff + view.getUint32(tiff + 4, le)
|
|
436
|
+
if (ifd + 2 > bytes.length) return 1
|
|
437
|
+
const count = view.getUint16(ifd, le)
|
|
438
|
+
for (let i = 0; i < count; i++) {
|
|
439
|
+
const entry = ifd + 2 + i * 12
|
|
440
|
+
if (entry + 12 > bytes.length) break
|
|
441
|
+
if (view.getUint16(entry, le) === 0x0112) return view.getUint16(entry + 8, le) || 1
|
|
442
|
+
}
|
|
443
|
+
return 1
|
|
444
|
+
}
|
|
445
|
+
off += 2 + size
|
|
446
|
+
}
|
|
447
|
+
return 1
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Draws a source into a fresh canvas and reads it back.
|
|
452
|
+
* Throws `SecurityError` when the source taints the canvas — that is the only
|
|
453
|
+
* way to find out, and the caller turns it into a diagnostic.
|
|
454
|
+
*/
|
|
455
|
+
function readback (source, w, h, mime = 'image/png', quality) {
|
|
456
|
+
const canvas = document.createElement('canvas')
|
|
457
|
+
canvas.width = Math.max(1, Math.round(w))
|
|
458
|
+
canvas.height = Math.max(1, Math.round(h))
|
|
459
|
+
const g = canvas.getContext('2d')
|
|
460
|
+
g.drawImage(source, 0, 0, canvas.width, canvas.height)
|
|
461
|
+
return canvas.toDataURL(mime, quality)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ——— svg ———
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Rewrites `url(#x)` / `href="#x"` references through a rename map.
|
|
468
|
+
* Chromium serializes computed `fill: url(#g)` with the document URL in front of
|
|
469
|
+
* the fragment, so the base is compared rather than assumed empty.
|
|
470
|
+
*/
|
|
471
|
+
function makeReferenceRewriter (map, missing) {
|
|
472
|
+
const base = typeof location !== 'undefined' ? location.href.split('#')[0] : ''
|
|
473
|
+
return (text) => text.replace(/url\((['"]?)([^'")]*)\1\)/gi, (whole, quote, target) => {
|
|
474
|
+
const hash = target.indexOf('#')
|
|
475
|
+
if (hash === -1) return whole
|
|
476
|
+
const head = target.slice(0, hash)
|
|
477
|
+
if (head && head !== base) return whole
|
|
478
|
+
const id = target.slice(hash + 1)
|
|
479
|
+
const next = map.get(id)
|
|
480
|
+
if (!next) { missing.add(id); return whole }
|
|
481
|
+
return `url(#${next})`
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const CSS_IDENT = /[A-Za-z0-9_\-\u00a0-\uffff]/
|
|
486
|
+
|
|
487
|
+
/** Rewrites `#id` / `.class` occurrences in a `<style>` body through a rename map. */
|
|
488
|
+
function rewriteSelectors (text, map, sigil) {
|
|
489
|
+
let out = ''
|
|
490
|
+
for (let i = 0; i < text.length; i++) {
|
|
491
|
+
if (text[i] !== sigil) { out += text[i]; continue }
|
|
492
|
+
let j = i + 1
|
|
493
|
+
while (j < text.length && CSS_IDENT.test(text[j])) j++
|
|
494
|
+
const name = text.slice(i + 1, j)
|
|
495
|
+
const next = map.get(name)
|
|
496
|
+
out += next ? sigil + next : text.slice(i, j)
|
|
497
|
+
i = j - 1
|
|
498
|
+
}
|
|
499
|
+
return out
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Every `#id` a subtree points at, from attributes and from `<style>` bodies. */
|
|
503
|
+
function collectReferencedIds (nodes) {
|
|
504
|
+
const wanted = new Set()
|
|
505
|
+
for (const node of nodes) {
|
|
506
|
+
if (!node.attributes) continue
|
|
507
|
+
const texts = Array.from(node.attributes).map((a) => a.value)
|
|
508
|
+
if (node.localName === 'style' && node.textContent) texts.push(node.textContent)
|
|
509
|
+
for (const text of texts) {
|
|
510
|
+
for (const m of text.matchAll(/url\((['"]?)([^'")]*)\1\)/gi)) {
|
|
511
|
+
const hash = m[2].indexOf('#')
|
|
512
|
+
if (hash !== -1 && m[2].length > hash + 1) wanted.add(m[2].slice(hash + 1))
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const href = node.getAttribute && (node.getAttribute('href') || node.getAttribute('xlink:href'))
|
|
516
|
+
if (href && href.length > 1 && href.startsWith('#')) wanted.add(href.slice(1))
|
|
517
|
+
}
|
|
518
|
+
return wanted
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Which element an `#id` reference resolves to, indexed in tree order:
|
|
523
|
+
* **the first occurrence wins**, which is what `getElementById` does and what
|
|
524
|
+
* the renderer therefore did on the page. Two icons that both declare
|
|
525
|
+
* `#cx-grad` do NOT get one gradient each — they both get the first one, and an
|
|
526
|
+
* export that hands them one each has changed the picture, not preserved it.
|
|
527
|
+
*
|
|
528
|
+
* snapdom's own `svg.inline-defs-container` is indexed LAST. It holds
|
|
529
|
+
* definitions hoisted from outside the captured subtree, so it is a fallback for
|
|
530
|
+
* ids the page could not resolve locally, never a competitor for ids it could.
|
|
531
|
+
*
|
|
532
|
+
* @param {Element} root
|
|
533
|
+
* @returns {Map<string, Element>}
|
|
534
|
+
*/
|
|
535
|
+
function buildIdIndex (root) {
|
|
536
|
+
const index = new Map()
|
|
537
|
+
const deferred = []
|
|
538
|
+
const visit = (node) => {
|
|
539
|
+
const id = node.getAttribute && node.getAttribute('id')
|
|
540
|
+
if (id && !index.has(id)) index.set(id, node)
|
|
541
|
+
for (const child of node.children || []) {
|
|
542
|
+
if (child.localName === 'svg' && child.classList &&
|
|
543
|
+
child.classList.contains('inline-defs-container')) deferred.push(child)
|
|
544
|
+
else visit(child)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
visit(root)
|
|
548
|
+
for (const node of deferred) visit(node)
|
|
549
|
+
return index
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** `cloneNode(true)` walks in the same order twice, so index i is index i. */
|
|
553
|
+
function pairSubtrees (live, copy, into) {
|
|
554
|
+
const a = [live, ...live.querySelectorAll('*')]
|
|
555
|
+
const b = [copy, ...copy.querySelectorAll('*')]
|
|
556
|
+
if (a.length !== b.length) return false
|
|
557
|
+
for (let i = 0; i < a.length; i++) into.set(a[i], b[i])
|
|
558
|
+
return true
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Prefixes every `id` and every class in an SVG subtree and repoints every
|
|
563
|
+
* reference at **the element the document actually resolved it to**, importing
|
|
564
|
+
* that element when it lives outside the subtree.
|
|
565
|
+
*
|
|
566
|
+
* Two failure modes, one mechanism. Without prefixing, two Illustrator icons
|
|
567
|
+
* both shipping `id="a"` overwrite each other's gradients once they share a
|
|
568
|
+
* document, with no error anywhere. With prefixing but no resolution, the
|
|
569
|
+
* opposite lie: a second icon that redeclares an id the first one already owns
|
|
570
|
+
* gets its OWN definition, when on the page it was rendering with the first
|
|
571
|
+
* one's — the export then shows two colours where the browser showed one. Both
|
|
572
|
+
* are silent, and the second is worse, because it looks deliberate.
|
|
573
|
+
*
|
|
574
|
+
* @param {SVGElement} clone the copy being serialized, mutated in place
|
|
575
|
+
* @param {string} prefix
|
|
576
|
+
* @param {string[]} warnings
|
|
577
|
+
* @param {{live?: Element|null, index?: Map<string, Element>|null}} [resolution]
|
|
578
|
+
* `live` is the mounted element `clone` was copied from and `index` the id
|
|
579
|
+
* index of the tree it was resolved in. Omit both for a fetched file, which is
|
|
580
|
+
* self-contained and resolves against itself.
|
|
581
|
+
*/
|
|
582
|
+
function namespaceSvg (clone, prefix, warnings, resolution = {}) {
|
|
583
|
+
const doc = clone.ownerDocument
|
|
584
|
+
const live = resolution.live || null
|
|
585
|
+
const index = resolution.index || null
|
|
586
|
+
|
|
587
|
+
// live element -> the node in `clone` that stands for it.
|
|
588
|
+
const liveToClone = new Map()
|
|
589
|
+
const paired = live && index ? pairSubtrees(live, clone, liveToClone) : false
|
|
590
|
+
if (live && index && !paired) {
|
|
591
|
+
warnings.push('image: the inline SVG changed shape while being cloned; its references were ' +
|
|
592
|
+
'resolved inside the element instead of against the document, so a definition it shares ' +
|
|
593
|
+
'with another SVG may be the wrong one.')
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const taken = new Set()
|
|
597
|
+
/** clone node -> its final, namespaced id. */
|
|
598
|
+
const finalId = new Map()
|
|
599
|
+
const assignId = (node, hint) => {
|
|
600
|
+
const base = prefix + String(hint).replace(/[^A-Za-z0-9_-]/g, '_')
|
|
601
|
+
let id = base
|
|
602
|
+
let n = 1
|
|
603
|
+
while (taken.has(id)) id = `${base}_${n++}`
|
|
604
|
+
taken.add(id)
|
|
605
|
+
finalId.set(node, id)
|
|
606
|
+
node.setAttribute('id', id)
|
|
607
|
+
return id
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Read every original id before renaming any of them: within one instance the
|
|
611
|
+
// ids can collide with each other too, and the first is the one that wins.
|
|
612
|
+
const nodes = [clone, ...clone.querySelectorAll('*')]
|
|
613
|
+
const localFirst = new Map()
|
|
614
|
+
let hasStyleElement = false
|
|
615
|
+
const originals = []
|
|
616
|
+
for (const node of nodes) {
|
|
617
|
+
const id = node.getAttribute && node.getAttribute('id')
|
|
618
|
+
if (id) {
|
|
619
|
+
originals.push([node, id])
|
|
620
|
+
if (!localFirst.has(id)) localFirst.set(id, node)
|
|
621
|
+
}
|
|
622
|
+
if (node.localName === 'style') hasStyleElement = true
|
|
623
|
+
}
|
|
624
|
+
for (const [node, id] of originals) assignId(node, id)
|
|
625
|
+
|
|
626
|
+
/** original id -> final id, the whole point of the pass. */
|
|
627
|
+
const refMap = new Map()
|
|
628
|
+
const missing = new Set()
|
|
629
|
+
const stolen = []
|
|
630
|
+
let imported = 0
|
|
631
|
+
|
|
632
|
+
const importDefinition = (id, winner) => {
|
|
633
|
+
let defs = clone.querySelector(':scope > defs')
|
|
634
|
+
if (!defs) {
|
|
635
|
+
defs = doc.createElementNS(SVG_NS, 'defs')
|
|
636
|
+
clone.insertBefore(defs, clone.firstChild)
|
|
637
|
+
}
|
|
638
|
+
const copy = winner.cloneNode(true)
|
|
639
|
+
// The imported node comes from elsewhere in the page: its stylesheet rules do
|
|
640
|
+
// not travel with the markup any more than this SVG's own did.
|
|
641
|
+
bakeComputedStyle(winner, copy, warnings)
|
|
642
|
+
defs.appendChild(copy)
|
|
643
|
+
pairSubtrees(winner, copy, liveToClone)
|
|
644
|
+
for (const node of [copy, ...copy.querySelectorAll('*')]) {
|
|
645
|
+
const own = node.getAttribute && node.getAttribute('id')
|
|
646
|
+
if (own) assignId(node, own)
|
|
647
|
+
}
|
|
648
|
+
imported++
|
|
649
|
+
return finalId.get(copy)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const resolve = (id) => {
|
|
653
|
+
if (refMap.has(id) || missing.has(id)) return
|
|
654
|
+
const local = localFirst.get(id) || null
|
|
655
|
+
if (!paired) {
|
|
656
|
+
if (local) refMap.set(id, finalId.get(local))
|
|
657
|
+
else missing.add(id)
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const winner = index.get(id) || null
|
|
661
|
+
if (!winner) {
|
|
662
|
+
if (local) refMap.set(id, finalId.get(local))
|
|
663
|
+
else missing.add(id)
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
const mapped = liveToClone.get(winner)
|
|
667
|
+
if (mapped) {
|
|
668
|
+
refMap.set(id, finalId.get(mapped))
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
if (winner.namespaceURI !== SVG_NS) { missing.add(id); return }
|
|
672
|
+
refMap.set(id, importDefinition(id, winner))
|
|
673
|
+
if (local) stolen.push(id)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
for (let pass = 0; pass < MAX_DEF_IMPORT_PASSES; pass++) {
|
|
677
|
+
const before = refMap.size + missing.size
|
|
678
|
+
for (const id of collectReferencedIds([clone, ...clone.querySelectorAll('*')])) resolve(id)
|
|
679
|
+
if (refMap.size + missing.size === before) break
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const classMap = new Map()
|
|
683
|
+
// Classes only collide through a <style> block; without one they are inert and
|
|
684
|
+
// renaming them would only make the markup harder to read.
|
|
685
|
+
if (hasStyleElement) {
|
|
686
|
+
for (const node of [clone, ...clone.querySelectorAll('*')]) {
|
|
687
|
+
const cls = node.getAttribute && node.getAttribute('class')
|
|
688
|
+
if (!cls) continue
|
|
689
|
+
for (const name of cls.trim().split(/\s+/)) if (name) classMap.set(name, prefix + name)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const rewriteUrls = makeReferenceRewriter(refMap, missing)
|
|
694
|
+
let smil = false
|
|
695
|
+
for (const node of [clone, ...clone.querySelectorAll('*')]) {
|
|
696
|
+
if (!node.attributes) continue
|
|
697
|
+
for (const attr of Array.from(node.attributes)) {
|
|
698
|
+
if (attr.localName === 'id') continue
|
|
699
|
+
let value = attr.value
|
|
700
|
+
if (value.includes('url(')) value = rewriteUrls(value)
|
|
701
|
+
if (attr.localName === 'href' && value.startsWith('#')) {
|
|
702
|
+
const next = refMap.get(value.slice(1))
|
|
703
|
+
if (next) value = '#' + next
|
|
704
|
+
else missing.add(value.slice(1))
|
|
705
|
+
}
|
|
706
|
+
if (attr.localName === 'class' && classMap.size) {
|
|
707
|
+
value = value.trim().split(/\s+/).map((n) => classMap.get(n) || n).join(' ')
|
|
708
|
+
}
|
|
709
|
+
// `begin="other.click"` and friends address elements by bare id; SMIL is
|
|
710
|
+
// out of scope, but the reference would break silently, so it is reported.
|
|
711
|
+
if ((attr.localName === 'begin' || attr.localName === 'end') && /[A-Za-z_][\w-]*\./.test(value)) smil = true
|
|
712
|
+
if (value !== attr.value) attr.value = value
|
|
713
|
+
}
|
|
714
|
+
if (node.localName === 'style' && node.textContent) {
|
|
715
|
+
let css = rewriteUrls(node.textContent)
|
|
716
|
+
css = rewriteSelectors(css, refMap, '#')
|
|
717
|
+
if (classMap.size) css = rewriteSelectors(css, classMap, '.')
|
|
718
|
+
node.textContent = css
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (imported) {
|
|
723
|
+
warnings.push(`image: ${imported} referenced definition(s) live outside this <svg> and were copied into it.`)
|
|
724
|
+
}
|
|
725
|
+
if (stolen.length) {
|
|
726
|
+
warnings.push(`image: this <svg> declares #${stolen.join(', #')} but does not win ${stolen.length > 1 ? 'them' : 'it'} — ` +
|
|
727
|
+
'an earlier element in the capture declares the same id and the document resolves the reference to that ' +
|
|
728
|
+
'one. The winning definition was imported so the export renders what the page rendered; the local ' +
|
|
729
|
+
'declaration is kept, renamed, and unreferenced.')
|
|
730
|
+
}
|
|
731
|
+
if (smil) {
|
|
732
|
+
warnings.push('image: SMIL timing references inside the SVG were not namespaced (animation is not captured).')
|
|
733
|
+
}
|
|
734
|
+
if (missing.size) {
|
|
735
|
+
warnings.push(`image: ${missing.size} dangling reference(s) inside the SVG (#${[...missing].join(', #')}) — the target is not in the captured subtree.`)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Copies the computed value of the SVG presentation properties onto the clone.
|
|
741
|
+
* The page's stylesheets do not travel with the markup, and `currentColor` — the
|
|
742
|
+
* usual way an icon gets its colour — resolves to nothing without this.
|
|
743
|
+
*/
|
|
744
|
+
function bakeComputedStyle (live, clone, warnings) {
|
|
745
|
+
const liveNodes = [live, ...live.querySelectorAll('*')]
|
|
746
|
+
const cloneNodes = [clone, ...clone.querySelectorAll('*')]
|
|
747
|
+
if (liveNodes.length !== cloneNodes.length) {
|
|
748
|
+
warnings.push('image: inline SVG changed shape while being cloned; CSS presentation values were not baked in.')
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
if (liveNodes.length > MAX_BAKE_NODES) {
|
|
752
|
+
warnings.push(`image: inline SVG has ${liveNodes.length} nodes (over ${MAX_BAKE_NODES}); CSS presentation values were not baked in, so stylesheet-driven fills may be lost.`)
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
const styles = new Map()
|
|
756
|
+
const styleOf = (node) => {
|
|
757
|
+
let s = styles.get(node)
|
|
758
|
+
if (!s) { s = getComputedStyle(node); styles.set(node, s) }
|
|
759
|
+
return s
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const hasText = !!live.querySelector('text, tspan, textPath')
|
|
763
|
+
const hasStylesheet = !!live.querySelector('style')
|
|
764
|
+
let cssTransforms = 0
|
|
765
|
+
for (let i = 0; i < liveNodes.length; i++) {
|
|
766
|
+
const node = liveNodes[i]
|
|
767
|
+
const target = cloneNodes[i]
|
|
768
|
+
let style
|
|
769
|
+
try {
|
|
770
|
+
style = styleOf(node)
|
|
771
|
+
} catch {
|
|
772
|
+
continue
|
|
773
|
+
}
|
|
774
|
+
const isRoot = i === 0
|
|
775
|
+
// Core freezes mask longhands even on unmasked SVG nodes. They cannot
|
|
776
|
+
// affect paint without a mask image; keep them when an embedded sheet
|
|
777
|
+
// could introduce one after the SVG leaves the document.
|
|
778
|
+
if (!hasStylesheet && style.maskImage === 'none') {
|
|
779
|
+
for (const prop of ['mask-position', 'mask-size', 'mask-repeat', 'mask-mode', 'mask-composite', 'mask-origin', 'mask-clip']) {
|
|
780
|
+
target.style.removeProperty(prop)
|
|
781
|
+
}
|
|
782
|
+
if (!target.getAttribute('style')) target.removeAttribute('style')
|
|
783
|
+
}
|
|
784
|
+
const parentStyle = !isRoot && node.parentElement ? styleOf(node.parentElement) : null
|
|
785
|
+
// Presentation attributes lose to both the inline `style` attribute and the
|
|
786
|
+
// SVG's own <style> block — which is why the attribute is left in place:
|
|
787
|
+
// wherever one of those wins, it won when the computed value was read too,
|
|
788
|
+
// so the two agree, and removing it would drop the properties NOT baked here
|
|
789
|
+
// (`d`, `transform`, `transform-origin`, …).
|
|
790
|
+
for (const [prop, initial] of INHERITED_SVG_PROPS) {
|
|
791
|
+
const value = style.getPropertyValue(prop)
|
|
792
|
+
if (!value) continue
|
|
793
|
+
if (isRoot) {
|
|
794
|
+
if (initial === null ? !hasText : value === initial) continue
|
|
795
|
+
} else if (parentStyle && value === parentStyle.getPropertyValue(prop)) {
|
|
796
|
+
continue
|
|
797
|
+
}
|
|
798
|
+
target.setAttribute(prop, value)
|
|
799
|
+
}
|
|
800
|
+
for (const [prop, initial] of OWN_SVG_PROPS) {
|
|
801
|
+
const value = style.getPropertyValue(prop)
|
|
802
|
+
if (value && value !== initial) target.setAttribute(prop, value)
|
|
803
|
+
}
|
|
804
|
+
if (style.display === 'none') target.setAttribute('display', 'none')
|
|
805
|
+
// A CSS `transform` on an SVG child cannot be baked as an attribute: the two
|
|
806
|
+
// resolve `transform-origin` against different boxes, so copying the matrix
|
|
807
|
+
// across would move the shape. Reported rather than guessed at.
|
|
808
|
+
if (style.transform && style.transform !== 'none' && !target.hasAttribute('transform')) cssTransforms++
|
|
809
|
+
}
|
|
810
|
+
if (cssTransforms) {
|
|
811
|
+
warnings.push(`image: ${cssTransforms} node(s) inside the inline SVG are transformed by CSS; that transform is not carried into the serialized markup.`)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Intrinsic size from the markup: `width`/`height` when absolute, else the viewBox. */
|
|
816
|
+
function svgIntrinsic (svgEl) {
|
|
817
|
+
const raw = svgEl.getAttribute('viewBox')
|
|
818
|
+
let viewBox = null
|
|
819
|
+
if (raw) {
|
|
820
|
+
const parts = raw.trim().split(/[\s,]+/).map(Number)
|
|
821
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) viewBox = parts
|
|
822
|
+
}
|
|
823
|
+
const attr = (name) => {
|
|
824
|
+
const v = svgEl.getAttribute(name)
|
|
825
|
+
if (!v || v.trim().endsWith('%')) return null
|
|
826
|
+
const n = parseFloat(v)
|
|
827
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
828
|
+
}
|
|
829
|
+
return { viewBox, w: attr('width'), h: attr('height') }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Pulls raster `<image>` children of an inlined SVG in as data URIs — they are external otherwise. */
|
|
833
|
+
async function embedNestedImages (root, timeout, warnings, base) {
|
|
834
|
+
const images = Array.from(root.querySelectorAll('image'))
|
|
835
|
+
if (!images.length) return
|
|
836
|
+
let embedded = 0
|
|
837
|
+
let failed = 0
|
|
838
|
+
for (const node of images.slice(0, MAX_NESTED_IMAGES)) {
|
|
839
|
+
const href = node.getAttribute('href') || node.getAttribute('xlink:href')
|
|
840
|
+
if (!href || href.startsWith('data:') || href.startsWith('#')) continue
|
|
841
|
+
let absolute = href
|
|
842
|
+
try {
|
|
843
|
+
absolute = new URL(href, base || (typeof location !== 'undefined' ? location.href : undefined)).href
|
|
844
|
+
} catch { /* keep the raw href; fetch will fail and be reported */ }
|
|
845
|
+
const got = await fetchBytes(absolute, timeout)
|
|
846
|
+
if (!got) { failed++; continue }
|
|
847
|
+
const uri = `data:${got.mime};base64,${bytesToBase64(got.bytes)}`
|
|
848
|
+
if (node.hasAttribute('xlink:href')) node.removeAttribute('xlink:href')
|
|
849
|
+
node.setAttribute('href', uri)
|
|
850
|
+
embedded++
|
|
851
|
+
}
|
|
852
|
+
if (embedded) warnings.push(`image: ${embedded} raster <image> child(ren) of the SVG were inlined as data URIs.`)
|
|
853
|
+
if (failed || images.length > MAX_NESTED_IMAGES) {
|
|
854
|
+
warnings.push(`image: ${failed + Math.max(0, images.length - MAX_NESTED_IMAGES)} raster <image> child(ren) of the SVG could not be inlined and stay external.`)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Grade-lowering constructs: the geometry survives, these do not. */
|
|
859
|
+
function auditSvgFeatures (root, warnings) {
|
|
860
|
+
let grade = 'E'
|
|
861
|
+
const notes = []
|
|
862
|
+
if (root.querySelector('filter')) {
|
|
863
|
+
warnings.push('image: the SVG uses <filter>; it is passed through verbatim and the target may render it differently.')
|
|
864
|
+
notes.push('svg-filter-passthrough')
|
|
865
|
+
grade = 'A'
|
|
866
|
+
}
|
|
867
|
+
if (root.querySelector('foreignObject')) {
|
|
868
|
+
warnings.push('image: the SVG contains <foreignObject>; its HTML content is not vectorized.')
|
|
869
|
+
notes.push('svg-foreignobject')
|
|
870
|
+
grade = 'A'
|
|
871
|
+
}
|
|
872
|
+
if (root.querySelector('animate, animateTransform, animateMotion, set')) {
|
|
873
|
+
warnings.push('image: the SVG is animated; the captured markup holds the declarations, not the current frame.')
|
|
874
|
+
notes.push('svg-animated')
|
|
875
|
+
grade = 'A'
|
|
876
|
+
}
|
|
877
|
+
return { grade, notes }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Active content, out of the exported markup.
|
|
882
|
+
*
|
|
883
|
+
* A `<script>` has no business in a vector file. Rendered as an image nothing runs
|
|
884
|
+
* it, rendered as a document something might, and either way the person who opens
|
|
885
|
+
* the file has to audit it first. It also breaks the file outright: measured on
|
|
886
|
+
* 2026-08-08, a dev server that injects a live-reload script into every `.svg` it
|
|
887
|
+
* serves put three of them inside one capture — the icons the fixture loads — and
|
|
888
|
+
* the emitted SVG stopped parsing at the first `// <![CDATA[`, which is a defect
|
|
889
|
+
* the export had no way to see and the buyer had no way to explain.
|
|
890
|
+
*
|
|
891
|
+
* The engine copies what the page gave it; it does not have to copy this.
|
|
892
|
+
*
|
|
893
|
+
* @param {Element} node the tree about to be serialized, never the live one
|
|
894
|
+
* @param {Array} warnings
|
|
895
|
+
*/
|
|
896
|
+
function stripActiveContent (node, warnings) {
|
|
897
|
+
let scripts = 0
|
|
898
|
+
for (const el of node.querySelectorAll('script')) { el.remove(); scripts++ }
|
|
899
|
+
let handlers = 0
|
|
900
|
+
for (const el of [node, ...node.querySelectorAll('*')]) {
|
|
901
|
+
for (const attr of [...el.attributes]) {
|
|
902
|
+
if (/^on/i.test(attr.name)) { el.removeAttribute(attr.name); handlers++ }
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (scripts || handlers) {
|
|
906
|
+
warnings.push({
|
|
907
|
+
code: 'collect.image.active-content-stripped',
|
|
908
|
+
// Nothing VISUAL was lost, so the node keeps its grade. What happened is
|
|
909
|
+
// still reported, because a capture that quietly edits its own output is
|
|
910
|
+
// the thing this engine exists not to be.
|
|
911
|
+
grade: 'E',
|
|
912
|
+
severity: 'warn',
|
|
913
|
+
message: `image: the SVG carried ${scripts} <script> element(s) and ${handlers} event-handler ` +
|
|
914
|
+
'attribute(s). They are removed from the exported markup: a drawing does not need them, and ' +
|
|
915
|
+
'an injected one (a dev server\'s live-reload snippet, for instance) makes the file fail to parse.',
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
return { scripts, handlers }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ——— assets ———
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Ids issued here are tracked on the ctx as well as checked against
|
|
925
|
+
* `ctx.assets`: the caller registers an asset only after this returns, so two
|
|
926
|
+
* sources whose file names agree (`/a/logo.png`, `/b/logo.png`) would otherwise
|
|
927
|
+
* both be handed `a_logo` and the second would overwrite the first.
|
|
928
|
+
*/
|
|
929
|
+
function nextAssetId (ctx, seed) {
|
|
930
|
+
if (typeof ctx.uid === 'function') return ctx.uid('a')
|
|
931
|
+
const used = ctx.assets || {}
|
|
932
|
+
if (!ctx.assetIds) ctx.assetIds = new Set()
|
|
933
|
+
const taken = (id) => ctx.assetIds.has(id) || Object.prototype.hasOwnProperty.call(used, id)
|
|
934
|
+
let id = `a_${seed}`
|
|
935
|
+
let n = 1
|
|
936
|
+
while (taken(id)) id = `a_${seed}_${n++}`
|
|
937
|
+
ctx.assetIds.add(id)
|
|
938
|
+
return id
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Keyed by URL, so one logo used twenty times is fetched and embedded once. */
|
|
942
|
+
function assetCache (ctx) {
|
|
943
|
+
if (!ctx || typeof ctx !== 'object') return new Map()
|
|
944
|
+
if (!ctx.assetCache) ctx.assetCache = new Map()
|
|
945
|
+
return ctx.assetCache
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function seedFromSrc (src, fallback) {
|
|
949
|
+
if (!src || src.startsWith('data:')) return fallback
|
|
950
|
+
const clean = String(src).split(/[?#]/)[0]
|
|
951
|
+
const name = clean.slice(clean.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '')
|
|
952
|
+
const safe = name.replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24)
|
|
953
|
+
return safe || fallback
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ——— sources ———
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Bytes that claim to be an SVG, as MARKUP: audited, its raster children
|
|
960
|
+
* inlined, its ids namespaced, and given a viewport in its own natural units.
|
|
961
|
+
*
|
|
962
|
+
* **An SVG never travels inside an `<image>`.** Measured pasting into Figma
|
|
963
|
+
* (FIGMA_FINDINGS §6): an `<image>` whose `href` is an SVG data URI draws
|
|
964
|
+
* NOTHING — no diagnostic, no fallback, a blank node. The same drawing as
|
|
965
|
+
* inline markup under a nested `<svg>` or a matrix lands correctly. That is why
|
|
966
|
+
* both SVG sources — `<img src="*.svg">` and `background-image: url(*.svg)` —
|
|
967
|
+
* come through here rather than through `bitmapAsset`.
|
|
968
|
+
*
|
|
969
|
+
* @param {Uint8Array} bytes
|
|
970
|
+
* @param {string} src for the messages, and the asset's `src`
|
|
971
|
+
* @param {string} id already minted by the caller: the id seeds the
|
|
972
|
+
* namespace prefix, so it cannot be issued after the markup is built
|
|
973
|
+
* @param {object} ctx
|
|
974
|
+
* @param {{w:number,h:number}|null} natural the size the LAYOUT resolved the
|
|
975
|
+
* source to (`naturalWidth/Height` for an `<img>`, the measured intrinsic
|
|
976
|
+
* size for a background layer). It wins over the markup's own attributes
|
|
977
|
+
* because it is the number CSS sized against.
|
|
978
|
+
* @param {{w:number,h:number}|null} box the element box the CSS default sizing
|
|
979
|
+
* algorithm fits a ratio-only SVG into; null for a background layer, whose
|
|
980
|
+
* positioning area is resolved by the caller instead.
|
|
981
|
+
* @param {string[]} warnings
|
|
982
|
+
* @param {number} timeout
|
|
983
|
+
* @returns {Promise<{asset:object, natural:{w:number,h:number}, grade:string, notes:string[]}|null>}
|
|
984
|
+
* null when the bytes are not a parseable SVG, which is a raster job again.
|
|
985
|
+
*/
|
|
986
|
+
async function svgMarkupAsset (bytes, src, id, ctx, natural, box, warnings, timeout) {
|
|
987
|
+
const text = new TextDecoder('utf-8').decode(bytes)
|
|
988
|
+
const parsed = new DOMParser().parseFromString(text, 'image/svg+xml')
|
|
989
|
+
const root = parsed.documentElement
|
|
990
|
+
if (!root || root.localName !== 'svg' || parsed.querySelector('parsererror')) return null
|
|
991
|
+
|
|
992
|
+
const audit = auditSvgFeatures(root, warnings)
|
|
993
|
+
await embedNestedImages(root, timeout, warnings, src)
|
|
994
|
+
namespaceSvg(root, `${id}__`, warnings)
|
|
995
|
+
|
|
996
|
+
const intrinsic = svgIntrinsic(root)
|
|
997
|
+
let nw = natural && natural.w > 0 ? natural.w : intrinsic.w
|
|
998
|
+
let nh = natural && natural.h > 0 ? natural.h : intrinsic.h
|
|
999
|
+
const notes = [...audit.notes]
|
|
1000
|
+
if (!(nw > 0) || !(nh > 0)) {
|
|
1001
|
+
if (intrinsic.viewBox && intrinsic.viewBox[2] > 0 && intrinsic.viewBox[3] > 0) {
|
|
1002
|
+
notes.push('svg-intrinsic-from-viewbox')
|
|
1003
|
+
if (box && box.w > 0) {
|
|
1004
|
+
// Ratio only: the default sizing algorithm fits the ratio into the box.
|
|
1005
|
+
nw = box.w
|
|
1006
|
+
nh = nw / (intrinsic.viewBox[2] / intrinsic.viewBox[3])
|
|
1007
|
+
warnings.push(`image: the SVG at ${src} has no intrinsic size; its viewBox ratio was fitted to the element box.`)
|
|
1008
|
+
} else {
|
|
1009
|
+
// No box to fit into — a background layer's area is resolved against
|
|
1010
|
+
// this asset, not the other way round. The viewBox is kept verbatim so
|
|
1011
|
+
// the RATIO, which is the only intrinsic thing the file has, survives.
|
|
1012
|
+
nw = intrinsic.viewBox[2]
|
|
1013
|
+
nh = intrinsic.viewBox[3]
|
|
1014
|
+
warnings.push(`image: the SVG at ${src} has no intrinsic size; its viewBox is used as the natural size, so only its ratio is trustworthy.`)
|
|
1015
|
+
}
|
|
1016
|
+
} else {
|
|
1017
|
+
nw = DEFAULT_OBJECT_SIZE.w
|
|
1018
|
+
nh = DEFAULT_OBJECT_SIZE.h
|
|
1019
|
+
notes.push('svg-default-object-size')
|
|
1020
|
+
warnings.push(`image: the SVG at ${src} declares neither size nor viewBox; the 300x150 default object size was assumed.`)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Self-describing: the consumer gets a viewport in the same units the crop is in.
|
|
1025
|
+
root.setAttribute('width', String(nw))
|
|
1026
|
+
root.setAttribute('height', String(nh))
|
|
1027
|
+
if (!root.getAttribute('xmlns')) root.setAttribute('xmlns', SVG_NS)
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
asset: {
|
|
1031
|
+
id,
|
|
1032
|
+
kind: 'vector',
|
|
1033
|
+
mime: 'image/svg+xml',
|
|
1034
|
+
w: nw,
|
|
1035
|
+
h: nh,
|
|
1036
|
+
viewBox: intrinsic.viewBox,
|
|
1037
|
+
src,
|
|
1038
|
+
idPrefix: `${id}__`,
|
|
1039
|
+
markup: (stripActiveContent(root, warnings), new XMLSerializer().serializeToString(root)),
|
|
1040
|
+
data: null,
|
|
1041
|
+
},
|
|
1042
|
+
natural: { w: nw, h: nh },
|
|
1043
|
+
grade: audit.grade,
|
|
1044
|
+
notes,
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* `<img>` whose source is an SVG: fetched, namespaced, kept as vector.
|
|
1050
|
+
* @returns {Promise<{asset:object, natural:{w:number,h:number}, grade:string, notes:string[]}|null>}
|
|
1051
|
+
*/
|
|
1052
|
+
async function svgAssetFromBytes (bytes, src, el, ctx, box, warnings, timeout) {
|
|
1053
|
+
const natural = el && el.naturalWidth > 0 && el.naturalHeight > 0
|
|
1054
|
+
? { w: el.naturalWidth, h: el.naturalHeight }
|
|
1055
|
+
: null
|
|
1056
|
+
const collected = await svgMarkupAsset(
|
|
1057
|
+
bytes, src, nextAssetId(ctx, seedFromSrc(src, 'svg')), ctx, natural, box, warnings, timeout)
|
|
1058
|
+
if (!collected) warnings.push(`image: the SVG at ${src} did not parse; falling back to raster.`)
|
|
1059
|
+
return collected
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* A `background-image: url(...)` layer whose bytes are an SVG, as markup.
|
|
1064
|
+
*
|
|
1065
|
+
* The counterpart of `svgAssetFromBytes` for the OTHER source of SVG in a
|
|
1066
|
+
* document, and the one that was missing: `background-image` went to
|
|
1067
|
+
* `<image href="data:image/svg+xml…">`, which Figma renders as nothing at all
|
|
1068
|
+
* (FIGMA_FINDINGS §6 — the fixture's header came out flat grey, which was the
|
|
1069
|
+
* legibility gradient over an empty node).
|
|
1070
|
+
*
|
|
1071
|
+
* The id is minted by the caller because a background asset is keyed and cached
|
|
1072
|
+
* by URL there; `natural` is the intrinsic size the caller measured, since that
|
|
1073
|
+
* is the number `background-size` resolved `cover`/`contain` against and the
|
|
1074
|
+
* placed rect the emitter gets is only consistent with the markup if both come
|
|
1075
|
+
* from it.
|
|
1076
|
+
*
|
|
1077
|
+
* @param {Uint8Array} bytes
|
|
1078
|
+
* @param {string} url
|
|
1079
|
+
* @param {string} id
|
|
1080
|
+
* @param {object} ctx
|
|
1081
|
+
* @param {{w:number,h:number}|null} natural
|
|
1082
|
+
* @param {string[]} warnings bare prose, in the same voice as the `<img>` path
|
|
1083
|
+
* so identical messages classify identically wherever they were collected
|
|
1084
|
+
* @param {number} [timeout]
|
|
1085
|
+
* @returns {Promise<{asset:object, natural:{w:number,h:number}, grade:string, notes:string[]}|null>}
|
|
1086
|
+
*/
|
|
1087
|
+
export async function svgBackgroundAsset (bytes, url, id, ctx, natural, warnings, timeout = 8000) {
|
|
1088
|
+
return svgMarkupAsset(bytes, url, id, ctx, natural, null, warnings, timeout)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Do these bytes hold an SVG? The `Content-Type` is the answer when the server
|
|
1093
|
+
* gave a real one, the extension is a hint, and the bytes themselves are the
|
|
1094
|
+
* only thing that cannot lie — a data URI with no mime, a CDN that serves
|
|
1095
|
+
* `application/octet-stream`, and `?v=2` on the end of a `.svg` all reach here.
|
|
1096
|
+
*
|
|
1097
|
+
* @param {Uint8Array|null} bytes
|
|
1098
|
+
* @param {string} url
|
|
1099
|
+
* @param {string} [mime] as declared by the transfer, when there is one
|
|
1100
|
+
*/
|
|
1101
|
+
export function looksLikeSvg (bytes, url, mime) {
|
|
1102
|
+
if (typeof mime === 'string' && /^image\/svg\+xml\b/i.test(mime.trim())) return true
|
|
1103
|
+
if (/^data:image\/svg\+xml[;,]/i.test(String(url || ''))) return true
|
|
1104
|
+
if (bytes && bytes.length && sniffMime(bytes) === 'image/svg+xml') return true
|
|
1105
|
+
return !bytes && /\.svgz?(?:$|[?#])/i.test(String(url || ''))
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* `<img>` with a raster source. The original bytes when we can get them (no
|
|
1110
|
+
* re-encode, metadata intact), a canvas readback otherwise, a bare reference
|
|
1111
|
+
* when the canvas is tainted.
|
|
1112
|
+
*
|
|
1113
|
+
* @param {{bytes: Uint8Array, mime: string}|null} fetched already downloaded by
|
|
1114
|
+
* the SVG probe, so the file is not requested twice.
|
|
1115
|
+
*/
|
|
1116
|
+
async function bitmapAsset (el, src, ctx, warnings, fetched) {
|
|
1117
|
+
const nw = el.naturalWidth || 0
|
|
1118
|
+
const nh = el.naturalHeight || 0
|
|
1119
|
+
const id = nextAssetId(ctx, seedFromSrc(src, 'img'))
|
|
1120
|
+
const notes = []
|
|
1121
|
+
const big = Number.isFinite(ctx.maxAssetBytes) ? ctx.maxAssetBytes : BIG_ASSET_BYTES
|
|
1122
|
+
|
|
1123
|
+
// EXIF orientation is already applied to naturalWidth/Height and to
|
|
1124
|
+
// drawImage, but NOT to the bytes — so an oriented JPEG has to go through the
|
|
1125
|
+
// canvas, where the rotation is baked in and the two agree again.
|
|
1126
|
+
const orientation = fetched && /jpeg|jpg/.test(fetched.mime) ? jpegOrientation(fetched.bytes) : 1
|
|
1127
|
+
|
|
1128
|
+
if (fetched && orientation === 1) {
|
|
1129
|
+
if (fetched.bytes.length > big) {
|
|
1130
|
+
warnings.push(`image: ${src} is ${(fetched.bytes.length / 1048576).toFixed(1)} MB and is embedded verbatim.`)
|
|
1131
|
+
notes.push('large-asset')
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
asset: {
|
|
1135
|
+
id,
|
|
1136
|
+
kind: 'bitmap',
|
|
1137
|
+
mime: fetched.mime,
|
|
1138
|
+
w: nw || 0,
|
|
1139
|
+
h: nh || 0,
|
|
1140
|
+
src,
|
|
1141
|
+
origin: 'original-bytes',
|
|
1142
|
+
data: `data:${fetched.mime};base64,${bytesToBase64(fetched.bytes)}`,
|
|
1143
|
+
},
|
|
1144
|
+
natural: { w: nw, h: nh },
|
|
1145
|
+
grade: 'E',
|
|
1146
|
+
notes,
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (el.complete && nw > 0 && nh > 0) {
|
|
1151
|
+
try {
|
|
1152
|
+
const data = readback(el, nw, nh)
|
|
1153
|
+
if (orientation !== 1) {
|
|
1154
|
+
warnings.push(`image: ${src} carries EXIF orientation ${orientation}; it was re-encoded upright, which drops its EXIF and colour profile.`)
|
|
1155
|
+
notes.push('exif-orientation-baked')
|
|
1156
|
+
} else {
|
|
1157
|
+
warnings.push(`image: the bytes of ${src} could not be read (CORS); it was re-encoded to PNG through a canvas, dropping metadata and colour profile.`)
|
|
1158
|
+
notes.push('re-encoded-png')
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
asset: { id, kind: 'bitmap', mime: 'image/png', w: nw, h: nh, src, origin: 'canvas', data },
|
|
1162
|
+
natural: { w: nw, h: nh },
|
|
1163
|
+
grade: 'A',
|
|
1164
|
+
notes,
|
|
1165
|
+
}
|
|
1166
|
+
} catch {
|
|
1167
|
+
warnings.push(`image: ${src} taints the canvas and its bytes are not fetchable; the asset is referenced by URL with no data.`)
|
|
1168
|
+
notes.push('tainted-canvas')
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
warnings.push(`image: ${src} is not decoded (broken or still loading); the asset is referenced by URL with no data.`)
|
|
1172
|
+
notes.push('not-decoded')
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return {
|
|
1176
|
+
asset: { id, kind: 'bitmap', mime: 'application/octet-stream', w: nw, h: nh, src, origin: 'unresolved', data: null },
|
|
1177
|
+
natural: { w: nw, h: nh },
|
|
1178
|
+
grade: 'O',
|
|
1179
|
+
notes,
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* The rendering context a `<canvas>` already has. Probing '2d' first is
|
|
1185
|
+
* deliberate: it returns null on a WebGL canvas without disturbing it, whereas
|
|
1186
|
+
* probing 'webgl' first would CREATE a WebGL context on a never-drawn canvas and
|
|
1187
|
+
* lock the page out of its own 2d context.
|
|
1188
|
+
*/
|
|
1189
|
+
function canvasKind (el) {
|
|
1190
|
+
try {
|
|
1191
|
+
if (el.getContext('2d')) return { kind: '2d', attributes: null }
|
|
1192
|
+
} catch { /* fall through to the GL probe */ }
|
|
1193
|
+
for (const type of ['webgl2', 'webgl', 'experimental-webgl']) {
|
|
1194
|
+
try {
|
|
1195
|
+
const gl = el.getContext(type)
|
|
1196
|
+
if (gl) return { kind: 'webgl', attributes: gl.getContextAttributes ? gl.getContextAttributes() : null }
|
|
1197
|
+
} catch { /* try the next type */ }
|
|
1198
|
+
}
|
|
1199
|
+
return { kind: 'unknown', attributes: null }
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function canvasAsset (el, ctx, box, warnings) {
|
|
1203
|
+
const w = el.width || 0
|
|
1204
|
+
const h = el.height || 0
|
|
1205
|
+
const id = nextAssetId(ctx, 'canvas')
|
|
1206
|
+
const natural = { w, h }
|
|
1207
|
+
const base = { id, kind: 'bitmap', mime: 'image/png', w, h, src: '', origin: 'canvas' }
|
|
1208
|
+
// The backing store is the resolution we read back at; `scale` records how it
|
|
1209
|
+
// relates to the CSS box so a consumer knows it is not a 1:1 asset.
|
|
1210
|
+
if (box.w > 0 && w > 0) base.scale = w / box.w
|
|
1211
|
+
|
|
1212
|
+
if (!w || !h) {
|
|
1213
|
+
warnings.push('image: the <canvas> has a zero-sized backing store; nothing was captured.')
|
|
1214
|
+
return { asset: { ...base, data: null }, natural, grade: 'O', notes: ['canvas-empty'] }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const { kind, attributes } = canvasKind(el)
|
|
1218
|
+
if (kind === 'webgl' && attributes && attributes.preserveDrawingBuffer === false) {
|
|
1219
|
+
warnings.push('image: the <canvas> is WebGL without preserveDrawingBuffer; its buffer is undefined outside the drawing frame, so no pixels were read.')
|
|
1220
|
+
return { asset: { ...base, data: null }, natural, grade: 'O', notes: ['webgl-no-preserve-drawing-buffer'] }
|
|
1221
|
+
}
|
|
1222
|
+
if (kind === 'unknown') {
|
|
1223
|
+
warnings.push('image: the <canvas> uses a rendering context we cannot identify; the readback may be blank.')
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
try {
|
|
1227
|
+
const data = el.toDataURL('image/png')
|
|
1228
|
+
return { asset: { ...base, data }, natural, grade: 'R', notes: ['canvas-readback'] }
|
|
1229
|
+
} catch {
|
|
1230
|
+
warnings.push('image: the <canvas> is tainted by cross-origin content; its pixels cannot be read and a placeholder was emitted.')
|
|
1231
|
+
return { asset: { ...base, data: null }, natural, grade: 'O', notes: ['tainted-canvas'] }
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function videoAsset (el, ctx, warnings, timeout) {
|
|
1236
|
+
const w = el.videoWidth || 0
|
|
1237
|
+
const h = el.videoHeight || 0
|
|
1238
|
+
const src = el.currentSrc || el.src || ''
|
|
1239
|
+
const id = nextAssetId(ctx, seedFromSrc(src, 'video'))
|
|
1240
|
+
const base = { id, kind: 'bitmap', mime: 'image/png', w, h, src, origin: 'video-frame' }
|
|
1241
|
+
|
|
1242
|
+
if (w > 0 && h > 0 && el.readyState >= 2) {
|
|
1243
|
+
try {
|
|
1244
|
+
const data = readback(el, w, h)
|
|
1245
|
+
warnings.push('image: the <video> was captured as its current frame; playback is not represented.')
|
|
1246
|
+
return { asset: { ...base, data }, natural: { w, h }, grade: 'R', notes: ['video-current-frame'] }
|
|
1247
|
+
} catch {
|
|
1248
|
+
warnings.push('image: the <video> is cross-origin and taints the canvas; its current frame could not be read.')
|
|
1249
|
+
}
|
|
1250
|
+
} else {
|
|
1251
|
+
warnings.push('image: the <video> has no decoded frame yet (readyState < HAVE_CURRENT_DATA).')
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const poster = el.getAttribute('poster')
|
|
1255
|
+
if (poster) {
|
|
1256
|
+
let absolute = poster
|
|
1257
|
+
try {
|
|
1258
|
+
absolute = new URL(poster, typeof location !== 'undefined' ? location.href : undefined).href
|
|
1259
|
+
} catch { /* keep the raw value */ }
|
|
1260
|
+
const fetched = await fetchBytes(absolute, timeout)
|
|
1261
|
+
if (fetched) {
|
|
1262
|
+
warnings.push('image: the <video> poster was embedded in place of a frame.')
|
|
1263
|
+
return {
|
|
1264
|
+
asset: { ...base, mime: fetched.mime, src: absolute, origin: 'video-poster', data: `data:${fetched.mime};base64,${bytesToBase64(fetched.bytes)}` },
|
|
1265
|
+
natural: { w, h },
|
|
1266
|
+
grade: 'R',
|
|
1267
|
+
notes: ['video-poster'],
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
warnings.push('image: the <video> was emitted as an empty placeholder.')
|
|
1273
|
+
return { asset: { ...base, data: null }, natural: { w, h }, grade: 'O', notes: ['video-placeholder'] }
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The tree an inline SVG's `#id` references were resolved in. The capture root
|
|
1278
|
+
* is the honest answer — it is the tree that was laid out and painted, and it
|
|
1279
|
+
* carries snapdom's hoisted `inline-defs-container` — but a caller may hand us a
|
|
1280
|
+
* detached element, so the element's own root is the fallback.
|
|
1281
|
+
*/
|
|
1282
|
+
function resolutionRoot (el, ctx) {
|
|
1283
|
+
const root = ctx && ctx.rootEl
|
|
1284
|
+
if (root && root.nodeType === 1 && typeof root.contains === 'function' && root.contains(el)) return root
|
|
1285
|
+
const own = typeof el.getRootNode === 'function' ? el.getRootNode() : null
|
|
1286
|
+
if (own && own.nodeType === 9) return own.documentElement || el
|
|
1287
|
+
if (own && own.nodeType === 1) return own
|
|
1288
|
+
return el
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** Inline `<svg>`: serialized, styles baked, external defs pulled in, ids namespaced. */
|
|
1292
|
+
async function inlineSvgAsset (el, ctx, box, warnings, timeout) {
|
|
1293
|
+
const id = nextAssetId(ctx, 'svg')
|
|
1294
|
+
const clone = el.cloneNode(true)
|
|
1295
|
+
const audit = auditSvgFeatures(el, warnings)
|
|
1296
|
+
bakeComputedStyle(el, clone, warnings)
|
|
1297
|
+
namespaceSvg(clone, `${id}__`, warnings, { live: el, index: buildIdIndex(resolutionRoot(el, ctx)) })
|
|
1298
|
+
await embedNestedImages(clone, timeout, warnings)
|
|
1299
|
+
|
|
1300
|
+
const intrinsic = svgIntrinsic(el)
|
|
1301
|
+
// The element box IS the SVG viewport, so that is the natural size; anything
|
|
1302
|
+
// inside is mapped by the markup's own viewBox and preserveAspectRatio.
|
|
1303
|
+
const w = box.w > 0 ? box.w : (intrinsic.w || (intrinsic.viewBox ? intrinsic.viewBox[2] : DEFAULT_OBJECT_SIZE.w))
|
|
1304
|
+
const h = box.h > 0 ? box.h : (intrinsic.h || (intrinsic.viewBox ? intrinsic.viewBox[3] : DEFAULT_OBJECT_SIZE.h))
|
|
1305
|
+
if (!(box.w > 0) || !(box.h > 0)) {
|
|
1306
|
+
warnings.push(`image: the inline <svg> has no laid-out box (${box.w}x${box.h}); ` +
|
|
1307
|
+
`${intrinsic.w || intrinsic.viewBox ? 'its own markup' : `the ${DEFAULT_OBJECT_SIZE.w}x${DEFAULT_OBJECT_SIZE.h} default object size`} was used as the viewport instead.`)
|
|
1308
|
+
}
|
|
1309
|
+
clone.setAttribute('width', String(w))
|
|
1310
|
+
clone.setAttribute('height', String(h))
|
|
1311
|
+
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', SVG_NS)
|
|
1312
|
+
|
|
1313
|
+
return {
|
|
1314
|
+
asset: {
|
|
1315
|
+
id,
|
|
1316
|
+
kind: 'vector',
|
|
1317
|
+
mime: 'image/svg+xml',
|
|
1318
|
+
w,
|
|
1319
|
+
h,
|
|
1320
|
+
viewBox: intrinsic.viewBox,
|
|
1321
|
+
src: '',
|
|
1322
|
+
idPrefix: `${id}__`,
|
|
1323
|
+
markup: (stripActiveContent(clone, warnings), new XMLSerializer().serializeToString(clone)),
|
|
1324
|
+
data: null,
|
|
1325
|
+
},
|
|
1326
|
+
natural: { w, h },
|
|
1327
|
+
grade: audit.grade,
|
|
1328
|
+
notes: [...audit.notes, 'inline-svg'],
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ——— entry point ———
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Collects the replaced content of an element as an asset plus the image paint
|
|
1336
|
+
* that places it.
|
|
1337
|
+
*
|
|
1338
|
+
* The paint carries `crop` (in the asset's own source pixels) and `transform`
|
|
1339
|
+
* (a 2x3 matrix mapping the unit square of that crop onto the node's BORDER
|
|
1340
|
+
* box, in px), which between them express all five `object-fit` values and any
|
|
1341
|
+
* `object-position`. `fill.grade` is the fidelity grade for the node and
|
|
1342
|
+
* `fill.notes` says why it is not `E`; `asset.kind === 'vector'` means the
|
|
1343
|
+
* caller must emit a `vector` node, not an `image` one.
|
|
1344
|
+
*
|
|
1345
|
+
* @param {Element} el `<img>`, `<canvas>`, `<video>` or an inline `<svg>`
|
|
1346
|
+
* @param {CSSStyleDeclaration} cs already-computed style for `el`
|
|
1347
|
+
* @param {CollectCtx} [ctx]
|
|
1348
|
+
* @returns {Promise<{asset: Asset, fill: object, warnings: string[]}|null>}
|
|
1349
|
+
* null when the element carries no replaced content at all — which is not a
|
|
1350
|
+
* degradation, so it is the one case with nothing to report.
|
|
1351
|
+
*/
|
|
1352
|
+
export async function collectImage (el, cs, ctx = {}) {
|
|
1353
|
+
const tag = el && el.tagName ? el.tagName.toUpperCase() : ''
|
|
1354
|
+
if (!IMAGE_TAGS.has(tag)) return null
|
|
1355
|
+
|
|
1356
|
+
const warnings = []
|
|
1357
|
+
const notes = []
|
|
1358
|
+
const box = contentBox(el, cs)
|
|
1359
|
+
const timeout = Number.isFinite(ctx.fetchTimeout) ? ctx.fetchTimeout : 8000
|
|
1360
|
+
const cache = assetCache(ctx)
|
|
1361
|
+
|
|
1362
|
+
let collected = null
|
|
1363
|
+
let fit = (cs && cs.objectFit) || 'fill'
|
|
1364
|
+
|
|
1365
|
+
if (tag === 'IMG') {
|
|
1366
|
+
const src = el.currentSrc || el.src || ''
|
|
1367
|
+
if (!src) {
|
|
1368
|
+
warnings.push('image: <img> has no resolved source; an empty placeholder was emitted.')
|
|
1369
|
+
collected = {
|
|
1370
|
+
asset: { id: nextAssetId(ctx, 'img'), kind: 'bitmap', mime: 'application/octet-stream', w: 0, h: 0, src: '', origin: 'unresolved', data: null },
|
|
1371
|
+
natural: { w: 0, h: 0 },
|
|
1372
|
+
grade: 'O',
|
|
1373
|
+
notes: ['no-source'],
|
|
1374
|
+
}
|
|
1375
|
+
} else {
|
|
1376
|
+
const cached = cache.get(src)
|
|
1377
|
+
if (cached) {
|
|
1378
|
+
// The grade travels with the asset, so its reason has to travel too: a
|
|
1379
|
+
// second node graded 'O' with no diagnostic is exactly what the schema
|
|
1380
|
+
// rejects, and rightly.
|
|
1381
|
+
warnings.push(...cached.warnings)
|
|
1382
|
+
collected = { ...cached, notes: [...cached.notes, 'asset-reused'] }
|
|
1383
|
+
} else {
|
|
1384
|
+
const before = warnings.length
|
|
1385
|
+
// srcset means `src` and `currentSrc` disagree; the paint has to describe
|
|
1386
|
+
// the file the browser actually chose.
|
|
1387
|
+
if (el.srcset || (el.parentElement && el.parentElement.tagName === 'PICTURE')) {
|
|
1388
|
+
notes.push('srcset-currentsrc')
|
|
1389
|
+
}
|
|
1390
|
+
const fetched = await fetchBytes(src, timeout)
|
|
1391
|
+
// One spelling of "is this an SVG?" for both sources: a `Content-Type`
|
|
1392
|
+
// when the transfer gave a real one, the bytes when it did not (a CDN
|
|
1393
|
+
// that answers `application/octet-stream` used to send an icon down the
|
|
1394
|
+
// raster road), and the extension only when there are no bytes to read.
|
|
1395
|
+
const looksSvg = looksLikeSvg(fetched && fetched.bytes, src, fetched && fetched.mime)
|
|
1396
|
+
|
|
1397
|
+
if (fetched && looksSvg) {
|
|
1398
|
+
collected = await svgAssetFromBytes(fetched.bytes, src, el, ctx, box, warnings, timeout)
|
|
1399
|
+
} else if (looksSvg) {
|
|
1400
|
+
warnings.push(`image: ${src} is an SVG whose bytes are unreachable (CORS); it was rasterized instead of vectorized.`)
|
|
1401
|
+
}
|
|
1402
|
+
// Falls through when the SVG did not parse, which is a raster job again.
|
|
1403
|
+
if (!collected) collected = await bitmapAsset(el, src, ctx, warnings, looksSvg ? null : fetched)
|
|
1404
|
+
collected = { ...collected, warnings: warnings.slice(before) }
|
|
1405
|
+
cache.set(src, collected)
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
} else if (tag === 'CANVAS') {
|
|
1409
|
+
collected = canvasAsset(el, ctx, box, warnings)
|
|
1410
|
+
} else if (tag === 'VIDEO') {
|
|
1411
|
+
collected = await videoAsset(el, ctx, warnings, timeout)
|
|
1412
|
+
} else {
|
|
1413
|
+
collected = await inlineSvgAsset(el, ctx, box, warnings, timeout)
|
|
1414
|
+
// object-fit does not apply to an inline SVG root: its own viewBox and
|
|
1415
|
+
// preserveAspectRatio already did that job inside the markup.
|
|
1416
|
+
if (fit && fit !== 'fill') {
|
|
1417
|
+
warnings.push(`image: object-fit:${fit} on an inline <svg> is ignored by CSS; the element box was used verbatim.`)
|
|
1418
|
+
}
|
|
1419
|
+
fit = 'fill'
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
notes.push(...collected.notes)
|
|
1423
|
+
|
|
1424
|
+
let position = parseObjectPosition(cs && cs.objectPosition)
|
|
1425
|
+
if (!position) {
|
|
1426
|
+
warnings.push(`image: object-position "${cs && cs.objectPosition}" could not be resolved; it was centred.`)
|
|
1427
|
+
notes.push('object-position-fallback')
|
|
1428
|
+
position = { x: (free) => free / 2, y: (free) => free / 2 }
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const placed = placeObject(fit, position, collected.natural, box)
|
|
1432
|
+
if (!placed.visible && box.w > 0 && box.h > 0) {
|
|
1433
|
+
warnings.push('image: object-position pushes the whole image outside the content box; nothing of it is painted.')
|
|
1434
|
+
notes.push('object-out-of-box')
|
|
1435
|
+
} else if (placed.overflowed) {
|
|
1436
|
+
notes.push(`object-fit:${fit}-cropped`)
|
|
1437
|
+
}
|
|
1438
|
+
if (cs && cs.imageRendering && cs.imageRendering !== 'auto') {
|
|
1439
|
+
notes.push(`image-rendering:${cs.imageRendering}`)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const fill = {
|
|
1443
|
+
type: 'image',
|
|
1444
|
+
asset: collected.asset.id,
|
|
1445
|
+
fit: 'stretch',
|
|
1446
|
+
crop: placed.crop,
|
|
1447
|
+
transform: placed.transform,
|
|
1448
|
+
clipBox: 'content',
|
|
1449
|
+
opacity: 1,
|
|
1450
|
+
grade: collected.grade,
|
|
1451
|
+
notes,
|
|
1452
|
+
srcCss: { objectFit: fit, objectPosition: (cs && cs.objectPosition) || '50% 50%' },
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
return { asset: collected.asset, fill, warnings }
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
export default collectImage
|