@zumer/snapdom-plugins 3.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +22 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,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
|