@zumer/snapdom-plugins 3.0.0 → 4.0.1
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 +21 -0
- package/README.md +50 -16
- 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 +22 -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,2910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snapdom Pro — vector capture, as a snapdom PLUGIN. One capture in, one SVD
|
|
3
|
+
* document out:
|
|
4
|
+
*
|
|
5
|
+
* const result = await snapdom(el, { plugins: [vector()] })
|
|
6
|
+
* const doc = await result.toVector() // the SVD document
|
|
7
|
+
* const svg = await result.toVectorSvg() // the flat SVG
|
|
8
|
+
* const h2d = await result.toVectorFigmaClipboard() // Figma's paste envelope
|
|
9
|
+
*
|
|
10
|
+
* The third one is a PARALLEL backend off the same clone, not a consumer of the
|
|
11
|
+
* SVD: it writes Figma's own `h2d` clipboard payload — the computed DOM, which
|
|
12
|
+
* Figma converts on paste — and touches neither `emit/svg-flat.js` nor the
|
|
13
|
+
* document. See `emit/h2d.js` for what that format costs.
|
|
14
|
+
*
|
|
15
|
+
* `vector()` at the bottom of this file is the whole public surface. There is no
|
|
16
|
+
* second way in — no loose `toVector(element)` that makes its own snapdom call —
|
|
17
|
+
* because two ways in means two behaviours to keep in step, and the one that
|
|
18
|
+
* hid the `snapdom()` call also hid its options from the person making it.
|
|
19
|
+
*
|
|
20
|
+
* This module is an ORCHESTRATOR and nothing else. It owns four things and
|
|
21
|
+
* delegates everything else:
|
|
22
|
+
*
|
|
23
|
+
* 1. the capture context — the root rect, the memoised `getComputedStyle`, the
|
|
24
|
+
* font-metric cache, the id counter, the shared measuring canvas and the
|
|
25
|
+
* warnings array every collector writes into;
|
|
26
|
+
* 2. the traversal — `buildPaintTree` decides structure and paint order, and
|
|
27
|
+
* each emitted node is handed to the collector its type calls for, with the
|
|
28
|
+
* asynchronous ones (images, background bytes) batched through `Promise.all`
|
|
29
|
+
* rather than serialised;
|
|
30
|
+
* 3. the bookkeeping no collector can do because each sees one element at a
|
|
31
|
+
* time — asset ids, text-style ids deduplicated across blocks, the fonts the
|
|
32
|
+
* page supplies and this build does not embed, and turning every `warnings`
|
|
33
|
+
* entry into a `diagnostics` entry with a fidelity grade. The GRADE belongs
|
|
34
|
+
* to the collector: a warning may arrive as `{code, grade, message}` and
|
|
35
|
+
* that grade is used verbatim. Guessing one from the prose is a fallback for
|
|
36
|
+
* the collectors that still speak only prose, and it is marked as such;
|
|
37
|
+
* 4. the refusal to ship a broken document: `validate` runs on our own output
|
|
38
|
+
* and a failure throws, with the schema's errors in the message.
|
|
39
|
+
*
|
|
40
|
+
* The rule that outranks the rest of this file: **nothing degrades silently.**
|
|
41
|
+
* A collector warning always becomes a diagnostic AND a grade on the node it
|
|
42
|
+
* came from. A node graded other than `E` that no diagnostic names is a bug the
|
|
43
|
+
* validator rejects — and it is right to.
|
|
44
|
+
*
|
|
45
|
+
* **The tree that gets walked is snapdom's CLONE, not the live DOM.** That is the
|
|
46
|
+
* founding decision of this engine and `packages/CONTRACT.md` carries the
|
|
47
|
+
* evidence: the plugin's `beforeRender` hook takes the resolved clone out of the
|
|
48
|
+
* capture, `adapters/snapdom.js` mounts it offscreen and hands it over already
|
|
49
|
+
* laid out, with pseudo-elements materialised
|
|
50
|
+
* into real elements, shadow DOM flattened, icon fonts resolved and images inlined
|
|
51
|
+
* past CORS. Membership in the tree is solved before this file runs; only geometry
|
|
52
|
+
* and paint are ours.
|
|
53
|
+
*
|
|
54
|
+
* Two consequences the rest of this file depends on:
|
|
55
|
+
*
|
|
56
|
+
* - **`capture.selector` and `capture.url` describe the ORIGINAL element.** The
|
|
57
|
+
* user wants to know what was captured, not where we mounted a copy of it.
|
|
58
|
+
* - **the mount is verified, every time.** `capture.drift` is the mounted
|
|
59
|
+
* clone's boxes measured against the live boxes they came from; it lands in
|
|
60
|
+
* `report.drift` and, past 1px, in a diagnostic. Every coordinate this engine
|
|
61
|
+
* emits is measured on the clone, so an unverified mount is an unverified
|
|
62
|
+
* document.
|
|
63
|
+
*
|
|
64
|
+
* The clone is transient: the export unmounts it in a `finally`, and drops the
|
|
65
|
+
* `el` back-reference each node carries so the document does not pin a detached
|
|
66
|
+
* copy of the user's subtree in memory for as long as it lives.
|
|
67
|
+
*/
|
|
68
|
+
import { emptyDocument, validate } from '../svd/index.js'
|
|
69
|
+
import { handoffFrom, mountClone } from './adapters/snapdom.js'
|
|
70
|
+
import { buildPaintTree } from './paint.js'
|
|
71
|
+
import { collectBox, resolveLayerRect } from './collect/box.js'
|
|
72
|
+
import { collectText } from './collect/text.js'
|
|
73
|
+
import { collectImage, svgBackgroundAsset, looksLikeSvg } from './collect/image.js'
|
|
74
|
+
import { collectMathML } from './collect/mathml.js'
|
|
75
|
+
import { collectFonts } from './collect/font.js'
|
|
76
|
+
import { svdToSvg, rasterFilterPlan } from './emit/svg-flat.js'
|
|
77
|
+
import { svdToFigma } from './emit/figma-json.js'
|
|
78
|
+
import { cloneToH2d, h2dEnvelope, h2dClipboardItem, H2D_PROPS, H2D_VERSION } from './emit/h2d.js'
|
|
79
|
+
import { findFallbackTargets, svdSliceToDataUri } from './emit/h2d-vector.js'
|
|
80
|
+
|
|
81
|
+
export { svdToSvg, svdToFigma }
|
|
82
|
+
export { cloneToH2d, h2dEnvelope, h2dClipboardItem, H2D_PROPS, H2D_VERSION }
|
|
83
|
+
|
|
84
|
+
/** Fidelity grades, worst-wins. Mirrors `FIDELITY` in @zumer/svd and `paint.js`. */
|
|
85
|
+
const GRADE_RANK = { E: 0, A: 1, C: 2, R: 3, RH: 3, O: 4 }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* CSS `mix-blend-mode` keywords the schema accepts. Duplicated from the
|
|
89
|
+
* validator deliberately: an unknown keyword has to become a diagnostic here,
|
|
90
|
+
* not a validation failure that takes a whole capture down.
|
|
91
|
+
*/
|
|
92
|
+
const BLEND_MODES = new Set([
|
|
93
|
+
'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten',
|
|
94
|
+
'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference',
|
|
95
|
+
'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'plus-lighter',
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
/** Past this a background asset is worth reporting; it is still embedded. */
|
|
99
|
+
const BIG_ASSET_BYTES = 8 * 1024 * 1024
|
|
100
|
+
|
|
101
|
+
const EXT_MIME = {
|
|
102
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
103
|
+
webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', svgz: 'image/svg+xml',
|
|
104
|
+
bmp: 'image/bmp', ico: 'image/x-icon',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Warnings that report WORK DONE rather than fidelity lost. They still become
|
|
109
|
+
* diagnostics — the document says what happened — but they must not degrade the
|
|
110
|
+
* node, or a capture that succeeded would grade itself down for succeeding.
|
|
111
|
+
*/
|
|
112
|
+
const INFORMATIONAL = [
|
|
113
|
+
/have been copied into it\.$/,
|
|
114
|
+
/were inlined as data URIs\.$/,
|
|
115
|
+
// CSS itself ignores the declaration, so the capture matches the page exactly.
|
|
116
|
+
// Nothing was lost; the reader still gets told why the property had no effect.
|
|
117
|
+
/is ignored by CSS/,
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Structural approximations: the cases where the contract says the CALLER
|
|
122
|
+
* composes several primitives and v0 does not. Each gets its own code so the
|
|
123
|
+
* count is readable in the grouped log and greppable in `diagnostics`.
|
|
124
|
+
*/
|
|
125
|
+
const COMPOSE_CASES = [
|
|
126
|
+
{
|
|
127
|
+
test: /border sides differ/,
|
|
128
|
+
code: 'compose.border-sides',
|
|
129
|
+
note: 'per-side border colors ride on one stroke (the first visible side wins); ' +
|
|
130
|
+
'one shape per side is not composed in v0',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
test: /background layers use more than one clip box/,
|
|
134
|
+
code: 'compose.background-clip',
|
|
135
|
+
note: 'the background layers stay on one node; splitting them per clip box is not done in v0',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
test: /outline is emitted as a sibling stroke/,
|
|
139
|
+
code: 'compose.outline',
|
|
140
|
+
note: 'the outline stays on this node as an outside-aligned stroke instead of becoming a ' +
|
|
141
|
+
'sibling painted last in the parent z-order',
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Loss keywords, in priority order: the first match decides the grade.
|
|
147
|
+
*
|
|
148
|
+
* LAST RESORT ONLY. A collector that knows what it lost says so with a grade
|
|
149
|
+
* (see `classify`), and this list is what is left for the ones that still return
|
|
150
|
+
* bare prose. Reading a grade off English is guessing, so the patterns are
|
|
151
|
+
* anchored to what a message can only mean:
|
|
152
|
+
*
|
|
153
|
+
* - `R` needs an actual rasterization VERB. The old pattern matched a bare
|
|
154
|
+
* `/canvas/i`, which graded *"the canvas rejected the font shorthand … metrics
|
|
155
|
+
* were measured with a generic sans-serif"* as R — a wholly vector text node
|
|
156
|
+
* counted as raster in `report.grades`, against a product whose claim is zero
|
|
157
|
+
* raster. A measuring canvas is not a rasterized node.
|
|
158
|
+
* - `O` needs something to be gone. `is ignored` used to be here and matched
|
|
159
|
+
* *"object-fit … is ignored by CSS"*, where nothing is missing at all — it is
|
|
160
|
+
* informational now.
|
|
161
|
+
*/
|
|
162
|
+
const GRADE_PATTERNS = [
|
|
163
|
+
{
|
|
164
|
+
// Every message that really means "pixels now" carries one of these verbs.
|
|
165
|
+
// The word `canvas` is deliberately absent: this engine MEASURES with a
|
|
166
|
+
// canvas (font metrics, colour resolution) far more often than it rasterizes
|
|
167
|
+
// with one, and those two must never share a grade.
|
|
168
|
+
test: /\brasteriz|\bre-encoded\b|\breadback\b|\bread back\b|current frame/i,
|
|
169
|
+
grade: 'R',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
test: /dropped|placeholder|not applied|not represented|not emitted|not captured|omitted|left out|could not be read|could not be fetched|unreachable|no pixels|with no data/i,
|
|
173
|
+
grade: 'O',
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
const SEVERITY_OF = { E: 'info', A: 'info', C: 'info', R: 'warn', RH: 'warn', O: 'warn' }
|
|
178
|
+
|
|
179
|
+
/** The three the schema accepts; anything else a collector offers is ignored. */
|
|
180
|
+
const SEVERITIES = new Set(['info', 'warn', 'error'])
|
|
181
|
+
|
|
182
|
+
const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 1)
|
|
183
|
+
|
|
184
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
|
|
185
|
+
|
|
186
|
+
/** Worst grade wins: a node that is both approximate and omitted is omitted. */
|
|
187
|
+
function degradeNode (node, grade, note) {
|
|
188
|
+
if (!node || !hasOwn(GRADE_RANK, grade)) return
|
|
189
|
+
if (!node.fidelity) node.fidelity = { grade: 'E', notes: [] }
|
|
190
|
+
if (!Array.isArray(node.fidelity.notes)) node.fidelity.notes = []
|
|
191
|
+
if (GRADE_RANK[grade] > GRADE_RANK[node.fidelity.grade]) node.fidelity.grade = grade
|
|
192
|
+
if (note && !node.fidelity.notes.includes(note)) node.fidelity.notes.push(note)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function escapeIdent (value) {
|
|
196
|
+
if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') return CSS.escape(value)
|
|
197
|
+
return String(value).replace(/([^\w-])/g, '\\$1')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A selector that finds the capture root again in the page it came from. It
|
|
202
|
+
* stops at the first ancestor carrying an id, because everything above that is
|
|
203
|
+
* noise to whoever reads `capture.selector`.
|
|
204
|
+
*/
|
|
205
|
+
function cssSelector (el) {
|
|
206
|
+
if (!el || el.nodeType !== 1) return ''
|
|
207
|
+
const parts = []
|
|
208
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
209
|
+
if (n.id) {
|
|
210
|
+
parts.unshift(`#${escapeIdent(n.id)}`)
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
const tag = n.tagName.toLowerCase()
|
|
214
|
+
const parent = n.parentElement
|
|
215
|
+
if (!parent) {
|
|
216
|
+
parts.unshift(tag)
|
|
217
|
+
break
|
|
218
|
+
}
|
|
219
|
+
let i = 1
|
|
220
|
+
for (const sib of parent.children) {
|
|
221
|
+
if (sib === n) break
|
|
222
|
+
i++
|
|
223
|
+
}
|
|
224
|
+
parts.unshift(`${tag}:nth-child(${i})`)
|
|
225
|
+
}
|
|
226
|
+
return parts.join(' > ')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Ids are issued at CALL time, not at registration time: `collectImage` runs
|
|
231
|
+
* concurrently, so two sources with the same file name would both be handed
|
|
232
|
+
* `a_logo` if the set were only updated once the asset came back. Same
|
|
233
|
+
* convention as `collect/image.js`, whose helper is not exported.
|
|
234
|
+
*/
|
|
235
|
+
function nextAssetId (ctx, seed) {
|
|
236
|
+
const used = ctx.assets || {}
|
|
237
|
+
const taken = (id) => ctx.assetIds.has(id) || hasOwn(used, id)
|
|
238
|
+
let id = `a_${seed}`
|
|
239
|
+
let n = 1
|
|
240
|
+
while (taken(id)) id = `a_${seed}_${n++}`
|
|
241
|
+
ctx.assetIds.add(id)
|
|
242
|
+
return id
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function seedFromUrl (url, fallback) {
|
|
246
|
+
if (!url || url.startsWith('data:')) return fallback
|
|
247
|
+
const clean = String(url).split(/[?#]/)[0]
|
|
248
|
+
const name = clean.slice(clean.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '')
|
|
249
|
+
const safe = name.replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24)
|
|
250
|
+
return safe || fallback
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ——— the capture context ———
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @typedef {object} CollectCtx
|
|
257
|
+
* @property {'design'|'replica'} mode
|
|
258
|
+
* @property {(el: Element) => CSSStyleDeclaration} styleOf memoised computed styles
|
|
259
|
+
* @property {{x:number,y:number,w:number,h:number}} rootRect viewport rect of the capture root
|
|
260
|
+
* @property {Map<string,{ascent:number,descent:number,ok:boolean}>} metrics by full font string
|
|
261
|
+
* @property {CanvasRenderingContext2D|null} measureCtx one reusable 2D context
|
|
262
|
+
* @property {(prefix: string) => string} nextId the capture's id counter
|
|
263
|
+
* @property {Array<{code:string,node:(string|undefined),message:string}>} warnings
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {Element} rootEl the MOUNTED CLONE root — everything the collectors
|
|
268
|
+
* read comes off this tree, including `rootRect`, which is the origin `abs` is
|
|
269
|
+
* expressed against. It is the clone's rect and not the live one on purpose:
|
|
270
|
+
* both sides are relative to their own root, so the offscreen mount offset
|
|
271
|
+
* cancels, and mixing the two would offset the whole document by ~99999px.
|
|
272
|
+
* @param {object} options
|
|
273
|
+
*/
|
|
274
|
+
function makeCtx (rootEl, options) {
|
|
275
|
+
const rect = rootEl.getBoundingClientRect()
|
|
276
|
+
const cache = new WeakMap()
|
|
277
|
+
const canvas = typeof document !== 'undefined' ? document.createElement('canvas') : null
|
|
278
|
+
const measureCtx = canvas ? canvas.getContext('2d') : null
|
|
279
|
+
let seq = 0
|
|
280
|
+
|
|
281
|
+
const ctx = {
|
|
282
|
+
mode: options.mode === 'replica' ? 'replica' : 'design',
|
|
283
|
+
rootEl,
|
|
284
|
+
rootRect: { x: rect.left, y: rect.top, w: rect.width, h: rect.height },
|
|
285
|
+
|
|
286
|
+
styleOf (el) {
|
|
287
|
+
let cs = cache.get(el)
|
|
288
|
+
if (!cs) {
|
|
289
|
+
cs = getComputedStyle(el)
|
|
290
|
+
cache.set(el, cs)
|
|
291
|
+
}
|
|
292
|
+
return cs
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
metrics: new Map(),
|
|
296
|
+
measureCtx,
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Ascent/descent for one EXACT font string, cached on that string and never
|
|
300
|
+
* on the family: Chromium rounds `fontBoundingBox*` to integers per size, so
|
|
301
|
+
* a 12px ascent scaled down from a 16px measurement can be a whole pixel out.
|
|
302
|
+
* `ok:false` means the context refused the shorthand and kept its previous
|
|
303
|
+
* font — the numbers describe some other font and must not be used.
|
|
304
|
+
*/
|
|
305
|
+
measureFont (fontCss) {
|
|
306
|
+
const hit = ctx.metrics.get(fontCss)
|
|
307
|
+
if (hit) return hit
|
|
308
|
+
let out = { ascent: 0, descent: 0, ok: false }
|
|
309
|
+
if (measureCtx) {
|
|
310
|
+
measureCtx.font = fontCss
|
|
311
|
+
if (measureCtx.font) {
|
|
312
|
+
const m = measureCtx.measureText('Hxg')
|
|
313
|
+
out = {
|
|
314
|
+
ascent: Number.isFinite(m.fontBoundingBoxAscent) ? m.fontBoundingBoxAscent : 0,
|
|
315
|
+
descent: Number.isFinite(m.fontBoundingBoxDescent) ? m.fontBoundingBoxDescent : 0,
|
|
316
|
+
ok: true,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
ctx.metrics.set(fontCss, out)
|
|
321
|
+
return out
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
/** The capture's one id counter, for anything not content-addressed. */
|
|
325
|
+
nextId: (prefix) => `${prefix}_${seq++}`,
|
|
326
|
+
|
|
327
|
+
warnings: [],
|
|
328
|
+
|
|
329
|
+
// Filled in once the document exists; `collect/image.js` reads both.
|
|
330
|
+
assets: null,
|
|
331
|
+
assetIds: new Set(),
|
|
332
|
+
assetCache: new Map(),
|
|
333
|
+
|
|
334
|
+
maxNodes: Number.isFinite(options.maxNodes) ? options.maxNodes : 20000,
|
|
335
|
+
maxDepth: Number.isFinite(options.maxDepth) ? options.maxDepth : 256,
|
|
336
|
+
maxDiagnosticsPerCode: Number.isFinite(options.maxDiagnosticsPerCode)
|
|
337
|
+
? options.maxDiagnosticsPerCode
|
|
338
|
+
: 100,
|
|
339
|
+
exclude: options.exclude,
|
|
340
|
+
fetchTimeout: Number.isFinite(options.fetchTimeout) ? options.fetchTimeout : 8000,
|
|
341
|
+
maxAssetBytes: Number.isFinite(options.maxAssetBytes) ? options.maxAssetBytes : BIG_ASSET_BYTES,
|
|
342
|
+
|
|
343
|
+
// OFF, and not an option. `paint.js` uses this to probe `getComputedStyle(el,
|
|
344
|
+
// '::before')` and declare generated content out of scope — which was true
|
|
345
|
+
// when the walk was over the live DOM and is false now: snapdom materialises
|
|
346
|
+
// `::before`/`::after`/`::first-letter` into real elements with real boxes,
|
|
347
|
+
// so they arrive as ordinary nodes and are collected like any other. Probing
|
|
348
|
+
// the clone would report a loss that did not happen, on top of the node that
|
|
349
|
+
// represents the thing it claims is missing.
|
|
350
|
+
//
|
|
351
|
+
// What the clone does NOT materialise says so under its own code:
|
|
352
|
+
// `B4.list-marker` for a `::marker` (snapdom keeps it as a native marker, and
|
|
353
|
+
// a native marker has no element for the collectors to read), and
|
|
354
|
+
// `capture.pseudo-not-materialised` below for a painting pseudo that snapdom
|
|
355
|
+
// was asked for and did not produce.
|
|
356
|
+
pseudo: false,
|
|
357
|
+
}
|
|
358
|
+
return ctx
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ——— warnings -> diagnostics + fidelity ———
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* One collector warning becomes exactly one diagnostic and one grade.
|
|
365
|
+
*
|
|
366
|
+
* **The grade belongs to the collector.** It is the only place that knows
|
|
367
|
+
* whether a message means "I approximated this" or "this is gone", so a warning
|
|
368
|
+
* may be `{code, grade, message}` and that grade is used verbatim. Bare strings
|
|
369
|
+
* are still accepted — `box` and `text` still speak prose — and only those fall
|
|
370
|
+
* through to `GRADE_PATTERNS`, which is a heuristic and says so. The fallback is
|
|
371
|
+
* deliberately pessimistic: an unrecognised warning grades `A`, so an
|
|
372
|
+
* approximation nobody anticipated is still declared.
|
|
373
|
+
*
|
|
374
|
+
* `image` sets a grade on the fill as well, and `applyImage` declares that
|
|
375
|
+
* separately; worst-wins makes the two agree.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} source collector name, for the default code
|
|
378
|
+
* @param {string} message
|
|
379
|
+
* @param {string|null} declared the collector's own grade, when it gave one
|
|
380
|
+
* @param {string|null} code the collector's own code, when it gave one
|
|
381
|
+
*/
|
|
382
|
+
function classify (source, message, declared, code) {
|
|
383
|
+
const fallback = code || `collect.${source}`
|
|
384
|
+
for (const rule of COMPOSE_CASES) {
|
|
385
|
+
if (rule.test.test(message)) {
|
|
386
|
+
return { code: code || rule.code, grade: declared || 'A', note: rule.note }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (declared) return { code: fallback, grade: declared, note: null }
|
|
390
|
+
for (const rule of INFORMATIONAL) {
|
|
391
|
+
if (rule.test(message)) return { code: fallback, grade: 'E', note: null }
|
|
392
|
+
}
|
|
393
|
+
for (const rule of GRADE_PATTERNS) {
|
|
394
|
+
if (rule.test.test(message)) return { code: fallback, grade: rule.grade, note: null }
|
|
395
|
+
}
|
|
396
|
+
return { code: fallback, grade: 'A', note: null }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Record one degradation: a `diagnostics` entry, a `warnings` entry for the
|
|
401
|
+
* grouped log, and the grade on the node. Every path that loses something goes
|
|
402
|
+
* through here — there is no other way to write a diagnostic in this file.
|
|
403
|
+
*/
|
|
404
|
+
function report (st, nodeId, code, grade, message, severity) {
|
|
405
|
+
const entry = { code, severity: severity || SEVERITY_OF[grade] || 'warn', message }
|
|
406
|
+
if (nodeId) entry.node = nodeId
|
|
407
|
+
st.doc.diagnostics.push(entry)
|
|
408
|
+
st.ctx.warnings.push({ code, node: nodeId, message })
|
|
409
|
+
if (nodeId && grade !== 'E') degradeNode(st.doc.nodes[nodeId], grade, message)
|
|
410
|
+
return entry
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* One collector warning, in either shape the collectors speak.
|
|
415
|
+
*
|
|
416
|
+
* @param {object} st
|
|
417
|
+
* @param {string|undefined} nodeId
|
|
418
|
+
* @param {string} source
|
|
419
|
+
* @param {string|{code?:string, grade?:string, message:string, severity?:string}} w
|
|
420
|
+
*/
|
|
421
|
+
function warn (st, nodeId, source, w) {
|
|
422
|
+
const object = w && typeof w === 'object'
|
|
423
|
+
const message = object ? w.message : w
|
|
424
|
+
if (typeof message !== 'string' || !message) return
|
|
425
|
+
const declared = object && hasOwn(GRADE_RANK, w.grade) ? w.grade : null
|
|
426
|
+
const code = object && typeof w.code === 'string' && w.code ? w.code : null
|
|
427
|
+
const severity = object && SEVERITIES.has(w.severity) ? w.severity : undefined
|
|
428
|
+
const classified = classify(source, message, declared, code)
|
|
429
|
+
const entry = report(st, nodeId, classified.code, classified.grade, message, severity)
|
|
430
|
+
const note = classified.note
|
|
431
|
+
if (note) entry.note = note
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* `warnings` hanging off a collected paint, stroke or effect.
|
|
436
|
+
*
|
|
437
|
+
* `parseColor()` returns `{rgba, srcCss, warnings}` and every approximation it
|
|
438
|
+
* measured lives in that array: an out-of-gamut colour mapped by chroma
|
|
439
|
+
* reduction WITH its ΔEOK computed, or a translucent `oklch()`/`lab()`/`color()`
|
|
440
|
+
* resolved through a canvas pixel and un-premultiplied from an 8-bit store.
|
|
441
|
+
* Tailwind v4 puts `oklch()` on every colour in its palette, so dropping this
|
|
442
|
+
* channel means a whole page of approximated colour exported at grade `E` with
|
|
443
|
+
* the error already calculated and thrown away.
|
|
444
|
+
*
|
|
445
|
+
* The collectors forward it by hanging it on the object they built; this is the
|
|
446
|
+
* receiving end. The array is consumed — it is a channel, not a document field.
|
|
447
|
+
*/
|
|
448
|
+
function foldWarnings (st, id, source, carriers) {
|
|
449
|
+
for (const carrier of carriers || []) {
|
|
450
|
+
if (!carrier || typeof carrier !== 'object') continue
|
|
451
|
+
const list = carrier.warnings
|
|
452
|
+
if (!Array.isArray(list)) continue
|
|
453
|
+
delete carrier.warnings
|
|
454
|
+
for (const w of list) warn(st, id, source, w)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ——— box ———
|
|
459
|
+
|
|
460
|
+
const isWeightList = (v) => Array.isArray(v) && v.length === 4 && v.every(Number.isFinite)
|
|
461
|
+
|
|
462
|
+
function normalizeStrokes (strokes, warnings) {
|
|
463
|
+
const out = []
|
|
464
|
+
for (const stroke of strokes || []) {
|
|
465
|
+
if (!stroke) continue
|
|
466
|
+
const next = { ...stroke }
|
|
467
|
+
// `collect/box.js` still speaks per-side `weights`; the schema's field is
|
|
468
|
+
// `weight`, scalar when the four sides agree so a backend without per-side
|
|
469
|
+
// support does not have to compare four numbers to find out they are the
|
|
470
|
+
// same. A stroke that already arrives schema-shaped is left alone, so this
|
|
471
|
+
// becomes a no-op the day the collector emits `weight` itself — and the
|
|
472
|
+
// collector's spelling is REMOVED either way, because two emitters currently
|
|
473
|
+
// carry a `|| stroke.weights` fallback for a private dialect that has no
|
|
474
|
+
// business leaving the collector.
|
|
475
|
+
if (!Number.isFinite(next.weight) && !isWeightList(next.weight)) {
|
|
476
|
+
if (isWeightList(next.weights)) {
|
|
477
|
+
const [t, r, b, l] = next.weights
|
|
478
|
+
next.weight = (t === r && r === b && b === l) ? t : next.weights.slice()
|
|
479
|
+
} else {
|
|
480
|
+
warnings.push('a stroke arrived with no weight and was dropped.')
|
|
481
|
+
continue
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
delete next.weights
|
|
485
|
+
if (!next.align) next.align = 'inside'
|
|
486
|
+
out.push(next)
|
|
487
|
+
}
|
|
488
|
+
return out
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Everything `collectBox` found, mapped onto the node's schema fields. */
|
|
492
|
+
function applyBox (st, id, node, box) {
|
|
493
|
+
const extra = []
|
|
494
|
+
node.fills = Array.isArray(box.fills) ? box.fills : []
|
|
495
|
+
node.strokes = normalizeStrokes(box.strokes, extra)
|
|
496
|
+
node.effects = Array.isArray(box.effects) ? box.effects : []
|
|
497
|
+
node.clip = box.clip || null
|
|
498
|
+
// `clip-path` is not `clip`: the overflow clip only cuts descendants, this one
|
|
499
|
+
// also cuts the node's own background, border and shadow. It travels as its
|
|
500
|
+
// own field for that reason.
|
|
501
|
+
if (box.clipShape) node.clipShape = box.clipShape
|
|
502
|
+
if (box.radii) node.radii = box.radii
|
|
503
|
+
node.opacity = clamp01(box.opacity)
|
|
504
|
+
if (typeof box.blend === 'string' && BLEND_MODES.has(box.blend)) node.blend = box.blend
|
|
505
|
+
// `collectBox` reads the `mask-image` layers with the same grammar backgrounds
|
|
506
|
+
// use, and then had nowhere to put them: the schema's `mask` is a REFERENCE to
|
|
507
|
+
// another node, and a paint is not a node. That gap is why a masked box came
|
|
508
|
+
// out as a full rectangle with a warning attached. The layers are parked here
|
|
509
|
+
// and `wireMasks` turns each set into the node the reference needs, once the
|
|
510
|
+
// whole tree exists and a free id can be chosen.
|
|
511
|
+
if (box.mask && Array.isArray(box.mask.layers) && box.mask.layers.length) {
|
|
512
|
+
st.masks.push({ id, mask: box.mask })
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
for (const message of box.warnings || []) warn(st, id, 'box', message)
|
|
516
|
+
for (const message of extra) warn(st, id, 'box', message)
|
|
517
|
+
// Whatever `parseColor`/`parseShadows` measured and hung on the paint itself.
|
|
518
|
+
foldWarnings(st, id, 'box', node.fills)
|
|
519
|
+
foldWarnings(st, id, 'box', node.strokes)
|
|
520
|
+
foldWarnings(st, id, 'box', node.strokes.map((s) => s && s.paint))
|
|
521
|
+
foldWarnings(st, id, 'box', node.effects)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ——— text ———
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Text styles are deduplicated across the whole document: `collectText` numbers
|
|
528
|
+
* them `ts_0..ts_n` per BLOCK, so keeping its ids would have every block after
|
|
529
|
+
* the first overwrite the styles of the one before it.
|
|
530
|
+
*
|
|
531
|
+
* The shape changes too — the schema's key is `size`, not `fontSize` — which is
|
|
532
|
+
* the one place the orchestrator translates rather than forwards.
|
|
533
|
+
*
|
|
534
|
+
* `warnings` on the style (the colour `parseColor` could only approximate, for
|
|
535
|
+
* one) are collected into `sink` rather than stored: they belong to the node
|
|
536
|
+
* that uses the style, and the caller is the only one holding its id.
|
|
537
|
+
*/
|
|
538
|
+
function registerTextStyle (st, style, sink) {
|
|
539
|
+
if (Array.isArray(style.warnings)) for (const w of style.warnings) sink.push(w)
|
|
540
|
+
const svd = {
|
|
541
|
+
// `family` is the family that PAINTED, resolved out of the stack by
|
|
542
|
+
// `collect/text.js`, and `stack` carries it in front of the page's own list —
|
|
543
|
+
// a font picker reads one name, never a stack. The rest of the resolution
|
|
544
|
+
// travels with it because a consumer has to know how good the name is:
|
|
545
|
+
// `cssFamily` is what `@font-face` calls the same face, `styleName` the
|
|
546
|
+
// subfamily its binary claims (`SemiBold`), `referenceable` false when the
|
|
547
|
+
// winner is a system keyword no picker can be handed.
|
|
548
|
+
family: style.family,
|
|
549
|
+
stack: style.fontFamily,
|
|
550
|
+
cssFamily: style.cssFamily || style.family,
|
|
551
|
+
stackCss: style.stackCss || style.fontFamily,
|
|
552
|
+
styleName: style.resolvedStyleName || null,
|
|
553
|
+
resolvedFrom: style.resolvedFrom || null,
|
|
554
|
+
genericClass: style.genericClass || null,
|
|
555
|
+
referenceable: style.referenceable !== false,
|
|
556
|
+
webfont: style.webfont === true,
|
|
557
|
+
size: style.fontSize,
|
|
558
|
+
weight: style.fontWeight,
|
|
559
|
+
italic: /^(italic|oblique)/.test(style.fontStyle || ''),
|
|
560
|
+
fontStyle: style.fontStyle,
|
|
561
|
+
stretch: style.fontStretch,
|
|
562
|
+
variant: style.fontVariant,
|
|
563
|
+
features: style.fontFeatureSettings,
|
|
564
|
+
variations: style.fontVariationSettings,
|
|
565
|
+
letterSpacing: style.letterSpacing,
|
|
566
|
+
wordSpacing: style.wordSpacing,
|
|
567
|
+
lineHeight: style.lineHeight,
|
|
568
|
+
case: style.textTransform,
|
|
569
|
+
fills: [{ type: 'solid', color: style.color, srcCss: style.colorCss }],
|
|
570
|
+
decoration: style.decorations && style.decorations.length ? style.decorations : null,
|
|
571
|
+
strokeWidth: style.strokeWidth,
|
|
572
|
+
strokeColor: style.strokeColor || null,
|
|
573
|
+
ascent: style.ascent,
|
|
574
|
+
descent: style.descent,
|
|
575
|
+
fontCss: style.fontCss,
|
|
576
|
+
}
|
|
577
|
+
const key = JSON.stringify(svd)
|
|
578
|
+
let id = st.textStyleIds.get(key)
|
|
579
|
+
if (!id) {
|
|
580
|
+
id = `ts_${st.textStyleIds.size}`
|
|
581
|
+
st.textStyleIds.set(key, id)
|
|
582
|
+
st.doc.styles.text[id] = svd
|
|
583
|
+
}
|
|
584
|
+
return id
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* A `text` node whose block turned out to paint no characters. It cannot stay a
|
|
589
|
+
* text node — the schema requires non-empty `characters`, and rightly: a text
|
|
590
|
+
* node with nothing in it is a hole in the export nobody would notice. So it
|
|
591
|
+
* becomes a plain box if it paints anything, and disappears if it does not.
|
|
592
|
+
*
|
|
593
|
+
* When it disappears the diagnostic must NOT name it: `applyDrops` deletes every
|
|
594
|
+
* entry naming a node that no longer exists, so an entry naming this one would
|
|
595
|
+
* take the only record of the block with it. The element's DOM path goes in the
|
|
596
|
+
* message instead, which is what someone reading the report needs anyway.
|
|
597
|
+
*/
|
|
598
|
+
function demoteTextNode (st, id, node, why, code = 'collect.text-empty') {
|
|
599
|
+
const paints = (node.fills && node.fills.length) || (node.strokes && node.strokes.length) ||
|
|
600
|
+
(node.effects && node.effects.length) || node.clip || node.transform ||
|
|
601
|
+
(node.children && node.children.length)
|
|
602
|
+
const where = (node.source && node.source.path) || node.name || id
|
|
603
|
+
if (!paints && id !== st.doc.root) {
|
|
604
|
+
st.dropped.add(id)
|
|
605
|
+
report(st, null, code, 'E', `${where}: ${why}; the text node was pruned.`,
|
|
606
|
+
code === 'collect.text-empty' ? 'info' : 'warn')
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
node.type = node.fills && node.fills.length ? 'frame' : 'group'
|
|
610
|
+
node.text = null
|
|
611
|
+
report(st, id, code, 'O', `${why}; the node keeps its box and loses its text.`)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function collectTextInto (st, id, node, cs) {
|
|
615
|
+
let out = null
|
|
616
|
+
try {
|
|
617
|
+
out = collectText(node.el, st.ctx)
|
|
618
|
+
} catch (error) {
|
|
619
|
+
// The reason travels INSIDE the demotion, not alongside it: a separate entry
|
|
620
|
+
// naming this node is deleted with the node on the pruning path, and a text
|
|
621
|
+
// block that vanished because a collector threw is the last thing that may
|
|
622
|
+
// go unrecorded.
|
|
623
|
+
demoteTextNode(st, id, node,
|
|
624
|
+
`the text of this block could not be measured — collectText threw: ${error && error.message}`,
|
|
625
|
+
'collect.text-failed')
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
if (!out || !out.characters) {
|
|
629
|
+
demoteTextNode(st, id, node, 'no painted characters were found in this block')
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const styleIds = {}
|
|
634
|
+
const styleWarnings = []
|
|
635
|
+
for (const local of Object.keys(out.styles || {})) {
|
|
636
|
+
styleIds[local] = registerTextStyle(st, out.styles[local], styleWarnings)
|
|
637
|
+
}
|
|
638
|
+
for (const w of styleWarnings) warn(st, id, 'text', w)
|
|
639
|
+
const runs = []
|
|
640
|
+
for (const run of out.runs || []) {
|
|
641
|
+
const styleId = styleIds[run.style]
|
|
642
|
+
if (!styleId) continue
|
|
643
|
+
// `dy` is the measured offset of a raised/lowered run from its line's
|
|
644
|
+
// baseline (sup/sub, `vertical-align`). It is a MEASUREMENT, not a hint: the
|
|
645
|
+
// fold that put the run on this line already removed it from the baseline,
|
|
646
|
+
// so dropping it here is the difference between H₂O and H2O.
|
|
647
|
+
runs.push({
|
|
648
|
+
start: run.start,
|
|
649
|
+
end: run.end,
|
|
650
|
+
style: styleId,
|
|
651
|
+
href: run.href || null,
|
|
652
|
+
dy: Number.isFinite(run.dy) ? run.dy : 0,
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
if (!runs.length) {
|
|
656
|
+
demoteTextNode(st, id, node, 'the block has characters but no styled runs')
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The schema requires a positive pitch: a backend that steps by it would stack
|
|
661
|
+
// every line on one baseline otherwise. A block with a single visual line has
|
|
662
|
+
// no baseline difference to measure, and `collectText` already says so in its
|
|
663
|
+
// own warnings — this only covers the case where even the fallback was zero.
|
|
664
|
+
let pitch = out.pitch
|
|
665
|
+
if (!(pitch > 0)) {
|
|
666
|
+
const first = out.lines && out.lines[0]
|
|
667
|
+
const box = first ? (first.asc || 0) + (first.desc || 0) : 0
|
|
668
|
+
pitch = box > 0 ? box : (st.doc.styles.text[runs[0].style].size || 1)
|
|
669
|
+
warn(st, id, 'text',
|
|
670
|
+
`the measured baseline pitch was ${out.pitch}; it was replaced by ${pitch.toFixed(2)}px ` +
|
|
671
|
+
'taken from the line box, which is not a measurement of the line spacing.')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const style = st.doc.styles.text[runs[0].style]
|
|
675
|
+
node.type = 'text'
|
|
676
|
+
node.text = {
|
|
677
|
+
characters: out.characters,
|
|
678
|
+
sourceText: out.sourceText,
|
|
679
|
+
lines: out.lines,
|
|
680
|
+
runs,
|
|
681
|
+
pitch,
|
|
682
|
+
align: out.align,
|
|
683
|
+
direction: out.direction,
|
|
684
|
+
letterSpacing: style.letterSpacing,
|
|
685
|
+
wordSpacing: style.wordSpacing,
|
|
686
|
+
// Visual lines are joined with LSEP, so the block is ONE paragraph by
|
|
687
|
+
// construction and nothing downstream may add paragraph spacing to it.
|
|
688
|
+
paragraphSpacing: 0,
|
|
689
|
+
}
|
|
690
|
+
for (const message of out.warnings || []) warn(st, id, 'text', message)
|
|
691
|
+
|
|
692
|
+
// `text-shadow` paints the glyphs, and this node IS the glyphs — but nothing
|
|
693
|
+
// on the text path collects it and `node.effects` would follow the box
|
|
694
|
+
// silhouette even if it did. `collect/box.js` covers the elements that also
|
|
695
|
+
// carry box paint (it hands the parsed shadows over on `textEffects`, which
|
|
696
|
+
// `applyBox` still drops) and says in as many words that the block whose only
|
|
697
|
+
// content is text "never reaches collectBox" — that half is here. Without
|
|
698
|
+
// this, a shadowed heading exports with no effect and no entry at all: the
|
|
699
|
+
// export looks clean and is wrong, which is the one outcome this format exists
|
|
700
|
+
// to prevent.
|
|
701
|
+
const shadow = cs && cs.textShadow
|
|
702
|
+
if (shadow && shadow !== 'none') {
|
|
703
|
+
report(st, id, 'collect.text-shadow-dropped', 'O',
|
|
704
|
+
`text-shadow "${shadow}" paints these glyphs and is not emitted: no backend here carries a ` +
|
|
705
|
+
'shadow on text, so the letters export flat.')
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ——— replaced content ———
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The image paint goes ON TOP of whatever `collectBox` found: in CSS the
|
|
713
|
+
* replaced content paints above the element's own backgrounds and below its
|
|
714
|
+
* borders, and `fills` runs back to front.
|
|
715
|
+
*/
|
|
716
|
+
function applyImage (st, id, node, result) {
|
|
717
|
+
const { asset, fill, warnings } = result
|
|
718
|
+
st.doc.assets[asset.id] = asset
|
|
719
|
+
node.fills = [...(node.fills || []), fill]
|
|
720
|
+
if (asset.kind === 'vector' && node.type === 'image') node.type = 'vector'
|
|
721
|
+
|
|
722
|
+
// A vector asset carries `markup`, never `data` — the whole point of the SVG
|
|
723
|
+
// path is that it is NOT bytes. The image fill can only ever be an `<image>`
|
|
724
|
+
// pointing at a data URI, so leaving it in `fills` bought one
|
|
725
|
+
// `emit.svg.image-no-data` per icon and painted nothing at all. `node.svg` is
|
|
726
|
+
// the field both emitters already read (`emit/svg-flat.js` `emitVector`,
|
|
727
|
+
// `emit/figma-json.js:1270`); nothing ever wrote it, which is why every inline
|
|
728
|
+
// SVG in `demo/challenges.html#fx-svg` came out as an empty group.
|
|
729
|
+
//
|
|
730
|
+
// The placement travels with it because the markup is in the asset's own
|
|
731
|
+
// natural units and the fill is what knows where those land: `crop` is the
|
|
732
|
+
// source-pixel window `object-fit`/`object-position` selected and `transform`
|
|
733
|
+
// maps the unit square of that crop onto the node's box. For an inline `<svg>`
|
|
734
|
+
// that reduces to a translate; for `<img src=*.svg>` it is what makes
|
|
735
|
+
// `object-fit: cover` crop instead of stretch.
|
|
736
|
+
if (asset.kind === 'vector' && typeof asset.markup === 'string' && asset.markup) {
|
|
737
|
+
node.svg = asset.markup
|
|
738
|
+
node.svgPlacement = { crop: fill.crop, transform: fill.transform, clipBox: fill.clipBox }
|
|
739
|
+
// The namespace the collector minted travels with the markup: one asset can
|
|
740
|
+
// reach two nodes (assets are cached by URL, so one icon used twice is one
|
|
741
|
+
// asset used twice), and the emitter has to rename the second copy's ids.
|
|
742
|
+
if (typeof asset.idPrefix === 'string' && asset.idPrefix) node.svgPrefix = asset.idPrefix
|
|
743
|
+
node.fills = node.fills.filter((f) => f !== fill)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// MathML's SVG contains native text without SVD text nodes. Register its
|
|
747
|
+
// painted font uses so they share the ordinary font embedding/diagnostics
|
|
748
|
+
// pass, including unicode-range checks. These references stay in the
|
|
749
|
+
// assembly state; the resulting font assets are all the emitter needs.
|
|
750
|
+
for (const style of result.fontStyles || []) {
|
|
751
|
+
const sink = []
|
|
752
|
+
const sid = registerTextStyle(st, style, sink)
|
|
753
|
+
st.markupTextUses.push({ nodeId: id, style: sid, characters: style.characters || '' })
|
|
754
|
+
for (const warning of sink) warn(st, id, 'text', warning)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
for (const message of warnings || []) warn(st, id, 'image', message)
|
|
758
|
+
foldWarnings(st, id, 'image', [fill])
|
|
759
|
+
|
|
760
|
+
// The grade travels on the fill, and some of the paths that set it emit no
|
|
761
|
+
// warning at all (a clean canvas readback is grade R and says nothing). An
|
|
762
|
+
// undeclared grade is exactly what the schema rejects, so it is declared here.
|
|
763
|
+
if (fill.grade && fill.grade !== 'E') {
|
|
764
|
+
const notes = (fill.notes || []).join(', ')
|
|
765
|
+
report(st, id, 'collect.image-grade', fill.grade,
|
|
766
|
+
`replaced content graded ${fill.grade}${notes ? ` (${notes})` : ''}.`)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ——— background images ———
|
|
771
|
+
|
|
772
|
+
function bytesToBase64 (bytes) {
|
|
773
|
+
let binary = ''
|
|
774
|
+
const CHUNK = 0x8000
|
|
775
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
776
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))
|
|
777
|
+
}
|
|
778
|
+
return btoa(binary)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function absoluteUrl (url) {
|
|
782
|
+
try {
|
|
783
|
+
return new URL(url, typeof location !== 'undefined' ? location.href : undefined).href
|
|
784
|
+
} catch {
|
|
785
|
+
return url
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function mimeFromUrl (url) {
|
|
790
|
+
const m = /\.([a-z0-9]+)(?:$|[?#])/i.exec(String(url).split(/[?#]/)[0])
|
|
791
|
+
return (m && EXT_MIME[m[1].toLowerCase()]) || ''
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function fetchBytes (url, timeout) {
|
|
795
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
796
|
+
const timer = controller && timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
|
|
797
|
+
try {
|
|
798
|
+
// force-cache: the page already downloaded this image, so the HTTP cache
|
|
799
|
+
// should answer without a second trip to the network.
|
|
800
|
+
const res = await fetch(url, {
|
|
801
|
+
signal: controller ? controller.signal : undefined,
|
|
802
|
+
cache: 'force-cache',
|
|
803
|
+
})
|
|
804
|
+
if (!res.ok) return null
|
|
805
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
806
|
+
const declared = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
807
|
+
const mime = declared && declared !== 'application/octet-stream'
|
|
808
|
+
? declared
|
|
809
|
+
: (mimeFromUrl(url) || 'application/octet-stream')
|
|
810
|
+
return { bytes, mime }
|
|
811
|
+
} catch {
|
|
812
|
+
return null
|
|
813
|
+
} finally {
|
|
814
|
+
if (timer) clearTimeout(timer)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Intrinsic size of an image source. It is loaded rather than fetched because
|
|
820
|
+
* `naturalWidth/Height` are the only numbers that agree with what CSS sized the
|
|
821
|
+
* layer against, and a cross-origin image still reports them — only its PIXELS
|
|
822
|
+
* are unreadable.
|
|
823
|
+
*/
|
|
824
|
+
function intrinsicSize (src) {
|
|
825
|
+
return new Promise((resolve) => {
|
|
826
|
+
if (typeof Image !== 'function' || !src) {
|
|
827
|
+
resolve(null)
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
const img = new Image()
|
|
831
|
+
let settled = false
|
|
832
|
+
const done = (value) => {
|
|
833
|
+
if (settled) return
|
|
834
|
+
settled = true
|
|
835
|
+
resolve(value)
|
|
836
|
+
}
|
|
837
|
+
img.onload = () => done({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 })
|
|
838
|
+
img.onerror = () => done(null)
|
|
839
|
+
img.src = src
|
|
840
|
+
if (img.complete && img.naturalWidth) done({ w: img.naturalWidth, h: img.naturalHeight })
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* One `background-image: url(...)` layer as an embedded asset.
|
|
846
|
+
*
|
|
847
|
+
* Invariant 7 says nothing may be external, so the bytes are fetched and
|
|
848
|
+
* inlined. When they are unreachable — a cross-origin CDN with no CORS header is
|
|
849
|
+
* the common case — the asset keeps its URL, carries no data, and says so: an
|
|
850
|
+
* export that silently references a URL nobody downstream can read is exactly
|
|
851
|
+
* the failure this format exists to make visible.
|
|
852
|
+
*
|
|
853
|
+
* **An SVG is collected as markup, not as bytes.** `collectImage` has always
|
|
854
|
+
* done that for `<img src="*.svg">`; a background layer took the other road and
|
|
855
|
+
* became `<image href="data:image/svg+xml…">`, which Figma draws as nothing at
|
|
856
|
+
* all (FIGMA_FINDINGS §6). Same rule, both roads, one asset shape: `kind:
|
|
857
|
+
* 'vector'` with `markup` and `idPrefix`.
|
|
858
|
+
*
|
|
859
|
+
* @returns {Promise<{asset:object, warnings:object[], svgWarnings:string[],
|
|
860
|
+
* intrinsic:{w:number,h:number}|null}>} `svgWarnings` is bare prose in
|
|
861
|
+
* `collectImage`'s own voice, so the caller classifies it the way the same
|
|
862
|
+
* sentence is classified when an `<img>` produces it.
|
|
863
|
+
*/
|
|
864
|
+
async function loadBackgroundAsset (url, st) {
|
|
865
|
+
const ctx = st.ctx
|
|
866
|
+
const id = nextAssetId(ctx, seedFromUrl(url, 'bg'))
|
|
867
|
+
const warnings = []
|
|
868
|
+
const svgWarnings = []
|
|
869
|
+
const fetched = await fetchBytes(url, ctx.fetchTimeout)
|
|
870
|
+
|
|
871
|
+
const mime = fetched ? fetched.mime : (mimeFromUrl(url) || 'application/octet-stream')
|
|
872
|
+
let data = null
|
|
873
|
+
if (fetched) {
|
|
874
|
+
if (fetched.bytes.length > ctx.maxAssetBytes) {
|
|
875
|
+
warnings.push({
|
|
876
|
+
grade: 'E',
|
|
877
|
+
message: `background-image ${url} is ${(fetched.bytes.length / 1048576).toFixed(1)} MB and is embedded verbatim.`,
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
data = `data:${mime};base64,${bytesToBase64(fetched.bytes)}`
|
|
881
|
+
} else {
|
|
882
|
+
warnings.push({
|
|
883
|
+
grade: 'O',
|
|
884
|
+
message: `background-image ${url}: the bytes could not be fetched (CORS or network); the asset is referenced by URL with no data.`,
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// The data URI is same-origin and cannot taint anything, so it is the better
|
|
889
|
+
// source to measure; the original URL is the fallback when there is no data.
|
|
890
|
+
const size = (await intrinsicSize(data)) || (await intrinsicSize(url))
|
|
891
|
+
if (!size || !(size.w > 0) || !(size.h > 0)) {
|
|
892
|
+
warnings.push({
|
|
893
|
+
grade: 'A',
|
|
894
|
+
message: `background-image ${url}: its intrinsic size could not be read, so the CSS sizing ` +
|
|
895
|
+
'could not be resolved against it; the layer is sized to the positioning area instead.',
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const intrinsic = size && size.w > 0 && size.h > 0 ? size : null
|
|
900
|
+
|
|
901
|
+
if (fetched && looksLikeSvg(fetched.bytes, url, mime)) {
|
|
902
|
+
const vector = await svgBackgroundAsset(fetched.bytes, url, id, ctx, intrinsic, svgWarnings, ctx.fetchTimeout)
|
|
903
|
+
if (vector) {
|
|
904
|
+
// `origin` and the URL stay on the asset: the markup is the payload, but a
|
|
905
|
+
// reader still has to be able to say where it came from.
|
|
906
|
+
return { asset: { ...vector.asset, origin: 'original-bytes' }, warnings, svgWarnings, intrinsic }
|
|
907
|
+
}
|
|
908
|
+
warnings.push({
|
|
909
|
+
grade: 'A',
|
|
910
|
+
message: `background-image ${url} looks like an SVG but did not parse, so it stays raster bytes inside ` +
|
|
911
|
+
'an <image>. A target that does not rasterize an SVG in an <image> (Figma, measured) draws nothing ' +
|
|
912
|
+
'for this layer.',
|
|
913
|
+
})
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const asset = {
|
|
917
|
+
id,
|
|
918
|
+
kind: mime === 'image/svg+xml' ? 'vector' : 'bitmap',
|
|
919
|
+
mime,
|
|
920
|
+
w: size ? size.w : 0,
|
|
921
|
+
h: size ? size.h : 0,
|
|
922
|
+
src: url,
|
|
923
|
+
origin: data ? 'original-bytes' : 'unresolved',
|
|
924
|
+
data,
|
|
925
|
+
}
|
|
926
|
+
return { asset, warnings, svgWarnings, intrinsic }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* `collectBox` cannot place a `url()` layer: the intrinsic size is unknown until
|
|
931
|
+
* the file loads, so it emits `asset: {pending:true, url}` and `rect: null` and
|
|
932
|
+
* leaves both to us. One fetch per distinct URL, all of them in flight at once.
|
|
933
|
+
*/
|
|
934
|
+
async function resolveBackgroundAssets (st) {
|
|
935
|
+
const pending = new Map()
|
|
936
|
+
for (const id of Object.keys(st.doc.nodes)) {
|
|
937
|
+
if (st.dropped.has(id)) continue
|
|
938
|
+
for (const fill of st.doc.nodes[id].fills || []) {
|
|
939
|
+
if (!fill || fill.type !== 'image') continue
|
|
940
|
+
if (!fill.asset || typeof fill.asset !== 'object' || !fill.asset.pending) continue
|
|
941
|
+
const url = absoluteUrl(fill.asset.url)
|
|
942
|
+
if (!pending.has(url)) pending.set(url, [])
|
|
943
|
+
pending.get(url).push({ id, fill })
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (!pending.size) return
|
|
947
|
+
|
|
948
|
+
await Promise.all([...pending].map(async ([url, uses]) => {
|
|
949
|
+
let loaded
|
|
950
|
+
try {
|
|
951
|
+
loaded = await loadBackgroundAsset(url, st)
|
|
952
|
+
} catch (error) {
|
|
953
|
+
for (const use of uses) {
|
|
954
|
+
report(st, use.id, 'assets.background-failed', 'O',
|
|
955
|
+
`background-image ${url} could not be loaded: ${error && error.message}; the layer is dropped.`)
|
|
956
|
+
const node = st.doc.nodes[use.id]
|
|
957
|
+
node.fills = node.fills.filter((f) => f !== use.fill)
|
|
958
|
+
}
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
st.doc.assets[loaded.asset.id] = loaded.asset
|
|
962
|
+
for (const use of uses) {
|
|
963
|
+
use.fill.asset = loaded.asset.id
|
|
964
|
+
if (!use.fill.rect && use.fill.placement) {
|
|
965
|
+
use.fill.rect = resolveLayerRect(use.fill.placement, loaded.intrinsic)
|
|
966
|
+
}
|
|
967
|
+
for (const w of loaded.warnings) report(st, use.id, 'assets.background', w.grade, w.message)
|
|
968
|
+
// The SVG path speaks `collectImage`'s prose, and `warn` grades a sentence
|
|
969
|
+
// the same way whether an <img> or a background layer produced it — the
|
|
970
|
+
// alternative is one message with two grades depending on where it came
|
|
971
|
+
// from, which is how a diagnostic stops meaning anything.
|
|
972
|
+
for (const w of loaded.svgWarnings || []) warn(st, use.id, 'image', w)
|
|
973
|
+
}
|
|
974
|
+
}))
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ——— filters the destination will not run: the opt-in PNG ———
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Device px per CSS px a filter raster is drawn at, and the largest side it may
|
|
981
|
+
* reach. 2x is what a design tool's canvas wants; the cap is what stops a
|
|
982
|
+
* full-page backdrop copy (fx-glass's frost is a copy of the whole capture root)
|
|
983
|
+
* from becoming a 4000px PNG for a 130px panel.
|
|
984
|
+
*/
|
|
985
|
+
const FILTER_RASTER_SCALE = 2
|
|
986
|
+
const FILTER_RASTER_MAX_PX = 2048
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Bytes past which the 2x pass is thrown away and repainted at 1x — the same
|
|
990
|
+
* shape, and the same number, as `TILE_RASTER_BYTES` in the emitter, and for the
|
|
991
|
+
* same reason: density is chosen against the FILE, not assumed. Measured on
|
|
992
|
+
* fx-glass, whose frost node is a copy of the whole capture root seen through a
|
|
993
|
+
* 416×130 window: 2x is 230 kB of PNG, which the emitter writes twice (`href` and
|
|
994
|
+
* `xlink:href`) and so 460 kB of a 14 kB document. 1x is a quarter of that on a
|
|
995
|
+
* layer that is a 14px blur to begin with and has no detail to lose.
|
|
996
|
+
*/
|
|
997
|
+
const FILTER_RASTER_BYTES = 128 * 1024
|
|
998
|
+
|
|
999
|
+
/** Attributes the raster pass stamps on the mounted clone, and removes again. */
|
|
1000
|
+
const RASTER_OFF = 'data-snapdom-vector-raster-off'
|
|
1001
|
+
const RASTER_ON = 'data-snapdom-vector-raster-on'
|
|
1002
|
+
const RASTER_UP = 'data-snapdom-vector-raster-up'
|
|
1003
|
+
const RASTER_ROOT = 'data-snapdom-vector-raster-root'
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* The isolation, and it is the whole trick.
|
|
1007
|
+
*
|
|
1008
|
+
* The raster has to be of ONE node's subtree, because it replaces one node's
|
|
1009
|
+
* subtree in the SVG — a crop of the mounted clone would carry whatever sits
|
|
1010
|
+
* behind it and paint it twice. Detaching the subtree and rendering it alone is
|
|
1011
|
+
* what snapdom does for a whole capture and it does not survive here: half these
|
|
1012
|
+
* nodes are `<th>`s, and a table cell outside its table lays out as something
|
|
1013
|
+
* else entirely. So the tree stays whole and everything except the target is
|
|
1014
|
+
* made INVISIBLE — `visibility` is the one property that hides an element's paint
|
|
1015
|
+
* while keeping every box where it was, and it is inheritable, so one rule on the
|
|
1016
|
+
* root and one on the target cover the whole document.
|
|
1017
|
+
*
|
|
1018
|
+
* `!important` is not decoration: snapdom's own per-node classes carry
|
|
1019
|
+
* `visibility: visible` for every element in the clone, and an ordinary rule
|
|
1020
|
+
* would lose to them.
|
|
1021
|
+
*
|
|
1022
|
+
* The ancestors are neutralised rather than refused. Everything above the target
|
|
1023
|
+
* — its transform, its opacity, its blend mode, its own filter — is applied AGAIN
|
|
1024
|
+
* by the SVG groups the `<image>` will sit inside, so leaving it in the pixels
|
|
1025
|
+
* would double it. Transform in particular has to go: `abs`, the coordinate this
|
|
1026
|
+
* whole placement is computed in, is defined as the border box with the transform
|
|
1027
|
+
* chain neutralised (`paint.js`), so a raster taken through that chain would not
|
|
1028
|
+
* land where the document says the node is. The target keeps its own filter (it
|
|
1029
|
+
* is the point) and loses its own transform (the SVG group still applies it).
|
|
1030
|
+
*/
|
|
1031
|
+
const RASTER_CSS =
|
|
1032
|
+
`[${RASTER_OFF}],[${RASTER_OFF}] *{visibility:hidden!important}` +
|
|
1033
|
+
`[${RASTER_ON}],[${RASTER_ON}] *{visibility:visible!important}` +
|
|
1034
|
+
`[${RASTER_UP}]{transform:none!important;opacity:1!important;mix-blend-mode:normal!important;` +
|
|
1035
|
+
'filter:none!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}' +
|
|
1036
|
+
`[${RASTER_ON}]{transform:none!important}` +
|
|
1037
|
+
`[${RASTER_ROOT}]{margin:0!important}` +
|
|
1038
|
+
'svg{overflow:visible}foreignObject{overflow:visible}'
|
|
1039
|
+
|
|
1040
|
+
/** Rect helpers, local to this pass: everything here is an axis-aligned box in `abs` px. */
|
|
1041
|
+
const rectIntersect = (a, b) => {
|
|
1042
|
+
const x = Math.max(a.x, b.x)
|
|
1043
|
+
const y = Math.max(a.y, b.y)
|
|
1044
|
+
const w = Math.min(a.x + a.w, b.x + b.w) - x
|
|
1045
|
+
const h = Math.min(a.y + a.h, b.y + b.h) - y
|
|
1046
|
+
return { x, y, w, h }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The ancestors of `id`, nearest first, as node ids. Built from `children`
|
|
1051
|
+
* because the schema has no parent pointer and inventing one on the document
|
|
1052
|
+
* would be a field every consumer then has to be told to ignore.
|
|
1053
|
+
*/
|
|
1054
|
+
function ancestorsOf (doc, id, parents) {
|
|
1055
|
+
const chain = []
|
|
1056
|
+
for (let cur = parents.get(id); cur; cur = parents.get(cur)) {
|
|
1057
|
+
if (chain.includes(cur)) break
|
|
1058
|
+
chain.push(cur)
|
|
1059
|
+
}
|
|
1060
|
+
return chain.filter((cid) => isPlainObject(doc.nodes[cid]))
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Shrink the raster box to what an ancestor's overflow clip will let through.
|
|
1067
|
+
*
|
|
1068
|
+
* Not an optimisation with a rounding error in it: the `<image>` is emitted
|
|
1069
|
+
* INSIDE the same clip groups, so anything the clip removes is invisible in both
|
|
1070
|
+
* forms and costs only bytes. It costs a great many — fx-glass's frost node is a
|
|
1071
|
+
* copy of the entire 470×320 capture root showing through a 416×130 window, and
|
|
1072
|
+
* without this the PNG is the whole root with 88% of it transparent.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {object} doc
|
|
1075
|
+
* @param {string} id the node being rasterized
|
|
1076
|
+
* @param {Map<string,string>} parents
|
|
1077
|
+
* @param {{x,y,w,h}} box in the node's LOCAL px
|
|
1078
|
+
* @returns {{x,y,w,h}} the same box, clipped
|
|
1079
|
+
*/
|
|
1080
|
+
function clipToAncestors (doc, id, parents, box) {
|
|
1081
|
+
const node = doc.nodes[id]
|
|
1082
|
+
const origin = isPlainObject(node.abs) ? node.abs : { x: 0, y: 0 }
|
|
1083
|
+
let out = box
|
|
1084
|
+
for (const aid of ancestorsOf(doc, id, parents)) {
|
|
1085
|
+
const anc = doc.nodes[aid]
|
|
1086
|
+
const clip = isPlainObject(anc.clip) ? anc.clip : null
|
|
1087
|
+
if (!clip) continue
|
|
1088
|
+
const abs = isPlainObject(anc.abs) ? anc.abs : null
|
|
1089
|
+
const frame = isPlainObject(anc.frame) ? anc.frame : abs
|
|
1090
|
+
if (!abs || !frame) continue
|
|
1091
|
+
// The clip rect in the ancestor's own local px — `clip.rect` when it carries
|
|
1092
|
+
// one, its border box otherwise, which is what `emitClipShape` falls back to.
|
|
1093
|
+
const rect = isPlainObject(clip.rect)
|
|
1094
|
+
? clip.rect
|
|
1095
|
+
: { x: 0, y: 0, w: Number(frame.w) || 0, h: Number(frame.h) || 0 }
|
|
1096
|
+
if (!(rect.w > 0) || !(rect.h > 0)) continue
|
|
1097
|
+
// Both boxes are transform-neutral, so `abs` deltas are the whole mapping.
|
|
1098
|
+
out = rectIntersect(out, {
|
|
1099
|
+
x: (abs.x - origin.x) + rect.x,
|
|
1100
|
+
y: (abs.y - origin.y) + rect.y,
|
|
1101
|
+
w: rect.w,
|
|
1102
|
+
h: rect.h,
|
|
1103
|
+
})
|
|
1104
|
+
if (!(out.w > 0) || !(out.h > 0)) return out
|
|
1105
|
+
}
|
|
1106
|
+
return out
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Everything the mounted clone needs to render on its own, once per export.
|
|
1111
|
+
*
|
|
1112
|
+
* `mountClone` deliberately injects only the FRONT half of snapdom's CSS into the
|
|
1113
|
+
* page — the per-node style snapshots would freeze used widths over the page's
|
|
1114
|
+
* own cascade and re-wrap text — so the style tag it leaves in `<head>` is not
|
|
1115
|
+
* enough to render the clone anywhere else. Inside a `foreignObject` there is no
|
|
1116
|
+
* page cascade at all, which is exactly the case snapdom itself builds for, so
|
|
1117
|
+
* the raster uses snapdom's full set: the base reset that neutralises the UA
|
|
1118
|
+
* stylesheet, the embedded faces, and the `.cN` snapshots.
|
|
1119
|
+
*/
|
|
1120
|
+
function rasterCss (handoff) {
|
|
1121
|
+
return `${handoff.baseCSS || ''}${handoff.fontsCSS || ''}${handoff.classCSS || ''}${RASTER_CSS}`
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* One node's subtree, painted through its own filter, as PNG bytes.
|
|
1126
|
+
*
|
|
1127
|
+
* The clone is COPIED rather than moved: it is still mounted and still being
|
|
1128
|
+
* measured by everything after this. What goes into the `foreignObject` is that
|
|
1129
|
+
* copy plus the capture's whole CSS, and the geometry is snapdom's own
|
|
1130
|
+
* (`core/capture.js`): the container's padding carries a positive offset and the
|
|
1131
|
+
* `foreignObject`'s x/y carries a negative one, because Chrome does not paint
|
|
1132
|
+
* `foreignObject` overflow when it rasterizes an SVG as an image.
|
|
1133
|
+
*
|
|
1134
|
+
* @param {Element} cloneRoot the mounted clone root
|
|
1135
|
+
* @param {Element} el the element to isolate, inside it
|
|
1136
|
+
* @param {Element[]} up `el` and its ancestors up to `cloneRoot`
|
|
1137
|
+
* @param {string} css
|
|
1138
|
+
* @param {{x,y,w,h}} box the raster window in `abs` px (root border box origin)
|
|
1139
|
+
* @returns {Promise<{data: string, w: number, h: number, scale: number}|null>}
|
|
1140
|
+
*/
|
|
1141
|
+
async function paintFilterRaster (cloneRoot, el, up, css, box, want) {
|
|
1142
|
+
if (typeof document === 'undefined' || typeof Image !== 'function') return null
|
|
1143
|
+
const vbW = Math.max(1, Math.ceil(box.w))
|
|
1144
|
+
const vbH = Math.max(1, Math.ceil(box.h))
|
|
1145
|
+
const scale = Math.min(want, FILTER_RASTER_MAX_PX / Math.max(vbW, vbH))
|
|
1146
|
+
const outW = Math.max(1, Math.round(vbW * scale))
|
|
1147
|
+
const outH = Math.max(1, Math.round(vbH * scale))
|
|
1148
|
+
|
|
1149
|
+
const offX = -box.x
|
|
1150
|
+
const offY = -box.y
|
|
1151
|
+
const padL = Math.max(0, offX)
|
|
1152
|
+
const padT = Math.max(0, offY)
|
|
1153
|
+
const foX = Math.min(0, offX)
|
|
1154
|
+
const foY = Math.min(0, offY)
|
|
1155
|
+
const foW = vbW - foX
|
|
1156
|
+
const foH = vbH - foY
|
|
1157
|
+
|
|
1158
|
+
cloneRoot.setAttribute(RASTER_OFF, '')
|
|
1159
|
+
cloneRoot.setAttribute(RASTER_ROOT, '')
|
|
1160
|
+
for (const a of up) a.setAttribute(RASTER_UP, '')
|
|
1161
|
+
el.removeAttribute(RASTER_UP)
|
|
1162
|
+
el.setAttribute(RASTER_ON, '')
|
|
1163
|
+
let markup
|
|
1164
|
+
try {
|
|
1165
|
+
const svgNS = 'http://www.w3.org/2000/svg'
|
|
1166
|
+
const fo = document.createElementNS(svgNS, 'foreignObject')
|
|
1167
|
+
fo.setAttribute('x', String(foX))
|
|
1168
|
+
fo.setAttribute('y', String(foY))
|
|
1169
|
+
fo.setAttribute('width', String(foW))
|
|
1170
|
+
fo.setAttribute('height', String(foH))
|
|
1171
|
+
fo.style.overflow = 'visible'
|
|
1172
|
+
const style = document.createElement('style')
|
|
1173
|
+
style.textContent = css
|
|
1174
|
+
fo.appendChild(style)
|
|
1175
|
+
const container = document.createElement('div')
|
|
1176
|
+
container.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
|
|
1177
|
+
// The synthesized-node rules `mountClone` generates are scoped to the host
|
|
1178
|
+
// attribute, so the container has to BE the host or every invented node
|
|
1179
|
+
// (materialised pseudos, scroll wrappers) renders unstyled.
|
|
1180
|
+
container.setAttribute('data-snapdom-vector', 'clone-host')
|
|
1181
|
+
container.style.cssText =
|
|
1182
|
+
'all:initial;box-sizing:border-box;display:block;overflow:visible;' +
|
|
1183
|
+
`width:${foW}px;height:${foH}px` +
|
|
1184
|
+
((padL || padT) ? `;padding:${padT}px 0 0 ${padL}px !important` : '')
|
|
1185
|
+
container.appendChild(cloneRoot.cloneNode(true))
|
|
1186
|
+
fo.appendChild(container)
|
|
1187
|
+
markup = `<svg xmlns="${svgNS}" width="${outW}" height="${outH}" viewBox="0 0 ${vbW} ${vbH}">` +
|
|
1188
|
+
`${new XMLSerializer().serializeToString(fo)}</svg>`
|
|
1189
|
+
} finally {
|
|
1190
|
+
cloneRoot.removeAttribute(RASTER_OFF)
|
|
1191
|
+
cloneRoot.removeAttribute(RASTER_ROOT)
|
|
1192
|
+
el.removeAttribute(RASTER_ON)
|
|
1193
|
+
for (const a of up) a.removeAttribute(RASTER_UP)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const img = new Image()
|
|
1197
|
+
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`
|
|
1198
|
+
await img.decode()
|
|
1199
|
+
const canvas = document.createElement('canvas')
|
|
1200
|
+
canvas.width = outW
|
|
1201
|
+
canvas.height = outH
|
|
1202
|
+
const c2d = canvas.getContext('2d')
|
|
1203
|
+
if (!c2d) return null
|
|
1204
|
+
c2d.drawImage(img, 0, 0, outW, outH)
|
|
1205
|
+
const data = canvas.toDataURL('image/png')
|
|
1206
|
+
if (typeof data !== 'string' || !data.startsWith('data:image/png')) return null
|
|
1207
|
+
return { data, w: outW, h: outH, scale }
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* `rasterFilters`, end to end.
|
|
1212
|
+
*
|
|
1213
|
+
* The emitter decides WHAT — `rasterFilterPlan` is the single predicate, so the
|
|
1214
|
+
* pass that paints and the pass that emits cannot hold two opinions about which
|
|
1215
|
+
* filters survive an import — and this decides whether a raster was possible at
|
|
1216
|
+
* all. A node it cannot paint keeps its vector form and its declaration; nothing
|
|
1217
|
+
* here silently leaves a node without either.
|
|
1218
|
+
*
|
|
1219
|
+
* It runs before the `el` back-references are dropped, which is the only window
|
|
1220
|
+
* in which the clone is both mounted and still attached to the document.
|
|
1221
|
+
*/
|
|
1222
|
+
async function rasterizeUnrunFilters (st, capture, handoff) {
|
|
1223
|
+
const doc = st.doc
|
|
1224
|
+
const parents = new Map()
|
|
1225
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
1226
|
+
for (const kid of doc.nodes[id].children || []) parents.set(kid, id)
|
|
1227
|
+
}
|
|
1228
|
+
const targets = []
|
|
1229
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
1230
|
+
if (st.dropped.has(id)) continue
|
|
1231
|
+
const plan = rasterFilterPlan(doc.nodes[id], doc.nodes)
|
|
1232
|
+
if (plan) targets.push({ id, plan, depth: ancestorsOf(doc, id, parents).length })
|
|
1233
|
+
}
|
|
1234
|
+
if (!targets.length) return
|
|
1235
|
+
// Outermost first, so the ancestor-already-baked test below is decidable in one
|
|
1236
|
+
// pass and never depends on `Object.keys` happening to be in tree order.
|
|
1237
|
+
targets.sort((a, b) => a.depth - b.depth)
|
|
1238
|
+
|
|
1239
|
+
const css = rasterCss(handoff)
|
|
1240
|
+
const done = new Set()
|
|
1241
|
+
let rasterized = 0
|
|
1242
|
+
|
|
1243
|
+
for (const { id, plan } of targets) {
|
|
1244
|
+
const node = doc.nodes[id]
|
|
1245
|
+
const el = node.el
|
|
1246
|
+
const why = plan.why.join('; ')
|
|
1247
|
+
// An ancestor already baked this subtree — the emitter stops at the outermost
|
|
1248
|
+
// raster and never reaches this node, so a second PNG of the same pixels would
|
|
1249
|
+
// be bytes nothing draws and a `report.raster` count of nodes that are not
|
|
1250
|
+
// separately rastered. It keeps its declaration; it just does not get its own
|
|
1251
|
+
// bitmap.
|
|
1252
|
+
if (ancestorsOf(doc, id, parents).some((aid) => done.has(aid))) continue
|
|
1253
|
+
if (!el || el.nodeType !== 1 || !el.isConnected) {
|
|
1254
|
+
report(st, id, 'raster.filter-unavailable', 'A',
|
|
1255
|
+
`rasterFilters could not rasterize this node (${why}): it has no element in the mounted clone. ` +
|
|
1256
|
+
'It keeps its vector form, and the filter is still declared as one the destination will not run.')
|
|
1257
|
+
continue
|
|
1258
|
+
}
|
|
1259
|
+
// The node's own transform is neutralised for the paint (the SVG group still
|
|
1260
|
+
// applies it), but a transform is only invertible for this if the emitter can
|
|
1261
|
+
// reproduce it — an unreadable one means the group applies nothing, and then
|
|
1262
|
+
// the raster and the vector disagree about where the node is.
|
|
1263
|
+
const readableTransform = node.transform === null || node.transform === undefined ||
|
|
1264
|
+
(isPlainObject(node.transform) && Array.isArray(node.transform.m) && node.transform.m.length === 6)
|
|
1265
|
+
if (!readableTransform) {
|
|
1266
|
+
report(st, id, 'raster.filter-unavailable', 'A',
|
|
1267
|
+
`rasterFilters could not rasterize this node (${why}): its transform is unreadable, so a raster ` +
|
|
1268
|
+
'taken with the transform chain neutralised could not be put back. It keeps its vector form.')
|
|
1269
|
+
continue
|
|
1270
|
+
}
|
|
1271
|
+
const local = clipToAncestors(doc, id, parents, plan.box)
|
|
1272
|
+
if (!(local.w > 0) || !(local.h > 0)) {
|
|
1273
|
+
report(st, id, 'raster.filter-unavailable', 'E',
|
|
1274
|
+
`rasterFilters had nothing to rasterize for this node (${why}): every pixel its filter reaches is ` +
|
|
1275
|
+
'outside an ancestor\'s overflow clip. Nothing is emitted for it either way.')
|
|
1276
|
+
continue
|
|
1277
|
+
}
|
|
1278
|
+
const abs = isPlainObject(node.abs) ? node.abs : { x: 0, y: 0 }
|
|
1279
|
+
const up = []
|
|
1280
|
+
for (let a = el; a && a !== capture.clone; a = a.parentElement) up.push(a)
|
|
1281
|
+
up.push(capture.clone)
|
|
1282
|
+
|
|
1283
|
+
const pane = { x: abs.x + local.x, y: abs.y + local.y, w: local.w, h: local.h }
|
|
1284
|
+
let painted = null
|
|
1285
|
+
let failure = ''
|
|
1286
|
+
try {
|
|
1287
|
+
painted = await paintFilterRaster(capture.clone, el, up, css, pane, FILTER_RASTER_SCALE)
|
|
1288
|
+
// Density decided by what the pass COSTS, which is not knowable until it
|
|
1289
|
+
// has run — the same rule `rasterTiles` follows in the emitter.
|
|
1290
|
+
if (painted && painted.scale > 1 && painted.data.length > FILTER_RASTER_BYTES) {
|
|
1291
|
+
painted = (await paintFilterRaster(capture.clone, el, up, css, pane, 1)) || painted
|
|
1292
|
+
}
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
failure = (error && error.message) || String(error)
|
|
1295
|
+
}
|
|
1296
|
+
if (!painted) {
|
|
1297
|
+
report(st, id, 'raster.filter-failed', 'A',
|
|
1298
|
+
`rasterFilters could not rasterize this node (${why})${failure ? `: ${failure}` : ''}. It keeps its ` +
|
|
1299
|
+
'vector form, and the filter is still declared as one the destination will not run.')
|
|
1300
|
+
continue
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const assetId = nextAssetId(st.ctx, `fx_${id}`)
|
|
1304
|
+
doc.assets[assetId] = {
|
|
1305
|
+
id: assetId,
|
|
1306
|
+
kind: 'bitmap',
|
|
1307
|
+
mime: 'image/png',
|
|
1308
|
+
w: painted.w,
|
|
1309
|
+
h: painted.h,
|
|
1310
|
+
origin: 'rasterized',
|
|
1311
|
+
data: painted.data,
|
|
1312
|
+
}
|
|
1313
|
+
node.filterRaster = { asset: assetId, box: local, scale: painted.scale, why: plan.why }
|
|
1314
|
+
done.add(id)
|
|
1315
|
+
// The grade is on the node here as well as in the emitter's own diagnostic,
|
|
1316
|
+
// because a document handed to `svdToFigma` or to the PDF backend carries the
|
|
1317
|
+
// same loss and never goes through `svdToSvg` to hear about it.
|
|
1318
|
+
report(st, id, 'raster.filter', 'R',
|
|
1319
|
+
`this node and its whole subtree are one ${painted.w}×${painted.h} PNG at ` +
|
|
1320
|
+
`${Math.round(painted.scale * 100) / 100}x, because ${why}.`)
|
|
1321
|
+
if (!rasterized++) {
|
|
1322
|
+
report(st, null, 'raster.filter', 'R',
|
|
1323
|
+
'rasterFilters is on, so every node whose filter the destination will not run — and only those — ' +
|
|
1324
|
+
'was painted from the MOUNTED CLONE, where snapdom has already pre-composed any backdrop-filter, ' +
|
|
1325
|
+
'over the filter\'s own region rather than the border box. Those subtrees are no longer editable ' +
|
|
1326
|
+
'anywhere — no text, no fills, no children, one bitmap each. ' +
|
|
1327
|
+
'AND IT DOES NOT ALWAYS LOOK BETTER: this trades a filter the destination drops for a bitmap it ' +
|
|
1328
|
+
'keeps, which wins only where the vector form was already wrong. Measured on these fixtures: the ' +
|
|
1329
|
+
'glass panel improved (its vector form was re-drawing by hand a backdrop-filter snapdom had ' +
|
|
1330
|
+
'already composed, 8.97 -> 7.38 mean px), while five table headers got WORSE (3.00 -> 3.04 ' +
|
|
1331
|
+
'overall, 8.93 -> 13.87 over the band that changed) because their inner-shadow chain already ' +
|
|
1332
|
+
'painted exactly in a browser and rasterising only resampled their text. Judge it per node, not ' +
|
|
1333
|
+
'per document. rasterFilters:false keeps the vector form and declares the loss instead. ' +
|
|
1334
|
+
'ONLY the flat-SVG backend reads these rasters: svdToFigma and the PDF backend do not know the ' +
|
|
1335
|
+
'field yet and emit these nodes\' vector paint, so a payload built through them carries the ' +
|
|
1336
|
+
'unfiltered node and not the bitmap — the loss this option buys back is bought back in ' +
|
|
1337
|
+
'toVectorSvg() and nowhere else.')
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ——— the walk over emitted nodes ———
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* One node, one collector, per its type. Returns a promise only when the node
|
|
1346
|
+
* carries replaced content, so the caller can await them all together instead of
|
|
1347
|
+
* serialising one fetch per image.
|
|
1348
|
+
*/
|
|
1349
|
+
function collectNode (st, id, node) {
|
|
1350
|
+
const el = node.el
|
|
1351
|
+
if (!el) return null
|
|
1352
|
+
|
|
1353
|
+
let cs
|
|
1354
|
+
try {
|
|
1355
|
+
cs = st.ctx.styleOf(el)
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
report(st, id, 'collect.style-failed', 'O',
|
|
1358
|
+
`the computed style of this element is unreadable: ${error && error.message}`)
|
|
1359
|
+
return null
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// A text node's fills are the GLYPH fills in every backend, so it never takes
|
|
1363
|
+
// the box paint: `buildPaintTree` already split the block's box onto a parent
|
|
1364
|
+
// node wherever both exist.
|
|
1365
|
+
if (node.type === 'text') {
|
|
1366
|
+
collectTextInto(st, id, node, cs)
|
|
1367
|
+
return null
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
try {
|
|
1371
|
+
applyBox(st, id, node, collectBox(el, cs, st.ctx))
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
report(st, id, 'collect.box-failed', 'O',
|
|
1374
|
+
`collectBox threw on this element: ${error && error.message}; the node is emitted with no paint.`)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (node.type !== 'image' && node.type !== 'vector') return null
|
|
1378
|
+
|
|
1379
|
+
const isMath = el.localName === 'math' && el.namespaceURI === 'http://www.w3.org/1998/Math/MathML'
|
|
1380
|
+
return (isMath ? collectMathML(el, cs, st.ctx) : collectImage(el, cs, st.ctx))
|
|
1381
|
+
.then((result) => {
|
|
1382
|
+
if (result) {
|
|
1383
|
+
applyImage(st, id, node, result)
|
|
1384
|
+
return
|
|
1385
|
+
}
|
|
1386
|
+
// `<iframe>`, `<embed>`, `<object>` and `<input type=image>` reach here:
|
|
1387
|
+
// the paint tree types them as replaced content and no collector in v0
|
|
1388
|
+
// knows how to read their contents.
|
|
1389
|
+
report(st, id, 'collect.replaced-unsupported', 'O',
|
|
1390
|
+
`<${node.source && node.source.tag}> is replaced content v0 cannot read; the box is emitted empty.`)
|
|
1391
|
+
})
|
|
1392
|
+
.catch((error) => {
|
|
1393
|
+
report(st, id, isMath ? 'collect.mathml-failed' : 'collect.image-failed', 'O',
|
|
1394
|
+
`${isMath ? 'collectMathML' : 'collectImage'} threw on this element: ${error && error.message}; the box is emitted empty.`)
|
|
1395
|
+
})
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// ——— assembly ———
|
|
1399
|
+
|
|
1400
|
+
/** Remove the nodes that collection pruned, and every reference to them. */
|
|
1401
|
+
function applyDrops (st) {
|
|
1402
|
+
if (!st.dropped.size) return
|
|
1403
|
+
const { nodes } = st.doc
|
|
1404
|
+
for (const id of st.dropped) delete nodes[id]
|
|
1405
|
+
for (const id of Object.keys(nodes)) {
|
|
1406
|
+
const node = nodes[id]
|
|
1407
|
+
if (Array.isArray(node.children) && node.children.some((c) => st.dropped.has(c))) {
|
|
1408
|
+
node.children = node.children.filter((c) => !st.dropped.has(c))
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// A diagnostic naming a node that no longer exists is a validation error, and
|
|
1412
|
+
// the node it described is gone, so the entry goes with it.
|
|
1413
|
+
st.doc.diagnostics = st.doc.diagnostics.filter((d) => !(d.node && st.dropped.has(d.node)))
|
|
1414
|
+
for (const d of st.doc.diagnostics) {
|
|
1415
|
+
for (const key of ['from', 'to']) if (d[key] && st.dropped.has(d[key])) delete d[key]
|
|
1416
|
+
if (Array.isArray(d.overlaps)) d.overlaps = d.overlaps.filter((n) => !st.dropped.has(n))
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* A gradient that collapsed to a single stop is a flat colour, and the schema
|
|
1422
|
+
* requires two, so the stop is repeated at both ends — same paint, no loss.
|
|
1423
|
+
*
|
|
1424
|
+
* This function used to do a second job: rename every stop's `t` to `p`.
|
|
1425
|
+
* `css/gradient.js` has always emitted `t` and never emitted `p`, so `p` was a
|
|
1426
|
+
* spelling invented right here, in the one module that is supposed to forward
|
|
1427
|
+
* rather than translate. The contract now fixes the stop as `{t, color, srcCss}`
|
|
1428
|
+
* and `schema.js` validates `t`, so the rename is gone.
|
|
1429
|
+
*
|
|
1430
|
+
* Consumers read `stop.t`: `emit/svg-flat.js` (`readStops`), `emit/figma-json.js`
|
|
1431
|
+
* (`toPaint`) and `figma-plugin/code.js` (`gradientStops`).
|
|
1432
|
+
*/
|
|
1433
|
+
/**
|
|
1434
|
+
* Turns each set of collected `mask-image` layers into the node the schema's
|
|
1435
|
+
* `mask` reference needs, and points the masked node at it.
|
|
1436
|
+
*
|
|
1437
|
+
* The mask node hangs off the node it masks. It has to hang off SOMETHING — the
|
|
1438
|
+
* schema rejects a document with a node the root cannot reach, and rightly: an
|
|
1439
|
+
* unreachable node is one a backend can silently never emit. Being a child is
|
|
1440
|
+
* also what `emit/svg-flat.js` already assumes, since it filters mask targets out
|
|
1441
|
+
* of the children it paints. Its `abs` is the masked node's own, so the two
|
|
1442
|
+
* spaces coincide and the layer rects — already node-local, the same ones `fills`
|
|
1443
|
+
* use — need no translation at all.
|
|
1444
|
+
*/
|
|
1445
|
+
function wireMasks (st) {
|
|
1446
|
+
if (!st.masks.length) return
|
|
1447
|
+
for (const { id, mask } of st.masks) {
|
|
1448
|
+
const node = st.doc.nodes[id]
|
|
1449
|
+
if (!node || node.mask) continue
|
|
1450
|
+
let maskId = `${id}__mask`
|
|
1451
|
+
for (let n = 2; st.doc.nodes[maskId]; n++) maskId = `${id}__mask${n}`
|
|
1452
|
+
const frame = node.frame || node.abs
|
|
1453
|
+
st.doc.nodes[maskId] = {
|
|
1454
|
+
type: 'frame',
|
|
1455
|
+
name: `${node.name || id} mask`,
|
|
1456
|
+
// The mask paints in the masked node's OWN space: same origin, same size,
|
|
1457
|
+
// and `frame.x/y` are zero because `emitMask` has already translated by
|
|
1458
|
+
// the difference of the two `abs` boxes (here, zero).
|
|
1459
|
+
frame: { x: 0, y: 0, w: frame.w, h: frame.h },
|
|
1460
|
+
abs: { ...node.abs },
|
|
1461
|
+
paint: { z: 0, stackingContext: false },
|
|
1462
|
+
opacity: 1,
|
|
1463
|
+
radii: mask.radii || null,
|
|
1464
|
+
fills: mask.layers,
|
|
1465
|
+
strokes: [],
|
|
1466
|
+
effects: [],
|
|
1467
|
+
children: [],
|
|
1468
|
+
fidelity: { grade: 'E', notes: [] },
|
|
1469
|
+
}
|
|
1470
|
+
node.children = [...(node.children || []), maskId]
|
|
1471
|
+
node.mask = { node: maskId, type: mask.type === 'luminance' ? 'luminance' : 'alpha' }
|
|
1472
|
+
report(st, id, 'collect.box.mask-applied', 'A',
|
|
1473
|
+
`mask-image "${mask.srcCss}" is applied: its ${mask.layers.length} layer(s) are painted into node ` +
|
|
1474
|
+
`"${maskId}" and referenced as ${mask.type === 'luminance' ? 'a luminance' : 'an alpha'} mask over the ` +
|
|
1475
|
+
`${mask.clipBox} box. It is not exact — mask-composite is not modelled and the layers stack as "add", ` +
|
|
1476
|
+
'and a backend that cannot express a mask by reference (Figma flags a sibling instead) still has to ' +
|
|
1477
|
+
'build it. What is no longer true is that the box comes out rectangular.', 'info')
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function padGradients (st) {
|
|
1482
|
+
let padded = 0
|
|
1483
|
+
|
|
1484
|
+
const fix = (paint) => {
|
|
1485
|
+
if (!paint || !Array.isArray(paint.stops) || paint.stops.length !== 1) return
|
|
1486
|
+
const only = paint.stops[0]
|
|
1487
|
+
paint.stops = [{ ...only, t: 0 }, { ...only, t: 1 }]
|
|
1488
|
+
padded++
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
for (const id of Object.keys(st.doc.nodes)) {
|
|
1492
|
+
const node = st.doc.nodes[id]
|
|
1493
|
+
for (const fill of node.fills || []) fix(fill)
|
|
1494
|
+
for (const stroke of node.strokes || []) fix(stroke && stroke.paint)
|
|
1495
|
+
}
|
|
1496
|
+
for (const id of Object.keys(st.doc.styles.text)) {
|
|
1497
|
+
for (const fill of st.doc.styles.text[id].fills || []) fix(fill)
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (padded) {
|
|
1501
|
+
st.doc.diagnostics.push({
|
|
1502
|
+
code: 'schema.gradient-stops-padded',
|
|
1503
|
+
severity: 'info',
|
|
1504
|
+
message: `${padded} gradient(s) had a single stop and it was repeated at both ends so the ` +
|
|
1505
|
+
'ramp is a flat colour rather than an invalid document. No colour or position was changed.',
|
|
1506
|
+
})
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// ——— fonts ———
|
|
1511
|
+
|
|
1512
|
+
/** Families CSS resolves itself; none of them can come from an `@font-face`. */
|
|
1513
|
+
const GENERIC_FAMILIES = new Set([
|
|
1514
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'math',
|
|
1515
|
+
'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'fangsong',
|
|
1516
|
+
'-apple-system', 'blinkmacsystemfont',
|
|
1517
|
+
])
|
|
1518
|
+
|
|
1519
|
+
const unquote = (v) => String(v == null ? '' : v).trim().replace(/^["']|["']$/g, '')
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The families this document's own page defines with `@font-face`.
|
|
1523
|
+
*
|
|
1524
|
+
* `document.fonts` is set-like and holds a CSS-connected `FontFace` for every
|
|
1525
|
+
* `@font-face` rule the page parsed, so membership answers exactly the question
|
|
1526
|
+
* that matters: is this family something the machine opening the export is
|
|
1527
|
+
* likely to have, or something the page shipped?
|
|
1528
|
+
*
|
|
1529
|
+
* Returns `null` when the API is not there — "I could not check" and "there is
|
|
1530
|
+
* nothing to declare" are different answers and must not share a return value.
|
|
1531
|
+
*/
|
|
1532
|
+
function webfontFamilies () {
|
|
1533
|
+
const set = typeof document !== 'undefined' ? document.fonts : null
|
|
1534
|
+
if (!set || typeof set.forEach !== 'function') return null
|
|
1535
|
+
const out = new Set()
|
|
1536
|
+
try {
|
|
1537
|
+
set.forEach((face) => {
|
|
1538
|
+
const name = unquote(face && face.family).toLowerCase()
|
|
1539
|
+
if (name && !GENERIC_FAMILIES.has(name)) out.add(name)
|
|
1540
|
+
})
|
|
1541
|
+
} catch {
|
|
1542
|
+
return null
|
|
1543
|
+
}
|
|
1544
|
+
return out
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* What is STILL not embedded after `collect/font.js` ran, declared per family
|
|
1549
|
+
* and naming every node that uses it.
|
|
1550
|
+
*
|
|
1551
|
+
* The collector writes `style.font` for every `@font-face` face it could fetch
|
|
1552
|
+
* and cover, and each failure mode it hits is its own `assets.*` diagnostic —
|
|
1553
|
+
* so what reaches this function is what has no bytes to bring (a face whose
|
|
1554
|
+
* rule vanished, an uncovered subset, a failed fetch, or `embedFonts: false`).
|
|
1555
|
+
* For those the export carries the family NAME and gets whatever the viewer has
|
|
1556
|
+
* installed, and the loss compounds: their lines carry no `textLength`
|
|
1557
|
+
* (`measuredFontTravels` refuses to pin a substituted face), so a substitute
|
|
1558
|
+
* sets its own advances and the line widths are the viewer's.
|
|
1559
|
+
*/
|
|
1560
|
+
function declareFonts (st) {
|
|
1561
|
+
const { nodes, styles, diagnostics } = st.doc
|
|
1562
|
+
const styleIds = Object.keys(styles.text)
|
|
1563
|
+
if (!styleIds.length) return
|
|
1564
|
+
|
|
1565
|
+
const faces = webfontFamilies()
|
|
1566
|
+
if (faces === null) {
|
|
1567
|
+
diagnostics.push({
|
|
1568
|
+
code: 'assets.font-embedding-unknown',
|
|
1569
|
+
severity: 'warn',
|
|
1570
|
+
message: 'this engine exposes no document.fonts, so whether the families in use come from ' +
|
|
1571
|
+
'@font-face could not be determined and none was embedded: the export names families ' +
|
|
1572
|
+
'and the viewer substitutes what it has.',
|
|
1573
|
+
})
|
|
1574
|
+
return
|
|
1575
|
+
}
|
|
1576
|
+
if (!faces.size) return
|
|
1577
|
+
|
|
1578
|
+
// style id -> family, for the ones that are page-supplied. `cssFamily` is what
|
|
1579
|
+
// the `@font-face` rule calls this face and `family` may now be the name its
|
|
1580
|
+
// BINARY claims — `"Inter var"` against `Inter` — so the membership test is on
|
|
1581
|
+
// the first and the message names the second. Matching only on `family` stopped
|
|
1582
|
+
// recognising every renamed webfont, which is the exact case this exists for.
|
|
1583
|
+
const familyOf = new Map()
|
|
1584
|
+
for (const sid of styleIds) {
|
|
1585
|
+
const style = styles.text[sid]
|
|
1586
|
+
// A style whose face travels in `assets` is the solved case, not this one.
|
|
1587
|
+
if (typeof style.font === 'string' && style.font) continue
|
|
1588
|
+
const declared = unquote(style.cssFamily || style.family)
|
|
1589
|
+
if (declared && faces.has(declared.toLowerCase())) familyOf.set(sid, unquote(style.family) || declared)
|
|
1590
|
+
}
|
|
1591
|
+
if (!familyOf.size) return
|
|
1592
|
+
|
|
1593
|
+
const used = new Map()
|
|
1594
|
+
for (const id of Object.keys(nodes)) {
|
|
1595
|
+
const node = nodes[id]
|
|
1596
|
+
if (node.type !== 'text' || !node.text || !Array.isArray(node.text.runs)) continue
|
|
1597
|
+
for (const run of node.text.runs) {
|
|
1598
|
+
const family = familyOf.get(run.style)
|
|
1599
|
+
if (!family) continue
|
|
1600
|
+
if (!used.has(family)) used.set(family, new Set())
|
|
1601
|
+
used.get(family).add(id)
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
for (const use of st.markupTextUses) {
|
|
1605
|
+
const family = familyOf.get(use.style)
|
|
1606
|
+
if (!family) continue
|
|
1607
|
+
if (!used.has(family)) used.set(family, new Set())
|
|
1608
|
+
used.get(family).add(use.nodeId)
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
for (const [family, ids] of used) {
|
|
1612
|
+
const list = [...ids]
|
|
1613
|
+
diagnostics.push({
|
|
1614
|
+
code: 'assets.font-not-embedded',
|
|
1615
|
+
severity: 'warn',
|
|
1616
|
+
family,
|
|
1617
|
+
nodes: list,
|
|
1618
|
+
message: `"${family}" is served by an @font-face rule on this page and its bytes did not make ` +
|
|
1619
|
+
'it into the document (the assets.* diagnostics above say why, or embedFonts is off), so the ' +
|
|
1620
|
+
'export carries the family NAME and nothing else. On a machine without it the viewer ' +
|
|
1621
|
+
'substitutes another face with its own advances, and no line of it carries textLength ' +
|
|
1622
|
+
'(justified lines excepted — see emit.svg.text-justified-length): pinning a substitute to ' +
|
|
1623
|
+
'widths another font measured squeezes the glyphs.',
|
|
1624
|
+
})
|
|
1625
|
+
for (const id of list) {
|
|
1626
|
+
degradeNode(nodes[id], 'A', `the font "${family}" is referenced by name and not embedded`)
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// ——— tracking, and what the destination will do with it ———
|
|
1632
|
+
|
|
1633
|
+
/** A font shorthand no caller passes, so a refused assignment is detectable. */
|
|
1634
|
+
const FONT_SENTINEL = '1px serif'
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* The advance of `text` in one font, or null when the context refused the
|
|
1638
|
+
* shorthand — which it does silently, keeping the font it had, so every width
|
|
1639
|
+
* measured after one would describe a font nobody asked for.
|
|
1640
|
+
*/
|
|
1641
|
+
function measureWidth (ctx, css, text) {
|
|
1642
|
+
const c = ctx.measureCtx
|
|
1643
|
+
if (!c || !text) return null
|
|
1644
|
+
c.font = FONT_SENTINEL
|
|
1645
|
+
c.font = css
|
|
1646
|
+
if (c.font === FONT_SENTINEL) return null
|
|
1647
|
+
const w = c.measureText(text).width
|
|
1648
|
+
return Number.isFinite(w) ? w : null
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/** @param {string} familyCss a family list, already CSS-ready — quoted if it needs to be */
|
|
1652
|
+
function fontShorthand (style, familyCss) {
|
|
1653
|
+
const italic = style.italic ? 'italic ' : ''
|
|
1654
|
+
const weight = Number.isFinite(style.weight) ? Math.round(style.weight) : 400
|
|
1655
|
+
const size = Number.isFinite(style.size) && style.size > 0 ? style.size : 16
|
|
1656
|
+
return `${italic}${weight} ${size}px ${familyCss}`
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
let segmenter
|
|
1660
|
+
/**
|
|
1661
|
+
* Typographic character units, which is what `letter-spacing` is added after —
|
|
1662
|
+
* NOT UTF-16 code units, and not code points either.
|
|
1663
|
+
*
|
|
1664
|
+
* MEASURED in Chromium (a span with and without `letter-spacing: 10px`, width
|
|
1665
|
+
* difference over 10): `🎯🌋👩🏽🚀🇪🇸🧑🚒` takes the spacing **5** times and is 11 code
|
|
1666
|
+
* points and 20 UTF-16 units; `áé👩🏽🚀x` takes it 4 times against 7 and 10. Counting
|
|
1667
|
+
* `string.length` would report four times the tracking on an emoji line — more
|
|
1668
|
+
* error than the whole effect being reported.
|
|
1669
|
+
*/
|
|
1670
|
+
function unitsOf (text) {
|
|
1671
|
+
if (!text) return 0
|
|
1672
|
+
if (segmenter === undefined) {
|
|
1673
|
+
try {
|
|
1674
|
+
segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
|
|
1675
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
1676
|
+
: null
|
|
1677
|
+
} catch {
|
|
1678
|
+
segmenter = null
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
if (!segmenter) return [...text].length
|
|
1682
|
+
let n = 0
|
|
1683
|
+
for (const piece of segmenter.segment(text)) n += piece ? 1 : 0
|
|
1684
|
+
return n
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* The line of a text block that loses the most to a destination ignoring
|
|
1689
|
+
* `letter-spacing`, and how much that is.
|
|
1690
|
+
*
|
|
1691
|
+
* The arithmetic is not an estimate: CSS adds the tracking after every character
|
|
1692
|
+
* of the line, so a target that drops it paints the line `|letterSpacing| ×
|
|
1693
|
+
* characters` narrower than it was measured — negative tracking WIDENS it by that
|
|
1694
|
+
* much, positive tracking narrows it, and either way the line no longer ends
|
|
1695
|
+
* where anything anchored to its end expects. Computed per line because runs on
|
|
1696
|
+
* one line can carry different tracking, and the worst line is what is reported:
|
|
1697
|
+
* an average would hide the one that overflows.
|
|
1698
|
+
*/
|
|
1699
|
+
function worstTrackedLine (text, textStyles) {
|
|
1700
|
+
const lines = Array.isArray(text.lines) ? text.lines : []
|
|
1701
|
+
const runs = Array.isArray(text.runs) ? text.runs : []
|
|
1702
|
+
const characters = typeof text.characters === 'string' ? text.characters : ''
|
|
1703
|
+
let worst = null
|
|
1704
|
+
let tracked = 0
|
|
1705
|
+
for (const line of lines) {
|
|
1706
|
+
let delta = 0
|
|
1707
|
+
let units = 0
|
|
1708
|
+
let dominant = null
|
|
1709
|
+
let dominantUnits = 0
|
|
1710
|
+
let spacing = 0
|
|
1711
|
+
for (const run of runs) {
|
|
1712
|
+
const from = Math.max(run.start, line.start)
|
|
1713
|
+
const to = Math.min(run.end, line.end)
|
|
1714
|
+
if (to <= from) continue
|
|
1715
|
+
const style = textStyles[run.style]
|
|
1716
|
+
if (!style) continue
|
|
1717
|
+
const n = unitsOf(characters.slice(from, to))
|
|
1718
|
+
if (n > dominantUnits) { dominantUnits = n; dominant = style }
|
|
1719
|
+
const ls = Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0
|
|
1720
|
+
if (!ls) continue
|
|
1721
|
+
delta += Math.abs(ls) * n
|
|
1722
|
+
units += n
|
|
1723
|
+
if (Math.abs(ls) > Math.abs(spacing)) spacing = ls
|
|
1724
|
+
}
|
|
1725
|
+
if (!(delta > 0)) continue
|
|
1726
|
+
tracked++
|
|
1727
|
+
if (!worst || delta > worst.delta) worst = { line, delta, units, spacing, style: dominant }
|
|
1728
|
+
}
|
|
1729
|
+
if (worst) worst.lines = tracked
|
|
1730
|
+
return worst
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* How much wider the same characters come out in the generic fallback of their
|
|
1735
|
+
* class than in the face the page actually painted them with. MEASURED on this
|
|
1736
|
+
* machine, where both faces are present, and never a constant: which face the
|
|
1737
|
+
* destination substitutes is its own business, but the ORDER of the error is the
|
|
1738
|
+
* distance between the real one and the generic its stack falls through to.
|
|
1739
|
+
*
|
|
1740
|
+
* The reference is the page's whole STACK, not the resolved family on its own,
|
|
1741
|
+
* and that is a measurement rather than a preference: MEASURED on this machine,
|
|
1742
|
+
* `bold 22px -apple-system` on a canvas is 397.24px where the same string laid
|
|
1743
|
+
* out by CSS is 425.72px and the canvas given the whole stack is 426.39px. A
|
|
1744
|
+
* system keyword on its own resolves to a different face in a canvas than it does
|
|
1745
|
+
* in layout, so measuring it alone would report a substitution that never
|
|
1746
|
+
* happened; the stack reproduces layout to 0.16%.
|
|
1747
|
+
*/
|
|
1748
|
+
function substitutionWidth (ctx, style, text) {
|
|
1749
|
+
const generic = typeof style.genericClass === 'string' && style.genericClass ? style.genericClass : 'sans-serif'
|
|
1750
|
+
const stack = typeof style.stackCss === 'string' && style.stackCss ? style.stackCss : style.stack
|
|
1751
|
+
const mine = measureWidth(ctx, fontShorthand(style, stack), text)
|
|
1752
|
+
const theirs = measureWidth(ctx, fontShorthand(style, generic), text)
|
|
1753
|
+
if (mine === null || theirs === null || !(mine > 0) || !(theirs > 0)) return null
|
|
1754
|
+
return { generic, mine, theirs, ratio: theirs / mine }
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* The one loss this engine can measure exactly and no destination will honour.
|
|
1759
|
+
*
|
|
1760
|
+
* `letter-spacing` survives into the SVD and into the SVG, and Figma — VERIFIED by
|
|
1761
|
+
* pasting, `FIGMA_FINDINGS.md` §5 — drops it: the pasted node reports
|
|
1762
|
+
* `letter-spacing: 0`. Nothing here can force it, and four ways of trying are
|
|
1763
|
+
* written up as dead ends in that file. What is left is to say, before anyone
|
|
1764
|
+
* pastes, exactly how many px wider each line will arrive — and to add the second
|
|
1765
|
+
* half of the same sum when the family cannot travel either, because the reader
|
|
1766
|
+
* is looking at one line, not at two independent facts about it.
|
|
1767
|
+
*/
|
|
1768
|
+
function declareTracking (st) {
|
|
1769
|
+
const { nodes, styles } = st.doc
|
|
1770
|
+
for (const id of Object.keys(nodes)) {
|
|
1771
|
+
const node = nodes[id]
|
|
1772
|
+
if (node.type !== 'text' || !node.text) continue
|
|
1773
|
+
const worst = worstTrackedLine(node.text, styles.text)
|
|
1774
|
+
if (!worst) continue
|
|
1775
|
+
|
|
1776
|
+
const { line, delta, units, spacing } = worst
|
|
1777
|
+
const style = worst.style || {}
|
|
1778
|
+
const text = typeof node.text.characters === 'string'
|
|
1779
|
+
? node.text.characters.slice(line.start, line.end)
|
|
1780
|
+
: ''
|
|
1781
|
+
const excerpt = text.length > 46 ? `${text.slice(0, 46)}…` : text
|
|
1782
|
+
const width = Number.isFinite(line.w) && line.w > 0 ? line.w : 0
|
|
1783
|
+
// What the glyphs advance to on their own: the tracking comes OUT of the
|
|
1784
|
+
// measured line, because that is what a target dropping it is left holding.
|
|
1785
|
+
const untracked = width ? width + (spacing < 0 ? delta : -delta) : 0
|
|
1786
|
+
const signed = (n) => `${n >= 0 ? '+' : '−'}${Math.abs(n).toFixed(1)}`
|
|
1787
|
+
const pct = (n, of) => `${n >= 0 ? '+' : '−'}${Math.abs(100 * n / of).toFixed(1)}%`
|
|
1788
|
+
|
|
1789
|
+
const parts = [
|
|
1790
|
+
`this text is tracked and the target may well drop it — Figma does, verified by pasting: ` +
|
|
1791
|
+
`letter-spacing ${spacing}px over the ${units} character(s) of line ${line.i}` +
|
|
1792
|
+
`${worst.lines > 1 ? `, the worst of ${worst.lines} tracked lines,` : ''} is ${delta.toFixed(1)}px of ` +
|
|
1793
|
+
'advance that a target ignoring tracking never adds' +
|
|
1794
|
+
(width
|
|
1795
|
+
? `: the line measures ${width.toFixed(1)}px here, its glyphs alone advance ${untracked.toFixed(1)}px, ` +
|
|
1796
|
+
`so it arrives ${signed(untracked - width)}px ${spacing < 0 ? 'wider' : 'narrower'} ` +
|
|
1797
|
+
`(${pct(untracked - width, width)})`
|
|
1798
|
+
: '') +
|
|
1799
|
+
`. The line is «${excerpt}».`,
|
|
1800
|
+
]
|
|
1801
|
+
|
|
1802
|
+
// The family is the other half of the same sum, and only when it really
|
|
1803
|
+
// cannot travel: a face the destination already has is not substituted.
|
|
1804
|
+
const travels = style.referenceable !== false && style.webfont !== true
|
|
1805
|
+
if (!travels) {
|
|
1806
|
+
const sub = substitutionWidth(st.ctx, style, text)
|
|
1807
|
+
const why = style.referenceable === false
|
|
1808
|
+
? `"${style.family}" is a system keyword and not a font name — no picker can be handed it`
|
|
1809
|
+
: `"${style.family}" is served by an @font-face rule of this page and v0 embeds no font bytes`
|
|
1810
|
+
if (sub && Math.abs(sub.ratio - 1) > 0.001) {
|
|
1811
|
+
const wide = 100 * (sub.ratio - 1)
|
|
1812
|
+
const dest = untracked * sub.ratio
|
|
1813
|
+
parts.push(`Its family does not travel either: ${why}, so the destination substitutes. Measured on this ` +
|
|
1814
|
+
`machine, these same characters are ${sub.mine.toFixed(1)}px in the page's own stack and ` +
|
|
1815
|
+
`${sub.theirs.toFixed(1)}px in the ${sub.generic} it falls back to — ${wide >= 0 ? '+' : '−'}` +
|
|
1816
|
+
`${Math.abs(wide).toFixed(1)}% of glyph advance. Which face the destination picks is its own business — ` +
|
|
1817
|
+
'pasting this engine\'s SVG into Figma, it picked Inter and the line came out 6.1% wider, measured ' +
|
|
1818
|
+
'once and on one line — but that is the order of the error' +
|
|
1819
|
+
(untracked
|
|
1820
|
+
? `, and on this line it puts the two together at about ${dest.toFixed(1)}px against the ` +
|
|
1821
|
+
`${width.toFixed(1)}px the page paints: ${signed(dest - width)}px, ${pct(dest - width, width)}`
|
|
1822
|
+
: '') + '.')
|
|
1823
|
+
} else {
|
|
1824
|
+
parts.push(`Its family does not travel either: ${why}, so the destination substitutes and the substitute ` +
|
|
1825
|
+
'sets its own advances' +
|
|
1826
|
+
(sub
|
|
1827
|
+
? ' — how much could not be shown here, because the generic fallback of its class measures the same on this machine'
|
|
1828
|
+
: ' — how much could not be measured here') + '.')
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
parts.push('Nothing is lost from THIS document — the tracking is in `styles.text` and on the emitted ' +
|
|
1833
|
+
'text — and nothing this engine can emit makes a target honour it: four ways of forcing it were tried ' +
|
|
1834
|
+
'against Figma and all four are written up as dead ends.')
|
|
1835
|
+
|
|
1836
|
+
report(st, id, 'text.tracking-may-be-ignored', 'A', parts.join(' '), 'warn')
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ——— the clone itself ———
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Past this the mount is not reproducing the live layout and every coordinate in
|
|
1844
|
+
* the document is measured against something the user never saw. In px, on the
|
|
1845
|
+
* MEDIAN — a single outlier is a node, a median is the mount.
|
|
1846
|
+
*/
|
|
1847
|
+
const DRIFT_LIMIT_PX = 1
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* The mounted clone measured against the live element, as a diagnostic.
|
|
1851
|
+
*
|
|
1852
|
+
* This is the safety net for the whole architecture, so it is loud in both of its
|
|
1853
|
+
* failure modes and silent in neither: drift past the limit says so, and a mount
|
|
1854
|
+
* that could not be measured at all says THAT, because "no pairs" and "zero
|
|
1855
|
+
* drift" are different answers and only one of them is good news.
|
|
1856
|
+
*
|
|
1857
|
+
* The capture is not aborted either way. A drifted clone still produces a
|
|
1858
|
+
* document, and a document that declares its own drift is more useful than an
|
|
1859
|
+
* exception with the same information in it.
|
|
1860
|
+
*/
|
|
1861
|
+
function declareDrift (st, drift) {
|
|
1862
|
+
if (!drift) return
|
|
1863
|
+
const { pairs, median, p95, max } = drift
|
|
1864
|
+
// `measureDrift` now filters out the nodes that generate no box at all
|
|
1865
|
+
// (`<stop>`, `<option>`, `<clipPath>` …): subtracting two origins when one of
|
|
1866
|
+
// them is not a position was producing ~100.451px of "drift" that had nothing
|
|
1867
|
+
// to do with layout. The denominator below is therefore a FILTERED one, and a
|
|
1868
|
+
// fidelity number whose denominator is silently smaller than the tree is the
|
|
1869
|
+
// same class of lie this format exists to prevent — so it is stated.
|
|
1870
|
+
const skipped = Number.isFinite(drift.skipped) ? drift.skipped : 0
|
|
1871
|
+
const filtered = skipped
|
|
1872
|
+
? ` ${skipped} clone node(s) generate no box on either side (no client rect at all — an SVG ` +
|
|
1873
|
+
'<stop>, an <option>, a display:none subtree) and are not comparable, so they are not in ' +
|
|
1874
|
+
'that count; a node with a box on ONE side is counted, because that is an element the ' +
|
|
1875
|
+
'export lost or invented.'
|
|
1876
|
+
: ''
|
|
1877
|
+
// Names for the offenders, so a drift warning points at an element instead of
|
|
1878
|
+
// asking the reader to go and find it.
|
|
1879
|
+
const worst = (drift.worst || []).slice(0, 3)
|
|
1880
|
+
.map((w) => `<${w.tag}${w.id ? '#' + w.id : ''}${w.cls ? '.' + w.cls.trim().split(/\s+/).join('.') : ''}> ` +
|
|
1881
|
+
`off by ${w.delta.toFixed(2)}px (clone ${w.clone}, live ${w.live})`)
|
|
1882
|
+
const blame = worst.length ? ` Worst: ${worst.join('; ')}.` : ''
|
|
1883
|
+
if (!pairs) {
|
|
1884
|
+
report(st, null, 'capture.clone-unverified', 'A',
|
|
1885
|
+
'not one clone node could be matched to a live element, so the mounted clone\'s layout was ' +
|
|
1886
|
+
'never checked against the layout it copies. Every coordinate in this document is measured ' +
|
|
1887
|
+
'on the clone and nothing here confirms the clone is right.' + filtered, 'warn')
|
|
1888
|
+
return
|
|
1889
|
+
}
|
|
1890
|
+
if (median > DRIFT_LIMIT_PX) {
|
|
1891
|
+
report(st, null, 'capture.clone-drift', 'A',
|
|
1892
|
+
`the mounted clone does not lay out like the element it copies: median ${median.toFixed(2)}px, ` +
|
|
1893
|
+
`p95 ${p95.toFixed(2)}px, max ${max.toFixed(2)}px over ${pairs} matched node(s), against a ` +
|
|
1894
|
+
`${DRIFT_LIMIT_PX}px limit. Every box, baseline and gradient in this document was measured on ` +
|
|
1895
|
+
'the clone, so they are all off by about that much.' + filtered + blame, 'warn')
|
|
1896
|
+
return
|
|
1897
|
+
}
|
|
1898
|
+
// The median is the mount and it is fine — but a median cannot see one node
|
|
1899
|
+
// that landed nowhere, and that is a whole element missing from the export
|
|
1900
|
+
// rather than a rounding error. The known cause is a custom element: mounting
|
|
1901
|
+
// the clone in the page CONNECTS it, the browser upgrades it, its
|
|
1902
|
+
// `connectedCallback` attaches a fresh shadow root, and the shadow content
|
|
1903
|
+
// snapdom had flattened into its light DOM stops being rendered. `paint.js`
|
|
1904
|
+
// sees the same thing from the other side and files `B4.unresolved-shadow-root`.
|
|
1905
|
+
if (max > DRIFT_LIMIT_PX) {
|
|
1906
|
+
report(st, null, 'capture.clone-drift-outlier', 'A',
|
|
1907
|
+
`the mounted clone lays out like its source overall (median ${median.toFixed(2)}px over ` +
|
|
1908
|
+
`${pairs} node(s)) but not everywhere: p95 ${p95.toFixed(2)}px, max ${max.toFixed(2)}px. A ` +
|
|
1909
|
+
'few nodes are somewhere else entirely, or have no box at all. The usual cause is a custom ' +
|
|
1910
|
+
'element upgrading when the clone is connected and hiding the shadow content snapdom ' +
|
|
1911
|
+
'flattened into it — look for `B4.unresolved-shadow-root` alongside this.' + filtered + blame, 'warn')
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/** clone attribute -> the pseudo snapdom stamps it for. See `snapdom/src/modules/pseudo.js`. */
|
|
1916
|
+
const PSEUDO_ATTRS = [
|
|
1917
|
+
['::before', 'data-snapdom-has-before'],
|
|
1918
|
+
['::after', 'data-snapdom-has-after'],
|
|
1919
|
+
]
|
|
1920
|
+
|
|
1921
|
+
const TRANSPARENT_RE = /^rgba\(\s*0,\s*0,\s*0,\s*0\s*\)$/
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Does this pseudo put anything on the screen?
|
|
1925
|
+
*
|
|
1926
|
+
* Deliberately mirrors snapdom's own `hasVisibleBox` test: a pseudo it decided
|
|
1927
|
+
* not to materialise BECAUSE it paints nothing is not a gap, and reporting one
|
|
1928
|
+
* would bury the real gaps in noise.
|
|
1929
|
+
*/
|
|
1930
|
+
function pseudoPaints (cs) {
|
|
1931
|
+
if (!cs || cs.display === 'none' || cs.visibility === 'hidden') return false
|
|
1932
|
+
const content = cs.content
|
|
1933
|
+
if (!content || content === 'none' || content === 'normal') return false
|
|
1934
|
+
if (content !== '""' && content !== "''") return true
|
|
1935
|
+
// `content:''` is the empty-box case: it paints only if it has a box.
|
|
1936
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') return true
|
|
1937
|
+
const bg = cs.backgroundColor
|
|
1938
|
+
if (bg && bg !== 'transparent' && !TRANSPARENT_RE.test(bg)) return true
|
|
1939
|
+
if (cs.boxShadow && cs.boxShadow !== 'none') return true
|
|
1940
|
+
for (const side of ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth']) {
|
|
1941
|
+
if (parseFloat(cs[side]) > 0) return true
|
|
1942
|
+
}
|
|
1943
|
+
return false
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* Generated content the clone was supposed to carry and does not.
|
|
1948
|
+
*
|
|
1949
|
+
* The clone materialises `::before`/`::after` into real `<span>`s and stamps the
|
|
1950
|
+
* host with `data-snapdom-has-before`/`-after` when it does, so the check is
|
|
1951
|
+
* exact in the direction that matters: the live element paints a pseudo, its
|
|
1952
|
+
* clone carries no stamp, therefore the export is missing it. That is a grade
|
|
1953
|
+
* `O` — something on the page is not in the document.
|
|
1954
|
+
*
|
|
1955
|
+
* This replaces `B4.pseudo-element`, which meant "generated content is out of
|
|
1956
|
+
* scope" and no longer is. The two must not share a code: one described a
|
|
1957
|
+
* limitation of the engine, this one describes a failure of a capture, and a
|
|
1958
|
+
* report that cannot tell them apart cannot be acted on.
|
|
1959
|
+
*
|
|
1960
|
+
* `::marker` is not here — snapdom leaves it as a native marker rather than an
|
|
1961
|
+
* element, so there is nothing for a collector to read and `paint.js` still
|
|
1962
|
+
* declares it under `B4.list-marker`.
|
|
1963
|
+
*/
|
|
1964
|
+
function declarePseudoGaps (st, nodeMap) {
|
|
1965
|
+
if (!nodeMap || typeof nodeMap.entries !== 'function') return
|
|
1966
|
+
if (typeof getComputedStyle !== 'function') return
|
|
1967
|
+
|
|
1968
|
+
let elToId = null
|
|
1969
|
+
const idFor = (el) => {
|
|
1970
|
+
if (!elToId) {
|
|
1971
|
+
elToId = new Map()
|
|
1972
|
+
for (const id of Object.keys(st.doc.nodes)) {
|
|
1973
|
+
const node = st.doc.nodes[id]
|
|
1974
|
+
if (node.el) elToId.set(node.el, id)
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
// The element itself may have been pruned; the loss is still inside the
|
|
1978
|
+
// nearest ancestor that survived, and that node is where it gets declared.
|
|
1979
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
1980
|
+
const id = elToId.get(n)
|
|
1981
|
+
if (id) return id
|
|
1982
|
+
}
|
|
1983
|
+
return null
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
let found = 0
|
|
1987
|
+
for (const [cloneEl, srcEl] of nodeMap.entries()) {
|
|
1988
|
+
if (found >= st.ctx.maxDiagnosticsPerCode) break
|
|
1989
|
+
if (!cloneEl || cloneEl.nodeType !== 1 || !srcEl || srcEl.nodeType !== 1) continue
|
|
1990
|
+
if (srcEl.isConnected === false) continue
|
|
1991
|
+
for (const [sel, attr] of PSEUDO_ATTRS) {
|
|
1992
|
+
if (cloneEl.getAttribute(attr) !== null) continue
|
|
1993
|
+
let cs
|
|
1994
|
+
try {
|
|
1995
|
+
cs = getComputedStyle(srcEl, sel)
|
|
1996
|
+
} catch {
|
|
1997
|
+
continue
|
|
1998
|
+
}
|
|
1999
|
+
if (!pseudoPaints(cs)) continue
|
|
2000
|
+
found++
|
|
2001
|
+
report(st, idFor(cloneEl), 'capture.pseudo-not-materialised', 'O',
|
|
2002
|
+
`${cssSelector(srcEl)}${sel} paints on the page (content: ${cs.content}) and the clone ` +
|
|
2003
|
+
'carries no element for it, so it is not in this document. The clone is where generated ' +
|
|
2004
|
+
'content becomes real; when it does not, nothing downstream can recover it.')
|
|
2005
|
+
if (found >= st.ctx.maxDiagnosticsPerCode) break
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* The last sweep before validation. Two jobs, both of them about the invariant
|
|
2012
|
+
* that outranks the schema:
|
|
2013
|
+
*
|
|
2014
|
+
* - a `blend` the schema does not know is replaced and declared, rather than
|
|
2015
|
+
* failing the whole document over one exotic keyword;
|
|
2016
|
+
* - a node graded other than `E` that no diagnostic names gets one. That
|
|
2017
|
+
* happens when `paint.js` suppresses repeats past its per-code cap: the node
|
|
2018
|
+
* is degraded, its notes say why, and the entry that said so was counted
|
|
2019
|
+
* instead of listed. The grade is real, so the declaration has to be too.
|
|
2020
|
+
*/
|
|
2021
|
+
function declareEverything (st) {
|
|
2022
|
+
const { nodes, diagnostics } = st.doc
|
|
2023
|
+
const named = new Set()
|
|
2024
|
+
for (const d of diagnostics) {
|
|
2025
|
+
for (const key of ['node', 'from', 'to']) if (typeof d[key] === 'string') named.add(d[key])
|
|
2026
|
+
for (const key of ['nodes', 'overlaps']) {
|
|
2027
|
+
if (Array.isArray(d[key])) for (const n of d[key]) if (typeof n === 'string') named.add(n)
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
for (const id of Object.keys(nodes)) {
|
|
2032
|
+
const node = nodes[id]
|
|
2033
|
+
if (typeof node.blend === 'string' && !BLEND_MODES.has(node.blend)) {
|
|
2034
|
+
const bad = node.blend
|
|
2035
|
+
node.blend = 'normal'
|
|
2036
|
+
report(st, id, 'compose.blend-unsupported', 'A',
|
|
2037
|
+
`mix-blend-mode:${bad} is not a mode the schema carries; the node blends normally.`)
|
|
2038
|
+
named.add(id)
|
|
2039
|
+
}
|
|
2040
|
+
node.opacity = clamp01(node.opacity)
|
|
2041
|
+
|
|
2042
|
+
const grade = node.fidelity && node.fidelity.grade
|
|
2043
|
+
if (!grade || grade === 'E' || named.has(id)) continue
|
|
2044
|
+
const notes = (node.fidelity.notes || []).join('; ')
|
|
2045
|
+
diagnostics.push({
|
|
2046
|
+
code: 'fidelity.undeclared',
|
|
2047
|
+
severity: 'warn',
|
|
2048
|
+
node: id,
|
|
2049
|
+
message: `graded ${grade} with no diagnostic naming it — reconstructed from fidelity.notes` +
|
|
2050
|
+
`${notes ? `: ${notes}` : ' (which are empty; the degradation was recorded without a reason)'}`,
|
|
2051
|
+
})
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/**
|
|
2056
|
+
* @param {object} st
|
|
2057
|
+
* @param {Element} element the ORIGINAL element — `domElements` is a fact about
|
|
2058
|
+
* the user's DOM and must not quietly become a fact about our clone, which has
|
|
2059
|
+
* more elements in it (materialised pseudos, scroll wrappers, backdrops).
|
|
2060
|
+
* @param {Element} cloneRoot the tree that was actually walked
|
|
2061
|
+
* @param {object} drift
|
|
2062
|
+
* @param {number} startedAt when THIS export started; snapdom's own capture is
|
|
2063
|
+
* not inside it any more — the caller makes that call and can time it.
|
|
2064
|
+
*/
|
|
2065
|
+
function buildReport (st, element, cloneRoot, drift, startedAt) {
|
|
2066
|
+
const { nodes, styles, assets } = st.doc
|
|
2067
|
+
const ids = Object.keys(nodes)
|
|
2068
|
+
const grades = { E: 0, A: 0, C: 0, R: 0, RH: 0, O: 0 }
|
|
2069
|
+
let raster = 0
|
|
2070
|
+
let textBlocks = 0
|
|
2071
|
+
|
|
2072
|
+
// What the clone bought. Every one of these is a node that walking the live
|
|
2073
|
+
// DOM could not have produced: `pseudo` and `iconFont` do not exist as
|
|
2074
|
+
// elements there at all, `shadow` was behind a boundary the walker could not
|
|
2075
|
+
// cross, `artifact` is scaffolding snapdom added and we chose to keep. They
|
|
2076
|
+
// are counted, not asserted — if a number here is 0 for a fixture that
|
|
2077
|
+
// visibly has the feature, the migration did not deliver it there.
|
|
2078
|
+
const coverage = { pseudo: 0, shadow: 0, iconFont: 0, artifact: 0, synthetic: 0 }
|
|
2079
|
+
const nodeMap = st.nodeMap
|
|
2080
|
+
const live = (el) => nodeMap && typeof nodeMap.get === 'function' ? nodeMap.get(el) : undefined
|
|
2081
|
+
|
|
2082
|
+
for (const id of ids) {
|
|
2083
|
+
const node = nodes[id]
|
|
2084
|
+
const grade = node.fidelity && node.fidelity.grade
|
|
2085
|
+
if (hasOwn(grades, grade)) grades[grade]++
|
|
2086
|
+
if (node.type === 'text') textBlocks++
|
|
2087
|
+
const hasBitmap = (node.fills || []).some((f) => {
|
|
2088
|
+
if (!f || f.type !== 'image' || typeof f.asset !== 'string') return false
|
|
2089
|
+
const asset = assets[f.asset]
|
|
2090
|
+
return !asset || asset.kind !== 'vector'
|
|
2091
|
+
})
|
|
2092
|
+
// `rasterFilters` produces a node that is a bitmap and carries no image fill
|
|
2093
|
+
// at all. Counting only fills would have reported `raster: 0` over a document
|
|
2094
|
+
// that had just baked a panel into a PNG — the exact false claim this field
|
|
2095
|
+
// was cleaned up to stop making.
|
|
2096
|
+
if (hasBitmap || (node.filterRaster && typeof node.filterRaster.asset === 'string')) raster++
|
|
2097
|
+
|
|
2098
|
+
const source = node.source || {}
|
|
2099
|
+
if (source.pseudo) coverage.pseudo++
|
|
2100
|
+
if (source.clone) coverage.artifact++
|
|
2101
|
+
const el = node.el
|
|
2102
|
+
if (el && el.nodeType === 1) {
|
|
2103
|
+
const src = live(el)
|
|
2104
|
+
if (!src) {
|
|
2105
|
+
coverage.synthetic++
|
|
2106
|
+
// snapdom turns a ligature glyph into an <img> with a baked data URL; the
|
|
2107
|
+
// <img> has no live counterpart because in the page it was a text node in
|
|
2108
|
+
// an icon font. That pair of facts is the only signal it leaves.
|
|
2109
|
+
if (el.tagName === 'IMG' && String(el.getAttribute('src') || '').startsWith('data:image')) {
|
|
2110
|
+
coverage.iconFont++
|
|
2111
|
+
}
|
|
2112
|
+
} else if (behindShadowBoundary(src)) {
|
|
2113
|
+
// Counted on the SOURCE, not on the clone: the clone has the shadow tree
|
|
2114
|
+
// flattened into light DOM, so by then there is no boundary left to see.
|
|
2115
|
+
// The host element itself is often pruned for painting nothing, so
|
|
2116
|
+
// counting hosts would report 0 for a component that came through whole.
|
|
2117
|
+
coverage.shadow++
|
|
2118
|
+
} else if (source.shadowHost) {
|
|
2119
|
+
coverage.shadow++
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const domElements = element.querySelectorAll ? element.querySelectorAll('*').length + 1 : 1
|
|
2125
|
+
const cloneElements = cloneRoot && cloneRoot.querySelectorAll
|
|
2126
|
+
? cloneRoot.querySelectorAll('*').length + 1
|
|
2127
|
+
: 0
|
|
2128
|
+
const fonts = Object.keys(styles.text).map((id) => ({
|
|
2129
|
+
id,
|
|
2130
|
+
family: styles.text[id].family,
|
|
2131
|
+
weight: styles.text[id].weight,
|
|
2132
|
+
italic: styles.text[id].italic,
|
|
2133
|
+
}))
|
|
2134
|
+
|
|
2135
|
+
return {
|
|
2136
|
+
nodes: ids.length,
|
|
2137
|
+
domElements,
|
|
2138
|
+
// The clone is the tree that was walked and it is BIGGER than the source:
|
|
2139
|
+
// both numbers are here so `nodeRatio` cannot be read as a claim about the
|
|
2140
|
+
// wrong one.
|
|
2141
|
+
cloneElements,
|
|
2142
|
+
// Measured, never promised: the whole point of the pruning in `paint.js` is
|
|
2143
|
+
// a number you can check, not a number in a pitch deck.
|
|
2144
|
+
nodeRatio: domElements ? ids.length / domElements : 0,
|
|
2145
|
+
// The mounted clone against the live element it copies, in px. Everything
|
|
2146
|
+
// above is measured ON the clone, so this is the error bar on all of it.
|
|
2147
|
+
drift,
|
|
2148
|
+
// Nodes the live-DOM walk could not have reached. See `coverage` above.
|
|
2149
|
+
coverage,
|
|
2150
|
+
vector: ids.length - raster,
|
|
2151
|
+
raster,
|
|
2152
|
+
textBlocks,
|
|
2153
|
+
fonts,
|
|
2154
|
+
grades,
|
|
2155
|
+
assets: Object.keys(assets).length,
|
|
2156
|
+
textStyles: Object.keys(styles.text).length,
|
|
2157
|
+
diagnostics: st.doc.diagnostics.length,
|
|
2158
|
+
promotions: st.doc.diagnostics.filter((d) => d.code === 'B4.promoted').length,
|
|
2159
|
+
// The mount, the walk, the collectors and the asset fetches — this export
|
|
2160
|
+
// and nothing else. What snapdom's own capture cost used to be itemised here
|
|
2161
|
+
// beside it, back when this module made that call itself; now the caller
|
|
2162
|
+
// does, and the caller is the only one who can time it honestly (the last
|
|
2163
|
+
// hook fires well before `captureDOM` finishes serializing).
|
|
2164
|
+
durationMs: now() - startedAt,
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function now () {
|
|
2169
|
+
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* Is this live node inside a shadow tree? `getRootNode()` walks past nested
|
|
2174
|
+
* boundaries to the top, so the test is "the root is not the document" rather
|
|
2175
|
+
* than an `instanceof` against a global that may not be there.
|
|
2176
|
+
* @param {Node} node
|
|
2177
|
+
*/
|
|
2178
|
+
function behindShadowBoundary (node) {
|
|
2179
|
+
if (!node || typeof node.getRootNode !== 'function') return false
|
|
2180
|
+
const root = node.getRootNode()
|
|
2181
|
+
return !!root && root.nodeType === 11 && !!root.host
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/** Grouped by code: 400 identical warnings are one line and a count, not 400 lines. */
|
|
2185
|
+
function printDiagnostics (doc) {
|
|
2186
|
+
if (typeof console === 'undefined' || !doc.diagnostics.length) return
|
|
2187
|
+
const groups = new Map()
|
|
2188
|
+
for (const d of doc.diagnostics) {
|
|
2189
|
+
if (!groups.has(d.code)) groups.set(d.code, [])
|
|
2190
|
+
groups.get(d.code).push(d)
|
|
2191
|
+
}
|
|
2192
|
+
const text = (d) => d.message || d.note || d.reason || ''
|
|
2193
|
+
const ordered = [...groups].sort((a, b) => b[1].length - a[1].length)
|
|
2194
|
+
|
|
2195
|
+
console.warn(
|
|
2196
|
+
`[snapdom-vector] ${doc.diagnostics.length} diagnostic(s) in ${ordered.length} code(s); ` +
|
|
2197
|
+
'all of them are in doc.diagnostics.'
|
|
2198
|
+
)
|
|
2199
|
+
for (const [code, list] of ordered) {
|
|
2200
|
+
const seen = []
|
|
2201
|
+
for (const d of list) {
|
|
2202
|
+
const message = text(d)
|
|
2203
|
+
if (message && !seen.includes(message)) seen.push(message)
|
|
2204
|
+
if (seen.length === 3) break
|
|
2205
|
+
}
|
|
2206
|
+
console.warn(`[snapdom-vector] ${code} ×${list.length}`)
|
|
2207
|
+
for (const message of seen) console.warn(`[snapdom-vector] ${message}`)
|
|
2208
|
+
if (list.length > seen.length && seen.length === 3) {
|
|
2209
|
+
console.warn(`[snapdom-vector] …and ${list.length - 3} more of this code`)
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// ——— entry points ———
|
|
2215
|
+
|
|
2216
|
+
/** Snapshot only plain option data, keeping functions and DOM objects opaque. */
|
|
2217
|
+
function snapshotOption (value, seen = new WeakMap()) {
|
|
2218
|
+
const snapshot = { value }
|
|
2219
|
+
if (!value || typeof value !== 'object') return snapshot
|
|
2220
|
+
const proto = Object.getPrototypeOf(value)
|
|
2221
|
+
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return snapshot
|
|
2222
|
+
if (seen.has(value)) return seen.get(value)
|
|
2223
|
+
seen.set(value, snapshot)
|
|
2224
|
+
snapshot.entries = new Map(Object.keys(value).map(key => [key, snapshotOption(value[key], seen)]))
|
|
2225
|
+
if (Array.isArray(value)) snapshot.length = value.length
|
|
2226
|
+
return snapshot
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/** Identity changes and in-place edits both count; cyclic option bags are safe. */
|
|
2230
|
+
function sameOption (value, snapshot, seen = new Set()) {
|
|
2231
|
+
if (!Object.is(value, snapshot.value)) return false
|
|
2232
|
+
if (!snapshot.entries || seen.has(snapshot)) return true
|
|
2233
|
+
seen.add(snapshot)
|
|
2234
|
+
if (Array.isArray(value) && value.length !== snapshot.length) return false
|
|
2235
|
+
const keys = Object.keys(value)
|
|
2236
|
+
return keys.length === snapshot.entries.size && keys.every(key =>
|
|
2237
|
+
snapshot.entries.has(key) && sameOption(value[key], snapshot.entries.get(key), seen))
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* v3 passes normalized capture options to exporters, including defaults such as
|
|
2242
|
+
* `exclude: []` and `embedFonts: 'auto'`. Those are not per-export requests and
|
|
2243
|
+
* must not erase defaults passed to vector(). Explicit export options and
|
|
2244
|
+
* detectable beforeExport changes win, including in-place array/object edits.
|
|
2245
|
+
* An assignment of the unchanged scalar value cannot be distinguished from an
|
|
2246
|
+
* untouched default; use an explicit export option for that case. Direct calls
|
|
2247
|
+
* without the v3 facade keep the usual defaults-then-options merge.
|
|
2248
|
+
*/
|
|
2249
|
+
function exportOptions (ctx, defaults, opts, captured) {
|
|
2250
|
+
const requested = ctx && ctx.export && ctx.export.requestedOptions
|
|
2251
|
+
if (!requested) return { ...defaults, ...opts }
|
|
2252
|
+
const merged = { ...opts, ...defaults }
|
|
2253
|
+
for (const [key, snapshot] of captured) {
|
|
2254
|
+
if (hasOwn(opts, key) && (hasOwn(requested, key) || !sameOption(opts[key], snapshot))) merged[key] = opts[key]
|
|
2255
|
+
}
|
|
2256
|
+
return merged
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* The engine, as a snapdom plugin — the one way to use it.
|
|
2261
|
+
*
|
|
2262
|
+
* Shaped like the twelve official plugins (`snapdom/packages/plugins/`): a
|
|
2263
|
+
* factory returning `{name, hook, defineExports}`. What is specific to this one
|
|
2264
|
+
* is that the export does nearly all of the work, and the hook does almost none:
|
|
2265
|
+
* a capture whose result nobody asks a vector question of costs one object.
|
|
2266
|
+
*
|
|
2267
|
+
* const result = await snapdom(el, { plugins: [vector({ mode: 'replica' })] })
|
|
2268
|
+
* const doc = await result.toVector() // SVD document
|
|
2269
|
+
* const svg = await result.toVectorSvg() // flat SVG markup
|
|
2270
|
+
* const fig = svdToFigma(doc) // …or any emitter, off the doc
|
|
2271
|
+
* const h2d = await result.toVectorFigmaClipboard() // Figma's paste envelope
|
|
2272
|
+
*
|
|
2273
|
+
* **The export names.** snapdom's own `svg` export is `result.toSvg()` — an
|
|
2274
|
+
* `<img>` of the capture (`snapdom/src/api/snapdom.js:333`) — and plugin exports
|
|
2275
|
+
* OVERRIDE core ones by name (`buildResult`: `{...coreExports, ...provided}`), so
|
|
2276
|
+
* a plugin publishing `svg` would silently replace it for every caller of that
|
|
2277
|
+
* capture. `vector` and `vectorSvg` are free, and `buildResult` turns them into
|
|
2278
|
+
* `toVector()` / `toVectorSvg()` by the same rule it uses for every other plugin
|
|
2279
|
+
* (`'to' + key[0].toUpperCase() + key.slice(1)`).
|
|
2280
|
+
*
|
|
2281
|
+
* `vectorFigmaClipboard` is free by the same check, and it is prefixed for the
|
|
2282
|
+
* same reason the other two are: taken are the core `img|svg|canvas|blob|png|
|
|
2283
|
+
* jpeg|jpg|webp|download` and the official plugins' `agentMap|ascii|context|gif|
|
|
2284
|
+
* html|htmlInCanvas|pdfImage|mp4`, plus `pdf` from the sibling package here — a
|
|
2285
|
+
* bare `figma` or `html` would be a name a future plugin can take from us, or we
|
|
2286
|
+
* from it, with no warning either way. It is also NOT `svdToFigma`, which exists
|
|
2287
|
+
* and is a different thing: that one turns an SVD document into JSON for our own
|
|
2288
|
+
* Figma plugin to build nodes with. This one produces the clipboard envelope
|
|
2289
|
+
* FIGMA'S OWN importer opens, and never touches the SVD.
|
|
2290
|
+
*
|
|
2291
|
+
* Options given here are defaults for every export; the same names passed to an
|
|
2292
|
+
* export call override them for that call.
|
|
2293
|
+
*
|
|
2294
|
+
* @param {object} [options]
|
|
2295
|
+
* @param {'design'|'replica'} [options.mode='design']
|
|
2296
|
+
* @param {number} [options.maxNodes=20000] ceiling; the walk stops and says so
|
|
2297
|
+
* @param {number} [options.maxDepth=256]
|
|
2298
|
+
* @param {string|Function|Array} [options.exclude] selectors or predicates to skip
|
|
2299
|
+
* @param {number} [options.fetchTimeout=8000] per asset, in ms
|
|
2300
|
+
* @param {number} [options.maxAssetBytes] size past which an asset is reported
|
|
2301
|
+
* @param {boolean} [options.embedFonts=true] fetch the bytes behind every
|
|
2302
|
+
* `@font-face` family the capture paints with and carry them in `doc.assets`
|
|
2303
|
+
* (`collect/font.js`): the emitted SVG then renders without the viewer's
|
|
2304
|
+
* installed fonts and every line is pinned with `textLength`. The file travels
|
|
2305
|
+
* WHOLE — there is no subsetter — and each face declares its size under
|
|
2306
|
+
* `assets.font-embedded`; `false` keeps the export small and the families
|
|
2307
|
+
* travel by name, declared per family instead.
|
|
2308
|
+
* @param {boolean} [options.silent=false] do not print the grouped diagnostics
|
|
2309
|
+
* @param {boolean} [options.debugPaint=false] `toVectorSvg` only: tint nodes by
|
|
2310
|
+
* stacking-context depth
|
|
2311
|
+
* @param {boolean} [options.rasterFilters=false] rasterize, to a PNG `<image>`,
|
|
2312
|
+
* the nodes whose FILTER an importer of Figma's class does not run — the colour
|
|
2313
|
+
* functions of `filter`/`backdrop-filter` (`saturate`, `brightness`, `contrast`,
|
|
2314
|
+
* `grayscale`, `sepia`, `hue-rotate`, `invert`), which become one `feColorMatrix`,
|
|
2315
|
+
* and inner shadows, which have no primitive and stay a composite chain. Those
|
|
2316
|
+
* nodes and no others: a filter that arrives intact is never touched. Off by
|
|
2317
|
+
* default because it is a real loss — the subtree stops being editable in every
|
|
2318
|
+
* consumer, not only in the one it is for — and because with it off the document
|
|
2319
|
+
* is unchanged and the loss is declared instead (`emit.svg.filter-not-imported`).
|
|
2320
|
+
* The pixels come from the MOUNTED CLONE, which is where snapdom has already
|
|
2321
|
+
* pre-composed `backdrop-filter`, over the filter's own region (`3σ + |offset|`)
|
|
2322
|
+
* rather than the border box. Every node it rasterizes says so (`raster.filter`,
|
|
2323
|
+
* grade R) and every node it could not says that (`raster.filter-failed`).
|
|
2324
|
+
* **Only `toVectorSvg` consumes them.** `svdToFigma` and the PDF backend do not
|
|
2325
|
+
* read `node.filterRaster` yet and emit the node's vector paint instead; that is
|
|
2326
|
+
* said in the diagnostic rather than left for someone to discover.
|
|
2327
|
+
*
|
|
2328
|
+
* **`toVectorFigmaClipboard()` is a different KIND of export from the other two**
|
|
2329
|
+
* and should be read before it is shipped behind a button. It does not produce a
|
|
2330
|
+
* document this engine defines: it produces Figma's own `h2d` clipboard payload —
|
|
2331
|
+
* the computed DOM, which Figma converts on paste — in a format that is
|
|
2332
|
+
* proprietary, undocumented, reverse-engineered from one decoded capture of their
|
|
2333
|
+
* extension, and already at `version: 2`. Figma can change it in any release,
|
|
2334
|
+
* with no negotiation and no error; the only symptom is a ⌘V that does nothing.
|
|
2335
|
+
* What it buys is what the SVG importer cannot do at all: `letter-spacing`
|
|
2336
|
+
* survives (FIGMA_FINDINGS.md §5). What it costs is that only Figma opens it,
|
|
2337
|
+
* which is why `toVectorSvg()` is not going anywhere.
|
|
2338
|
+
*
|
|
2339
|
+
* @returns {object} snapdom plugin
|
|
2340
|
+
*/
|
|
2341
|
+
export function vector (options = {}) {
|
|
2342
|
+
return {
|
|
2343
|
+
name: 'vector',
|
|
2344
|
+
|
|
2345
|
+
// No `pure: true`, and that is load-bearing rather than an omission. The flag
|
|
2346
|
+
// opts a plugin back into auto-burst (`snapdom/src/core/plugins.js:184`), and
|
|
2347
|
+
// burst's memo and differential paths both answer with a URL WITHOUT
|
|
2348
|
+
// re-entering `captureDOM` — so the hook below would not run and the export
|
|
2349
|
+
// would have no clone. Declaring a read-only hook impure is what buys the
|
|
2350
|
+
// `burst: false` the loose API used to force by hand.
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* The hand-off, and nothing else. v3 passes one capture context to every
|
|
2354
|
+
* hook; `state.options` aliases that same context. The export views preserve
|
|
2355
|
+
* its enumerable fields, including this hand-off.
|
|
2356
|
+
*/
|
|
2357
|
+
beforeRender (state) {
|
|
2358
|
+
if (!state || !state.options) return
|
|
2359
|
+
const handoff = handoffFrom(state)
|
|
2360
|
+
// `baseCSS` is snapdom's UA-stylesheet reset, and it belongs to the RASTER
|
|
2361
|
+
// path and to nothing else. `handoffFrom` stays the adapter's six fields:
|
|
2362
|
+
// the mount does not want this string — injecting a tag reset into the live
|
|
2363
|
+
// page would restyle the page the clone is measured against — and only
|
|
2364
|
+
// `rasterizeUnrunFilters`, which renders the clone inside a foreignObject
|
|
2365
|
+
// where there is no page cascade at all, has any use for it. Read here
|
|
2366
|
+
// because `beforeRender` is the one hook where snapdom's state carries it
|
|
2367
|
+
// (`engines/svg.js` assembles the CSS before the hook runs).
|
|
2368
|
+
if (handoff) handoff.baseCSS = state.baseCSS || ''
|
|
2369
|
+
state.options.__vectorHandoff = handoff
|
|
2370
|
+
},
|
|
2371
|
+
|
|
2372
|
+
defineExports (captureCtx) {
|
|
2373
|
+
// Only collisions need snapshots. Never traverse the whole capture context:
|
|
2374
|
+
// it owns the clone and maps, and export hooks share its option references.
|
|
2375
|
+
const captured = new Map(Object.keys(options).map(key => [key, snapshotOption(captureCtx && captureCtx[key])]))
|
|
2376
|
+
return {
|
|
2377
|
+
vector: (ctx, opts = {}) => buildDocument(ctx, exportOptions(ctx, options, opts, captured)),
|
|
2378
|
+
vectorSvg: async (ctx, opts = {}) => {
|
|
2379
|
+
const merged = exportOptions(ctx, options, opts, captured)
|
|
2380
|
+
return svgFrom(await buildDocument(ctx, merged), merged)
|
|
2381
|
+
},
|
|
2382
|
+
vectorFigmaClipboard: (ctx, opts = {}) => buildFigmaClipboard(ctx, exportOptions(ctx, options, opts, captured)),
|
|
2383
|
+
}
|
|
2384
|
+
},
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
export default vector
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
2391
|
+
* The two guards every export that walks the clone has to pass, in one place so
|
|
2392
|
+
* the second backend cannot drift from the first one's diagnosis.
|
|
2393
|
+
*
|
|
2394
|
+
* @param {object} ctx the export context
|
|
2395
|
+
* @param {string} label the export's own name, so the error names what was called
|
|
2396
|
+
* @returns {{handoff: object, element: Element}}
|
|
2397
|
+
*/
|
|
2398
|
+
function openCapture (ctx, label) {
|
|
2399
|
+
const handoff = ctx && ctx.__vectorHandoff
|
|
2400
|
+
if (!handoff) {
|
|
2401
|
+
throw new Error(
|
|
2402
|
+
`${label}: this capture carries no clone. The vector plugin's \`beforeRender\` hook never ` +
|
|
2403
|
+
'ran, so the capture did not go through captureDOM — the usual cause is an explicit ' +
|
|
2404
|
+
'`burst: true`, which serves a memoized URL instead. Leave `burst` unset: a plugin with ' +
|
|
2405
|
+
'render hooks suspends auto-burst on its own.'
|
|
2406
|
+
)
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// No "is there a DOM?" check any more: the hand-off only exists because a hook
|
|
2410
|
+
// ran inside a capture, and a capture only happens in a browser.
|
|
2411
|
+
const element = handoff.element
|
|
2412
|
+
const rect = element.getBoundingClientRect()
|
|
2413
|
+
if (!(rect.width > 0) || !(rect.height > 0)) {
|
|
2414
|
+
const cs = getComputedStyle(element)
|
|
2415
|
+
const why = cs.display === 'none'
|
|
2416
|
+
? 'it is display:none'
|
|
2417
|
+
: element.isConnected === false
|
|
2418
|
+
? 'it is not connected to a document'
|
|
2419
|
+
: `its border box is ${rect.width}×${rect.height}`
|
|
2420
|
+
throw new Error(`${label}: the captured element has no layout box (${why}); there is nothing to capture`)
|
|
2421
|
+
}
|
|
2422
|
+
return { handoff, element }
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* The `h2d` backend, end to end: mount the clone, walk it into Figma's own
|
|
2427
|
+
* clipboard payload, wrap it in the envelope, unmount.
|
|
2428
|
+
*
|
|
2429
|
+
* **It does not build an SVD document, and that is the point.** This is a
|
|
2430
|
+
* parallel backend off the same clone, not a second consumer of the first one's
|
|
2431
|
+
* output: Figma converts the DOM itself on this channel, so a paint tree,
|
|
2432
|
+
* gradients, measured text lines and everything else the SVD models would be work
|
|
2433
|
+
* done twice — once here and once by Figma, from raw styles, differently.
|
|
2434
|
+
* `emit/svg-flat.js` and the SVD are untouched by this path.
|
|
2435
|
+
*
|
|
2436
|
+
* The clone is unmounted in a `finally` for the same reason `buildDocument` does
|
|
2437
|
+
* it: without that, every call leaves a full copy of the user's subtree in their
|
|
2438
|
+
* page.
|
|
2439
|
+
*
|
|
2440
|
+
* @param {object} ctx
|
|
2441
|
+
* @param {object} options
|
|
2442
|
+
* @returns {Promise<import('./emit/h2d.js').H2DResult & {html: string, plain: string,
|
|
2443
|
+
* json: string, bytes: number, clipboardItem: () => ClipboardItem}>}
|
|
2444
|
+
*/
|
|
2445
|
+
/**
|
|
2446
|
+
* The hybrid, in one place: find the nodes whose paint the h2d channel cannot spell,
|
|
2447
|
+
* draw them with the SVD backend, hand back a clone-element -> data-URI map.
|
|
2448
|
+
*
|
|
2449
|
+
* It is skipped entirely when the capture has none of them, and that is the common
|
|
2450
|
+
* case — assembling an SVD is the expensive half of this library, and paying for it
|
|
2451
|
+
* on a page of text and boxes to discover there was nothing to draw would make every
|
|
2452
|
+
* capture slower for the benefit of the ones that need it. The probe that decides is
|
|
2453
|
+
* a style read per element.
|
|
2454
|
+
*
|
|
2455
|
+
* @param {Element} element
|
|
2456
|
+
* @param {import('./adapters/snapdom.js').CloneCapture} capture
|
|
2457
|
+
* @param {object} options
|
|
2458
|
+
* @param {object} handoff
|
|
2459
|
+
* @returns {Promise<{overrides: Map<Element,string>|null, withheld: object[], bytes: number,
|
|
2460
|
+
* diagnostics: object[]}>}
|
|
2461
|
+
*/
|
|
2462
|
+
async function hybridPaint (element, capture, options, handoff) {
|
|
2463
|
+
const none = { overrides: null, withheld: [], bytes: 0, diagnostics: [] }
|
|
2464
|
+
// `vectorFallback: false` turns off the DRAWING, not the reporting. A capture that
|
|
2465
|
+
// stops fixing something and also stops mentioning it is the failure mode this whole
|
|
2466
|
+
// engine is built against.
|
|
2467
|
+
const mode = options.mode === 'replica' ? 'replica' : 'design'
|
|
2468
|
+
const { targets, withheld, backdrop } = findFallbackTargets(capture.clone, {
|
|
2469
|
+
mode,
|
|
2470
|
+
isExcluded: (el) => !!(el.closest && el.closest('[data-capture="exclude"]')),
|
|
2471
|
+
})
|
|
2472
|
+
if (!targets.size && !withheld.length && !backdrop.length) return none
|
|
2473
|
+
|
|
2474
|
+
const diagnostics = []
|
|
2475
|
+
if (backdrop.length) {
|
|
2476
|
+
const byCode = new Map()
|
|
2477
|
+
for (const c of backdrop) byCode.set(c, (byCode.get(c) || 0) + 1)
|
|
2478
|
+
diagnostics.push({
|
|
2479
|
+
code: 'h2d.backdrop-effects-lost',
|
|
2480
|
+
severity: 'warn',
|
|
2481
|
+
message: `${backdrop.length} node(s) use an effect that reads what is UNDERNEATH them ` +
|
|
2482
|
+
`(${[...byCode].map(([c, n]) => `${c}×${n}`).join(', ')}). The channel does not carry those ` +
|
|
2483
|
+
'properties and the vector fallback cannot supply them either: a per-node asset is its own ' +
|
|
2484
|
+
'document with a transparent backdrop, so multiplying against nothing produces the same flat ' +
|
|
2485
|
+
'shape as describing it as a box — measured on `demo/hybrid-probe.html`, where a ' +
|
|
2486
|
+
'`mix-blend-mode: multiply` square drew identically both ways. They arrive unblended. Figma ' +
|
|
2487
|
+
'does not apply blend on import either, so this is not a gap against their extension.',
|
|
2488
|
+
})
|
|
2489
|
+
}
|
|
2490
|
+
if (withheld.length) {
|
|
2491
|
+
const byCode = new Map()
|
|
2492
|
+
for (const w of withheld) for (const c of w.codes) byCode.set(c, (byCode.get(c) || 0) + 1)
|
|
2493
|
+
diagnostics.push({
|
|
2494
|
+
code: 'h2d.vector-paint-withheld',
|
|
2495
|
+
severity: 'warn',
|
|
2496
|
+
message: `${withheld.length} node(s) paint something this channel cannot describe ` +
|
|
2497
|
+
`(${[...byCode].map(([c, n]) => `${c}×${n}`).join(', ')}) and were NOT redrawn as vector, ` +
|
|
2498
|
+
'because they have text inside and replacing them would replace the editable text with a ' +
|
|
2499
|
+
'drawing. In `design` mode the text wins and the effect is lost; run the capture with ' +
|
|
2500
|
+
'`mode: "replica"` to trade it the other way. Figma\'s own extension loses these too — ' +
|
|
2501
|
+
'measured, FIGMA_FINDINGS.md § «El híbrido» — so this is not a gap against their tool.',
|
|
2502
|
+
})
|
|
2503
|
+
}
|
|
2504
|
+
if (!targets.size) return { ...none, withheld, diagnostics }
|
|
2505
|
+
if (options.vectorFallback === false) {
|
|
2506
|
+
diagnostics.push({
|
|
2507
|
+
code: 'h2d.vector-paint-off',
|
|
2508
|
+
severity: 'warn',
|
|
2509
|
+
message: `${targets.size} node(s) would have had their paint drawn as vector and \`vectorFallback: ` +
|
|
2510
|
+
'false\' turned it off. They are in the payload as plain CSS boxes, which means Figma paints ' +
|
|
2511
|
+
'them without the effect — no conic ramp, no filter, no clip.',
|
|
2512
|
+
})
|
|
2513
|
+
return { ...none, withheld, diagnostics }
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
const elementIndex = new Map()
|
|
2517
|
+
let doc
|
|
2518
|
+
try {
|
|
2519
|
+
doc = await assemble(element, capture, options, now(), handoff, elementIndex)
|
|
2520
|
+
} catch (error) {
|
|
2521
|
+
diagnostics.push({
|
|
2522
|
+
code: 'h2d.vector-paint-failed',
|
|
2523
|
+
severity: 'warn',
|
|
2524
|
+
message: `${targets.size} node(s) needed their paint drawn as vector and the SVD document ` +
|
|
2525
|
+
`could not be assembled: ${error && error.message}. They are in the payload described as ` +
|
|
2526
|
+
'plain CSS boxes, which is what they looked like before this step existed — the effect is ' +
|
|
2527
|
+
'lost, not the node.',
|
|
2528
|
+
})
|
|
2529
|
+
return { ...none, withheld, diagnostics }
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
const overrides = new Map()
|
|
2533
|
+
let bytes = 0
|
|
2534
|
+
const unresolved = []
|
|
2535
|
+
for (const [el, info] of targets) {
|
|
2536
|
+
const id = elementIndex.get(el)
|
|
2537
|
+
const slice = id ? svdSliceToDataUri(doc, id) : null
|
|
2538
|
+
if (!slice) { unresolved.push({ tag: el.tagName, codes: info.codes }); continue }
|
|
2539
|
+
overrides.set(el, slice.uri)
|
|
2540
|
+
bytes += slice.bytes
|
|
2541
|
+
}
|
|
2542
|
+
if (unresolved.length) {
|
|
2543
|
+
diagnostics.push({
|
|
2544
|
+
code: 'h2d.vector-paint-unresolved',
|
|
2545
|
+
severity: 'warn',
|
|
2546
|
+
message: `${unresolved.length} node(s) were selected for vector paint and no SVD node could be ` +
|
|
2547
|
+
`found for them (${unresolved.slice(0, 4).map((u) => u.tag + ':' + u.codes.join('+')).join(', ')}). ` +
|
|
2548
|
+
'They arrive as plain CSS boxes without the effect. This is an engine bug, not a page one: ' +
|
|
2549
|
+
'the paint tree and the h2d walk disagreed about which elements exist.',
|
|
2550
|
+
})
|
|
2551
|
+
}
|
|
2552
|
+
if (overrides.size) {
|
|
2553
|
+
const byCode = new Map()
|
|
2554
|
+
for (const [, info] of targets) for (const c of info.codes) byCode.set(c, (byCode.get(c) || 0) + 1)
|
|
2555
|
+
diagnostics.push({
|
|
2556
|
+
code: 'h2d.vector-paint',
|
|
2557
|
+
severity: 'info',
|
|
2558
|
+
message: `${overrides.size} node(s) have their paint drawn by this engine's SVD backend and ` +
|
|
2559
|
+
`carried as image/svg+xml instead of described as CSS (${[...byCode].map(([c, n]) => `${c}×${n}`).join(', ')}), ` +
|
|
2560
|
+
`${Math.round(bytes / 1024)} kB of markup. Measured in Figma (2026-08-07): an SVG asset from ` +
|
|
2561
|
+
'this channel imports AS PATHS in a group, not as an image fill, so those layers stay ' +
|
|
2562
|
+
'editable. It is done because the alternative is not a worse paste but no paint at all — ' +
|
|
2563
|
+
'Figma\'s importer has no conic gradient, does not run filters and does not clip, and their ' +
|
|
2564
|
+
'own extension loses exactly the same things. Turn it off with `vectorFallback: false`.',
|
|
2565
|
+
})
|
|
2566
|
+
}
|
|
2567
|
+
return { overrides: overrides.size ? overrides : null, withheld, bytes, diagnostics }
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
async function buildFigmaClipboard (ctx, options) {
|
|
2571
|
+
const { handoff, element } = openCapture(ctx, 'toVectorFigmaClipboard')
|
|
2572
|
+
const rect = element.getBoundingClientRect()
|
|
2573
|
+
const capture = mountClone(handoff)
|
|
2574
|
+
let out
|
|
2575
|
+
let hybrid = { overrides: null, withheld: [], bytes: 0, diagnostics: [] }
|
|
2576
|
+
try {
|
|
2577
|
+
hybrid = await hybridPaint(element, capture, options, handoff)
|
|
2578
|
+
out = cloneToH2d(capture.clone, {
|
|
2579
|
+
paintOverrides: hybrid.overrides,
|
|
2580
|
+
// The LIVE element's document position. Every rect in the payload is
|
|
2581
|
+
// measured on the clone and re-expressed against this, so the paste lands
|
|
2582
|
+
// where the element sits on the page and not 99999px to the left of it.
|
|
2583
|
+
docOrigin: {
|
|
2584
|
+
x: rect.left + (typeof scrollX === 'number' ? scrollX : 0),
|
|
2585
|
+
y: rect.top + (typeof scrollY === 'number' ? scrollY : 0),
|
|
2586
|
+
},
|
|
2587
|
+
rootSize: { width: rect.width, height: rect.height },
|
|
2588
|
+
// Clone node -> live node. The walk reads the resolved styles off the CLONE and
|
|
2589
|
+
// the authored ones off the live twin: snapdom resolves `width: 100%` into a px
|
|
2590
|
+
// when it clones, and that percentage is the only thing that tells Figma a layer
|
|
2591
|
+
// fills its parent instead of being pinned to the width it happens to have.
|
|
2592
|
+
nodeMap: capture.nodeMap,
|
|
2593
|
+
documentTitle: typeof document !== 'undefined' ? document.title : '',
|
|
2594
|
+
devicePixelRatio: typeof devicePixelRatio === 'number' ? devicePixelRatio : 1,
|
|
2595
|
+
exclude: options.exclude,
|
|
2596
|
+
maxNodes: options.maxNodes,
|
|
2597
|
+
// Off puts inline <svg> back to being a tree of CSS boxes, which Figma imports as
|
|
2598
|
+
// an empty frame. It exists so a paste that fails can be bisected against the one
|
|
2599
|
+
// behaviour of this backend that rewrites a node instead of describing it.
|
|
2600
|
+
inlineSvgAsAsset: options.inlineSvgAsAsset,
|
|
2601
|
+
})
|
|
2602
|
+
} finally {
|
|
2603
|
+
capture.unmount()
|
|
2604
|
+
}
|
|
2605
|
+
out.diagnostics.push(...hybrid.diagnostics)
|
|
2606
|
+
|
|
2607
|
+
// The mount is verified on this path too, by the same `DRIFT_LIMIT_PX` the SVD
|
|
2608
|
+
// path uses. `drift` is the whole claim behind reading the clone instead of the
|
|
2609
|
+
// page: a payload built on an unverified mount is a paste whose coordinates
|
|
2610
|
+
// nobody checked, and here they are the ONLY geometry — Figma lays the tree out
|
|
2611
|
+
// from these rects.
|
|
2612
|
+
const drift = capture.drift || {}
|
|
2613
|
+
if (!drift.pairs) {
|
|
2614
|
+
out.diagnostics.push({
|
|
2615
|
+
code: 'h2d.clone-unverified',
|
|
2616
|
+
severity: 'warn',
|
|
2617
|
+
message: 'Not one clone node could be matched to a live element, so the mounted clone\'s layout ' +
|
|
2618
|
+
'was never checked against the layout it copies. Every rect in this payload is measured on the ' +
|
|
2619
|
+
'clone and nothing here confirms the clone is right.',
|
|
2620
|
+
})
|
|
2621
|
+
} else if (drift.median > DRIFT_LIMIT_PX || drift.max > DRIFT_LIMIT_PX) {
|
|
2622
|
+
out.diagnostics.push({
|
|
2623
|
+
code: 'h2d.clone-drift',
|
|
2624
|
+
severity: 'warn',
|
|
2625
|
+
message: `The mounted clone does not lay out like the element it copies: median ` +
|
|
2626
|
+
`${drift.median.toFixed(2)}px, p95 ${drift.p95.toFixed(2)}px, max ${drift.max.toFixed(2)}px ` +
|
|
2627
|
+
`over ${drift.pairs} matched node(s), against a ${DRIFT_LIMIT_PX}px limit. Every rect in this ` +
|
|
2628
|
+
'payload was measured on the clone, so the paste is off by about that much.',
|
|
2629
|
+
})
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
const envelope = h2dEnvelope(out.payload, {
|
|
2633
|
+
selector: cssSelector(element),
|
|
2634
|
+
url: typeof location !== 'undefined' ? location.href : '',
|
|
2635
|
+
})
|
|
2636
|
+
const result = {
|
|
2637
|
+
...envelope,
|
|
2638
|
+
payload: out.payload,
|
|
2639
|
+
diagnostics: out.diagnostics,
|
|
2640
|
+
stats: out.stats,
|
|
2641
|
+
drift: capture.drift,
|
|
2642
|
+
/** The envelope as a ClipboardItem, with the empty `text/plain` half. */
|
|
2643
|
+
clipboardItem: () => h2dClipboardItem(envelope),
|
|
2644
|
+
}
|
|
2645
|
+
if (!options.silent) printDiagnostics({ diagnostics: out.diagnostics })
|
|
2646
|
+
return result
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* One export call: take the clone the hook parked, mount it, walk it, validate
|
|
2651
|
+
* the document, unmount.
|
|
2652
|
+
*
|
|
2653
|
+
* @param {object} ctx the export context snapdom builds from the capture context
|
|
2654
|
+
* @param {object} options
|
|
2655
|
+
* @returns {Promise<SVDDocument>}
|
|
2656
|
+
* @throws when the capture handed over no clone, when the element has nothing to
|
|
2657
|
+
* capture, or when the document we assembled does not validate — the engine
|
|
2658
|
+
* does not emit broken documents.
|
|
2659
|
+
*/
|
|
2660
|
+
async function buildDocument (ctx, options) {
|
|
2661
|
+
const startedAt = now()
|
|
2662
|
+
|
|
2663
|
+
const { handoff, element } = openCapture(ctx, 'toVector')
|
|
2664
|
+
|
|
2665
|
+
// From here on the tree being read is the CLONE. `element` is still what the
|
|
2666
|
+
// capture is ABOUT — its selector, its url, its position in the document — and
|
|
2667
|
+
// nothing below may take a measurement off it: the two trees sit ~99999px
|
|
2668
|
+
// apart and every coordinate is relative to its own root.
|
|
2669
|
+
const capture = mountClone(handoff)
|
|
2670
|
+
try {
|
|
2671
|
+
return await assemble(element, capture, options, startedAt, handoff)
|
|
2672
|
+
} finally {
|
|
2673
|
+
// Not optional and not on the happy path: without this every export leaves
|
|
2674
|
+
// a full copy of the user's subtree, and a <style> for it, in their page.
|
|
2675
|
+
capture.unmount()
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/**
|
|
2680
|
+
* Everything between a mounted clone and a validated document. Split out of
|
|
2681
|
+
* `buildDocument` for one reason: it lets the unmount live in a `finally` that
|
|
2682
|
+
* cannot be skipped by an early return or a throw halfway down.
|
|
2683
|
+
*
|
|
2684
|
+
* @param {Element} element the original, for the capture header and the report
|
|
2685
|
+
* @param {import('./adapters/snapdom.js').CloneCapture} capture
|
|
2686
|
+
* @param {object} options
|
|
2687
|
+
* @param {number} startedAt
|
|
2688
|
+
* @param {object} handoff the capture's own CSS, for `rasterFilters` — the only
|
|
2689
|
+
* step here that has to RENDER the clone rather than read it
|
|
2690
|
+
* @param {Map<Element,string>} [elementIndex] filled with clone element -> node id
|
|
2691
|
+
* just before the back-references are dropped, for callers that have to go back
|
|
2692
|
+
* from the DOM to the document (the h2d hybrid does)
|
|
2693
|
+
* @returns {Promise<SVDDocument>}
|
|
2694
|
+
*/
|
|
2695
|
+
async function assemble (element, capture, options, startedAt, handoff, elementIndex) {
|
|
2696
|
+
const { clone, nodeMap, drift } = capture
|
|
2697
|
+
const rect = element.getBoundingClientRect()
|
|
2698
|
+
|
|
2699
|
+
const ctx = makeCtx(clone, options)
|
|
2700
|
+
const tree = buildPaintTree(clone, ctx)
|
|
2701
|
+
const rootNode = tree.nodes[tree.rootId]
|
|
2702
|
+
|
|
2703
|
+
const doc = emptyDocument({
|
|
2704
|
+
url: typeof location !== 'undefined' ? location.href : '',
|
|
2705
|
+
selector: cssSelector(element),
|
|
2706
|
+
capturedAt: new Date().toISOString(),
|
|
2707
|
+
viewport: {
|
|
2708
|
+
w: typeof innerWidth === 'number' ? innerWidth : 0,
|
|
2709
|
+
h: typeof innerHeight === 'number' ? innerHeight : 0,
|
|
2710
|
+
dpr: typeof devicePixelRatio === 'number' ? devicePixelRatio : 1,
|
|
2711
|
+
},
|
|
2712
|
+
// The root's border box in DOCUMENT coordinates: `abs` is relative to it, so
|
|
2713
|
+
// this is what puts a capture back where it came from.
|
|
2714
|
+
docOrigin: {
|
|
2715
|
+
x: rect.left + (typeof scrollX === 'number' ? scrollX : 0),
|
|
2716
|
+
y: rect.top + (typeof scrollY === 'number' ? scrollY : 0),
|
|
2717
|
+
},
|
|
2718
|
+
root: { w: rootNode.abs.w, h: rootNode.abs.h },
|
|
2719
|
+
mode: ctx.mode,
|
|
2720
|
+
})
|
|
2721
|
+
doc.nodes = tree.nodes
|
|
2722
|
+
doc.root = tree.rootId
|
|
2723
|
+
doc.diagnostics = tree.diagnostics.slice()
|
|
2724
|
+
ctx.assets = doc.assets
|
|
2725
|
+
|
|
2726
|
+
const st = {
|
|
2727
|
+
ctx,
|
|
2728
|
+
doc,
|
|
2729
|
+
dropped: new Set(),
|
|
2730
|
+
textStyleIds: new Map(),
|
|
2731
|
+
markupTextUses: [],
|
|
2732
|
+
/** `{id, mask}` per node whose `mask-image` layers still need a node. */
|
|
2733
|
+
masks: [],
|
|
2734
|
+
// clone node -> live node. `buildReport` needs it to tell a node snapdom
|
|
2735
|
+
// SYNTHESIZED (a materialised pseudo, an icon-font <img>, a scroll wrapper)
|
|
2736
|
+
// from one that was already in the user's DOM — that difference is the whole
|
|
2737
|
+
// coverage claim of walking the clone.
|
|
2738
|
+
nodeMap,
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// Every node, one collector each. The image calls are awaited together: a card
|
|
2742
|
+
// with twelve icons is twelve fetches in flight, not twelve round trips.
|
|
2743
|
+
const pending = []
|
|
2744
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
2745
|
+
const task = collectNode(st, id, doc.nodes[id])
|
|
2746
|
+
if (task) pending.push(task)
|
|
2747
|
+
}
|
|
2748
|
+
if (pending.length) await Promise.all(pending)
|
|
2749
|
+
|
|
2750
|
+
// Background `url()` layers can only be placed once their intrinsic size is
|
|
2751
|
+
// known, so they are a second batch rather than part of the first.
|
|
2752
|
+
await resolveBackgroundAssets(st)
|
|
2753
|
+
|
|
2754
|
+
applyDrops(st)
|
|
2755
|
+
// snapdom empties four properties on the clone ROOT so a raster capture is not
|
|
2756
|
+
// asked to draw outside its own bitmap; the adapter puts them back from the
|
|
2757
|
+
// live element because a vector export has somewhere to put them. That is a
|
|
2758
|
+
// repair of the input, so it is stated — a reader comparing this document to
|
|
2759
|
+
// the clone would otherwise find paint the clone does not have.
|
|
2760
|
+
if (Array.isArray(capture.restoredRootPaint) && capture.restoredRootPaint.length) {
|
|
2761
|
+
report(st, doc.root, 'capture.root-paint-restored', 'A',
|
|
2762
|
+
`snapdom strips ${capture.restoredRootPaint.join(', ')} from the capture root (and only from it, ` +
|
|
2763
|
+
'`stripRootShadows`): for a bitmap that paint would bleed past the edge of the image. It is read ' +
|
|
2764
|
+
'back off the live element and put on the clone before anything is measured, so this document ' +
|
|
2765
|
+
'carries the root\'s own outer paint instead of silently losing it. It paints OUTSIDE the root box ' +
|
|
2766
|
+
'in every case, so a backend whose canvas is that box (a standalone SVG, whose viewport is the ' +
|
|
2767
|
+
'root frame) still clips it; one whose frames can overflow (Figma) draws it.', 'info')
|
|
2768
|
+
}
|
|
2769
|
+
// The mount got the pseudo-suppressor and nothing else: the shadow-scoped
|
|
2770
|
+
// rules could not be cut out of `classCSS`. Everything under a flattened
|
|
2771
|
+
// shadow root is then painted by the page's cascade alone, which does not
|
|
2772
|
+
// reach it — so it is declared here rather than left to be read off `drift`.
|
|
2773
|
+
if (capture.classPrefixExact === false) {
|
|
2774
|
+
report(st, null, 'capture.clone-css-partial', 'A',
|
|
2775
|
+
'snapdom\'s shadow-scoped rules could not be separated from its per-node style snapshots ' +
|
|
2776
|
+
'(the anchor `classPrefixFrom` cuts on has moved in this build of snapdom), so the mounted ' +
|
|
2777
|
+
'clone was styled with the pseudo-element suppressor alone. Content that came out of a ' +
|
|
2778
|
+
'shadow root is laid out and painted without its component\'s own CSS; check `drift` before ' +
|
|
2779
|
+
'trusting any coordinate in this document.', 'warn')
|
|
2780
|
+
}
|
|
2781
|
+
wireMasks(st)
|
|
2782
|
+
padGradients(st)
|
|
2783
|
+
// Last of the passes that touch the clone, and the only one that RENDERS it.
|
|
2784
|
+
// It runs here rather than in the emitter because the emitter is synchronous
|
|
2785
|
+
// and DOM-free by contract (`svdToSvg`, the oracle backend, has to run in
|
|
2786
|
+
// Node): this is the one place that is both inside the mount and allowed to
|
|
2787
|
+
// wait. After it, the clone is only measured — and shortly after that, gone.
|
|
2788
|
+
if (options.rasterFilters) await rasterizeUnrunFilters(st, capture, handoff || {})
|
|
2789
|
+
// After every text style exists and before `declareFonts`, which then declares
|
|
2790
|
+
// only what is STILL not embedded. Inside the mount like the raster pass above,
|
|
2791
|
+
// because this is the other collector that is allowed to await.
|
|
2792
|
+
if (options.embedFonts !== false) await collectFonts(st, report)
|
|
2793
|
+
declareFonts(st)
|
|
2794
|
+
declareTracking(st)
|
|
2795
|
+
declareDrift(st, drift)
|
|
2796
|
+
declarePseudoGaps(st, nodeMap)
|
|
2797
|
+
declareEverything(st)
|
|
2798
|
+
doc.report = buildReport(st, element, clone, drift, startedAt)
|
|
2799
|
+
|
|
2800
|
+
// The clone is unmounted the moment this returns, and every node holds a
|
|
2801
|
+
// non-enumerable `el` pointing into it. Left in place, a document nobody has
|
|
2802
|
+
// finished with keeps a detached copy of the whole subtree alive. Nothing
|
|
2803
|
+
// downstream reads it — the collectors are the last consumers.
|
|
2804
|
+
// The h2d hybrid needs to go from a clone element to the SVD node that describes it,
|
|
2805
|
+
// and `el` is the only link there is — `source.path` is a selector rebuilt from
|
|
2806
|
+
// classes snapdom generated, which is a string match where an identity is available.
|
|
2807
|
+
// It is handed over HERE because the next statement destroys it.
|
|
2808
|
+
if (elementIndex instanceof Map) {
|
|
2809
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
2810
|
+
const el = doc.nodes[id].el
|
|
2811
|
+
if (el && !elementIndex.has(el)) elementIndex.set(el, id)
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
2816
|
+
const node = doc.nodes[id]
|
|
2817
|
+
if (node.el) node.el = null
|
|
2818
|
+
if (node._kids) node._kids = null
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
const result = validate(doc)
|
|
2822
|
+
if (!result.ok) {
|
|
2823
|
+
const shown = result.errors.slice(0, 20)
|
|
2824
|
+
const rest = result.errors.length - shown.length
|
|
2825
|
+
throw new Error(
|
|
2826
|
+
`toVector: the assembled SVD document is invalid (${result.errors.length} error(s)) and ` +
|
|
2827
|
+
'was not emitted — a document that does not validate is a bug in the engine, not in the page:\n ' +
|
|
2828
|
+
shown.join('\n ') + (rest > 0 ? `\n …and ${rest} more` : '')
|
|
2829
|
+
)
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
if (!options.silent) printDiagnostics(doc)
|
|
2833
|
+
return doc
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* A validated document through the flat SVG backend, which is the oracle the
|
|
2838
|
+
* rest of the pipeline is checked against. No `foreignObject`: text is `<text>`
|
|
2839
|
+
* with one `<tspan>` per measured visual line.
|
|
2840
|
+
*
|
|
2841
|
+
* This is what `toVectorSvg()` is: `toVector()` and then this. Already holding a
|
|
2842
|
+
* document? `svdToSvg(doc)` is exported and walking the clone twice is not free.
|
|
2843
|
+
*
|
|
2844
|
+
* @param {SVDDocument} doc
|
|
2845
|
+
* @param {object} [options]
|
|
2846
|
+
* @param {object} [options]
|
|
2847
|
+
* @param {'universal'|'figma'} [options.target='universal'] who this file is FOR.
|
|
2848
|
+
* Not a style preference — the destinations disagree on things that cannot be
|
|
2849
|
+
* satisfied at once, and one file cannot serve both. Today it decides exactly one
|
|
2850
|
+
* choice, gradient text:
|
|
2851
|
+
*
|
|
2852
|
+
* - `universal` — `<text fill="url(#lg)">`: plain SVG 1.1, an editable and
|
|
2853
|
+
* searchable text layer, correct in every conformant renderer. **Figma's SVG
|
|
2854
|
+
* importer does not resolve the reference and paints it black.**
|
|
2855
|
+
* - `figma` — the same text as a `<mask>` with the gradient painted through it:
|
|
2856
|
+
* the colours arrive in Figma, and the block stops being a text layer there. A
|
|
2857
|
+
* renderer without `<mask>` support paints the rect unmasked and loses the text
|
|
2858
|
+
* entirely, which is why this is not the default.
|
|
2859
|
+
*
|
|
2860
|
+
* **Getting a capture INTO Figma: paste the universal SVG first (product call,
|
|
2861
|
+
* Martin, 2026-08-13).** In practice the universal file pastes better into
|
|
2862
|
+
* today's Figma than the h2d clipboard channel lands, so the default dev flow
|
|
2863
|
+
* is `toVectorSvg()` → paste, accepting the importer's known losses (tracking
|
|
2864
|
+
* dropped, gradient text black unless `target: 'figma'`, @font-face and
|
|
2865
|
+
* textLength ignored — all measured, FIGMA_FINDINGS). `toVectorFigmaClipboard()`
|
|
2866
|
+
* stays for when the editable text layer is worth more than paste fidelity;
|
|
2867
|
+
* it does not go through the importer at all.
|
|
2868
|
+
* @param {boolean} [options.debugPaint=false] tint nodes by stacking-context depth
|
|
2869
|
+
* @param {boolean} [options.textGradientMask] the same choice, per-property. Wins
|
|
2870
|
+
* over `target` when given, so an explicit false still means false.
|
|
2871
|
+
* @returns {string} SVG markup
|
|
2872
|
+
*/
|
|
2873
|
+
function svgFrom (doc, options = {}) {
|
|
2874
|
+
const out = svdToSvg(doc, {
|
|
2875
|
+
debugPaint: !!options.debugPaint,
|
|
2876
|
+
// Same destination split as the gradient: the exact chain is more faithful
|
|
2877
|
+
// everywhere and Figma cannot run `feMorphology`, so `target: 'figma'` gives the
|
|
2878
|
+
// single-primitive form back.
|
|
2879
|
+
exactShadows: typeof options.exactShadows === 'boolean'
|
|
2880
|
+
? options.exactShadows
|
|
2881
|
+
: options.target !== 'figma',
|
|
2882
|
+
// Opt-IN since 2026-08-07: the mask is the Figma-SVG-importer branch, and Figma
|
|
2883
|
+
// has `toVectorFigmaClipboard()` now. See `svdToSvg` for the measurement.
|
|
2884
|
+
textGradientMask: typeof options.textGradientMask === 'boolean'
|
|
2885
|
+
? options.textGradientMask
|
|
2886
|
+
: options.target === 'figma',
|
|
2887
|
+
})
|
|
2888
|
+
if (typeof out === 'string') return out
|
|
2889
|
+
|
|
2890
|
+
// `svdToSvg` is moving to `{svg, diagnostics}` so its approximations — a conic
|
|
2891
|
+
// fanned into 128 wedges, a gradient stroke sampled on the node box, a line
|
|
2892
|
+
// with no measured width — stop living in an XML comment nobody opens. Both
|
|
2893
|
+
// shapes are accepted while that lands, and the diagnostics, when they come,
|
|
2894
|
+
// are merged into the document and degrade the nodes they name, exactly like
|
|
2895
|
+
// the ones the collectors produce.
|
|
2896
|
+
if (!out || typeof out.svg !== 'string') {
|
|
2897
|
+
throw new Error('toVectorSvg: svdToSvg returned neither markup nor {svg, diagnostics}')
|
|
2898
|
+
}
|
|
2899
|
+
const extra = Array.isArray(out.diagnostics) ? out.diagnostics : []
|
|
2900
|
+
for (const d of extra) {
|
|
2901
|
+
if (!d || typeof d !== 'object') continue
|
|
2902
|
+
doc.diagnostics.push(d)
|
|
2903
|
+
const grade = hasOwn(GRADE_RANK, d.grade) ? d.grade : 'A'
|
|
2904
|
+
if (typeof d.node === 'string') {
|
|
2905
|
+
degradeNode(doc.nodes[d.node], grade, d.message || d.note || d.code)
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
if (extra.length && !options.silent) printDiagnostics({ diagnostics: extra })
|
|
2909
|
+
return out.svg
|
|
2910
|
+
}
|