@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transform` + `transform-origin` -> one affine matrix and its decomposition.
|
|
3
|
+
*
|
|
4
|
+
* Matrices are the CSS/SVG six-tuple `[a, b, c, d, e, f]`, i.e.
|
|
5
|
+
*
|
|
6
|
+
* | a c e |
|
|
7
|
+
* | b d f |
|
|
8
|
+
* | 0 0 1 |
|
|
9
|
+
*
|
|
10
|
+
* Two conventions that everything downstream depends on:
|
|
11
|
+
*
|
|
12
|
+
* - `multiply(m1, m2)` is the matrix product, so it composes the way function
|
|
13
|
+
* application does: `applyToPoint(multiply(m1, m2), x, y)` equals
|
|
14
|
+
* `applyToPoint(m1, ...applyToPoint(m2, x, y))`. Same as `DOMMatrix.multiply`.
|
|
15
|
+
* - `transform-origin` is NOT a separate field. CSS applies the transform about
|
|
16
|
+
* the origin, which is exactly `T(o) · M · T(-o)`, and that product is what
|
|
17
|
+
* `m` holds. A backend that reads `m` and ignores the origin still lands the
|
|
18
|
+
* box in the right place, which is the whole point of baking it in here.
|
|
19
|
+
*
|
|
20
|
+
* The decomposition is the QR one (css-transforms-1 §decomposing), because Figma
|
|
21
|
+
* has no matrix input: it wants rotation, size and position separately, and it
|
|
22
|
+
* has no skew at all — a non-zero `skewX` is the caller's cue to outline or
|
|
23
|
+
* raster. Reconstruction order is `translate(tx,ty) rotate(rot) skewX(skewX)
|
|
24
|
+
* scale(sx,sy)`.
|
|
25
|
+
*
|
|
26
|
+
* Pure: no DOM.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const ABS_UNITS = {
|
|
30
|
+
px: 1, pt: 96 / 72, pc: 16, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 25.4 / 4,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ANGLE_UNITS = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 }
|
|
34
|
+
|
|
35
|
+
const NUMBER = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z%]*)$/i
|
|
36
|
+
|
|
37
|
+
const IDENTITY = [1, 0, 0, 1, 0, 0]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Matrix product. See the header for the composition order — getting it
|
|
41
|
+
* backwards produces geometry that is right for symmetric transforms and subtly
|
|
42
|
+
* wrong for every other one, which is the worst possible failure mode.
|
|
43
|
+
*
|
|
44
|
+
* @param {number[]} m1
|
|
45
|
+
* @param {number[]} m2
|
|
46
|
+
* @returns {number[]} `[a, b, c, d, e, f]`
|
|
47
|
+
*/
|
|
48
|
+
export function multiply(m1, m2) {
|
|
49
|
+
const [a1, b1, c1, d1, e1, f1] = m1
|
|
50
|
+
const [a2, b2, c2, d2, e2, f2] = m2
|
|
51
|
+
return [
|
|
52
|
+
a1 * a2 + c1 * b2,
|
|
53
|
+
b1 * a2 + d1 * b2,
|
|
54
|
+
a1 * c2 + c1 * d2,
|
|
55
|
+
b1 * c2 + d1 * d2,
|
|
56
|
+
a1 * e2 + c1 * f2 + e1,
|
|
57
|
+
b1 * e2 + d1 * f2 + f1,
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {number[]} m
|
|
63
|
+
* @param {number} x
|
|
64
|
+
* @param {number} y
|
|
65
|
+
* @returns {[number, number]}
|
|
66
|
+
*/
|
|
67
|
+
export function applyToPoint(m, x, y) {
|
|
68
|
+
return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toPx(token, base) {
|
|
72
|
+
const m = NUMBER.exec(token)
|
|
73
|
+
if (!m) return null
|
|
74
|
+
const n = parseFloat(m[1])
|
|
75
|
+
if (!Number.isFinite(n)) return null
|
|
76
|
+
const unit = m[2].toLowerCase()
|
|
77
|
+
if (unit === '%') return (n / 100) * base
|
|
78
|
+
if (!unit) return n === 0 ? 0 : null
|
|
79
|
+
const k = ABS_UNITS[unit]
|
|
80
|
+
return k === undefined ? null : n * k
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toDeg(token) {
|
|
84
|
+
const m = NUMBER.exec(token)
|
|
85
|
+
if (!m) return null
|
|
86
|
+
const n = parseFloat(m[1])
|
|
87
|
+
if (!Number.isFinite(n)) return null
|
|
88
|
+
const unit = m[2].toLowerCase()
|
|
89
|
+
if (!unit) return n === 0 ? 0 : null
|
|
90
|
+
const k = ANGLE_UNITS[unit]
|
|
91
|
+
return k === undefined ? null : n * k
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Split on `sep` at paren depth 0 — function arguments carry commas of their own. */
|
|
95
|
+
function splitTop(str, sep) {
|
|
96
|
+
const out = []
|
|
97
|
+
let depth = 0
|
|
98
|
+
let start = 0
|
|
99
|
+
for (let i = 0; i < str.length; i++) {
|
|
100
|
+
const ch = str[i]
|
|
101
|
+
if (ch === '(') depth++
|
|
102
|
+
else if (ch === ')') { if (depth > 0) depth-- }
|
|
103
|
+
else if (depth === 0 && ch === sep) { out.push(str.slice(start, i)); start = i + 1 }
|
|
104
|
+
}
|
|
105
|
+
out.push(str.slice(start))
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `[{name, args}]`, in source order. Anything malformed simply does not appear. */
|
|
110
|
+
function functions(str) {
|
|
111
|
+
const out = []
|
|
112
|
+
const re = /([a-zA-Z][\w-]*)\s*\(/g
|
|
113
|
+
let m
|
|
114
|
+
while ((m = re.exec(str))) {
|
|
115
|
+
let depth = 1
|
|
116
|
+
let i = re.lastIndex
|
|
117
|
+
for (; i < str.length && depth > 0; i++) {
|
|
118
|
+
if (str[i] === '(') depth++
|
|
119
|
+
else if (str[i] === ')') depth--
|
|
120
|
+
}
|
|
121
|
+
if (depth !== 0) break
|
|
122
|
+
out.push({
|
|
123
|
+
name: m[1].toLowerCase(),
|
|
124
|
+
args: splitTop(str.slice(re.lastIndex, i - 1), ',').map(s => s.trim()).filter(Boolean),
|
|
125
|
+
})
|
|
126
|
+
re.lastIndex = i
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const HORIZONTAL = { left: 0, center: 50, right: 100 }
|
|
132
|
+
const VERTICAL = { top: 0, center: 50, bottom: 100 }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `transform-origin` -> `[ox, oy]` in px. The computed value is a px pair, but an
|
|
136
|
+
* authored string can be keywords in either order (`top left` is legal), so the
|
|
137
|
+
* keyword table decides which token is which axis rather than position alone.
|
|
138
|
+
*/
|
|
139
|
+
function parseOrigin(css, w, h) {
|
|
140
|
+
const def = [w / 2, h / 2]
|
|
141
|
+
if (typeof css !== 'string') return def
|
|
142
|
+
const tokens = css.trim().split(/\s+/).filter(Boolean)
|
|
143
|
+
if (!tokens.length) return def
|
|
144
|
+
// The third component is the z origin: it only bends 3D transforms, which this
|
|
145
|
+
// module flattens anyway.
|
|
146
|
+
if (tokens.length > 2) tokens.length = 2
|
|
147
|
+
|
|
148
|
+
let xs = null
|
|
149
|
+
let ys = null
|
|
150
|
+
for (const raw of tokens) {
|
|
151
|
+
const token = raw.toLowerCase()
|
|
152
|
+
if (token in HORIZONTAL && !(token in VERTICAL)) { xs = `${HORIZONTAL[token]}%`; continue }
|
|
153
|
+
if (token in VERTICAL && !(token in HORIZONTAL)) { ys = `${VERTICAL[token]}%`; continue }
|
|
154
|
+
if (token === 'center') { if (xs === null) xs = '50%'; else ys = '50%'; continue }
|
|
155
|
+
if (xs === null) xs = token
|
|
156
|
+
else ys = token
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ox = xs === null ? def[0] : toPx(xs, w)
|
|
160
|
+
const oy = ys === null ? def[1] : toPx(ys, h)
|
|
161
|
+
return [ox === null ? def[0] : ox, oy === null ? def[1] : oy]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every number, or `null` if any of them is unreadable — a partial matrix is not a matrix. */
|
|
165
|
+
function numbers(args, count) {
|
|
166
|
+
if (args.length !== count) return null
|
|
167
|
+
const out = []
|
|
168
|
+
for (const a of args) {
|
|
169
|
+
const n = parseFloat(a)
|
|
170
|
+
if (!Number.isFinite(n) || !/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(a.trim())) return null
|
|
171
|
+
out.push(n)
|
|
172
|
+
}
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One transform function -> matrix. `null` when the function is unknown or its
|
|
178
|
+
* arguments do not parse, so the caller can name it in `notes` instead of
|
|
179
|
+
* quietly composing an identity in its place.
|
|
180
|
+
*/
|
|
181
|
+
function toMatrix(fn, w, h) {
|
|
182
|
+
const { name, args } = fn
|
|
183
|
+
const px = (i, base, dflt = 0) => (args[i] === undefined ? dflt : toPx(args[i], base))
|
|
184
|
+
const deg = (i, dflt = 0) => (args[i] === undefined ? dflt : toDeg(args[i]))
|
|
185
|
+
const num = (i, dflt) => {
|
|
186
|
+
if (args[i] === undefined) return dflt
|
|
187
|
+
const n = parseFloat(args[i])
|
|
188
|
+
return Number.isFinite(n) ? n : null
|
|
189
|
+
}
|
|
190
|
+
const ok = (...vs) => vs.every(v => v !== null && v !== undefined)
|
|
191
|
+
|
|
192
|
+
switch (name) {
|
|
193
|
+
case 'matrix': {
|
|
194
|
+
const v = numbers(args, 6)
|
|
195
|
+
return v
|
|
196
|
+
}
|
|
197
|
+
case 'matrix3d': {
|
|
198
|
+
const v = numbers(args, 16)
|
|
199
|
+
// The 2D projection is the top-left 2x2 plus the translation column. m13,
|
|
200
|
+
// m23 and the perspective row are dropped; the caller warns, per contract.
|
|
201
|
+
return v && [v[0], v[1], v[4], v[5], v[12], v[13]]
|
|
202
|
+
}
|
|
203
|
+
case 'translate': {
|
|
204
|
+
const x = px(0, w)
|
|
205
|
+
const y = px(1, h)
|
|
206
|
+
return ok(x, y) ? [1, 0, 0, 1, x, y] : null
|
|
207
|
+
}
|
|
208
|
+
case 'translatex': {
|
|
209
|
+
const x = px(0, w)
|
|
210
|
+
return ok(x) ? [1, 0, 0, 1, x, 0] : null
|
|
211
|
+
}
|
|
212
|
+
case 'translatey': {
|
|
213
|
+
const y = px(0, h)
|
|
214
|
+
return ok(y) ? [1, 0, 0, 1, 0, y] : null
|
|
215
|
+
}
|
|
216
|
+
case 'translate3d': {
|
|
217
|
+
const x = px(0, w)
|
|
218
|
+
const y = px(1, h)
|
|
219
|
+
return ok(x, y) ? [1, 0, 0, 1, x, y] : null
|
|
220
|
+
}
|
|
221
|
+
case 'translatez': {
|
|
222
|
+
return args.length === 1 ? IDENTITY.slice() : null
|
|
223
|
+
}
|
|
224
|
+
case 'scale': {
|
|
225
|
+
const sx = num(0)
|
|
226
|
+
const sy = num(1, sx)
|
|
227
|
+
return ok(sx, sy) ? [sx, 0, 0, sy, 0, 0] : null
|
|
228
|
+
}
|
|
229
|
+
case 'scalex': {
|
|
230
|
+
const sx = num(0)
|
|
231
|
+
return ok(sx) ? [sx, 0, 0, 1, 0, 0] : null
|
|
232
|
+
}
|
|
233
|
+
case 'scaley': {
|
|
234
|
+
const sy = num(0)
|
|
235
|
+
return ok(sy) ? [1, 0, 0, sy, 0, 0] : null
|
|
236
|
+
}
|
|
237
|
+
case 'scale3d': {
|
|
238
|
+
const sx = num(0)
|
|
239
|
+
const sy = num(1)
|
|
240
|
+
return ok(sx, sy) ? [sx, 0, 0, sy, 0, 0] : null
|
|
241
|
+
}
|
|
242
|
+
case 'scalez': {
|
|
243
|
+
return args.length === 1 ? IDENTITY.slice() : null
|
|
244
|
+
}
|
|
245
|
+
case 'rotate':
|
|
246
|
+
case 'rotatez': {
|
|
247
|
+
const a = deg(0)
|
|
248
|
+
if (!ok(a)) return null
|
|
249
|
+
const r = (a * Math.PI) / 180
|
|
250
|
+
return [Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), 0, 0]
|
|
251
|
+
}
|
|
252
|
+
// The 3D rotations flatten to their own top-left 2x2 — the same submatrix the
|
|
253
|
+
// browser would hand us as matrix3d, so the authored path and the computed
|
|
254
|
+
// path agree. An X rotation reads as a vertical squash, a Y one as horizontal.
|
|
255
|
+
case 'rotatex': {
|
|
256
|
+
const a = deg(0)
|
|
257
|
+
return ok(a) ? [1, 0, 0, Math.cos((a * Math.PI) / 180), 0, 0] : null
|
|
258
|
+
}
|
|
259
|
+
case 'rotatey': {
|
|
260
|
+
const a = deg(0)
|
|
261
|
+
return ok(a) ? [Math.cos((a * Math.PI) / 180), 0, 0, 1, 0, 0] : null
|
|
262
|
+
}
|
|
263
|
+
case 'rotate3d': {
|
|
264
|
+
const x = num(0)
|
|
265
|
+
const y = num(1)
|
|
266
|
+
const z = num(2)
|
|
267
|
+
const a = deg(3)
|
|
268
|
+
if (!ok(x, y, z, a)) return null
|
|
269
|
+
const len = Math.hypot(x, y, z)
|
|
270
|
+
if (!len) return IDENTITY.slice()
|
|
271
|
+
const ux = x / len
|
|
272
|
+
const uy = y / len
|
|
273
|
+
const uz = z / len
|
|
274
|
+
const r = (a * Math.PI) / 180
|
|
275
|
+
const c = Math.cos(r)
|
|
276
|
+
const s = Math.sin(r)
|
|
277
|
+
const t = 1 - c
|
|
278
|
+
return [c + ux * ux * t, uy * ux * t + uz * s, ux * uy * t - uz * s, c + uy * uy * t, 0, 0]
|
|
279
|
+
}
|
|
280
|
+
// Perspective only fills the 4th row, which the 2D submatrix does not have.
|
|
281
|
+
case 'perspective':
|
|
282
|
+
return args.length === 1 ? IDENTITY.slice() : null
|
|
283
|
+
case 'skew': {
|
|
284
|
+
const ax = deg(0)
|
|
285
|
+
const ay = deg(1)
|
|
286
|
+
return ok(ax, ay)
|
|
287
|
+
? [1, Math.tan((ay * Math.PI) / 180), Math.tan((ax * Math.PI) / 180), 1, 0, 0]
|
|
288
|
+
: null
|
|
289
|
+
}
|
|
290
|
+
case 'skewx': {
|
|
291
|
+
const ax = deg(0)
|
|
292
|
+
return ok(ax) ? [1, 0, Math.tan((ax * Math.PI) / 180), 1, 0, 0] : null
|
|
293
|
+
}
|
|
294
|
+
case 'skewy': {
|
|
295
|
+
const ay = deg(0)
|
|
296
|
+
return ok(ay) ? [1, Math.tan((ay * Math.PI) / 180), 0, 1, 0, 0] : null
|
|
297
|
+
}
|
|
298
|
+
default:
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** atan2 already lands in (-180, 180]; the modulo is for values built by hand. */
|
|
304
|
+
function clampDeg(rad) {
|
|
305
|
+
const d = (rad * 180) / Math.PI
|
|
306
|
+
const n = ((d + 180) % 360 + 360) % 360 - 180
|
|
307
|
+
return Object.is(n, -0) ? 0 : n
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* QR decomposition of the linear part.
|
|
312
|
+
*
|
|
313
|
+
* Reconstructs as `translate(tx,ty) rotate(rot) skewX(skewX) scale(sx,sy)`, which
|
|
314
|
+
* gives `[a, b] = sx·(cos, sin)` — so the rotation and sx fall straight out of
|
|
315
|
+
* the first column, sy out of the determinant, and `tan(skewX) = (ac+bd)/det`.
|
|
316
|
+
* A negative determinant lands in `sy` as a flip rather than in a second
|
|
317
|
+
* "flip" flag nobody downstream would read.
|
|
318
|
+
*/
|
|
319
|
+
function decompose(m) {
|
|
320
|
+
const [a, b, c, d, e, f] = m
|
|
321
|
+
const det = a * d - b * c
|
|
322
|
+
const out = { tx: e, ty: f, rot: 0, sx: 0, sy: 0, skewX: 0 }
|
|
323
|
+
|
|
324
|
+
const r = Math.hypot(a, b)
|
|
325
|
+
if (r > 0) {
|
|
326
|
+
out.rot = clampDeg(Math.atan2(b, a))
|
|
327
|
+
out.sx = r
|
|
328
|
+
out.sy = det / r
|
|
329
|
+
const n = a * c + b * d
|
|
330
|
+
// det === 0 means the matrix collapses the plane onto a line: sy is 0 and the
|
|
331
|
+
// skew is unbounded, so it saturates at the perpendicular rather than NaN.
|
|
332
|
+
out.skewX = det !== 0
|
|
333
|
+
? clampDeg(Math.atan(n / det))
|
|
334
|
+
: (n === 0 ? 0 : Math.sign(n) * 90)
|
|
335
|
+
return out
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const s = Math.hypot(c, d)
|
|
339
|
+
if (s > 0) {
|
|
340
|
+
// sx is 0, so the first column carries no direction and the decomposition is
|
|
341
|
+
// underdetermined. Pinning skewX at 0 makes it unique: the y axis alone then
|
|
342
|
+
// fixes the rotation, since it maps to (-sin, cos)·sy.
|
|
343
|
+
out.rot = clampDeg(Math.atan2(-c, d))
|
|
344
|
+
out.sy = s
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
return out
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} cssTransform a computed `transform` — `matrix(...)`,
|
|
352
|
+
* `matrix3d(...)` or `none`. Authored function
|
|
353
|
+
* lists are composed too, for callers that have
|
|
354
|
+
* not been through `getComputedStyle`.
|
|
355
|
+
* @param {string} cssOrigin a computed `transform-origin`; defaults to the
|
|
356
|
+
* box centre when absent or unreadable
|
|
357
|
+
* @param {number} w border-box width, for percentage resolution
|
|
358
|
+
* @param {number} h border-box height
|
|
359
|
+
* @returns {{m:number[], decomposed:{tx:number,ty:number,rot:number,sx:number,
|
|
360
|
+
* sy:number,skewX:number}, is3d?:boolean, notes?:string[]}|null}
|
|
361
|
+
* `null` for `none`/empty. `m` already has the origin baked in. `is3d` is set
|
|
362
|
+
* only when the input was a 3D transform that had to be flattened — the caller
|
|
363
|
+
* turns that into the diagnostic, per contract. `notes` lists any function that
|
|
364
|
+
* could not be read, and is present only when non-empty.
|
|
365
|
+
*/
|
|
366
|
+
export function parseTransform(cssTransform, cssOrigin, w, h) {
|
|
367
|
+
const src = typeof cssTransform === 'string' ? cssTransform.trim() : ''
|
|
368
|
+
if (!src || src === 'none') return null
|
|
369
|
+
|
|
370
|
+
const width = Number.isFinite(w) ? w : 0
|
|
371
|
+
const height = Number.isFinite(h) ? h : 0
|
|
372
|
+
|
|
373
|
+
const notes = []
|
|
374
|
+
const fns = functions(src)
|
|
375
|
+
if (!fns.length) notes.push(`transform: no transform function found in "${src}" — treated as identity`)
|
|
376
|
+
|
|
377
|
+
let is3d = false
|
|
378
|
+
let m = IDENTITY.slice()
|
|
379
|
+
for (const fn of fns) {
|
|
380
|
+
if (/3d$|^perspective$|^rotate[xy]$/.test(fn.name)) is3d = true
|
|
381
|
+
const step = toMatrix(fn, width, height)
|
|
382
|
+
if (!step) {
|
|
383
|
+
notes.push(`transform: could not read "${fn.name}(${fn.args.join(', ')})" — ignored`)
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
m = multiply(m, step)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const [ox, oy] = parseOrigin(cssOrigin, width, height)
|
|
390
|
+
if (ox || oy) {
|
|
391
|
+
m = multiply(multiply([1, 0, 0, 1, ox, oy], m), [1, 0, 0, 1, -ox, -oy])
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const out = { m, decomposed: decompose(m) }
|
|
395
|
+
if (is3d) out.is3d = true
|
|
396
|
+
if (notes.length) out.notes = notes
|
|
397
|
+
return out
|
|
398
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `border-radius` -> four `[rx, ry]` pairs, in the CSS corner order
|
|
3
|
+
* tl, tr, br, bl.
|
|
4
|
+
*
|
|
5
|
+
* Two things are easy to get wrong and both are visible on screen:
|
|
6
|
+
*
|
|
7
|
+
* - a percentage is resolved against a DIFFERENT base per axis — `rx` against
|
|
8
|
+
* the box width, `ry` against its height. `50%` on a 200x40 box is an
|
|
9
|
+
* 100x20 ellipse quadrant, not a 100x100 one.
|
|
10
|
+
* - adjacent radii that together exceed the side they share are ALL scaled
|
|
11
|
+
* down by one shared factor `f` (CSS Backgrounds §5.5). The browser does
|
|
12
|
+
* this before painting, so the declared values never reach the screen; a
|
|
13
|
+
* document that carried them would round the wrong corners. The step is
|
|
14
|
+
* irreversible and it is applied here, once, for everyone downstream.
|
|
15
|
+
*
|
|
16
|
+
* Pure: `cs` is only ever read by property name, so a plain object works and the
|
|
17
|
+
* module needs no DOM.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ABS_UNITS = {
|
|
21
|
+
px: 1, pt: 96 / 72, pc: 16, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 25.4 / 4,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NUMBER = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z%]*)$/i
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A radius is `<length-percentage>` and cannot be negative; anything else
|
|
28
|
+
* (`em` without a font, garbage, `auto`) resolves to 0, which is the initial
|
|
29
|
+
* value and therefore the safe floor — a square corner, never a rounder one.
|
|
30
|
+
*/
|
|
31
|
+
function resolve(token, base) {
|
|
32
|
+
const m = NUMBER.exec(token)
|
|
33
|
+
if (!m) return 0
|
|
34
|
+
const n = parseFloat(m[1])
|
|
35
|
+
if (!Number.isFinite(n) || n <= 0) return 0
|
|
36
|
+
const unit = m[2].toLowerCase()
|
|
37
|
+
if (unit === '%') return (n / 100) * base
|
|
38
|
+
if (!unit) return 0
|
|
39
|
+
const k = ABS_UNITS[unit]
|
|
40
|
+
return k === undefined ? 0 : n * k
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function read(cs, camel, dashed) {
|
|
44
|
+
if (!cs) return ''
|
|
45
|
+
const v = cs[camel]
|
|
46
|
+
if (v != null && v !== '') return String(v)
|
|
47
|
+
if (typeof cs.getPropertyValue === 'function') return String(cs.getPropertyValue(dashed) || '')
|
|
48
|
+
return ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 1-4 values expand to tl, tr, br, bl the same way in every CSS box shorthand. */
|
|
52
|
+
function expand(parts) {
|
|
53
|
+
const [a, b = a, c = a, d = b] = parts
|
|
54
|
+
return [a, b, c, d]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The shorthand, for callers that hand us a style object carrying only
|
|
59
|
+
* `border-radius`. A computed style always has the longhands, so this is the
|
|
60
|
+
* fallback path — but returning four zeros for a perfectly readable `12px` is
|
|
61
|
+
* the kind of silent wrong answer this codebase does not ship.
|
|
62
|
+
*/
|
|
63
|
+
function fromShorthand(cs, w, h) {
|
|
64
|
+
const value = read(cs, 'borderRadius', 'border-radius').trim()
|
|
65
|
+
if (!value) return null
|
|
66
|
+
const [hor, ver] = value.split('/')
|
|
67
|
+
const hParts = hor.trim().split(/\s+/).filter(Boolean)
|
|
68
|
+
if (!hParts.length) return null
|
|
69
|
+
const vParts = ver ? ver.trim().split(/\s+/).filter(Boolean) : []
|
|
70
|
+
const xs = expand(hParts)
|
|
71
|
+
const ys = expand(vParts.length ? vParts : hParts)
|
|
72
|
+
return xs.map((x, i) => [resolve(x, w), resolve(ys[i], h)])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const CORNERS = [
|
|
76
|
+
['borderTopLeftRadius', 'border-top-left-radius'],
|
|
77
|
+
['borderTopRightRadius', 'border-top-right-radius'],
|
|
78
|
+
['borderBottomRightRadius', 'border-bottom-right-radius'],
|
|
79
|
+
['borderBottomLeftRadius', 'border-bottom-left-radius'],
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {CSSStyleDeclaration|Record<string,string>} cs computed style of the element
|
|
84
|
+
* @param {number} w width of the box the radii are resolved against (border box for
|
|
85
|
+
* the border/background edge; the caller insets it for inner boxes)
|
|
86
|
+
* @param {number} h height of that same box
|
|
87
|
+
* @returns {[[number,number],[number,number],[number,number],[number,number]]}
|
|
88
|
+
* tl, tr, br, bl as `[rx, ry]`, with the §5.5 overlap factor already applied.
|
|
89
|
+
*/
|
|
90
|
+
export function parseRadii(cs, w, h) {
|
|
91
|
+
const width = Number.isFinite(w) && w > 0 ? w : 0
|
|
92
|
+
const height = Number.isFinite(h) && h > 0 ? h : 0
|
|
93
|
+
|
|
94
|
+
let radii = null
|
|
95
|
+
for (let i = 0; i < CORNERS.length; i++) {
|
|
96
|
+
const value = read(cs, CORNERS[i][0], CORNERS[i][1]).trim()
|
|
97
|
+
if (!value) continue
|
|
98
|
+
if (!radii) radii = [[0, 0], [0, 0], [0, 0], [0, 0]]
|
|
99
|
+
// One value is a circle: `12px` means rx and ry alike, each still resolved
|
|
100
|
+
// against its own axis, so `50%` stays an ellipse.
|
|
101
|
+
const parts = value.split(/\s+/).filter(Boolean)
|
|
102
|
+
radii[i] = [resolve(parts[0], width), resolve(parts[1] !== undefined ? parts[1] : parts[0], height)]
|
|
103
|
+
}
|
|
104
|
+
if (!radii) radii = fromShorthand(cs, width, height)
|
|
105
|
+
if (!radii) return [[0, 0], [0, 0], [0, 0], [0, 0]]
|
|
106
|
+
|
|
107
|
+
const [tl, tr, br, bl] = radii
|
|
108
|
+
|
|
109
|
+
// §5.5: f is the minimum over the four sides of side / (sum of its two radii).
|
|
110
|
+
// A side with no radii contributes nothing (0/0 is not a constraint), and the
|
|
111
|
+
// factor is only ever applied when it is < 1 — radii that fit are left exact.
|
|
112
|
+
let f = 1
|
|
113
|
+
const limit = (side, a, b) => {
|
|
114
|
+
const sum = a + b
|
|
115
|
+
if (sum > 0 && side >= 0) f = Math.min(f, side / sum)
|
|
116
|
+
}
|
|
117
|
+
limit(width, tl[0], tr[0]) // top
|
|
118
|
+
limit(height, tr[1], br[1]) // right
|
|
119
|
+
limit(width, bl[0], br[0]) // bottom
|
|
120
|
+
limit(height, tl[1], bl[1]) // left
|
|
121
|
+
|
|
122
|
+
if (!(f < 1)) return radii
|
|
123
|
+
return radii.map(([rx, ry]) => [rx * f, ry * f])
|
|
124
|
+
}
|