@zumer/snapdom-plugins 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,1995 @@
1
+ /**
2
+ * SVD -> the payload the Figma plugin consumes.
3
+ *
4
+ * Pure data, and deliberately so: this module runs in the page and the Figma
5
+ * API lives in the plugin sandbox, so everything the plugin needs has to
6
+ * arrive as JSON — node records keyed by id, the font list for the blocking
7
+ * preflight, and the diagnostics for whatever the translation could not carry.
8
+ * There is no `figma.*` call anywhere in this file and there must never be one.
9
+ *
10
+ * This is the ONLY translator (CONTRACT.md "Module signatures"): the plugin
11
+ * consumes what comes out of here verbatim and never reads an SVD field. So
12
+ * everything the plugin cannot re-derive travels in the payload, including
13
+ * `doc.assets` — image fills still reference them by id, but the bytes now ride
14
+ * along under `document.assets` because the plugin is no longer handed the SVD.
15
+ *
16
+ * Four conversions carry all the risk, and each is stated once, here:
17
+ *
18
+ * 1. GRADIENTS. Figma has no handles and no angle — the geometry IS the
19
+ * `gradientTransform`. SVD stores the SVG convention: `unit` is the
20
+ * gradient's geometry in the unit square (keys fixed by CONTRACT.md and
21
+ * never probed for here), and `transform` maps that unit space forward onto
22
+ * the painted layer IN PX. Figma's matrix runs the other way AND in another
23
+ * space: it maps a point of the shape's box **normalized to 0..1** into a
24
+ * canonical gradient space where the ramp runs from x=0 to x=1. So the
25
+ * forward chain is `norm · translate(rect) · transform · basis` and the
26
+ * matrix Figma wants is its inverse. Skipping the `norm` step is not a
27
+ * rounding error: a 46px translation reads as 46 box widths and the whole
28
+ * ramp lands outside the shape.
29
+ * A singular product is precisely the "failed to invert transform" the SVG
30
+ * importer throws; here it becomes a diagnostic and an identity matrix,
31
+ * never a crash.
32
+ * 2. PER-SIDE STROKES. `strokeTopWeight` and friends are legal only on
33
+ * rect-like nodes. Anywhere else — and anywhere the four sides differ in
34
+ * colour — the border is composed as four filled shapes, and says so.
35
+ * 3. TRANSFORMS. Figma's `relativeTransform` is a rotation plus a
36
+ * translation; scale lives in `width`/`height` and skew does not exist at
37
+ * all. A non-zero `skewX` marks the node for outline or raster.
38
+ * 4. TEXT. Figma drops the letter-spacing that follows the last glyph and CSS
39
+ * does not, so the measured width is compensated and the box is nudged by
40
+ * the alignment. `characters` keeps U+2028 verbatim: a newline would make a
41
+ * paragraph and pull in `paragraphSpacing`.
42
+ *
43
+ * **THE TWO ROUTES INTO FIGMA DO NOT LOSE THE SAME THINGS, and the difference is
44
+ * counter-intuitive enough to be worth stating here rather than being rediscovered.**
45
+ * There is this payload (Plugin API) and there is pasting `emit/svg-flat.js`'s
46
+ * markup (Figma's SVG importer). They are not two spellings of one capability:
47
+ *
48
+ * - `letter-spacing` EXISTS in the plugin model — `TextNode.letterSpacing` and
49
+ * `setRangeLetterSpacing`, both `{unit:'PIXELS'|'PERCENT', value}` — so this
50
+ * payload carries it, per node and per run, and the plugin path is exact.
51
+ * The SVG-paste path loses it OUTRIGHT: FIGMA_FINDINGS.md §5 measured a
52
+ * 22px/700 line arriving 19% wide (376.8px -> 449px), of which +50px is the
53
+ * ignored `letter-spacing` (41 chars x 1.21px, confirmed as `0` in Figma's own
54
+ * panel) and +23px the font substitution. Four ways of forcing it through the
55
+ * SVG — the attribute with a unit, a mirrored `style=`, `textLength` +
56
+ * `lengthAdjust="spacing"`, an internal `<style>` sheet — were each tried and
57
+ * each ignored. So the intuition "the SVG is the honest one, the payload is the
58
+ * lossy convenience" is backwards for text metrics.
59
+ * - Same shape for `-webkit-text-stroke` (here: a node-level Figma stroke, and
60
+ * only when every run agrees, because Figma has no per-range stroke) and for
61
+ * small caps (here: `setRangeTextCase`, which the SVG route spells as a
62
+ * presentation attribute the importer may or may not honour).
63
+ * - The other way round for the hyphen at a hyphenation break: the SVG route
64
+ * paints it (each line is its own positioned piece there, so it cannot move),
65
+ * and this one deliberately does not, because a TextNode reflows.
66
+ *
67
+ * Effects are the one place where the vocabulary is simply smaller: Figma's
68
+ * effect list is shadows and blurs — `DROP_SHADOW`, `INNER_SHADOW`, `LAYER_BLUR`,
69
+ * `BACKGROUND_BLUR` — and nothing else. There is no colour-matrix effect, so an
70
+ * SVD `colorMatrix` (what `saturate()`, `hue-rotate()`, `grayscale()` and friends
71
+ * in a `filter`/`backdrop-filter` become) cannot be carried by ANY Figma node
72
+ * property. It is declared lost by name, never dropped quietly.
73
+ *
74
+ * Every one of those, and every other approximation, appends to `diagnostics`.
75
+ * The document's own `diagnostics` are NOT merged in — they already travel with
76
+ * the SVD; this list is what *this* translation lost on top of them.
77
+ */
78
+ import { multiply } from '../css/matrix.js'
79
+
80
+ const VERSION = 1
81
+
82
+ /** CSS `mix-blend-mode` -> Figma `BlendMode`. */
83
+ const BLEND = {
84
+ normal: 'NORMAL',
85
+ multiply: 'MULTIPLY',
86
+ screen: 'SCREEN',
87
+ overlay: 'OVERLAY',
88
+ darken: 'DARKEN',
89
+ lighten: 'LIGHTEN',
90
+ 'color-dodge': 'COLOR_DODGE',
91
+ 'color-burn': 'COLOR_BURN',
92
+ 'hard-light': 'HARD_LIGHT',
93
+ 'soft-light': 'SOFT_LIGHT',
94
+ difference: 'DIFFERENCE',
95
+ exclusion: 'EXCLUSION',
96
+ hue: 'HUE',
97
+ saturation: 'SATURATION',
98
+ color: 'COLOR',
99
+ luminosity: 'LUMINOSITY',
100
+ }
101
+
102
+ /** Blend modes Figma only approximates. Kept apart so they can be declared. */
103
+ const BLEND_APPROX = { 'plus-lighter': 'LINEAR_DODGE' }
104
+
105
+ const EFFECT = {
106
+ dropShadow: 'DROP_SHADOW',
107
+ innerShadow: 'INNER_SHADOW',
108
+ layerBlur: 'LAYER_BLUR',
109
+ backgroundBlur: 'BACKGROUND_BLUR',
110
+ }
111
+
112
+ const GRADIENT = { linear: 'GRADIENT_LINEAR', radial: 'GRADIENT_RADIAL', angular: 'GRADIENT_ANGULAR' }
113
+
114
+ const STROKE_ALIGN = { inside: 'INSIDE', center: 'CENTER', outside: 'OUTSIDE' }
115
+ const STROKE_CAP = { butt: 'NONE', none: 'NONE', round: 'ROUND', square: 'SQUARE' }
116
+ const STROKE_JOIN = { miter: 'MITER', bevel: 'BEVEL', round: 'ROUND' }
117
+
118
+ /** Figma's style names for the nine CSS weights; the plugin fuzzy-matches from here. */
119
+ const WEIGHT_NAMES = {
120
+ 100: 'Thin', 200: 'Extra Light', 300: 'Light', 400: 'Regular', 500: 'Medium',
121
+ 600: 'Semi Bold', 700: 'Bold', 800: 'Extra Bold', 900: 'Black',
122
+ }
123
+
124
+ const RECT_LIKE = new Set(['RECTANGLE', 'FRAME'])
125
+
126
+ /** Corner control-point ratio for a quarter ellipse as one cubic (max error ~0.02%). */
127
+ const KAPPA = 0.5522847498307936
128
+
129
+ /**
130
+ * Above this the two radii of a corner are visibly different ellipse axes and a
131
+ * single Figma corner radius stops being a defensible approximation, so the node
132
+ * is emitted as a path instead. Below it, `min(rx, ry)` is used.
133
+ */
134
+ const RADIUS_ANISOTROPY = 0.15
135
+
136
+ /** Under half a pixel of difference the approximation is not worth declaring. */
137
+ const RADIUS_EPS = 0.5
138
+
139
+ /**
140
+ * Where the CSS conic ramp starts, in degrees clockwise from "up", when the SVD
141
+ * gradient does not say. `conic-gradient` with no `from` starts at 12 o'clock and
142
+ * sweeps clockwise; Figma's canonical angular space has t=0 on the +x axis, so
143
+ * the basis below rotates by (start - 90deg). One constant, one place to correct
144
+ * it if a probe in the plugin ever proves the seam sits elsewhere.
145
+ */
146
+ const ANGULAR_START_DEG = 0
147
+
148
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
149
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
150
+ const num = (v, d = 0) => (isNum(v) ? v : d)
151
+ const clamp01 = (v) => (isNum(v) ? Math.min(1, Math.max(0, v)) : 0)
152
+
153
+ /** Rounding belongs to the emitter (SVD invariant 1); `p` digits is plenty for Figma. */
154
+ function round (v, p = 4) {
155
+ if (!isNum(v)) return 0
156
+ const k = 10 ** p
157
+ const n = Math.round(v * k) / k
158
+ return Object.is(n, -0) ? 0 : n
159
+ }
160
+
161
+ const color3 = (rgba) => ({
162
+ r: round(clamp01(rgba && rgba[0]), 6),
163
+ g: round(clamp01(rgba && rgba[1]), 6),
164
+ b: round(clamp01(rgba && rgba[2]), 6),
165
+ })
166
+
167
+ const color4 = (rgba) => ({ ...color3(rgba), a: round(clamp01(rgba && rgba[3]), 6) })
168
+
169
+ /** `[x, y]` or `{x, y}` -> `[x, y]`. Anything else falls back, never throws. */
170
+ function vec (v, dx, dy) {
171
+ if (Array.isArray(v) && v.length >= 2) return [num(v[0], dx), num(v[1], dy)]
172
+ if (isObject(v)) return [num(v.x, dx), num(v.y, dy)]
173
+ return [dx, dy]
174
+ }
175
+
176
+ /** Inverse of the 2x3 affine `[a,b,c,d,e,f]`, or null when it is singular. */
177
+ function invert (m) {
178
+ const [a, b, c, d, e, f] = m
179
+ const det = a * d - b * c
180
+ if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null
181
+ return [
182
+ d / det, -b / det, -c / det, a / det,
183
+ (c * f - d * e) / det, (b * e - a * f) / det,
184
+ ]
185
+ }
186
+
187
+ /** `[a,b,c,d,e,f]` -> Figma's two-row form `[[a,c,e],[b,d,f]]`. */
188
+ const toRows = (m) => [
189
+ [round(m[0], 6), round(m[2], 6), round(m[4], 6)],
190
+ [round(m[1], 6), round(m[3], 6), round(m[5], 6)],
191
+ ]
192
+
193
+ /** The back-to-front index (SVD invariant 3). Never re-derived, only read. */
194
+ function zOf (src) {
195
+ const z = isObject(src) && isObject(src.paint) ? src.paint.z : undefined
196
+ return Number.isInteger(z) ? z : 0
197
+ }
198
+
199
+ /** `root` is `"n_0"` in one spelling and `{$ref:"n_0"}` in the other; both resolve. */
200
+ function readRootId (root) {
201
+ if (typeof root === 'string' && root) return root
202
+ if (isObject(root) && typeof root.$ref === 'string' && root.$ref) return root.$ref
203
+ return null
204
+ }
205
+
206
+ /**
207
+ * @typedef {object} Diag
208
+ * @property {string} code
209
+ * @property {'info'|'warn'|'error'} severity
210
+ * @property {string} [node]
211
+ * @property {string} message
212
+ */
213
+
214
+ // ——— paint ———
215
+
216
+ /**
217
+ * The matrix taking Figma's canonical gradient space to the SVD's unit space.
218
+ *
219
+ * Canonical space is the same for all three types once you look at what Figma
220
+ * actually samples: the linear ramp runs (0,0) -> (1,0); the radial one is the
221
+ * circle of radius 0.5 about (0.5,0.5); the angular one sweeps clockwise about
222
+ * (0.5,0.5) starting on +x. Placing that canonical figure onto `unit` is all
223
+ * this function does — the paint box is `transform`'s job, and the caller's.
224
+ */
225
+ function canonicalBasis (paint, warn) {
226
+ const unit = isObject(paint.unit) ? paint.unit : {}
227
+ // CONTRACT.md fixes these keys per type. They are read, never guessed: the
228
+ // probe lists this file used to carry (`unit.from || unit.p0`) are exactly
229
+ // what let the plugin flatten every linear gradient to top-to-bottom.
230
+ const missing = (key, fallback) => {
231
+ warn('figma.gradient-unit-missing', 'warn',
232
+ `a ${paint.type} gradient has no \`unit.${key}\`, which CONTRACT.md makes mandatory; ` +
233
+ `${fallback} is assumed and the ramp's direction or extent is wrong, not merely imprecise.`)
234
+ }
235
+
236
+ if (paint.type === 'linear') {
237
+ if (!Array.isArray(unit.p0)) missing('p0', 'the box corner (0,0)')
238
+ if (!Array.isArray(unit.p1)) missing('p1', 'the point (1,0)')
239
+ const [fx, fy] = vec(unit.p0, 0, 0)
240
+ const [tx, ty] = vec(unit.p1, 1, 0)
241
+ const dx = tx - fx
242
+ const dy = ty - fy
243
+ // The second column is the perpendicular of the axis, which is what makes
244
+ // the inverse's x-component the orthogonal projection onto it — i.e. what
245
+ // makes the iso-colour lines square to the ramp.
246
+ return [dx, dy, -dy, dx, fx, fy]
247
+ }
248
+
249
+ if (!Array.isArray(unit.center)) missing('center', 'the box centre (0.5, 0.5)')
250
+ const [cx, cy] = vec(unit.center, 0.5, 0.5)
251
+
252
+ if (paint.type === 'radial') {
253
+ // `[rx, ry]` per the contract; a bare number is the isotropic spelling of the
254
+ // same thing, which is unambiguous and so is taken rather than warned about.
255
+ let rx = 0.5
256
+ let ry = 0.5
257
+ if (Array.isArray(unit.radius) && unit.radius.length >= 2) {
258
+ rx = num(unit.radius[0], 0.5)
259
+ ry = num(unit.radius[1], 0.5)
260
+ } else if (isNum(unit.radius)) {
261
+ rx = unit.radius
262
+ ry = unit.radius
263
+ } else {
264
+ missing('radius', 'half the box (0.5)')
265
+ }
266
+ // 2r because the canonical circle has radius 0.5, not 1.
267
+ return [2 * rx, 0, 0, 2 * ry, cx - rx, cy - ry]
268
+ }
269
+
270
+ if (!isNum(unit.startAngle)) missing('startAngle', `${ANGULAR_START_DEG}deg`)
271
+ const start = num(unit.startAngle, ANGULAR_START_DEG)
272
+ // CSS measures the conic ramp from "up", clockwise; Figma from +x. The extra
273
+ // -90deg is that difference and nothing else. The contract keeps the conic's
274
+ // rotation out of `transform` precisely so it can be composed here.
275
+ const rad = ((start - 90) * Math.PI) / 180
276
+ const a = Math.cos(rad)
277
+ const b = Math.sin(rad)
278
+ warn('figma.gradient-angular', 'info',
279
+ `conic gradient: Figma's angular ramp is seamed at ${round(start, 2)}deg from vertical by convention; ` +
280
+ 'it has no hard stops, so a hard-stopped conic is interpolated across the seam.')
281
+ // Unit similarity: the canonical sweep is about (0.5, 0.5) with radius 0.5, so
282
+ // mapping it onto `unit.center` needs the rotation and the centre, nothing else.
283
+ return [a, b, -b, a, cx - (a - b) * 0.5, cy - (b + a) * 0.5]
284
+ }
285
+
286
+ /**
287
+ * SVD gradient geometry -> `gradientTransform`, the only handle Figma exposes.
288
+ *
289
+ * `box` is the shape's own width/height in px and `rect` the painted layer's
290
+ * offset inside it (background-position/size); the whole point of this function
291
+ * is that Figma reads the result in the shape's 0..1 space, so the px chain has
292
+ * to be divided by the box before it is inverted. `box.ox`/`box.oy` shift the
293
+ * shape's origin inside the element, which is how a composed border side gets
294
+ * the part of the gradient that actually runs across it.
295
+ */
296
+ function gradientTransform (paint, box, warn) {
297
+ const t = Array.isArray(paint.transform) && paint.transform.length === 6
298
+ ? paint.transform.map((n) => num(n, 0))
299
+ : [1, 0, 0, 1, 0, 0]
300
+ const w = num(box && box.w, 0)
301
+ const h = num(box && box.h, 0)
302
+ const ox = num(box && box.ox, 0)
303
+ const oy = num(box && box.oy, 0)
304
+ if (!(w > 0) || !(h > 0)) {
305
+ warn('figma.gradient-boxless', 'warn',
306
+ 'a gradient sits on a node with no measurable box, so its geometry cannot be normalised into ' +
307
+ 'Figma\'s 0..1 space; the identity matrix is used and the ramp runs left to right.')
308
+ return [[1, 0, 0], [0, 1, 0]]
309
+ }
310
+
311
+ const rect = layerRect(paint, w, h)
312
+ // px -> the shape's normalized box, with the layer's own origin folded in.
313
+ const norm = [1 / w, 0, 0, 1 / h, (rect.x - ox) / w, (rect.y - oy) / h]
314
+ const m = multiply(norm, multiply(t, canonicalBasis(paint, warn)))
315
+
316
+ if (paint.type === 'angular') {
317
+ const k = w > h ? w / h : h / w
318
+ if (k > 1.01) {
319
+ // atan((k-1)/(2*sqrt(k))) is the peak of |theta - atan(tan(theta)/k)|.
320
+ const peak = (Math.atan((k - 1) / (2 * Math.sqrt(k))) * 180) / Math.PI
321
+ warn('figma.gradient-angular-anisotropic', 'warn',
322
+ `a conic gradient sits on a ${round(w, 1)}x${round(h, 1)} box and Figma sweeps its angle in the ` +
323
+ `shape's normalized space, so the ramp is sheared: colours land up to ${round(peak, 1)}deg away ` +
324
+ 'from where CSS puts them. Only a square box is exact.')
325
+ }
326
+ }
327
+
328
+ const inv = invert(m)
329
+ if (inv) return toRows(inv)
330
+ warn('figma.gradient-singular', 'warn',
331
+ 'gradient geometry collapses to a line or a point and cannot be inverted into a ' +
332
+ 'gradientTransform; the identity matrix is used and the ramp will run left to right.')
333
+ return [[1, 0, 0], [0, 1, 0]]
334
+ }
335
+
336
+ /**
337
+ * Where a background layer is painted inside the node's box, in px. `rect` is
338
+ * what `collectBox` measures; `placement.area` is the same box before the
339
+ * background-size step, and is the honest fallback.
340
+ */
341
+ function layerRect (paint, w, h) {
342
+ const r = isObject(paint.rect) ? paint.rect
343
+ : (isObject(paint.placement) && isObject(paint.placement.area) ? paint.placement.area : null)
344
+ if (!r) return { x: 0, y: 0, w, h }
345
+ return {
346
+ x: num(r.x, 0),
347
+ y: num(r.y, 0),
348
+ w: num(r.w, w) > 0 ? num(r.w, w) : w,
349
+ h: num(r.h, h) > 0 ? num(r.h, h) : h,
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Figma's CROP transform maps the shape's normalized box onto the region of the
355
+ * image that shows. Everything needed for that is already in the SVD: the
356
+ * layer's rect in element-local px and the element's own box.
357
+ */
358
+ function cropTransform (rect, w, h) {
359
+ if (!isObject(rect) || !(rect.w > 0) || !(rect.h > 0) || !(w > 0) || !(h > 0)) return null
360
+ return [
361
+ [round(w / rect.w, 6), 0, round(-rect.x / rect.w, 6)],
362
+ [0, round(h / rect.h, 6), round(-rect.y / rect.h, 6)],
363
+ ]
364
+ }
365
+
366
+ /**
367
+ * A replaced element's image layer, as `collectImage` hands it over.
368
+ *
369
+ * `crop` is the visible region of the asset in its OWN source pixels and
370
+ * `transform` maps the unit square of that crop onto the node's border box in
371
+ * px — between them that is every `object-fit` and `object-position` exactly.
372
+ * Reading `p.rect` instead (which a replaced element does not have) is what
373
+ * turned every `object-fit: contain` into a centred crop.
374
+ *
375
+ * @returns {{dst:{x,y,w,h}, imageTransform:number[][], covers:boolean, notes:string[]}|null}
376
+ */
377
+ function objectPlacement (p, box, assets, warn) {
378
+ const crop = p.crop
379
+ if (!isObject(crop) || !Array.isArray(p.transform) || p.transform.length !== 6) return null
380
+ const t = p.transform.map((n) => num(n, 0))
381
+ if (!(crop.w > 0) || !(crop.h > 0) || !(t[0] > 0) || !(t[3] > 0)) {
382
+ warn('figma.image-not-painted', 'warn',
383
+ 'object-position places the whole image outside its content box, so nothing of it is painted; ' +
384
+ 'the layer is dropped rather than shown in the wrong place.')
385
+ return null
386
+ }
387
+ if (Math.abs(t[1]) > 1e-6 || Math.abs(t[2]) > 1e-6) {
388
+ warn('figma.image-rotated', 'warn',
389
+ 'the image placement matrix is rotated or skewed and a Figma image fill is axis-aligned; ' +
390
+ 'only its bounding placement is kept.')
391
+ }
392
+
393
+ const asset = isObject(assets) ? assets[p.asset] : null
394
+ let nw = isObject(asset) ? num(asset.w, 0) : 0
395
+ let nh = isObject(asset) ? num(asset.h, 0) : 0
396
+ if (!(nw > 0) || !(nh > 0)) {
397
+ // Without the intrinsic size the crop cannot be normalised. Assuming it
398
+ // reaches the far edge is exact whenever nothing was cropped off that edge,
399
+ // which is the common case; when it is not, the placement is wrong and says so.
400
+ nw = num(crop.x, 0) + crop.w
401
+ nh = num(crop.y, 0) + crop.h
402
+ if (num(crop.x, 0) > 0.01 || num(crop.y, 0) > 0.01) {
403
+ warn('figma.image-crop-unnormalized', 'warn',
404
+ `asset "${p.asset}" carries no intrinsic size, so the crop could not be expressed as a fraction ` +
405
+ 'of the image; the visible region is off by however much was cropped from the right and bottom.')
406
+ }
407
+ }
408
+
409
+ const dst = { x: t[4], y: t[5], w: t[0], h: t[3] }
410
+ const covers = dst.x <= 0.5 && dst.y <= 0.5 &&
411
+ dst.x + dst.w >= num(box.w, 0) - 0.5 && dst.y + dst.h >= num(box.h, 0) - 0.5
412
+ return {
413
+ dst,
414
+ covers,
415
+ // shape-normalized -> image-normalized, which is what CROP samples through.
416
+ imageTransform: [
417
+ [round(crop.w / nw, 6), 0, round(num(crop.x, 0) / nw, 6)],
418
+ [0, round(crop.h / nh, 6), round(num(crop.y, 0) / nh, 6)],
419
+ ],
420
+ }
421
+ }
422
+
423
+ function blendOf (css, warn, what) {
424
+ const key = String(css || 'normal').toLowerCase()
425
+ if (BLEND[key]) return BLEND[key]
426
+ if (BLEND_APPROX[key]) {
427
+ warn('figma.blend-approx', 'warn',
428
+ `${what}: mix-blend-mode "${key}" has no exact Figma equivalent; ${BLEND_APPROX[key]} is used.`)
429
+ return BLEND_APPROX[key]
430
+ }
431
+ warn('figma.blend-unsupported', 'warn',
432
+ `${what}: mix-blend-mode "${key}" is not a Figma blend mode; NORMAL is used.`)
433
+ return 'NORMAL'
434
+ }
435
+
436
+ /**
437
+ * One SVD paint -> one Figma paint. `null` when nothing can be emitted, which
438
+ * only happens for an image whose asset never resolved.
439
+ *
440
+ * An image layer may come back as `{paint, place}`: `place` is the sub-rectangle
441
+ * of the node the bitmap actually occupies, which Figma cannot express with a
442
+ * fill and the caller composes as its own shape.
443
+ */
444
+ function toPaint (p, box, warn, assets) {
445
+ const blendMode = blendOf(p.blend, warn, 'fill')
446
+ // A layer opacity of its own (background-image never has one; a replaced
447
+ // element's does) is Figma's paint opacity, not the node's.
448
+ const layerOpacity = isNum(p.opacity) ? round(clamp01(p.opacity), 6) : 1
449
+
450
+ if (p.type === 'solid') {
451
+ return {
452
+ type: 'SOLID',
453
+ color: color3(p.color),
454
+ opacity: round(clamp01(p.color && p.color[3]) * layerOpacity, 6),
455
+ blendMode,
456
+ visible: true,
457
+ }
458
+ }
459
+
460
+ if (p.type === 'linear' || p.type === 'radial' || p.type === 'angular') {
461
+ const fill = {
462
+ type: GRADIENT[p.type],
463
+ gradientTransform: gradientTransform(p, box, warn),
464
+ // The contract's stop offset is `t`. This read used to be `s.p` — a private
465
+ // dialect that `index.js` no longer emits — so every stop landed at
466
+ // position 0 and every gradient came out flat. A stop that reaches here
467
+ // without a numeric `t` is unplaceable, and saying so beats stacking it at 0.
468
+ gradientStops: (Array.isArray(p.stops) ? p.stops : []).map((s, i, all) => {
469
+ if (!isNum(s && s.t)) {
470
+ warn('figma.gradient-stop-unplaced', 'warn',
471
+ `gradient stop ${i + 1} of ${all.length} carries no numeric \`t\`; it is placed by its ` +
472
+ 'index in the list, which is not where the browser painted it.')
473
+ return {
474
+ position: all.length > 1 ? round(i / (all.length - 1), 6) : 0,
475
+ color: color4(s && s.color),
476
+ }
477
+ }
478
+ return { position: round(clamp01(s.t), 6), color: color4(s.color) }
479
+ }),
480
+ blendMode,
481
+ visible: true,
482
+ }
483
+ if (layerOpacity !== 1) fill.opacity = layerOpacity
484
+ return fill
485
+ }
486
+
487
+ if (p.type === 'image') {
488
+ if (typeof p.asset !== 'string' || !p.asset) {
489
+ warn('figma.image-asset-unresolved', 'error',
490
+ 'an image fill still carries an unresolved asset; the layer is dropped.')
491
+ return null
492
+ }
493
+ const fit = String(p.fit || 'stretch').toLowerCase()
494
+ const repeat = isObject(p.placement) && isObject(p.placement.repeat) ? p.placement.repeat : null
495
+ const tiled = repeat && (repeat.x === 'repeat' || repeat.y === 'repeat')
496
+
497
+ // `imageHash` is the plugin's to fill in: only the sandbox can call
498
+ // `figma.createImage`. `asset` is the id it resolves against `document.assets`.
499
+ const fill = { type: 'IMAGE', imageHash: null, asset: p.asset, blendMode, visible: true }
500
+ if (layerOpacity !== 1) fill.opacity = layerOpacity
501
+
502
+ // A replaced element: `crop` + `transform` say exactly where the bitmap goes.
503
+ const placed = objectPlacement(p, box, assets, warn)
504
+ if (placed) {
505
+ fill.scaleMode = 'CROP'
506
+ fill.imageTransform = placed.imageTransform
507
+ return placed.covers ? fill : { paint: fill, place: placed.dst }
508
+ }
509
+ if (isObject(p.crop)) return null // objectPlacement already said why
510
+
511
+ if (tiled) {
512
+ fill.scaleMode = 'TILE'
513
+ // Figma's scalingFactor is the tile size relative to the image's own size;
514
+ // without an intrinsic size there is nothing to take the ratio against.
515
+ const asset = isObject(assets) ? assets[p.asset] : null
516
+ const natural = num(p.assetWidth, isObject(asset) ? num(asset.w, 0) : 0)
517
+ const rect = isObject(p.rect) ? p.rect : (isObject(p.placement) ? p.placement.area : null)
518
+ fill.scalingFactor = isObject(rect) && rect.w > 0 && natural > 0
519
+ ? round(rect.w / natural, 6)
520
+ : 1
521
+ if (fill.scalingFactor === 1 && !(natural > 0)) {
522
+ warn('figma.image-repeat-approx', 'warn',
523
+ 'a repeating background is tiled at 1:1 because the image\'s intrinsic size is not in the document.')
524
+ }
525
+ if (repeat.x !== repeat.y) {
526
+ warn('figma.image-repeat-approx', 'warn',
527
+ `background-repeat differs per axis (x:${repeat.x}, y:${repeat.y}); Figma tiles both.`)
528
+ }
529
+ return fill
530
+ }
531
+
532
+ if (fit === 'cover') { fill.scaleMode = 'FILL'; return fill }
533
+ if (fit === 'contain') { fill.scaleMode = 'FIT'; return fill }
534
+
535
+ const crop = cropTransform(p.rect, box.w, box.h)
536
+ if (crop) {
537
+ fill.scaleMode = 'CROP'
538
+ fill.imageTransform = crop
539
+ const r = p.rect
540
+ const covers = r.x <= 1e-6 && r.y <= 1e-6 && r.x + r.w >= box.w - 1e-6 && r.y + r.h >= box.h - 1e-6
541
+ if (!covers) {
542
+ warn('figma.image-fit-approx', 'warn',
543
+ 'the image does not cover its box; Figma clamps the edge pixels outside the crop instead of ' +
544
+ 'leaving it transparent.')
545
+ }
546
+ return fill
547
+ }
548
+
549
+ fill.scaleMode = 'FILL'
550
+ warn('figma.image-fit-approx', 'warn',
551
+ `object-fit "${fit}" could not be placed exactly (no resolved layer rect); the image is emitted as FILL.`)
552
+ return fill
553
+ }
554
+
555
+ warn('figma.paint-unsupported', 'warn', `paint type "${p.type}" has no Figma equivalent; it is dropped.`)
556
+ return null
557
+ }
558
+
559
+ /**
560
+ * `toPaint` for the call sites that have nowhere to put a composed shape — a
561
+ * border side, a text run. The sub-rectangle is lost there, so it is declared.
562
+ */
563
+ function toPaintFlat (p, box, warn, assets) {
564
+ const res = toPaint(p, box, warn, assets)
565
+ if (isObject(res) && isObject(res.paint)) {
566
+ warn('figma.image-placement-flattened', 'warn',
567
+ 'an image only covers part of this layer and this layer cannot host a shape of its own, ' +
568
+ 'so the bitmap is stretched across the whole of it.')
569
+ return res.paint
570
+ }
571
+ return res
572
+ }
573
+
574
+ /**
575
+ * Figma only accepts a shadow `spread` on rectangles and ellipses, and on
576
+ * frames that both have a visible fill and clip their content. Everywhere else
577
+ * it is rejected, so it is dropped here — loudly — rather than at runtime.
578
+ */
579
+ function spreadAllowed (node) {
580
+ if (node.type === 'RECTANGLE' || node.type === 'ELLIPSE') return true
581
+ return node.type === 'FRAME' && node.clipsContent === true &&
582
+ node.fills.some((f) => f.visible !== false)
583
+ }
584
+
585
+ /**
586
+ * The CSS function an effect came from, in the spelling the page wrote, or null.
587
+ *
588
+ * A colour effect is only worth declaring if the message can NAME it — "a colour
589
+ * filter was dropped" sends the reader back to the stylesheet, "saturate(180%)
590
+ * was dropped" does not. `srcCss` is where `collect/box.js` puts it and the
591
+ * schema REQUIRES it on a `colorMatrix` (`svd/src/schema.js`, `checkColorMatrix`),
592
+ * so it is read first; the other spellings are tried for a document that reached
593
+ * here without validating, and the caller says so when none of them is there.
594
+ */
595
+ function filterSourceCss (e) {
596
+ for (const key of ['srcCss', 'css', 'fn', 'function']) {
597
+ const v = e[key]
598
+ if (typeof v === 'string' && v.trim()) return v.trim()
599
+ }
600
+ if (Array.isArray(e.functions) && e.functions.length) {
601
+ const list = e.functions.filter((v) => typeof v === 'string' && v.trim()).map((v) => v.trim())
602
+ if (list.length) return list.join(' ')
603
+ }
604
+ return null
605
+ }
606
+
607
+ function toEffect (e, node, warn) {
608
+ // Not in `EFFECT` and never will be: Figma's effect vocabulary is shadows and
609
+ // blurs, and a colour matrix is neither. `feColorMatrix` has no counterpart in
610
+ // any Figma node property — not an effect, not a paint, not a blend mode — so
611
+ // there is nothing to approximate it WITH, and an approximation nobody can
612
+ // build is a loss to declare, by name, not a type to fall through the generic
613
+ // "unsupported" arm that names only the SVD word `colorMatrix`.
614
+ if (e.type === 'colorMatrix') {
615
+ const css = filterSourceCss(e)
616
+ const prop = e.backdrop === true ? 'backdrop-filter' : 'filter'
617
+ warn('figma.effect-colormatrix-dropped', 'warn',
618
+ `${prop}: ${css || '(a colour matrix whose `srcCss` is missing, so it cannot be named)'} recolours ` +
619
+ `${e.backdrop === true ? 'everything behind this node' : 'this node and its contents'}. ` +
620
+ 'Figma has four effect types — DROP_SHADOW, INNER_SHADOW, LAYER_BLUR, BACKGROUND_BLUR — and no ' +
621
+ 'colour-matrix effect anywhere in the Plugin API: not as an effect, not as a paint, not as a blend ' +
622
+ 'mode. There is nothing to approximate it with, so it is dropped and the colours arrive unfiltered ' +
623
+ '— a saturate() or hue-rotate() backdrop lands as the plain backdrop.')
624
+ return null
625
+ }
626
+ const type = EFFECT[e.type]
627
+ if (!type) {
628
+ warn('figma.effect-unsupported', 'warn', `effect "${e.type}" has no Figma equivalent; it is dropped.`)
629
+ return null
630
+ }
631
+ // Figma's blur radius is the CSS radius (SVD invariant 5 halves it for SVG
632
+ // sigma and only for SVG) — nothing is converted here.
633
+ if (type === 'LAYER_BLUR' || type === 'BACKGROUND_BLUR') {
634
+ return { type, blurType: 'NORMAL', radius: round(Math.max(0, num(e.blur, 0))), visible: true }
635
+ }
636
+ const effect = {
637
+ type,
638
+ color: color4(e.color),
639
+ offset: { x: round(num(e.offset && e.offset[0], 0)), y: round(num(e.offset && e.offset[1], 0)) },
640
+ radius: round(Math.max(0, num(e.blur, 0))),
641
+ visible: true,
642
+ blendMode: 'NORMAL',
643
+ }
644
+ const spread = num(e.spread, 0)
645
+ if (spread !== 0) {
646
+ if (spreadAllowed(node)) effect.spread = round(spread)
647
+ else {
648
+ warn('figma.effect-spread-dropped', 'warn',
649
+ `a shadow spread of ${round(spread)}px is dropped: Figma accepts spread only on rectangles, ` +
650
+ `ellipses and clipping frames with a fill, and this node is a ${node.type}.`)
651
+ }
652
+ }
653
+ if (type === 'DROP_SHADOW') effect.showShadowBehindNode = e.behind === true
654
+ return effect
655
+ }
656
+
657
+ // ——— geometry ———
658
+
659
+ /**
660
+ * Radii -> what Figma can hold.
661
+ *
662
+ * `corners` are the four isotropic radii when one number per corner is still
663
+ * honest; `path` is set instead when a corner's two axes differ by more than
664
+ * RADIUS_ANISOTROPY and the node has to become a VECTOR to keep its shape.
665
+ */
666
+ function readRadii (radii, w, h, warn) {
667
+ if (!Array.isArray(radii) || radii.length !== 4) return { corners: null, path: false }
668
+ let anisotropic = false
669
+ let approximated = 0
670
+ const corners = radii.map((corner) => {
671
+ const rx = Math.max(0, num(corner && corner[0], 0))
672
+ const ry = Math.max(0, num(corner && corner[1], 0))
673
+ const max = Math.max(rx, ry)
674
+ if (max > 0) {
675
+ const delta = Math.abs(rx - ry)
676
+ if (delta / max > RADIUS_ANISOTROPY) anisotropic = true
677
+ else if (delta > RADIUS_EPS) approximated++
678
+ }
679
+ return Math.min(rx, ry)
680
+ })
681
+ if (anisotropic) {
682
+ warn('figma.radii-anisotropic', 'info',
683
+ 'a corner radius is elliptical beyond 15%; the node is emitted as a VECTOR path instead of a ' +
684
+ 'rectangle, because Figma corner radii are circular.')
685
+ return { corners, path: true, w, h }
686
+ }
687
+ if (approximated) {
688
+ warn('figma.radii-approx', 'info',
689
+ `${approximated} corner radius/radii are slightly elliptical; min(rx, ry) is used.`)
690
+ }
691
+ return { corners, path: false }
692
+ }
693
+
694
+ /** A rounded rectangle with elliptical corners as absolute M/L/C/Z — Figma's path subset. */
695
+ function roundedRectPath (w, h, radii) {
696
+ const c = (radii || []).map((corner) => {
697
+ const rx = Math.min(Math.max(0, num(corner && corner[0], 0)), w / 2)
698
+ const ry = Math.min(Math.max(0, num(corner && corner[1], 0)), h / 2)
699
+ return [rx, ry]
700
+ })
701
+ const [tl, tr, br, bl] = c.length === 4 ? c : [[0, 0], [0, 0], [0, 0], [0, 0]]
702
+ const n = (v) => round(v, 3)
703
+ const k = KAPPA
704
+ const out = [`M ${n(tl[0])} 0`]
705
+ out.push(`L ${n(w - tr[0])} 0`)
706
+ if (tr[0] || tr[1]) {
707
+ out.push(`C ${n(w - tr[0] + tr[0] * k)} 0 ${n(w)} ${n(tr[1] - tr[1] * k)} ${n(w)} ${n(tr[1])}`)
708
+ }
709
+ out.push(`L ${n(w)} ${n(h - br[1])}`)
710
+ if (br[0] || br[1]) {
711
+ out.push(`C ${n(w)} ${n(h - br[1] + br[1] * k)} ${n(w - br[0] + br[0] * k)} ${n(h)} ${n(w - br[0])} ${n(h)}`)
712
+ }
713
+ out.push(`L ${n(bl[0])} ${n(h)}`)
714
+ if (bl[0] || bl[1]) {
715
+ out.push(`C ${n(bl[0] - bl[0] * k)} ${n(h)} 0 ${n(h - bl[1] + bl[1] * k)} 0 ${n(h - bl[1])}`)
716
+ }
717
+ out.push(`L 0 ${n(tl[1])}`)
718
+ if (tl[0] || tl[1]) {
719
+ out.push(`C 0 ${n(tl[1] - tl[1] * k)} ${n(tl[0] - tl[0] * k)} 0 ${n(tl[0])} 0`)
720
+ }
721
+ out.push('Z')
722
+ return out.join(' ')
723
+ }
724
+
725
+ /**
726
+ * SVD transform -> Figma placement.
727
+ *
728
+ * Figma cannot hold scale in `relativeTransform` (it renormalizes it) and has no
729
+ * skew at all, so the scale is folded into width/height and a non-zero skew is
730
+ * escalated to the caller as a degradation.
731
+ */
732
+ function placement (src, warn) {
733
+ const frame = isObject(src.frame) ? src.frame : { x: 0, y: 0, w: 0, h: 0 }
734
+ const base = {
735
+ x: round(num(frame.x)),
736
+ y: round(num(frame.y)),
737
+ width: round(Math.max(0, num(frame.w))),
738
+ height: round(Math.max(0, num(frame.h))),
739
+ place: null,
740
+ }
741
+ const t = src.transform
742
+ if (!isObject(t) || !Array.isArray(t.m) || t.m.length !== 6) return base
743
+
744
+ const m = t.m.map((n) => num(n, 0))
745
+ const d = isObject(t.decomposed) ? t.decomposed : {}
746
+ const rot = num(d.rot, 0)
747
+ const sx = num(d.sx, 1)
748
+ const sy = num(d.sy, 1)
749
+
750
+ if (Math.abs(num(d.skewX, 0)) > 1e-6) {
751
+ warn('figma.skew', 'warn',
752
+ `transform carries skewX ${round(d.skewX, 3)}deg and Figma has no skew; the node must be ` +
753
+ 'outlined or rasterized to keep its shape.')
754
+ base.degrade = { action: src.type === 'text' ? 'OUTLINE' : 'RASTER', reason: 'skewX' }
755
+ }
756
+ if (sx < 0 || sy < 0) {
757
+ warn('figma.flip', 'warn',
758
+ 'transform mirrors the node and Figma\'s relativeTransform cannot hold a reflection; ' +
759
+ 'the absolute scale is used and the flip is lost.')
760
+ }
761
+
762
+ const rad = (rot * Math.PI) / 180
763
+ const cos = Math.cos(rad)
764
+ const sin = Math.sin(rad)
765
+ // `m` already has transform-origin baked in, so the element's own (0,0) lands
766
+ // at (m[4], m[5]) inside its frame.
767
+ const x = num(frame.x) + m[4]
768
+ const y = num(frame.y) + m[5]
769
+
770
+ return {
771
+ x: round(x),
772
+ y: round(y),
773
+ width: round(Math.max(0, num(frame.w) * Math.abs(sx))),
774
+ height: round(Math.max(0, num(frame.h) * Math.abs(sy))),
775
+ // Figma's rotation is counter-clockwise; the SVD's is CSS clockwise.
776
+ rotation: round(-rot, 4),
777
+ relativeTransform: toRows([cos, sin, -sin, cos, x, y]),
778
+ degrade: base.degrade,
779
+ place: { cos, sin, x, y, sx: Math.abs(sx), sy: Math.abs(sy) },
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Position a composed sibling given in the owner's local px, in the owner's
785
+ * parent's space. Without a transform it is a translation; with one it inherits
786
+ * the owner's rotation and scale so the four border shapes stay on the border.
787
+ */
788
+ function placeLocal (box, x, y, w, h) {
789
+ const p = box.place
790
+ if (!p) {
791
+ return { x: round(box.x + x), y: round(box.y + y), width: round(w), height: round(h) }
792
+ }
793
+ const lx = x * p.sx
794
+ const ly = y * p.sy
795
+ const ox = p.x + p.cos * lx - p.sin * ly
796
+ const oy = p.y + p.sin * lx + p.cos * ly
797
+ return {
798
+ x: round(ox),
799
+ y: round(oy),
800
+ width: round(w * p.sx),
801
+ height: round(h * p.sy),
802
+ rotation: round(-Math.atan2(p.sin, p.cos) * 180 / Math.PI, 4),
803
+ relativeTransform: toRows([p.cos, p.sin, -p.sin, p.cos, ox, oy]),
804
+ }
805
+ }
806
+
807
+ // ——— strokes ———
808
+
809
+ /** SVD carries `weight` (schema) and `weights` (collectBox); both mean the same thing. */
810
+ function sideWeights (stroke) {
811
+ const w = stroke.weight !== undefined ? stroke.weight : stroke.weights
812
+ if (isNum(w)) return [w, w, w, w]
813
+ if (Array.isArray(w) && w.length === 4) return w.map((n) => Math.max(0, num(n, 0)))
814
+ return [0, 0, 0, 0]
815
+ }
816
+
817
+ const sameWeights = (w) => w[0] === w[1] && w[1] === w[2] && w[2] === w[3]
818
+
819
+ /** Per-side colours, when the collector could not fold them into one paint. */
820
+ function sidePaints (stroke) {
821
+ if (!Array.isArray(stroke.sides) || stroke.sides.length !== 4) return null
822
+ return stroke.sides.map((s) => (
823
+ isObject(s) && Array.isArray(s.color) ? { type: 'solid', color: s.color } : null
824
+ ))
825
+ }
826
+
827
+ /**
828
+ * The four filled shapes a border becomes when Figma cannot express it: a
829
+ * non-rect-like node with differing weights, or four differing colours. The
830
+ * mitre is what this loses — the corners overlap instead of meeting on the
831
+ * diagonal — and that is why it is a diagnostic and not a silent rewrite.
832
+ */
833
+ function composeSides (stroke, box, name, warn, reason) {
834
+ const weights = sideWeights(stroke)
835
+ const paints = sidePaints(stroke)
836
+ const w = box.width
837
+ const h = box.height
838
+ const geom = [
839
+ [0, 0, w, weights[0]],
840
+ [w - weights[1], 0, weights[1], h],
841
+ [0, h - weights[2], w, weights[2]],
842
+ [0, 0, weights[3], h],
843
+ ]
844
+ const labels = ['top', 'right', 'bottom', 'left']
845
+ warn('figma.stroke-per-side-composed', 'info',
846
+ `${reason}; the border is composed as four filled shapes and the mitred corners overlap instead.`)
847
+ const out = []
848
+ for (let i = 0; i < 4; i++) {
849
+ const [x, y, sw, sh] = geom[i]
850
+ if (!(sw > 0) || !(sh > 0)) continue
851
+ const paint = paints && paints[i] ? paints[i] : stroke.paint
852
+ if (!isObject(paint)) continue
853
+ // `ox`/`oy`: the side is a window onto the element's own box, so a gradient
854
+ // border keeps running across the element instead of restarting per side.
855
+ const fill = toPaintFlat(paint, { w: sw, h: sh, ox: x, oy: y }, warn)
856
+ if (!fill) continue
857
+ out.push({
858
+ type: 'RECTANGLE',
859
+ name: `${name} / border ${labels[i]}`,
860
+ ...placeLocal(box, x, y, sw, sh),
861
+ fills: [fill],
862
+ strokes: [],
863
+ effects: [],
864
+ children: [],
865
+ composed: 'border-side',
866
+ })
867
+ }
868
+ return out
869
+ }
870
+
871
+ // ——— text ———
872
+
873
+ /** `"Inter var", system-ui, sans-serif` -> `Inter var`. */
874
+ function firstFamily (stack) {
875
+ if (typeof stack !== 'string') return null
876
+ let depth = 0
877
+ let quote = ''
878
+ let buf = ''
879
+ for (const ch of stack) {
880
+ if (quote) {
881
+ if (ch === quote) quote = ''
882
+ else buf += ch
883
+ continue
884
+ }
885
+ if (ch === '"' || ch === "'") { quote = ch; continue }
886
+ if (ch === '(') depth++
887
+ else if (ch === ')') depth--
888
+ else if (ch === ',' && depth === 0) break
889
+ buf += ch
890
+ }
891
+ const name = buf.trim()
892
+ return name || null
893
+ }
894
+
895
+ function weightStyleName (weight, italic) {
896
+ const w = Math.min(900, Math.max(100, Math.round(num(weight, 400) / 100) * 100))
897
+ const name = WEIGHT_NAMES[w] || 'Regular'
898
+ if (!italic) return name
899
+ return name === 'Regular' ? 'Italic' : `${name} Italic`
900
+ }
901
+
902
+ /**
903
+ * The `{family, style}` Figma will be asked to load.
904
+ *
905
+ * The `name` table beats the CSS stack every time: the page says "Inter var",
906
+ * the binary says Inter / Semi Bold, and only the second one exists in Figma.
907
+ */
908
+ function fontOf (style, assets, warn) {
909
+ const asset = typeof style.font === 'string' ? assets[style.font] : null
910
+ const table = isObject(asset) && isObject(asset.nameTable) ? asset.nameTable : {}
911
+ const pick = (a, b) => {
912
+ const v = table[a] !== undefined ? table[a] : table[b]
913
+ return typeof v === 'string' && v.trim() ? v.trim() : null
914
+ }
915
+ const family = pick('16', '1') || firstFamily(style.stack) || 'Inter'
916
+ const sub = pick('17', '2') || weightStyleName(style.weight, style.italic === true)
917
+ if (isObject(asset) && asset.synthetic === true) {
918
+ warn('figma.font-synthetic', 'warn',
919
+ `"${family} ${sub}" is a synthesised bold/italic; no editor has that face, so the run must be outlined.`)
920
+ }
921
+ return { family, style: sub }
922
+ }
923
+
924
+ /** Figma holds three decoration styles; everything else collapses onto SOLID. */
925
+ const DECORATION_STYLE = { solid: 'SOLID', dotted: 'DOTTED', wavy: 'WAVY' }
926
+
927
+ /**
928
+ * `text-decoration` -> the three `setRange*` values Figma has for it.
929
+ *
930
+ * The SVD carries `style.decoration` as the ARRAY of every decoration that
931
+ * propagated onto the run (an `<a>` and a `<span>` can each contribute one), so
932
+ * an array is the shape to expect; the scalar spellings are accepted because a
933
+ * single decoration is not worth a wrapper. Reading only `decoration.line` — the
934
+ * old behaviour — silently returned NONE for every underline in the document.
935
+ */
936
+ function decorationOf (decoration, warn) {
937
+ const none = { textDecoration: 'NONE' }
938
+ if (!decoration) return none
939
+
940
+ const list = (Array.isArray(decoration) ? decoration : [decoration])
941
+ .map((d) => (typeof d === 'string' ? { line: d } : d))
942
+ .filter((d) => isObject(d) && typeof d.line === 'string' && d.line && d.line !== 'none')
943
+ if (!list.length) return none
944
+
945
+ const lines = list.map((d) => d.line.toLowerCase()).join(' ')
946
+ const under = lines.includes('underline')
947
+ const strike = lines.includes('line-through') || lines.includes('strikethrough')
948
+ // The first entry that actually draws is the one whose style and colour win.
949
+ const src = list.find((d) => /underline|line-through|strikethrough/.test(d.line.toLowerCase())) || list[0]
950
+
951
+ if (lines.includes('overline') && !under && !strike) {
952
+ warn('figma.text-decoration-dropped', 'warn',
953
+ 'text-decoration:overline has no Figma equivalent; the run is emitted undecorated.')
954
+ return none
955
+ }
956
+ if (under && strike) {
957
+ warn('figma.text-decoration-approx', 'warn',
958
+ 'a run is both underlined and struck through; Figma holds one decoration per range, so the ' +
959
+ 'underline is kept and the strikethrough is lost.')
960
+ }
961
+
962
+ const out = { textDecoration: under ? 'UNDERLINE' : 'STRIKETHROUGH' }
963
+ const styleName = String(src.style || 'solid').toLowerCase()
964
+ if (DECORATION_STYLE[styleName]) {
965
+ if (styleName !== 'solid') out.textDecorationStyle = DECORATION_STYLE[styleName]
966
+ } else {
967
+ warn('figma.text-decoration-approx', 'info',
968
+ `text-decoration-style "${styleName}" is not one of Figma's (solid, dotted, wavy); solid is used.`)
969
+ }
970
+ // Figma's own shapes for these two: `{value: SolidPaint}` and `{unit, value}`.
971
+ // They are newer than the rest of the text API, so the plugin applies them
972
+ // defensively — but it never has to invent them.
973
+ if (Array.isArray(src.color)) {
974
+ out.textDecorationColor = {
975
+ value: { type: 'SOLID', color: color3(src.color), opacity: round(clamp01(src.color[3]), 6) },
976
+ }
977
+ }
978
+ if (isNum(src.thickness) && src.thickness > 0) {
979
+ out.textDecorationThickness = { unit: 'PIXELS', value: round(src.thickness, 3) }
980
+ }
981
+ return out
982
+ }
983
+
984
+ const ALIGN = { left: 'LEFT', right: 'RIGHT', center: 'CENTER', justify: 'JUSTIFIED' }
985
+
986
+ /**
987
+ * `font-variant-caps` values Figma's `textCase` holds exactly.
988
+ *
989
+ * This is the one half of `font-variant` that Figma can carry, and it carries it
990
+ * per RANGE (`setRangeTextCase`), which is why it belongs on the setRange entry
991
+ * and not on the node. It repaints letters, so losing it is visible: a run set in
992
+ * small caps arrives in full lowercase.
993
+ */
994
+ const CAPS_CASE = { 'small-caps': 'SMALL_CAPS', 'all-small-caps': 'SMALL_CAPS_FORCED' }
995
+
996
+ /** Caps values Figma has no separate setting for; they collapse onto small caps. */
997
+ const CAPS_APPROX = new Set(['petite-caps', 'all-petite-caps', 'unicase', 'titling-caps'])
998
+
999
+ /**
1000
+ * The SVD's `variant` — the whole `font-variant` shorthand, with
1001
+ * `font-variant-caps` merged in by `collect/text.js` — split into the part Figma
1002
+ * holds and the part it does not.
1003
+ *
1004
+ * The numeric half (`tabular-nums`, `lining-nums`, `oldstyle-nums`, `slashed-zero`,
1005
+ * ligature and position keywords) is OpenType feature selection, and the Plugin
1006
+ * API exposes no feature control at all — same wall as `font-feature-settings`.
1007
+ * It is returned so the caller can declare it once per block instead of once per
1008
+ * run.
1009
+ *
1010
+ * @returns {{textCase: string|null, approx: string|null, dropped: string[]}}
1011
+ */
1012
+ function variantOf (variant) {
1013
+ const out = { textCase: null, approx: null, dropped: [] }
1014
+ if (typeof variant !== 'string') return out
1015
+ for (const token of variant.trim().toLowerCase().split(/\s+/)) {
1016
+ if (!token || token === 'normal' || token === 'none') continue
1017
+ if (CAPS_CASE[token]) { out.textCase = CAPS_CASE[token]; continue }
1018
+ if (CAPS_APPROX.has(token)) {
1019
+ out.textCase = out.textCase || 'SMALL_CAPS'
1020
+ out.approx = token
1021
+ continue
1022
+ }
1023
+ out.dropped.push(token)
1024
+ }
1025
+ return out
1026
+ }
1027
+
1028
+ /**
1029
+ * `-webkit-text-stroke` as a Figma stroke, or null when there is none.
1030
+ *
1031
+ * CSS centres the text stroke on the glyph outline and paints it over the fill;
1032
+ * so does Figma with `strokeAlign: 'CENTER'`, and the weight is the same number
1033
+ * in both. That makes this exact — the only reason it needs a function is that
1034
+ * Figma holds the stroke on the NODE and CSS holds it on the run.
1035
+ */
1036
+ function textStrokeOf (style) {
1037
+ const width = num(style.strokeWidth, 0)
1038
+ if (!(width > 0) || !Array.isArray(style.strokeColor)) return null
1039
+ return {
1040
+ key: `${round(width, 4)}|${style.strokeColor.map((c) => round(num(c, 0), 6)).join(',')}`,
1041
+ strokes: [{
1042
+ type: 'SOLID',
1043
+ color: color3(style.strokeColor),
1044
+ opacity: round(clamp01(style.strokeColor[3]), 6),
1045
+ blendMode: 'NORMAL',
1046
+ visible: true,
1047
+ }],
1048
+ strokeWeight: round(width, 4),
1049
+ strokeAlign: 'CENTER',
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * How much wider the widest line of this block would arrive if it went through
1055
+ * the SVG-paste route instead of this payload: the letter-spacing of every run,
1056
+ * times the characters it covers on that line.
1057
+ *
1058
+ * Figma's SVG importer ignores `letter-spacing` entirely (FIGMA_FINDINGS.md §5,
1059
+ * measured by pasting, four ways of forcing it tried and rejected), and the
1060
+ * Plugin API does not. This number is the size of that difference, and it is the
1061
+ * only reason a consumer can be told which route to take before it pastes.
1062
+ */
1063
+ function svgPasteWidthDelta (lines, runs, styles) {
1064
+ let worst = 0
1065
+ for (const line of lines) {
1066
+ if (!isObject(line) || !isNum(line.start) || !isNum(line.end)) continue
1067
+ let delta = 0
1068
+ for (const run of runs) {
1069
+ if (!isObject(run)) continue
1070
+ const from = Math.max(num(run.start, 0), line.start)
1071
+ const to = Math.min(num(run.end, 0), line.end)
1072
+ if (to <= from) continue
1073
+ const style = isObject(styles[run.style]) ? styles[run.style] : {}
1074
+ delta += Math.abs(num(style.letterSpacing, 0)) * (to - from)
1075
+ }
1076
+ if (delta > worst) worst = delta
1077
+ }
1078
+ return worst
1079
+ }
1080
+
1081
+ /**
1082
+ * Everything a TEXT node needs, including the `setRange*` instruction list.
1083
+ *
1084
+ * The width is where the two engines disagree: CSS counts the letter-spacing
1085
+ * that follows the last glyph inside the line box and Figma does not, so a box
1086
+ * measured in CSS is `ls` too wide — and a box that is even 0.3px too NARROW
1087
+ * reflows the whole line. Hence: compensate the trailing spacing, add a pixel of
1088
+ * slack, and shift the box by both wherever the alignment pins the other edge.
1089
+ */
1090
+ function buildText (src, id, doc, ctx, warn) {
1091
+ const t = src.text
1092
+ if (!isObject(t) || typeof t.characters !== 'string' || !t.characters) {
1093
+ warn('figma.text-missing', 'error', 'a text node carries no characters; it is emitted as an empty frame.')
1094
+ return null
1095
+ }
1096
+ const styles = isObject(doc.styles) && isObject(doc.styles.text) ? doc.styles.text : {}
1097
+ const assets = isObject(doc.assets) ? doc.assets : {}
1098
+ const runs = Array.isArray(t.runs) ? t.runs : []
1099
+ const emit = isObject(t.emit) ? t.emit : {}
1100
+ const frame = isObject(src.frame) ? src.frame : { x: 0, y: 0, w: 0, h: 0 }
1101
+ // A gradient glyph fill is normalised against the text block's own box, which
1102
+ // is the closest thing Figma has to `background-clip: text`.
1103
+ const glyphBox = { w: num(frame.w, 0), h: num(frame.h, 0) }
1104
+
1105
+ const setRange = []
1106
+ let base = null
1107
+ let lsMismatch = 0
1108
+ let dyRuns = 0
1109
+ const openType = new Set()
1110
+ const variantDropped = new Set()
1111
+ const variantApprox = new Set()
1112
+ // `-webkit-text-stroke` is a Figma stroke on the NODE, so the runs have to
1113
+ // agree before one can be emitted: contouring the glyphs of a run that carries
1114
+ // no stroke is wrong output, and wrong output is worse than plain output.
1115
+ const strokeKeys = new Set()
1116
+ let strokeSpec = null
1117
+ let strokedRuns = 0
1118
+ let plainRuns = 0
1119
+ const ls = num(t.letterSpacing, 0)
1120
+
1121
+ for (const run of runs) {
1122
+ if (!isObject(run)) continue
1123
+ const style = isObject(styles[run.style]) ? styles[run.style] : {}
1124
+ const fontName = fontOf(style, assets, warn)
1125
+ const start = Math.max(0, Math.round(num(run.start, 0)))
1126
+ const end = Math.max(0, Math.round(num(run.end, 0)))
1127
+ // The preflight is only useful if it says how much of the document each font
1128
+ // carries, so the counts are accumulated here, at run level, where they exist.
1129
+ ctx.fonts.record(fontName, style, id, Math.max(0, end - start), emit)
1130
+ const fills = (Array.isArray(style.fills) ? style.fills : [])
1131
+ .map((p) => toPaintFlat(p, glyphBox, warn, assets))
1132
+ .filter(Boolean)
1133
+ const entry = {
1134
+ start,
1135
+ end,
1136
+ fontName,
1137
+ fontSize: round(Math.max(1, num(style.size, 16)), 3),
1138
+ fills,
1139
+ hyperlink: typeof run.href === 'string' && run.href ? { type: 'URL', value: run.href } : null,
1140
+ ...decorationOf(style.decoration, warn),
1141
+ }
1142
+ // The measured baseline offset of a raised/lowered run. Figma's Plugin API
1143
+ // has no per-range baseline shift, so this rides along for a consumer that
1144
+ // wants to nudge the glyphs itself and is declared below as a loss.
1145
+ if (isNum(run.dy) && run.dy !== 0) {
1146
+ entry.baselineShift = round(run.dy, 3)
1147
+ dyRuns++
1148
+ }
1149
+ // Letter-spacing IS in the plugin model, per range, so it travels per range —
1150
+ // unconditionally, so the plugin calls `setRangeLetterSpacing` for every run
1151
+ // instead of inferring which ones inherit the node's value. This is where the
1152
+ // two routes into Figma diverge; see the header. `PIXELS` because the SVD
1153
+ // resolved `letter-spacing` to px in the browser.
1154
+ if (isNum(style.letterSpacing)) {
1155
+ entry.letterSpacing = { unit: 'PIXELS', value: round(style.letterSpacing, 4) }
1156
+ }
1157
+ // Small caps repaint the letters and Figma sets them per range. The node's
1158
+ // own `textCase` stays ORIGINAL: `characters` is already text-transformed.
1159
+ const variant = variantOf(style.variant)
1160
+ if (variant.textCase) entry.textCase = variant.textCase
1161
+ if (variant.approx) variantApprox.add(variant.approx)
1162
+ for (const token of variant.dropped) variantDropped.add(token)
1163
+
1164
+ const stroke = textStrokeOf(style)
1165
+ if (stroke) {
1166
+ strokedRuns++
1167
+ strokeKeys.add(stroke.key)
1168
+ if (!strokeSpec) strokeSpec = stroke
1169
+ } else if (end > start) {
1170
+ plainRuns++
1171
+ }
1172
+ // OpenType features and variable-font axes are computed CSS strings. The
1173
+ // Plugin API exposes neither, so they can only be declared — silently
1174
+ // dropping them is exactly the failure the audit names.
1175
+ const otf = typeof style.features === 'string' ? style.features.trim() : ''
1176
+ const vf = typeof style.variations === 'string' ? style.variations.trim() : ''
1177
+ if (otf && otf !== 'normal') openType.add(otf)
1178
+ if (vf && vf !== 'normal') openType.add(vf)
1179
+ if (isNum(style.letterSpacing) && Math.abs(style.letterSpacing - ls) > 1e-6) lsMismatch++
1180
+ setRange.push(entry)
1181
+ if (!base) base = entry
1182
+ }
1183
+
1184
+ if (!base) {
1185
+ warn('figma.text-missing', 'error', 'a text node has no styled runs; its font cannot be preloaded.')
1186
+ return null
1187
+ }
1188
+ if (lsMismatch) {
1189
+ warn('figma.text-letterspacing-run', 'info',
1190
+ `${lsMismatch} run(s) have a letter-spacing of their own, different from the block's ${round(ls, 4)}px. ` +
1191
+ 'It is NOT lost here: each carries its own `setRange[].letterSpacing` and the plugin applies it with ' +
1192
+ 'setRangeLetterSpacing, which the Plugin API does expose. (Pasting the SVG instead loses every one of ' +
1193
+ 'them — see FIGMA_FINDINGS.md §5.)')
1194
+ }
1195
+ if (openType.size) {
1196
+ warn('figma.text-opentype-dropped', 'warn',
1197
+ `the block asks for ${[...openType].map((v) => JSON.stringify(v)).join(', ')} ` +
1198
+ '(font-feature-settings / font-variation-settings); the Plugin API exposes neither, so the ' +
1199
+ 'run is set with the face defaults.')
1200
+ }
1201
+ if (variantApprox.size) {
1202
+ warn('figma.text-variant-approx', 'info',
1203
+ `font-variant-caps: ${[...variantApprox].join(', ')} has no Figma setting of its own; the range is set ` +
1204
+ 'to SMALL_CAPS, which is the nearest thing Figma draws.')
1205
+ }
1206
+ if (variantDropped.size) {
1207
+ warn('figma.text-variant-dropped', 'warn',
1208
+ `the block asks for font-variant ${[...variantDropped].join(' ')}; those are OpenType feature ` +
1209
+ 'selections (figures, ligatures, positions) and the Plugin API exposes no feature control, so the ' +
1210
+ 'runs are set with the face defaults — proportional, lining figures where the page asked otherwise. ' +
1211
+ 'Only the caps half of font-variant survives, as textCase.')
1212
+ }
1213
+ if (strokedRuns && (plainRuns || strokeKeys.size > 1)) {
1214
+ warn('figma.text-stroke-dropped', 'warn',
1215
+ `${strokedRuns} run(s) carry -webkit-text-stroke and ` +
1216
+ (strokeKeys.size > 1
1217
+ ? `they do not agree (${strokeKeys.size} different width/colour pairs)`
1218
+ : `${plainRuns} run(s) in the same block carry none`) +
1219
+ '. A Figma stroke belongs to the whole text node and there is no per-range stroke, so applying it ' +
1220
+ 'would contour glyphs the page does not contour: it is dropped instead, and the outlined glyphs ' +
1221
+ 'arrive solid. (`emit/svg-flat.js` keeps it — SVG has per-tspan stroke.)')
1222
+ }
1223
+ if (dyRuns) {
1224
+ warn('figma.text-baseline-shift', 'warn',
1225
+ `${dyRuns} run(s) sit off the line baseline (superscript, subscript, vertical-align); the Plugin ` +
1226
+ 'API has no per-range baseline shift, so the measured offset rides on `setRange[].baselineShift` ' +
1227
+ 'and Figma paints those glyphs on the baseline.')
1228
+ }
1229
+ if (t.direction === 'rtl') {
1230
+ warn('figma.text-direction', 'warn',
1231
+ 'the block is right-to-left; the Plugin API exposes no direction, so the logical string is emitted ' +
1232
+ 'and Figma re-orders it with its own bidi.')
1233
+ }
1234
+
1235
+ const lines = Array.isArray(t.lines) ? t.lines : []
1236
+
1237
+ // The break hyphen is measured (`collect.text-hyphen`) and the flat-SVG backend
1238
+ // paints it back on, because there each line is its own positioned piece and the
1239
+ // hyphen lands exactly where the browser drew it. Here it is DECLARED LOST, and
1240
+ // deliberately: a Figma TextNode reflows its own `characters` to its own box with
1241
+ // its own substituted font, so the break falls somewhere else and a hyphen written
1242
+ // into the string would end up in the middle of a word. A missing hyphen reads as
1243
+ // a break Figma made; a stray one reads as a spelling mistake.
1244
+ const hyphenAt = []
1245
+ lines.forEach((l, i) => {
1246
+ if (isObject(l) && typeof l.hyphen === 'string' && l.hyphen) hyphenAt.push(i)
1247
+ })
1248
+ const hyphenLines = hyphenAt.length
1249
+ if (hyphenLines) {
1250
+ warn('figma.text-hyphen-dropped', 'warn',
1251
+ `${hyphenLines} line(s) end in a hyphen the browser painted at a hyphenation break (on \`line.hyphen\`; ` +
1252
+ 'it is not in `characters`). It is NOT written into the string: Figma reflows the TextNode with its own ' +
1253
+ 'box and its own font, so the break moves and the hyphen would land inside a word. The words arrive ' +
1254
+ 'whole and the hyphenation is Figma\'s to redo.')
1255
+ }
1256
+
1257
+ // Counted for the document-level entry in `svdToFigma`: what this block keeps
1258
+ // that the other route into Figma would drop.
1259
+ const pasteDelta = svgPasteWidthDelta(lines, runs, styles)
1260
+ if (pasteDelta > 0.5) {
1261
+ ctx.letterSpacingBlocks++
1262
+ if (pasteDelta > ctx.letterSpacingWorstPx) ctx.letterSpacingWorstPx = pasteDelta
1263
+ }
1264
+
1265
+ const comp = num(t.trailingSpacingComp, ls)
1266
+ const slack = num(t.widthSlackPx, 1)
1267
+ const pitch = Math.max(0.01, num(t.pitch, base.fontSize))
1268
+
1269
+ const align = ALIGN[String(t.align || 'left').toLowerCase()] ||
1270
+ (String(t.align).toLowerCase() === 'end'
1271
+ ? (t.direction === 'rtl' ? 'LEFT' : 'RIGHT')
1272
+ : String(t.align).toLowerCase() === 'start'
1273
+ ? (t.direction === 'rtl' ? 'RIGHT' : 'LEFT')
1274
+ : 'LEFT')
1275
+ if (align === 'JUSTIFIED') {
1276
+ warn('figma.text-justified', 'info',
1277
+ 'the block is justified; Figma justifies to its own box width, so the inter-word spacing is ' +
1278
+ 'recomputed even though the line breaks are preserved.')
1279
+ }
1280
+
1281
+ // The text box is the INK, not the element: `frame` is the block's border box,
1282
+ // so using it puts every padded block's first glyph a padding-left too far
1283
+ // left and a padding-top too high. `lines[].x` and `lines[].baseline` are
1284
+ // measured and are what the box has to be pinned to.
1285
+ const placed = lines.length > 0 && lines.every((l) => isObject(l) && isNum(l.x) && isNum(l.w))
1286
+ const originX = placed ? Math.min(...lines.map((l) => l.x)) : 0
1287
+ const rightEdge = placed
1288
+ ? Math.max(...lines.map((l) => l.x + l.w))
1289
+ : Math.max(num(frame.w), 0)
1290
+ const contentW = Math.max(0, rightEdge - originX)
1291
+ if (!placed && lines.length) {
1292
+ warn('figma.text-unmeasured-lines', 'warn',
1293
+ 'a text block has lines with no measured x/width, so its box falls back to the element box; ' +
1294
+ 'any padding on that element shifts the text by exactly that padding.')
1295
+ }
1296
+
1297
+ // Figma's line is `comp` narrower than the CSS one (CSS keeps the spacing that
1298
+ // follows the last glyph, Figma drops it) and 0.3px too narrow reflows the
1299
+ // whole line, so `slack` is added and the pinned edge is compensated back.
1300
+ const width = Math.max(0, contentW - comp) + slack
1301
+ const dx = originX + (align === 'RIGHT' ? -slack : align === 'CENTER' ? -slack / 2 : 0)
1302
+
1303
+ // Figma exposes no way to set a baseline: it stacks lines from the box top by
1304
+ // `lineHeight`, putting the first baseline at halfLeading + ascent. Solving
1305
+ // that for the top is what turns a measured baseline into a y.
1306
+ const first = lines.length && isObject(lines[0]) ? lines[0] : null
1307
+ const asc = first && isNum(first.asc) ? first.asc : null
1308
+ const desc = first && isNum(first.desc) ? num(first.desc, 0) : 0
1309
+ // Half-leading is NOT clamped at zero: a `line-height` tighter than the font's
1310
+ // own ink box gives negative leading, and both engines distribute it the same
1311
+ // way. Clamping would push every tight-leading block a pixel or two up.
1312
+ const firstBaselineFromTop = asc === null ? 0 : asc + (pitch - (asc + desc)) / 2
1313
+ let dy = 0
1314
+ if (first && isNum(first.baseline) && asc !== null) {
1315
+ dy = first.baseline - firstBaselineFromTop
1316
+ } else {
1317
+ warn('figma.text-baseline-unmeasured', 'warn',
1318
+ 'a text block carries no measured first baseline, so its box is placed on the element box; ' +
1319
+ 'the first line can sit a whole padding-top away from where CSS put it.')
1320
+ }
1321
+
1322
+ return {
1323
+ characters: t.characters,
1324
+ fontName: base.fontName,
1325
+ fontSize: base.fontSize,
1326
+ fills: base.fills,
1327
+ lineHeight: { unit: 'PIXELS', value: round(pitch, 3) },
1328
+ letterSpacing: { unit: 'PIXELS', value: round(ls, 4) },
1329
+ paragraphSpacing: round(num(t.paragraphSpacing, 0), 3),
1330
+ // The rendered string is already text-transformed (SVD keeps the original in
1331
+ // `sourceText`), so asking Figma to transform it again would double it.
1332
+ textCase: 'ORIGINAL',
1333
+ textAutoResize: 'NONE',
1334
+ textAlignHorizontal: align,
1335
+ textAlignVertical: 'TOP',
1336
+ setRange,
1337
+ // Applied by the caller, not spread onto the node: `strokes` is also where a
1338
+ // box border lands, and the two must not overwrite each other silently.
1339
+ stroke: strokeSpec && !plainRuns && strokeKeys.size === 1 ? strokeSpec : null,
1340
+ width: round(Math.max(0.01, width)),
1341
+ height: round(Math.max(0.01, pitch * Math.max(1, lines.length))),
1342
+ dx: round(dx),
1343
+ dy: round(dy),
1344
+ // Not consumed by any setter: this is the measured layout the plugin can
1345
+ // check its result against, and the only record of what the browser saw.
1346
+ layout: {
1347
+ pitch: round(pitch, 4),
1348
+ lineCount: lines.length,
1349
+ baselines: lines.map((l) => round(num(l && l.baseline, 0), 4)),
1350
+ lineWidths: lines.map((l) => round(num(l && l.w, 0), 4)),
1351
+ lineX: lines.map((l) => round(num(l && l.x, 0), 4)),
1352
+ firstBaselineFromTop: round(firstBaselineFromTop, 4),
1353
+ trailingSpacingComp: round(comp, 4),
1354
+ widthSlackPx: round(slack, 4),
1355
+ // The lines the browser hyphenated, so a consumer can SEE where the glyph
1356
+ // it will not find in `characters` was painted, and check Figma's own
1357
+ // re-hyphenation against it. The glyph itself is declared dropped above.
1358
+ hyphenLines: hyphenAt,
1359
+ // What the SVG-paste route would cost this block, in px on its widest
1360
+ // line. Zero for a block with no letter-spacing.
1361
+ svgPasteWidthDelta: round(pasteDelta, 3),
1362
+ },
1363
+ }
1364
+ }
1365
+
1366
+ // ——— nodes ———
1367
+
1368
+ /** Figma node type for an SVD node, plus whether it can host children. */
1369
+ function typeOf (src, radii, warn) {
1370
+ switch (src.type) {
1371
+ case 'frame':
1372
+ return 'FRAME'
1373
+ case 'group':
1374
+ return 'GROUP'
1375
+ case 'text':
1376
+ return 'TEXT'
1377
+ case 'image':
1378
+ return 'RECTANGLE'
1379
+ case 'vector':
1380
+ return 'VECTOR'
1381
+ case 'shape': {
1382
+ if (typeof src.path === 'string' || Array.isArray(src.paths)) return 'VECTOR'
1383
+ if (src.shape === 'ellipse') return 'ELLIPSE'
1384
+ return radii && radii.path ? 'VECTOR' : 'RECTANGLE'
1385
+ }
1386
+ default:
1387
+ warn('figma.node-unknown-type', 'error',
1388
+ `node type "${src.type}" is not in the SVD vocabulary; it is emitted as a FRAME.`)
1389
+ return 'FRAME'
1390
+ }
1391
+ }
1392
+
1393
+ function vectorPathsOf (src, box, radii) {
1394
+ if (Array.isArray(src.paths)) {
1395
+ return src.paths
1396
+ .filter((p) => p && typeof p.data === 'string')
1397
+ .map((p) => ({ windingRule: p.windingRule === 'EVENODD' ? 'EVENODD' : 'NONZERO', data: p.data }))
1398
+ }
1399
+ if (typeof src.path === 'string' && src.path) {
1400
+ return [{ windingRule: src.windingRule === 'evenodd' ? 'EVENODD' : 'NONZERO', data: src.path }]
1401
+ }
1402
+ if (radii && radii.path) {
1403
+ return [{ windingRule: 'NONZERO', data: roundedRectPath(box.width, box.height, src.radii) }]
1404
+ }
1405
+ return null
1406
+ }
1407
+
1408
+ /**
1409
+ * @param {object} src the SVD node
1410
+ * @param {string} id
1411
+ * @param {object} ctx
1412
+ * @returns {{node:object, inner:object[], outer:object[]}}
1413
+ * `inner` are composed shapes that belong to the node's own box and may live
1414
+ * inside it; `outer` must be siblings of it — an outline is painted outside
1415
+ * the border box and a clipping frame would eat it.
1416
+ */
1417
+ function emitNode (src, id, ctx) {
1418
+ const warn = (code, severity, message) => {
1419
+ ctx.diagnostics.push({ code, severity, node: id, message })
1420
+ }
1421
+ const inner = []
1422
+ const outer = []
1423
+ const name = typeof src.name === 'string' && src.name
1424
+ ? src.name
1425
+ : (typeof src.type === 'string' && src.type ? src.type : 'node')
1426
+
1427
+ const box = placement(src, warn)
1428
+ const radii = readRadii(src.radii, box.width, box.height, warn)
1429
+ let type = typeOf(src, radii, warn)
1430
+
1431
+ const fills = []
1432
+ const placedImages = []
1433
+ const clipBoxes = new Set()
1434
+ for (const p of Array.isArray(src.fills) ? src.fills : []) {
1435
+ if (!isObject(p)) continue
1436
+ if (p.clipBox) clipBoxes.add(p.clipBox)
1437
+ if (p.clipBox === 'text') {
1438
+ warn('figma.fill-clip-text', 'warn',
1439
+ 'background-clip:text needs a glyph mask Figma cannot build from a paint; the layer is painted ' +
1440
+ 'over the whole box.')
1441
+ }
1442
+ const fill = toPaint(p, { w: box.width, h: box.height }, warn, ctx.assets)
1443
+ if (!fill) continue
1444
+ // An image that covers only part of the box (`object-fit: contain`, a small
1445
+ // `object-fit: none`) is a shape of its own: a Figma fill always spans the
1446
+ // whole node, and FIT would silently re-centre it.
1447
+ if (isObject(fill.paint) && isObject(fill.place)) placedImages.push(fill)
1448
+ else fills.push(fill)
1449
+ }
1450
+ if (clipBoxes.size > 1) {
1451
+ warn('figma.fill-clipbox', 'warn',
1452
+ `fills use ${clipBoxes.size} different clip boxes (${[...clipBoxes].join(', ')}) and one Figma node ` +
1453
+ 'paints them all over its own box; they should have been split into separate nodes upstream.')
1454
+ }
1455
+
1456
+ // A GROUP has no paint of its own in Figma, and a group with no children is
1457
+ // not a legal node at all.
1458
+ const kids = Array.isArray(src.children) ? src.children.filter((k) => ctx.src[k]) : []
1459
+ if (type === 'GROUP' && (fills.length || (Array.isArray(src.strokes) && src.strokes.length) || src.clip)) {
1460
+ warn('figma.group-promoted', 'info',
1461
+ 'a group carries paint or a clip, which Figma groups cannot hold; it is emitted as a FRAME.')
1462
+ type = 'FRAME'
1463
+ }
1464
+ if (type === 'GROUP' && !kids.length) {
1465
+ warn('figma.group-empty', 'info', 'a group has no children; Figma cannot hold an empty group, so it is a FRAME.')
1466
+ type = 'FRAME'
1467
+ }
1468
+
1469
+ const node = {
1470
+ id,
1471
+ type,
1472
+ name,
1473
+ x: box.x,
1474
+ y: box.y,
1475
+ width: box.width,
1476
+ height: box.height,
1477
+ z: zOf(src),
1478
+ opacity: round(clamp01(num(src.opacity, 1)), 6),
1479
+ // Figma frames and groups default to PASS_THROUGH, and NORMAL on a group is
1480
+ // a real visual change: it isolates the children.
1481
+ blendMode: src.blend && src.blend !== 'normal'
1482
+ ? blendOf(src.blend, warn, 'node')
1483
+ : (type === 'FRAME' || type === 'GROUP' ? 'PASS_THROUGH' : 'NORMAL'),
1484
+ visible: true,
1485
+ fills,
1486
+ strokes: [],
1487
+ effects: [],
1488
+ children: [],
1489
+ }
1490
+ if (box.rotation !== undefined) {
1491
+ node.rotation = box.rotation
1492
+ node.relativeTransform = box.relativeTransform
1493
+ }
1494
+ if (box.degrade) node.degrade = box.degrade
1495
+ if (type === 'GROUP') node.sizedByChildren = true
1496
+
1497
+ // Provenance, so a designer can ask a layer where it came from. The plugin
1498
+ // writes it to `setPluginData` verbatim and never opens an SVD field itself.
1499
+ const source = isObject(src.source) ? src.source : {}
1500
+ const fidelity = isObject(src.fidelity) ? src.fidelity : {}
1501
+ node.provenance = {
1502
+ selector: typeof source.path === 'string' ? source.path : null,
1503
+ tag: typeof source.tag === 'string' ? source.tag : null,
1504
+ grade: typeof fidelity.grade === 'string' ? fidelity.grade : 'E',
1505
+ notes: Array.isArray(fidelity.notes) ? fidelity.notes : [],
1506
+ css: isObject(source.css) ? source.css : undefined,
1507
+ }
1508
+
1509
+ // ——— images that do not fill their box ———
1510
+ for (const { paint, place } of placedImages) {
1511
+ inner.push({
1512
+ type: 'RECTANGLE',
1513
+ name: `${name} / image`,
1514
+ ...placeLocal(box, place.x, place.y, place.w, place.h),
1515
+ fills: [paint],
1516
+ strokes: [],
1517
+ effects: [],
1518
+ children: [],
1519
+ composed: 'image',
1520
+ })
1521
+ warn('figma.image-composed', 'info',
1522
+ `object-fit leaves ${round(place.w)}x${round(place.h)}px of image inside a ` +
1523
+ `${round(box.width)}x${round(box.height)}px box; a Figma fill always spans the whole node, so the ` +
1524
+ 'bitmap is emitted as its own rectangle at the measured position instead of being stretched.')
1525
+ }
1526
+
1527
+ // ——— radii / paths ———
1528
+ const paths = vectorPathsOf(src, box, radii)
1529
+ if (paths && type === 'VECTOR') node.vectorPaths = paths
1530
+ else if (paths && radii.path) { node.type = 'VECTOR'; node.vectorPaths = paths }
1531
+ if (typeof src.svg === 'string' && src.svg && !node.vectorPaths) {
1532
+ // `createNodeFromSvg` is the plugin's job; all this side can do is carry the
1533
+ // already-namespaced markup across.
1534
+ node.svg = src.svg
1535
+ warn('figma.vector-svg', 'info',
1536
+ 'the node is passthrough SVG; the plugin must build it with createNodeFromSvg, which returns a FRAME.')
1537
+ }
1538
+ if (radii.corners && !node.vectorPaths && (node.type === 'RECTANGLE' || node.type === 'FRAME')) {
1539
+ const [tl, tr, br, bl] = radii.corners
1540
+ if (tl || tr || br || bl) {
1541
+ node.topLeftRadius = round(tl, 3)
1542
+ node.topRightRadius = round(tr, 3)
1543
+ node.bottomRightRadius = round(br, 3)
1544
+ node.bottomLeftRadius = round(bl, 3)
1545
+ }
1546
+ }
1547
+
1548
+ // ——— clip ———
1549
+ if (isObject(src.clip)) {
1550
+ if (node.type === 'FRAME') {
1551
+ node.clipsContent = true
1552
+ if (src.clip.box && src.clip.box !== 'border') {
1553
+ warn('figma.clip-box', 'info',
1554
+ `the overflow clip is the ${src.clip.box} box; Figma clips a frame to its own bounds, so the ` +
1555
+ 'clip is one border width wider than the CSS one.')
1556
+ }
1557
+ } else {
1558
+ warn('figma.clip-unsupported', 'warn',
1559
+ `only frames clip their content and this node is a ${node.type}; the clip is dropped.`)
1560
+ }
1561
+ }
1562
+
1563
+ // ——— strokes ———
1564
+ const strokes = (Array.isArray(src.strokes) ? src.strokes : []).filter(isObject)
1565
+ strokes.forEach((stroke, i) => {
1566
+ const weights = sideWeights(stroke)
1567
+ const uniformWeight = sameWeights(weights)
1568
+ const uniformColor = stroke.uniform !== false
1569
+ const rectLike = RECT_LIKE.has(node.type)
1570
+
1571
+ const asSibling = stroke.sibling === true || i > 0
1572
+ if (!uniformColor || (!uniformWeight && !rectLike)) {
1573
+ const reason = !uniformColor
1574
+ ? 'the four border sides have different colours and one Figma stroke carries one paint'
1575
+ : `per-side stroke weights are legal only on rect-like nodes and this one is a ${node.type}`
1576
+ const sides = composeSides(stroke, box, name, warn, reason)
1577
+ // A border sits ON the box's edge, so a clipping frame keeps it; an
1578
+ // outline sits outside it and has to leave.
1579
+ ;(stroke.sibling === true ? outer : inner).push(...sides)
1580
+ return
1581
+ }
1582
+
1583
+ // An outline is painted on a box grown by its offset, so a gradient stroke
1584
+ // has to be normalised against that grown box, not the element's.
1585
+ const grow = asSibling ? num(stroke.offset, 0) : 0
1586
+ const paint = isObject(stroke.paint)
1587
+ ? toPaintFlat(stroke.paint, {
1588
+ w: box.width + 2 * grow,
1589
+ h: box.height + 2 * grow,
1590
+ ox: -grow,
1591
+ oy: -grow,
1592
+ }, warn, ctx.assets)
1593
+ : null
1594
+ if (!paint) return
1595
+
1596
+ const target = asSibling
1597
+ ? {
1598
+ type: 'RECTANGLE',
1599
+ name: `${name} / ${stroke.kind || 'stroke'}`,
1600
+ ...placeLocal(box, -num(stroke.offset, 0), -num(stroke.offset, 0),
1601
+ box.width + 2 * num(stroke.offset, 0), box.height + 2 * num(stroke.offset, 0)),
1602
+ fills: [],
1603
+ strokes: [],
1604
+ effects: [],
1605
+ children: [],
1606
+ composed: stroke.kind || 'stroke',
1607
+ }
1608
+ : node
1609
+
1610
+ target.strokes = [paint]
1611
+ target.strokeAlign = STROKE_ALIGN[String(stroke.align || 'inside').toLowerCase()] || 'INSIDE'
1612
+ if (uniformWeight) {
1613
+ target.strokeWeight = round(weights[0], 4)
1614
+ } else {
1615
+ // No scalar alongside these: `strokeWeight` overwrites all four sides, so
1616
+ // emitting both would make the result depend on the order the plugin
1617
+ // happens to assign them in.
1618
+ target.strokeTopWeight = round(weights[0], 4)
1619
+ target.strokeRightWeight = round(weights[1], 4)
1620
+ target.strokeBottomWeight = round(weights[2], 4)
1621
+ target.strokeLeftWeight = round(weights[3], 4)
1622
+ }
1623
+ if (Array.isArray(stroke.dash) && stroke.dash.length) {
1624
+ target.dashPattern = stroke.dash.map((n) => round(Math.max(0, num(n, 0)), 3))
1625
+ }
1626
+ if (stroke.cap) target.strokeCap = STROKE_CAP[String(stroke.cap).toLowerCase()] || 'NONE'
1627
+ if (stroke.join) target.strokeJoin = STROKE_JOIN[String(stroke.join).toLowerCase()] || 'MITER'
1628
+ if (isNum(stroke.miter)) target.strokeMiterLimit = round(stroke.miter, 3)
1629
+
1630
+ if (asSibling) {
1631
+ const sideRadii = Array.isArray(stroke.radii) ? stroke.radii : null
1632
+ const corners = sideRadii ? readRadii(sideRadii, box.width, box.height, warn) : null
1633
+ if (corners && corners.corners && !corners.path) {
1634
+ const [tl, tr, br, bl] = corners.corners
1635
+ target.topLeftRadius = round(tl, 3)
1636
+ target.topRightRadius = round(tr, 3)
1637
+ target.bottomRightRadius = round(br, 3)
1638
+ target.bottomLeftRadius = round(bl, 3)
1639
+ }
1640
+ ;(stroke.sibling === true ? outer : inner).push(target)
1641
+ warn('figma.stroke-sibling', 'info',
1642
+ `"${stroke.kind || 'stroke'}" cannot share a node with the box's own border (Figma holds one ` +
1643
+ 'stroke weight per node); it is emitted as a sibling shape painted last.')
1644
+ }
1645
+ })
1646
+
1647
+ // ——— effects ———
1648
+ for (const e of Array.isArray(src.effects) ? src.effects : []) {
1649
+ if (!isObject(e)) continue
1650
+ const effect = toEffect(e, node, warn)
1651
+ if (effect) node.effects.push(effect)
1652
+ }
1653
+
1654
+ // ——— mask ———
1655
+ if (isObject(src.mask) && typeof src.mask.node === 'string') {
1656
+ node.mask = { node: src.mask.node, type: src.mask.type === 'luminance' ? 'LUMINANCE' : 'ALPHA' }
1657
+ warn('figma.mask-unsupported', 'warn',
1658
+ 'Figma masks by flagging the bottom sibling of a group, not by reference; the plugin must set ' +
1659
+ `isMask on "${src.mask.node}" and place it below this node, and luminance masks need a group wrapper.`)
1660
+ }
1661
+
1662
+ // ——— text ———
1663
+ if (node.type === 'TEXT') {
1664
+ const text = buildText(src, id, ctx.doc, ctx, warn)
1665
+ if (!text) {
1666
+ node.type = 'FRAME'
1667
+ } else {
1668
+ if (node.fills.length) {
1669
+ warn('figma.text-background', 'warn',
1670
+ 'a text node also carries background fills; Figma text fills are the glyph colour, so the ' +
1671
+ 'background is dropped and should have been its own node.')
1672
+ }
1673
+ const { width, height, dx, dy, layout, setRange, stroke, ...props } = text
1674
+ Object.assign(node, props)
1675
+ // `-webkit-text-stroke`, which Figma holds on the node. The border loop ran
1676
+ // first, so a text node that somehow also has a box border keeps it and the
1677
+ // glyph contour is declared lost rather than overwriting it.
1678
+ if (stroke) {
1679
+ if (node.strokes.length) {
1680
+ warn('figma.text-stroke-dropped', 'warn',
1681
+ 'this text node carries both a box border and -webkit-text-stroke, and Figma has one stroke ' +
1682
+ 'list per node; the border is kept and the glyph contour is dropped.')
1683
+ } else {
1684
+ node.strokes = stroke.strokes
1685
+ node.strokeWeight = stroke.strokeWeight
1686
+ node.strokeAlign = stroke.strokeAlign
1687
+ warn('figma.text-stroke', 'info',
1688
+ `-webkit-text-stroke ${stroke.strokeWeight}px is emitted as the text node's own stroke with ` +
1689
+ 'strokeAlign CENTER: CSS centres it on the glyph outline and paints it over the fill, and so ' +
1690
+ 'does Figma, so the weight carries across unchanged. Every run in the block shares it — Figma ' +
1691
+ 'has no per-range stroke, and a block whose runs disagreed would have had it dropped instead.')
1692
+ }
1693
+ }
1694
+ node.width = width
1695
+ node.height = height
1696
+ node.x = round(node.x + dx)
1697
+ node.y = round(node.y + dy)
1698
+ if (node.relativeTransform) {
1699
+ node.relativeTransform[0][2] = node.x
1700
+ node.relativeTransform[1][2] = node.y
1701
+ }
1702
+ node.setRange = setRange
1703
+ node.textLayout = layout
1704
+ // The two pre-baked escape hatches, so the plugin never has to look for
1705
+ // them inside an SVD `text.emit` it no longer receives.
1706
+ const emit = isObject(src.text.emit) ? src.text.emit : {}
1707
+ if (typeof emit.outlineAsset === 'string' && emit.outlineAsset) node.outlineAsset = emit.outlineAsset
1708
+ if (typeof emit.rasterAsset === 'string' && emit.rasterAsset) node.rasterAsset = emit.rasterAsset
1709
+ if (emit.mode && emit.mode !== 'block') {
1710
+ node.degrade = {
1711
+ action: emit.mode === 'raster' ? 'RASTER' : 'OUTLINE',
1712
+ reason: `text.emit.mode=${emit.mode}`,
1713
+ asset: (emit.mode === 'raster' ? emit.rasterAsset : emit.outlineAsset) || null,
1714
+ }
1715
+ }
1716
+ ctx.textNodes++
1717
+ if (layout.lineCount > 1) ctx.multilineText++
1718
+ }
1719
+ }
1720
+
1721
+ return { node, inner, outer }
1722
+ }
1723
+
1724
+ /**
1725
+ * Walk the containment tree, emitting back to front.
1726
+ *
1727
+ * `children` is DOM containment order and `paint.z` is the paint order (SVD
1728
+ * invariant 3), and Figma's `children[0]` is the BOTTOM layer — so sorting by
1729
+ * `z` here is the whole of the translation. Composed siblings follow the node
1730
+ * they belong to, which puts them on top of it.
1731
+ */
1732
+ function walk (id, ctx) {
1733
+ if (ctx.seen.has(id)) return null
1734
+ ctx.seen.add(id)
1735
+ const src = ctx.src[id]
1736
+ if (!isObject(src)) return null
1737
+
1738
+ const { node, inner, outer } = emitNode(src, id, ctx)
1739
+ ctx.out[id] = node
1740
+
1741
+ const kids = (Array.isArray(src.children) ? src.children : [])
1742
+ .filter((k) => typeof k === 'string' && ctx.src[k])
1743
+ .map((k, i) => ({ k, i, z: zOf(ctx.src[k]) }))
1744
+ .sort((a, b) => a.z - b.z || a.i - b.i)
1745
+
1746
+ const childIds = []
1747
+ for (const { k } of kids) {
1748
+ const res = walk(k, ctx)
1749
+ if (!res) continue
1750
+ childIds.push(res.id)
1751
+ // A child's outer shapes belong right after it, which paints them on top.
1752
+ for (const extra of res.outer) childIds.push(extra.id)
1753
+ }
1754
+
1755
+ const register = (list) => {
1756
+ for (const extra of list) {
1757
+ extra.id = `${id}__c${ctx.seq++}`
1758
+ extra.z = node.z
1759
+ ctx.out[extra.id] = extra
1760
+ }
1761
+ return list
1762
+ }
1763
+ register(inner)
1764
+ register(outer)
1765
+
1766
+ // Only a frame or a group can hold children; anything else hands its own
1767
+ // composed shapes up to be its siblings instead.
1768
+ const hosts = node.type === 'FRAME' || node.type === 'GROUP'
1769
+ const up = hosts ? [] : [...inner]
1770
+ if (hosts) for (const extra of inner) childIds.push(extra.id)
1771
+ up.push(...outer)
1772
+
1773
+ node.children = childIds
1774
+ return { id, outer: up }
1775
+ }
1776
+
1777
+ /**
1778
+ * The preflight's font ledger.
1779
+ *
1780
+ * `{family, style}` alone cannot answer the only question the preflight exists
1781
+ * to ask — "how much of this document breaks if I substitute?" — so the block
1782
+ * and character counts are accumulated per face while the runs go by, along with
1783
+ * whether every block using it has a pre-baked outline or bitmap to fall back on.
1784
+ */
1785
+ function makeFontLedger () {
1786
+ const byKey = new Map()
1787
+ return {
1788
+ // U+0000 cannot occur in a font name, so "Inter Semi"+"Bold" and
1789
+ // "Inter"+"SemiBold" cannot collapse into one row the way a space would.
1790
+ key: (family, style) => `${family}\u0000${style}`,
1791
+ record (fontName, style, nodeId, chars, emit) {
1792
+ const key = this.key(fontName.family, fontName.style)
1793
+ let entry = byKey.get(key)
1794
+ if (!entry) {
1795
+ entry = {
1796
+ key,
1797
+ family: fontName.family,
1798
+ style: fontName.style,
1799
+ weight: num(style && style.weight, 400),
1800
+ italic: (style && style.italic) === true,
1801
+ stack: typeof (style && style.stack) === 'string' ? style.stack : '',
1802
+ nodes: 0,
1803
+ chars: 0,
1804
+ nodeIds: [],
1805
+ canOutline: true,
1806
+ canRaster: true,
1807
+ }
1808
+ byKey.set(key, entry)
1809
+ }
1810
+ entry.chars += Math.max(0, num(chars, 0))
1811
+ if (entry.nodeIds.indexOf(nodeId) === -1) {
1812
+ entry.nodeIds.push(nodeId)
1813
+ entry.nodes++
1814
+ if (typeof (emit && emit.outlineAsset) !== 'string') entry.canOutline = false
1815
+ if (typeof (emit && emit.rasterAsset) !== 'string') entry.canRaster = false
1816
+ }
1817
+ },
1818
+ list () {
1819
+ return [...byKey.values()].sort((a, b) => (
1820
+ a.family < b.family ? -1 : a.family > b.family ? 1
1821
+ : a.style < b.style ? -1 : a.style > b.style ? 1 : 0
1822
+ ))
1823
+ },
1824
+ }
1825
+ }
1826
+
1827
+ /**
1828
+ * A Figma GROUP has no box: its bounds are its children's, so a child placed
1829
+ * relative to the group's frame lands `frame.x/y` short. Folding that origin into
1830
+ * the children here — top-down, so a nested group inherits its parent's fold
1831
+ * before it passes its own down — is geometry, and geometry belongs on this side
1832
+ * of the wire, not in the sandbox.
1833
+ */
1834
+ function foldGroupOrigins (out, rootId) {
1835
+ const queue = [rootId]
1836
+ const seen = new Set()
1837
+ while (queue.length) {
1838
+ const id = queue.shift()
1839
+ if (seen.has(id)) continue
1840
+ seen.add(id)
1841
+ const node = out[id]
1842
+ if (!isObject(node)) continue
1843
+ const kids = Array.isArray(node.children) ? node.children : []
1844
+ if (node.type === 'GROUP' && (node.x !== 0 || node.y !== 0)) {
1845
+ const dx = node.x
1846
+ const dy = node.y
1847
+ for (const k of kids) {
1848
+ const child = out[k]
1849
+ if (!isObject(child)) continue
1850
+ child.x = round(child.x + dx)
1851
+ child.y = round(child.y + dy)
1852
+ if (Array.isArray(child.relativeTransform)) {
1853
+ child.relativeTransform[0][2] = child.x
1854
+ child.relativeTransform[1][2] = child.y
1855
+ }
1856
+ }
1857
+ node.x = 0
1858
+ node.y = 0
1859
+ }
1860
+ for (const k of kids) queue.push(k)
1861
+ for (const k of Array.isArray(node.siblings) ? node.siblings : []) queue.push(k)
1862
+ }
1863
+ }
1864
+
1865
+ /**
1866
+ * Translate an SVD document into the payload the Figma plugin consumes.
1867
+ *
1868
+ * Pure: no DOM, no measurement, and no Figma API — the result is JSON the
1869
+ * plugin sandbox can act on directly, and the ONLY thing it is given, so the
1870
+ * assets, the capture header and the engine's own diagnostics travel with it
1871
+ * under `document`.
1872
+ *
1873
+ * @param {object} doc a valid SVD document
1874
+ * @returns {{version:1, root:string, nodes:Record<string,object>,
1875
+ * fonts:Array<{family:string, style:string, nodes:number, chars:number}>,
1876
+ * diagnostics:Diag[], document:object}}
1877
+ * `nodes` is keyed by id; each `children` array is already in Figma's
1878
+ * back-to-front order (`children[0]` is the bottom layer). The root record may
1879
+ * carry `siblings`, ids the plugin must append *beside* the root rather than
1880
+ * inside it. `fonts` is the unique set every `setRange` entry names, with the
1881
+ * block and character counts the preflight puts in front of the user.
1882
+ * `diagnostics` is what THIS translation lost; the document's own are under
1883
+ * `document.diagnostics`, unmerged, so neither list double-counts the other.
1884
+ */
1885
+ export function svdToFigma (doc) {
1886
+ if (!isObject(doc)) throw new TypeError('svdToFigma: doc must be an SVD document object')
1887
+ if (!isObject(doc.nodes)) throw new TypeError('svdToFigma: doc.nodes must be an object keyed by node id')
1888
+ const rootId = readRootId(doc.root)
1889
+ if (!rootId || !isObject(doc.nodes[rootId])) {
1890
+ throw new TypeError(`svdToFigma: doc.root does not resolve to a node (got ${JSON.stringify(doc.root)})`)
1891
+ }
1892
+
1893
+ const ctx = {
1894
+ doc,
1895
+ src: doc.nodes,
1896
+ assets: isObject(doc.assets) ? doc.assets : {},
1897
+ out: {},
1898
+ fonts: makeFontLedger(),
1899
+ diagnostics: [],
1900
+ seen: new Set(),
1901
+ seq: 0,
1902
+ multilineText: 0,
1903
+ textNodes: 0,
1904
+ letterSpacingBlocks: 0,
1905
+ letterSpacingWorstPx: 0,
1906
+ }
1907
+
1908
+ const res = walk(rootId, ctx)
1909
+ // The root has no parent to be a sibling of. A frame or group can adopt what
1910
+ // is left; anything else cannot, and the plugin has to place those shapes
1911
+ // beside the root in whatever container it imports into.
1912
+ if (res && res.outer.length) {
1913
+ const ids = res.outer.map((extra) => extra.id)
1914
+ const canAdopt = (ctx.out[rootId].type === 'FRAME' && ctx.out[rootId].clipsContent !== true) ||
1915
+ ctx.out[rootId].type === 'GROUP'
1916
+ if (canAdopt) {
1917
+ ctx.out[rootId].children.push(...ids)
1918
+ } else {
1919
+ ctx.out[rootId].siblings = ids
1920
+ ctx.diagnostics.push({
1921
+ code: 'figma.root-siblings',
1922
+ severity: 'warn',
1923
+ node: rootId,
1924
+ message:
1925
+ `the capture root cannot hold the ${ids.length} shape(s) its own border or outline had to be ` +
1926
+ `composed into — it is a ${ctx.out[rootId].type}${ctx.out[rootId].clipsContent ? ' that clips its content' : ''}. ` +
1927
+ 'They are listed in `siblings`, and the plugin must append them next to the root, in order, after it.',
1928
+ })
1929
+ }
1930
+ }
1931
+
1932
+ for (const id of Object.keys(doc.nodes)) {
1933
+ if (!ctx.seen.has(id)) {
1934
+ ctx.diagnostics.push({
1935
+ code: 'figma.unreachable',
1936
+ severity: 'error',
1937
+ node: id,
1938
+ message: 'the node is not reachable from the root and is not emitted.',
1939
+ })
1940
+ }
1941
+ }
1942
+
1943
+ if (ctx.textNodes) {
1944
+ ctx.diagnostics.push({
1945
+ code: 'figma.text-vertical-calibration',
1946
+ severity: 'info',
1947
+ message:
1948
+ `${ctx.textNodes} text block(s) (${ctx.multilineText} of them multi-line) are positioned by ` +
1949
+ 'solving Figma\'s own stacking for the measured first baseline: y = baseline - (ascent + ' +
1950
+ 'half-leading). Figma exposes no way to set a baseline directly, so the residual error is the ' +
1951
+ 'difference between Chromium\'s and Figma\'s ascent for the face that ends up loaded — sub-pixel ' +
1952
+ 'for the same face, and up to a whole leading step for a substituted one. The measured ' +
1953
+ 'baselines are in `textLayout` for anything that wants to check.',
1954
+ })
1955
+ }
1956
+
1957
+ if (ctx.letterSpacingBlocks) {
1958
+ // Not a loss — the opposite, and it is here because a consumer choosing
1959
+ // between the two routes has no other way to find out. Pasting the SVG drops
1960
+ // letter-spacing outright (FIGMA_FINDINGS.md §5: measured, and four ways of
1961
+ // forcing it through the importer tried and rejected); this payload keeps it.
1962
+ ctx.diagnostics.push({
1963
+ code: 'figma.text-letterspacing-kept',
1964
+ severity: 'info',
1965
+ message:
1966
+ `${ctx.letterSpacingBlocks} text block(s) carry a non-zero letter-spacing, and this payload keeps ` +
1967
+ 'every one of them: `letterSpacing` on the node and on each `setRange` entry, which the Plugin API ' +
1968
+ 'applies with setRangeLetterSpacing. The OTHER route into Figma — pasting the SVG from ' +
1969
+ 'emit/svg-flat.js — discards it entirely, which would make the worst line here arrive up to ' +
1970
+ `${round(ctx.letterSpacingWorstPx, 1)}px wider than the browser painted it (plus whatever the font ` +
1971
+ 'substitution adds). Per block, the number is in `textLayout.svgPasteWidthDelta`.',
1972
+ })
1973
+ }
1974
+
1975
+ foldGroupOrigins(ctx.out, rootId)
1976
+
1977
+ return {
1978
+ version: VERSION,
1979
+ root: rootId,
1980
+ nodes: ctx.out,
1981
+ fonts: ctx.fonts.list(),
1982
+ diagnostics: ctx.diagnostics,
1983
+ // The plugin is handed this payload and nothing else, so everything it
1984
+ // cannot re-derive from the nodes rides along here. `assets` is a reference
1985
+ // to the document's own map: image fills name them by id, exactly as before.
1986
+ document: {
1987
+ assets: ctx.assets,
1988
+ capture: isObject(doc.capture) ? doc.capture : {},
1989
+ report: isObject(doc.report) ? doc.report : {},
1990
+ diagnostics: Array.isArray(doc.diagnostics) ? doc.diagnostics : [],
1991
+ },
1992
+ }
1993
+ }
1994
+
1995
+ export default svdToFigma