@zumer/snapdom-plugins 3.0.0 → 4.0.0

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