@zumer/snapdom-plugins 3.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. 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