@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,1126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS gradient layers -> SVD `Paint`.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of this module is that **nothing downstream ever sees a
|
|
5
|
+
* percentage or a px**. A gradient is a shape in the unit square [0,1]² plus a
|
|
6
|
+
* 2x3 matrix that maps that square onto the painting box. Everything the CSS
|
|
7
|
+
* geometry knows — the angle, the gradient-line length, the ellipse aspect
|
|
8
|
+
* ratio, the `at <position>` offset — is absorbed into the matrix, so a backend
|
|
9
|
+
* only has to compose one affine transform and read stops whose `t` is already
|
|
10
|
+
* in 0..1. That is SVD invariant 6, and it is the documented cure for Figma's
|
|
11
|
+
* "failed to invert transform" on SVG import: every matrix this module emits is
|
|
12
|
+
* non-singular by construction.
|
|
13
|
+
*
|
|
14
|
+
* Unit-space conventions, per gradient type — these are normative for emitters:
|
|
15
|
+
*
|
|
16
|
+
* linear `t = x`. The gradient runs from `unit.p0` (0,0) to `unit.p1` (1,0).
|
|
17
|
+
* The matrix' second basis vector is the perpendicular of the first
|
|
18
|
+
* and the same length, so the map is a similarity: colours do not
|
|
19
|
+
* shear.
|
|
20
|
+
* radial `t = 2·|p − (0.5,0.5)|`. The ending shape is the circle of radius
|
|
21
|
+
* 0.5 centred at (0.5,0.5); the unit SQUARE maps onto the ellipse's
|
|
22
|
+
* bounding box, which is where the anisotropy lives. A backend that
|
|
23
|
+
* wants an SVG `<radialGradient>` emits r=0.5, cx=cy=0.5 and this
|
|
24
|
+
* matrix as `gradientTransform`, and gets the ellipse for free.
|
|
25
|
+
* angular `t = θ / 360`, θ measured **clockwise from `unit.startAngle`**, and
|
|
26
|
+
* `startAngle` itself is degrees clockwise from straight up — CSS's
|
|
27
|
+
* own convention for `from <angle>`. The matrix is deliberately a
|
|
28
|
+
* uniform scale (no aspect, no rotation): a conic gradient's angles
|
|
29
|
+
* are true screen angles, so squashing the unit square would bend
|
|
30
|
+
* them. `startAngle` is therefore NOT folded into the matrix; an
|
|
31
|
+
* emitter that needs it there composes the rotation itself.
|
|
32
|
+
*
|
|
33
|
+
* Approximations are named in `notes`, never silent. The interesting ones:
|
|
34
|
+
* colour-stop hints (`red, 30%, blue`) are a power curve no target primitive has,
|
|
35
|
+
* so they are resampled to 8–16 stops chosen to keep the CIEDE2000 error under
|
|
36
|
+
* 1.0 — below the just-noticeable difference — and reported as `hint-resampled:N`.
|
|
37
|
+
*
|
|
38
|
+
* Pure: no DOM, no `getComputedStyle`, no `eval`/`new Function`. Node can fuzz it.
|
|
39
|
+
*/
|
|
40
|
+
import { parseColor, toCssString } from './color.js'
|
|
41
|
+
|
|
42
|
+
const DEG = 180 / Math.PI
|
|
43
|
+
const RAD = Math.PI / 180
|
|
44
|
+
const EPS = 1e-9
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Repetitions unrolled for `repeating-*` before we give up and pad, and the stop
|
|
48
|
+
* budget that also bounds them: a 0.5px period carrying a resampled hint is 10
|
|
49
|
+
* stops a copy, and 64 of those is a paint no backend wants to be handed.
|
|
50
|
+
*/
|
|
51
|
+
const MAX_REPEATS = 64
|
|
52
|
+
const MAX_STOPS = 512
|
|
53
|
+
|
|
54
|
+
/** Stops emitted per hinted segment, endpoints included, and the error they must beat. */
|
|
55
|
+
const HINT_MIN = 8
|
|
56
|
+
const HINT_MAX = 16
|
|
57
|
+
const HINT_DE = 1.0
|
|
58
|
+
|
|
59
|
+
const GRADIENT_HEAD = /^(-webkit-|-moz-|-o-|-ms-)?(repeating-)?(linear|radial|conic)-gradient\s*\(/i
|
|
60
|
+
|
|
61
|
+
const SIZE_KEYWORDS = new Set(['closest-side', 'closest-corner', 'farthest-side', 'farthest-corner'])
|
|
62
|
+
const SHAPE_KEYWORDS = new Set(['circle', 'ellipse'])
|
|
63
|
+
const POS_KEYWORDS = new Set(['left', 'right', 'top', 'bottom', 'center'])
|
|
64
|
+
|
|
65
|
+
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v
|
|
66
|
+
|
|
67
|
+
// ————————————————————————————————————————————————————————————————
|
|
68
|
+
// Tokenizing
|
|
69
|
+
// ————————————————————————————————————————————————————————————————
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Split on `sep` (a comma) or on whitespace, ignoring separators nested inside
|
|
73
|
+
* parentheses — `rgb(0, 0, 0)` is one token, not three.
|
|
74
|
+
*/
|
|
75
|
+
function splitTop (s, sep) {
|
|
76
|
+
const out = []
|
|
77
|
+
let depth = 0
|
|
78
|
+
let start = 0
|
|
79
|
+
for (let i = 0; i < s.length; i++) {
|
|
80
|
+
const c = s[i]
|
|
81
|
+
if (c === '(') depth++
|
|
82
|
+
else if (c === ')') depth--
|
|
83
|
+
else if (depth === 0 && (sep === ',' ? c === ',' : c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f')) {
|
|
84
|
+
out.push(s.slice(start, i))
|
|
85
|
+
start = i + 1
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
out.push(s.slice(start))
|
|
89
|
+
const trimmed = []
|
|
90
|
+
for (const t of out) {
|
|
91
|
+
const v = t.trim()
|
|
92
|
+
if (v !== '') trimmed.push(v)
|
|
93
|
+
}
|
|
94
|
+
return trimmed
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Recognise `<prefix?><repeating-?><kind>-gradient( … )` and return its inside.
|
|
99
|
+
* The closing paren is found by balancing rather than by regex: a stop colour
|
|
100
|
+
* may contain arbitrarily nested functions.
|
|
101
|
+
*/
|
|
102
|
+
function matchGradient (css) {
|
|
103
|
+
const s = String(css).trim()
|
|
104
|
+
const head = GRADIENT_HEAD.exec(s)
|
|
105
|
+
if (!head) return null
|
|
106
|
+
const open = head[0].lastIndexOf('(') + head.index
|
|
107
|
+
let depth = 0
|
|
108
|
+
let close = -1
|
|
109
|
+
for (let i = open; i < s.length; i++) {
|
|
110
|
+
if (s[i] === '(') depth++
|
|
111
|
+
else if (s[i] === ')' && --depth === 0) { close = i; break }
|
|
112
|
+
}
|
|
113
|
+
if (close === -1 || s.slice(close + 1).trim() !== '') return null
|
|
114
|
+
return {
|
|
115
|
+
prefixed: !!head[1],
|
|
116
|
+
repeating: !!head[2],
|
|
117
|
+
kind: head[3].toLowerCase(),
|
|
118
|
+
args: s.slice(open + 1, close),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ————————————————————————————————————————————————————————————————
|
|
123
|
+
// Dimensions — px / % / deg components, resolved against a reference later.
|
|
124
|
+
// `n` is the unitless part; it only ever legally survives as zero.
|
|
125
|
+
// ————————————————————————————————————————————————————————————————
|
|
126
|
+
|
|
127
|
+
const UNIT_PX = { px: 1, pt: 96 / 72, pc: 16, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6 }
|
|
128
|
+
const UNIT_DEG = { deg: 1, grad: 0.9, rad: DEG, turn: 360 }
|
|
129
|
+
|
|
130
|
+
const dim = (px = 0, pct = 0, deg = 0, n = 0) => ({ px, pct, deg, n })
|
|
131
|
+
|
|
132
|
+
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
|
|
133
|
+
|
|
134
|
+
/** A single `<number><unit>` token. `null` when the unit is not resolvable here (em, vw, ch…). */
|
|
135
|
+
function parseNumeric (tok) {
|
|
136
|
+
const m = NUMBER.exec(tok)
|
|
137
|
+
if (!m || m[0].length === 0) return null
|
|
138
|
+
const n = parseFloat(m[0])
|
|
139
|
+
if (!Number.isFinite(n)) return null
|
|
140
|
+
const unit = tok.slice(m[0].length).toLowerCase()
|
|
141
|
+
if (unit === '') return dim(0, 0, 0, n)
|
|
142
|
+
if (unit === '%') return dim(0, n)
|
|
143
|
+
if (UNIT_PX[unit] !== undefined) return dim(n * UNIT_PX[unit])
|
|
144
|
+
if (UNIT_DEG[unit] !== undefined) return dim(0, 0, n * UNIT_DEG[unit])
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isPureNumber = (d) => d.px === 0 && d.pct === 0 && d.deg === 0
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `calc()` without an evaluator: the expression is tokenized, then walked by
|
|
152
|
+
* recursive descent over dimension vectors. `*` and `/` demand a dimensionless
|
|
153
|
+
* operand, which is exactly the CSS restriction, so a malformed expression
|
|
154
|
+
* returns `null` instead of a plausible-looking number.
|
|
155
|
+
*/
|
|
156
|
+
function calcTokens (s) {
|
|
157
|
+
const out = []
|
|
158
|
+
let i = 0
|
|
159
|
+
while (i < s.length) {
|
|
160
|
+
const c = s[i]
|
|
161
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f') { i++; continue }
|
|
162
|
+
if (c === '(' || c === ')' || c === '*' || c === '/') { out.push(c); i++; continue }
|
|
163
|
+
if (c === '+' || c === '-') {
|
|
164
|
+
const prev = out[out.length - 1]
|
|
165
|
+
const opens = prev === undefined || prev === '(' || (typeof prev === 'string' && '+-*/'.indexOf(prev) >= 0)
|
|
166
|
+
const next = s[i + 1] || ''
|
|
167
|
+
if (!(opens && (next >= '0' && next <= '9' || next === '.'))) { out.push(c); i++; continue }
|
|
168
|
+
}
|
|
169
|
+
const m = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[a-z%]*/i.exec(s.slice(i))
|
|
170
|
+
if (!m) return null
|
|
171
|
+
const v = parseNumeric(m[0])
|
|
172
|
+
if (!v) return null
|
|
173
|
+
out.push(v)
|
|
174
|
+
i += m[0].length
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function calcParse (tokens) {
|
|
180
|
+
let i = 0
|
|
181
|
+
|
|
182
|
+
const primary = () => {
|
|
183
|
+
const t = tokens[i]
|
|
184
|
+
if (t === '(') {
|
|
185
|
+
i++
|
|
186
|
+
const v = sum()
|
|
187
|
+
if (tokens[i] !== ')') return null
|
|
188
|
+
i++
|
|
189
|
+
return v
|
|
190
|
+
}
|
|
191
|
+
if (t === '-' || t === '+') {
|
|
192
|
+
i++
|
|
193
|
+
const v = primary()
|
|
194
|
+
if (!v) return null
|
|
195
|
+
return t === '-' ? dim(-v.px, -v.pct, -v.deg, -v.n) : v
|
|
196
|
+
}
|
|
197
|
+
if (t && typeof t === 'object') { i++; return t }
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const product = () => {
|
|
202
|
+
let left = primary()
|
|
203
|
+
if (!left) return null
|
|
204
|
+
while (tokens[i] === '*' || tokens[i] === '/') {
|
|
205
|
+
const op = tokens[i++]
|
|
206
|
+
const right = primary()
|
|
207
|
+
if (!right) return null
|
|
208
|
+
if (op === '*') {
|
|
209
|
+
const k = isPureNumber(right) ? right.n : isPureNumber(left) ? left.n : null
|
|
210
|
+
if (k === null) return null
|
|
211
|
+
const other = isPureNumber(right) ? left : right
|
|
212
|
+
left = dim(other.px * k, other.pct * k, other.deg * k, other.n * k)
|
|
213
|
+
} else {
|
|
214
|
+
if (!isPureNumber(right) || right.n === 0) return null
|
|
215
|
+
const k = 1 / right.n
|
|
216
|
+
left = dim(left.px * k, left.pct * k, left.deg * k, left.n * k)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return left
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sum = () => {
|
|
223
|
+
let left = product()
|
|
224
|
+
if (!left) return null
|
|
225
|
+
while (tokens[i] === '+' || tokens[i] === '-') {
|
|
226
|
+
const op = tokens[i++]
|
|
227
|
+
const right = product()
|
|
228
|
+
if (!right) return null
|
|
229
|
+
const s = op === '+' ? 1 : -1
|
|
230
|
+
left = dim(left.px + s * right.px, left.pct + s * right.pct, left.deg + s * right.deg, left.n + s * right.n)
|
|
231
|
+
}
|
|
232
|
+
return left
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const v = sum()
|
|
236
|
+
return v && i === tokens.length ? v : null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `<length-percentage>` / `<angle>` / `calc(…)` -> dimension vector, or `null`. */
|
|
240
|
+
function parseDim (tok) {
|
|
241
|
+
const s = tok.trim()
|
|
242
|
+
const lower = s.toLowerCase()
|
|
243
|
+
if (lower.startsWith('calc(') && s.endsWith(')')) {
|
|
244
|
+
const tokens = calcTokens(s.slice(5, -1))
|
|
245
|
+
return tokens ? calcParse(tokens) : null
|
|
246
|
+
}
|
|
247
|
+
return parseNumeric(s)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** True for tokens worth handing to `parseDim` — keeps `red` out of the numeric path. */
|
|
251
|
+
function looksNumeric (tok) {
|
|
252
|
+
if (!tok) return false
|
|
253
|
+
const c = tok[0]
|
|
254
|
+
return (c >= '0' && c <= '9') || c === '.' || c === '+' || c === '-' || /^calc\(/i.test(tok)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const resolveLength = (d, ref) => (d && d.n === 0 && d.deg === 0) ? d.px + d.pct / 100 * ref : null
|
|
258
|
+
const resolveAngle = (d) => (d && d.n === 0 && d.px === 0) ? d.deg + d.pct / 100 * 360 : null
|
|
259
|
+
|
|
260
|
+
// ————————————————————————————————————————————————————————————————
|
|
261
|
+
// Colour maths
|
|
262
|
+
// ————————————————————————————————————————————————————————————————
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Interpolate in **premultiplied** sRGB, which is what CSS, SVG and Figma all
|
|
266
|
+
* do. Doing it unpremultiplied is where the grey halo around a fade-to-
|
|
267
|
+
* transparent comes from.
|
|
268
|
+
*/
|
|
269
|
+
function lerpColor (c0, c1, t) {
|
|
270
|
+
const a0 = c0[3]
|
|
271
|
+
const a1 = c1[3]
|
|
272
|
+
const a = a0 + (a1 - a0) * t
|
|
273
|
+
if (a <= EPS) return [c0[0] + (c1[0] - c0[0]) * t, c0[1] + (c1[1] - c0[1]) * t, c0[2] + (c1[2] - c0[2]) * t, 0]
|
|
274
|
+
const out = [0, 0, 0, a]
|
|
275
|
+
for (let i = 0; i < 3; i++) {
|
|
276
|
+
const p = c0[i] * a0 + (c1[i] * a1 - c0[i] * a0) * t
|
|
277
|
+
out[i] = clamp(p / a, 0, 1)
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
283
|
+
|
|
284
|
+
/** sRGB 0..1 -> CIE Lab (D65), the space CIEDE2000 is defined in. */
|
|
285
|
+
function srgbToLab (rgb) {
|
|
286
|
+
const r = srgbToLinear(clamp(rgb[0], 0, 1))
|
|
287
|
+
const g = srgbToLinear(clamp(rgb[1], 0, 1))
|
|
288
|
+
const b = srgbToLinear(clamp(rgb[2], 0, 1))
|
|
289
|
+
const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
|
|
290
|
+
const y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b) / 1.0
|
|
291
|
+
const z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883
|
|
292
|
+
const f = (t) => t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29
|
|
293
|
+
const fx = f(x)
|
|
294
|
+
const fy = f(y)
|
|
295
|
+
const fz = f(z)
|
|
296
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** CIEDE2000. Verbatim from Sharma et al. 2005; kL = kC = kH = 1. */
|
|
300
|
+
function deltaE00 (lab1, lab2) {
|
|
301
|
+
const [L1, a1, b1] = lab1
|
|
302
|
+
const [L2, a2, b2] = lab2
|
|
303
|
+
const C1 = Math.hypot(a1, b1)
|
|
304
|
+
const C2 = Math.hypot(a2, b2)
|
|
305
|
+
const Cb = (C1 + C2) / 2
|
|
306
|
+
const Cb7 = Math.pow(Cb, 7)
|
|
307
|
+
const G = 0.5 * (1 - Math.sqrt(Cb7 / (Cb7 + 6103515625)))
|
|
308
|
+
const a1p = (1 + G) * a1
|
|
309
|
+
const a2p = (1 + G) * a2
|
|
310
|
+
const C1p = Math.hypot(a1p, b1)
|
|
311
|
+
const C2p = Math.hypot(a2p, b2)
|
|
312
|
+
const hue = (b, ap) => (b === 0 && ap === 0) ? 0 : (Math.atan2(b, ap) * DEG + 360) % 360
|
|
313
|
+
const h1p = hue(b1, a1p)
|
|
314
|
+
const h2p = hue(b2, a2p)
|
|
315
|
+
const dLp = L2 - L1
|
|
316
|
+
const dCp = C2p - C1p
|
|
317
|
+
let dhp = 0
|
|
318
|
+
if (C1p * C2p !== 0) {
|
|
319
|
+
dhp = h2p - h1p
|
|
320
|
+
if (dhp > 180) dhp -= 360
|
|
321
|
+
else if (dhp < -180) dhp += 360
|
|
322
|
+
}
|
|
323
|
+
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp / 2 * RAD)
|
|
324
|
+
const Lbp = (L1 + L2) / 2
|
|
325
|
+
const Cbp = (C1p + C2p) / 2
|
|
326
|
+
let hbp
|
|
327
|
+
if (C1p * C2p === 0) hbp = h1p + h2p
|
|
328
|
+
else if (Math.abs(h1p - h2p) <= 180) hbp = (h1p + h2p) / 2
|
|
329
|
+
else hbp = (h1p + h2p + (h1p + h2p < 360 ? 360 : -360)) / 2
|
|
330
|
+
const T = 1 - 0.17 * Math.cos((hbp - 30) * RAD) + 0.24 * Math.cos(2 * hbp * RAD) +
|
|
331
|
+
0.32 * Math.cos((3 * hbp + 6) * RAD) - 0.20 * Math.cos((4 * hbp - 63) * RAD)
|
|
332
|
+
const dTheta = 30 * Math.exp(-Math.pow((hbp - 275) / 25, 2))
|
|
333
|
+
const Cbp7 = Math.pow(Cbp, 7)
|
|
334
|
+
const Rc = 2 * Math.sqrt(Cbp7 / (Cbp7 + 6103515625))
|
|
335
|
+
const Sl = 1 + (0.015 * Math.pow(Lbp - 50, 2)) / Math.sqrt(20 + Math.pow(Lbp - 50, 2))
|
|
336
|
+
const Sc = 1 + 0.045 * Cbp
|
|
337
|
+
const Sh = 1 + 0.015 * Cbp * T
|
|
338
|
+
const Rt = -Math.sin(2 * dTheta * RAD) * Rc
|
|
339
|
+
const tL = dLp / Sl
|
|
340
|
+
const tC = dCp / Sc
|
|
341
|
+
const tH = dHp / Sh
|
|
342
|
+
return Math.sqrt(tL * tL + tC * tC + tH * tH + Rt * tC * tH)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Perceptual distance between two possibly-translucent colours: the worst case
|
|
347
|
+
* of compositing both over black and over white. A plain ΔE on the unmultiplied
|
|
348
|
+
* channels would call two colours identical when only their alpha differs.
|
|
349
|
+
*/
|
|
350
|
+
function colorError (c0, c1) {
|
|
351
|
+
const over = (c, bg) => srgbToLab([
|
|
352
|
+
c[0] * c[3] + bg * (1 - c[3]),
|
|
353
|
+
c[1] * c[3] + bg * (1 - c[3]),
|
|
354
|
+
c[2] * c[3] + bg * (1 - c[3]),
|
|
355
|
+
])
|
|
356
|
+
return Math.max(deltaE00(over(c0, 0), over(c1, 0)), deltaE00(over(c0, 1), over(c1, 1)))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const sameColor = (a, b) =>
|
|
360
|
+
Math.abs(a[0] - b[0]) < 1e-4 && Math.abs(a[1] - b[1]) < 1e-4 &&
|
|
361
|
+
Math.abs(a[2] - b[2]) < 1e-4 && Math.abs(a[3] - b[3]) < 1e-4
|
|
362
|
+
|
|
363
|
+
const stop = (t, color, srcCss) => ({ t, color, srcCss: srcCss || toCssString(color) })
|
|
364
|
+
|
|
365
|
+
// ————————————————————————————————————————————————————————————————
|
|
366
|
+
// Position (`at <position>` / legacy leading position)
|
|
367
|
+
// ————————————————————————————————————————————————————————————————
|
|
368
|
+
|
|
369
|
+
const H_KEY = { left: 0, center: 0.5, right: 1 }
|
|
370
|
+
const V_KEY = { top: 0, center: 0.5, bottom: 1 }
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `<position>` in all four of its arities, resolved to px in the painting box.
|
|
374
|
+
* `null` when the tokens are not a position at all — which is how the radial
|
|
375
|
+
* prelude sniffer tells `at 10px 20px` from a colour stop.
|
|
376
|
+
*/
|
|
377
|
+
function parsePosition (tokens, w, h) {
|
|
378
|
+
if (tokens.length === 0 || tokens.length > 4) return null
|
|
379
|
+
let x = null
|
|
380
|
+
let y = null
|
|
381
|
+
|
|
382
|
+
if (tokens.length >= 3) {
|
|
383
|
+
// Edge-offset form: `left 10px top 20px`, `right 5% bottom`.
|
|
384
|
+
let i = 0
|
|
385
|
+
while (i < tokens.length) {
|
|
386
|
+
const k = tokens[i++].toLowerCase()
|
|
387
|
+
if (!POS_KEYWORDS.has(k)) return null
|
|
388
|
+
let off = null
|
|
389
|
+
if (i < tokens.length && !POS_KEYWORDS.has(tokens[i].toLowerCase())) {
|
|
390
|
+
if (!looksNumeric(tokens[i])) return null
|
|
391
|
+
off = parseDim(tokens[i++])
|
|
392
|
+
if (!off) return null
|
|
393
|
+
}
|
|
394
|
+
if (k === 'center') {
|
|
395
|
+
if (off) return null
|
|
396
|
+
if (x === null) x = w / 2
|
|
397
|
+
else if (y === null) y = h / 2
|
|
398
|
+
else return null
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
const vertical = k === 'top' || k === 'bottom'
|
|
402
|
+
const ref = vertical ? h : w
|
|
403
|
+
const d = off ? resolveLength(off, ref) : 0
|
|
404
|
+
if (d === null) return null
|
|
405
|
+
const v = (k === 'right' || k === 'bottom') ? ref - d : d
|
|
406
|
+
if (vertical) { if (y !== null) return null; y = v } else { if (x !== null) return null; x = v }
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const a = tokens[0]
|
|
410
|
+
const b = tokens[1]
|
|
411
|
+
const ka = a.toLowerCase()
|
|
412
|
+
const kb = b === undefined ? undefined : b.toLowerCase()
|
|
413
|
+
// `top left` is legal, so a leading vertical keyword swaps the pair.
|
|
414
|
+
const swap = tokens.length === 2 &&
|
|
415
|
+
((ka === 'top' || ka === 'bottom') || (kb === 'left' || kb === 'right'))
|
|
416
|
+
const first = swap ? b : a
|
|
417
|
+
const second = swap ? a : b
|
|
418
|
+
const axis = (tok, keys, ref) => {
|
|
419
|
+
if (tok === undefined) return ref / 2
|
|
420
|
+
const k = tok.toLowerCase()
|
|
421
|
+
if (keys[k] !== undefined) return keys[k] * ref
|
|
422
|
+
if (!looksNumeric(tok)) return null
|
|
423
|
+
return resolveLength(parseDim(tok), ref)
|
|
424
|
+
}
|
|
425
|
+
if (tokens.length === 1 && (ka === 'top' || ka === 'bottom')) {
|
|
426
|
+
x = w / 2
|
|
427
|
+
y = V_KEY[ka] * h
|
|
428
|
+
} else {
|
|
429
|
+
x = axis(first, H_KEY, w)
|
|
430
|
+
y = axis(second, V_KEY, h)
|
|
431
|
+
}
|
|
432
|
+
if (x === null || y === null) return null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return { x: x === null ? w / 2 : x, y: y === null ? h / 2 : y }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ————————————————————————————————————————————————————————————————
|
|
439
|
+
// Geometry
|
|
440
|
+
// ————————————————————————————————————————————————————————————————
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The corner formula from CSS Images 3: for `to top right` the gradient line is
|
|
444
|
+
* angled so that the perpendicular through the centre passes through the two
|
|
445
|
+
* NEIGHBOURING corners (top-left and bottom-right) — it does not point at the
|
|
446
|
+
* corner itself. Perpendicular to (w, h) is (h, −w), and CSS measures clockwise
|
|
447
|
+
* from straight up, hence atan2(h, w). On a 200x100 box that is 26.57deg, not
|
|
448
|
+
* the naive 63.43deg of the diagonal.
|
|
449
|
+
*/
|
|
450
|
+
function cornerAngle (w, h) {
|
|
451
|
+
return Math.atan2(h, w) * DEG
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const SIDE_ANGLE = { top: 0, right: 90, bottom: 180, left: 270 }
|
|
455
|
+
|
|
456
|
+
function sideOrCornerAngle (words, w, h) {
|
|
457
|
+
const set = new Set(words.map((s) => s.toLowerCase()))
|
|
458
|
+
if (set.size !== words.length) return null
|
|
459
|
+
const horiz = set.has('left') ? 'left' : set.has('right') ? 'right' : null
|
|
460
|
+
const vert = set.has('top') ? 'top' : set.has('bottom') ? 'bottom' : null
|
|
461
|
+
if (set.size === 1) {
|
|
462
|
+
const only = words[0].toLowerCase()
|
|
463
|
+
return SIDE_ANGLE[only] === undefined ? null : SIDE_ANGLE[only]
|
|
464
|
+
}
|
|
465
|
+
if (set.size !== 2 || !horiz || !vert) return null
|
|
466
|
+
const a = cornerAngle(w, h)
|
|
467
|
+
if (vert === 'top') return horiz === 'right' ? a : 360 - a
|
|
468
|
+
return horiz === 'right' ? 180 - a : 180 + a
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Gradient line for an angle, per spec: it passes through the centre, and its
|
|
473
|
+
* length is `|w·sinθ| + |h·cosθ|` — the projection of the whole box onto it. So
|
|
474
|
+
* t=0 and t=1 land exactly on the corner projections, and the painted domain is
|
|
475
|
+
* exactly [0,1]. That is what lets the stop list be clamped to [0,1] with no loss.
|
|
476
|
+
*/
|
|
477
|
+
function linearLine (angle, w, h) {
|
|
478
|
+
const r = angle * RAD
|
|
479
|
+
const sin = Math.sin(r)
|
|
480
|
+
const cos = Math.cos(r)
|
|
481
|
+
const L = Math.abs(w * sin) + Math.abs(h * cos)
|
|
482
|
+
const dx = sin
|
|
483
|
+
const dy = -cos
|
|
484
|
+
const cx = w / 2
|
|
485
|
+
const cy = h / 2
|
|
486
|
+
return {
|
|
487
|
+
L,
|
|
488
|
+
p0: [cx - dx * L / 2, cy - dy * L / 2],
|
|
489
|
+
p1: [cx + dx * L / 2, cy + dy * L / 2],
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Ending-shape size for every keyword, both shapes. Returns px radii. */
|
|
494
|
+
function radialSize (keyword, shape, cx, cy, w, h) {
|
|
495
|
+
const left = Math.abs(cx)
|
|
496
|
+
const right = Math.abs(w - cx)
|
|
497
|
+
const top = Math.abs(cy)
|
|
498
|
+
const bottom = Math.abs(h - cy)
|
|
499
|
+
if (shape === 'circle') {
|
|
500
|
+
let r
|
|
501
|
+
if (keyword === 'closest-side') r = Math.min(left, right, top, bottom)
|
|
502
|
+
else if (keyword === 'farthest-side') r = Math.max(left, right, top, bottom)
|
|
503
|
+
else {
|
|
504
|
+
const dx = keyword === 'closest-corner' ? Math.min(left, right) : Math.max(left, right)
|
|
505
|
+
const dy = keyword === 'closest-corner' ? Math.min(top, bottom) : Math.max(top, bottom)
|
|
506
|
+
r = Math.hypot(dx, dy)
|
|
507
|
+
}
|
|
508
|
+
return { rx: r, ry: r }
|
|
509
|
+
}
|
|
510
|
+
const near = keyword === 'closest-side' || keyword === 'closest-corner'
|
|
511
|
+
const rx = near ? Math.min(left, right) : Math.max(left, right)
|
|
512
|
+
const ry = near ? Math.min(top, bottom) : Math.max(top, bottom)
|
|
513
|
+
// A corner ellipse keeps the side ellipse's aspect ratio and passes through the
|
|
514
|
+
// corner; solving (dx/rx)²+(dy/ry)²=1 under that constraint collapses to √2.
|
|
515
|
+
const k = (keyword === 'closest-corner' || keyword === 'farthest-corner') ? Math.SQRT2 : 1
|
|
516
|
+
return { rx: rx * k, ry: ry * k }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Largest `t` any pixel of the box reaches, in ending-shape units. */
|
|
520
|
+
function radialCornerT (cx, cy, rx, ry, w, h) {
|
|
521
|
+
let m = 0
|
|
522
|
+
for (const [x, y] of [[0, 0], [w, 0], [0, h], [w, h]]) {
|
|
523
|
+
m = Math.max(m, Math.hypot((x - cx) / rx, (y - cy) / ry))
|
|
524
|
+
}
|
|
525
|
+
return m
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ————————————————————————————————————————————————————————————————
|
|
529
|
+
// Stop list
|
|
530
|
+
// ————————————————————————————————————————————————————————————————
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* One comma-separated argument -> zero or more entries. A bare position is a
|
|
534
|
+
* colour hint; a colour with two positions is the shorthand for two stops at the
|
|
535
|
+
* same colour, which is how hard-edged stripes are written.
|
|
536
|
+
*/
|
|
537
|
+
function parseStopPart (part, notes) {
|
|
538
|
+
const tokens = splitTop(part, ' ')
|
|
539
|
+
if (tokens.length === 0) return []
|
|
540
|
+
if (tokens.length === 1 && looksNumeric(tokens[0])) {
|
|
541
|
+
const d = parseDim(tokens[0])
|
|
542
|
+
if (!d) { notes.push(`unsupported-value:${tokens[0]}`); return [] }
|
|
543
|
+
return [{ hint: true, d, t: null }]
|
|
544
|
+
}
|
|
545
|
+
const parsed = parseColor(tokens[0])
|
|
546
|
+
if (!parsed) {
|
|
547
|
+
notes.push(`unparsed-color:${tokens[0]}`)
|
|
548
|
+
return []
|
|
549
|
+
}
|
|
550
|
+
const positions = []
|
|
551
|
+
for (let i = 1; i < tokens.length && i < 3; i++) {
|
|
552
|
+
const d = parseDim(tokens[i])
|
|
553
|
+
if (!d) { notes.push(`unsupported-value:${tokens[i]}`); continue }
|
|
554
|
+
positions.push(d)
|
|
555
|
+
}
|
|
556
|
+
if (tokens.length > 3) notes.push(`extra-stop-tokens:${tokens.length - 3}`)
|
|
557
|
+
if (positions.length === 0) return [{ color: parsed.rgba, srcCss: parsed.srcCss, d: null, t: null }]
|
|
558
|
+
return positions.map((d) => ({ color: parsed.rgba, srcCss: parsed.srcCss, d, t: null }))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* CSS Images 3 §3.4.3 colour-stop fixup: default the ends, force the sequence
|
|
563
|
+
* non-decreasing, then spread unpositioned runs evenly. Hints participate as
|
|
564
|
+
* fixed anchors — they always carry a position.
|
|
565
|
+
*/
|
|
566
|
+
function fixupPositions (entries) {
|
|
567
|
+
const first = entries[0]
|
|
568
|
+
const last = entries[entries.length - 1]
|
|
569
|
+
if (first.t === null) first.t = 0
|
|
570
|
+
if (last.t === null) last.t = 1
|
|
571
|
+
|
|
572
|
+
let high = -Infinity
|
|
573
|
+
for (const e of entries) {
|
|
574
|
+
if (e.t === null) continue
|
|
575
|
+
if (e.t < high) e.t = high
|
|
576
|
+
else high = e.t
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (let i = 0; i < entries.length; i++) {
|
|
580
|
+
if (entries[i].t !== null) continue
|
|
581
|
+
let j = i
|
|
582
|
+
while (j < entries.length && entries[j].t === null) j++
|
|
583
|
+
const before = entries[i - 1].t
|
|
584
|
+
const after = j < entries.length ? entries[j].t : 1
|
|
585
|
+
const n = j - i + 1
|
|
586
|
+
for (let k = i; k < j; k++) entries[k].t = before + (after - before) * (k - i + 1) / n
|
|
587
|
+
i = j - 1
|
|
588
|
+
}
|
|
589
|
+
return entries
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Give every fully transparent stop the RGB of its neighbour. Under
|
|
594
|
+
* premultiplied interpolation the RGB behind a zero alpha is unobservable, so
|
|
595
|
+
* this is lossless — but every backend that interpolates unpremultiplied (and
|
|
596
|
+
* most raster paths do, somewhere) otherwise fades through the black of
|
|
597
|
+
* `rgba(0,0,0,0)`, which is the classic grey halo. When the two neighbours
|
|
598
|
+
* disagree the stop is SPLIT into two coincident ones, one carrying each
|
|
599
|
+
* neighbour's RGB: still invisible, and now correct on both sides.
|
|
600
|
+
*/
|
|
601
|
+
function fixTransparentStops (entries, notes) {
|
|
602
|
+
const out = []
|
|
603
|
+
let fixed = 0
|
|
604
|
+
for (let i = 0; i < entries.length; i++) {
|
|
605
|
+
const e = entries[i]
|
|
606
|
+
if (e.hint || e.color[3] > EPS) { out.push(e); continue }
|
|
607
|
+
let prev = null
|
|
608
|
+
let next = null
|
|
609
|
+
for (let j = i - 1; j >= 0; j--) if (!entries[j].hint && entries[j].color[3] > EPS) { prev = entries[j].color; break }
|
|
610
|
+
for (let j = i + 1; j < entries.length; j++) if (!entries[j].hint && entries[j].color[3] > EPS) { next = entries[j].color; break }
|
|
611
|
+
if (!prev && !next) { out.push(e); continue }
|
|
612
|
+
fixed++
|
|
613
|
+
const mk = (c) => ({ t: e.t, color: [c[0], c[1], c[2], 0], srcCss: toCssString([c[0], c[1], c[2], 0]) })
|
|
614
|
+
if (prev && next && !sameColor([prev[0], prev[1], prev[2], 0], [next[0], next[1], next[2], 0])) {
|
|
615
|
+
out.push(mk(prev), mk(next))
|
|
616
|
+
} else {
|
|
617
|
+
out.push(mk(prev || next))
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (fixed) notes.push(`transparent-neighbour-rgb:${fixed}`)
|
|
621
|
+
return out
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const SEG_SAMPLES = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A colour hint places the 50% point of a transition at an arbitrary position,
|
|
628
|
+
* which is a power curve — `mix(c0, c1, u^k)` with `k = log(.5)/log(ratio)`.
|
|
629
|
+
* No target primitive has that, so it is sampled.
|
|
630
|
+
*
|
|
631
|
+
* Nodes are placed by greedy refinement rather than on a fixed grid: split the
|
|
632
|
+
* segment with the worst error, measure again, repeat. A fixed grid — uniform in
|
|
633
|
+
* position, or uniform in colour — is badly wrong at one end or the other
|
|
634
|
+
* whenever the hint is far from the middle, because the curvature of `u^k` is
|
|
635
|
+
* concentrated there. Refinement finds those places on its own, and stops as
|
|
636
|
+
* soon as the whole segment is under a just-noticeable ΔE00.
|
|
637
|
+
*/
|
|
638
|
+
function resampleHint (a, hint, b, notes) {
|
|
639
|
+
const span = b.t - a.t
|
|
640
|
+
if (span <= EPS) { notes.push('hint-degenerate'); return [] }
|
|
641
|
+
const ratio = (hint.t - a.t) / span
|
|
642
|
+
if (sameColor(a.color, b.color)) return []
|
|
643
|
+
if (ratio <= EPS) {
|
|
644
|
+
// Midpoint at the very start: the transition is instantaneous there.
|
|
645
|
+
notes.push('hint-degenerate')
|
|
646
|
+
return [stop(a.t, b.color)]
|
|
647
|
+
}
|
|
648
|
+
if (ratio >= 1 - EPS) {
|
|
649
|
+
notes.push('hint-degenerate')
|
|
650
|
+
return [stop(b.t, a.color)]
|
|
651
|
+
}
|
|
652
|
+
if (Math.abs(ratio - 0.5) < 1e-6) return [] // already linear
|
|
653
|
+
|
|
654
|
+
const k = Math.log(0.5) / Math.log(ratio)
|
|
655
|
+
const curve = (u) => lerpColor(a.color, b.color, Math.pow(clamp(u, 0, 1), k))
|
|
656
|
+
const segError = (u0, u1) => {
|
|
657
|
+
const c0 = curve(u0)
|
|
658
|
+
const c1 = curve(u1)
|
|
659
|
+
let e = 0
|
|
660
|
+
for (const f of SEG_SAMPLES) e = Math.max(e, colorError(curve(u0 + (u1 - u0) * f), lerpColor(c0, c1, f)))
|
|
661
|
+
return e
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const nodes = [0, 1]
|
|
665
|
+
const errs = [segError(0, 1)]
|
|
666
|
+
while (nodes.length < HINT_MAX) {
|
|
667
|
+
let worst = 0
|
|
668
|
+
for (let i = 1; i < errs.length; i++) if (errs[i] > errs[worst]) worst = i
|
|
669
|
+
if (nodes.length >= HINT_MIN && errs[worst] < HINT_DE) break
|
|
670
|
+
const u0 = nodes[worst]
|
|
671
|
+
const u1 = nodes[worst + 1]
|
|
672
|
+
// Split where the curve is halfway in COLOUR, not in position: on a power
|
|
673
|
+
// curve that is where the remaining error actually divides evenly.
|
|
674
|
+
let um = Math.pow((Math.pow(u0, k) + Math.pow(u1, k)) / 2, 1 / k)
|
|
675
|
+
if (!(um > u0 + EPS && um < u1 - EPS)) um = (u0 + u1) / 2
|
|
676
|
+
nodes.splice(worst + 1, 0, um)
|
|
677
|
+
errs.splice(worst, 1, segError(u0, um), segError(um, u1))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
notes.push(`hint-resampled:${nodes.length}`)
|
|
681
|
+
const err = Math.max(...errs)
|
|
682
|
+
if (err >= HINT_DE) notes.push(`hint-residual-de:${err.toFixed(2)}`)
|
|
683
|
+
// The two endpoints are the neighbouring stops, already in the list.
|
|
684
|
+
const out = []
|
|
685
|
+
for (let i = 1; i < nodes.length - 1; i++) out.push(stop(a.t + nodes[i] * span, curve(nodes[i])))
|
|
686
|
+
return out
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function applyHints (entries, notes) {
|
|
690
|
+
const out = []
|
|
691
|
+
for (let i = 0; i < entries.length; i++) {
|
|
692
|
+
const e = entries[i]
|
|
693
|
+
if (!e.hint) { out.push(stop(e.t, e.color, e.srcCss)); continue }
|
|
694
|
+
const a = entries[i - 1]
|
|
695
|
+
const b = entries[i + 1]
|
|
696
|
+
if (!a || !b || a.hint || b.hint) { notes.push('hint-orphan'); continue }
|
|
697
|
+
for (const s of resampleHint(a, e, b, notes)) out.push(s)
|
|
698
|
+
}
|
|
699
|
+
return out
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Colour anywhere on the ramp, with the CSS pad behaviour outside it. */
|
|
703
|
+
function colorAt (stops, t) {
|
|
704
|
+
if (t <= stops[0].t) return stops[0].color
|
|
705
|
+
const last = stops[stops.length - 1]
|
|
706
|
+
if (t >= last.t) return last.color
|
|
707
|
+
for (let i = 1; i < stops.length; i++) {
|
|
708
|
+
const a = stops[i - 1]
|
|
709
|
+
const b = stops[i]
|
|
710
|
+
if (t <= b.t) {
|
|
711
|
+
const span = b.t - a.t
|
|
712
|
+
return span <= EPS ? b.color : lerpColor(a.color, b.color, (t - a.t) / span)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return last.color
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Restrict the ramp to the domain that is actually painted, inserting the
|
|
720
|
+
* interpolated boundary colours. Lossless: outside [lo,hi] nothing is drawn, and
|
|
721
|
+
* inside it the colours are unchanged.
|
|
722
|
+
*/
|
|
723
|
+
function clampDomain (stops, lo, hi) {
|
|
724
|
+
const out = []
|
|
725
|
+
if (stops[0].t < lo - EPS) out.push(stop(lo, colorAt(stops, lo)))
|
|
726
|
+
for (const s of stops) {
|
|
727
|
+
if (s.t < lo - EPS || s.t > hi + EPS) continue
|
|
728
|
+
out.push(stop(clamp(s.t, lo, hi), s.color, s.srcCss))
|
|
729
|
+
}
|
|
730
|
+
if (stops[stops.length - 1].t > hi + EPS) out.push(stop(hi, colorAt(stops, hi)))
|
|
731
|
+
return out.length ? out : [stop(lo, colorAt(stops, lo)), stop(hi, colorAt(stops, hi))]
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Premultiplied average of every stop — what a zero-period repeating gradient renders as. */
|
|
735
|
+
function averageColor (stops) {
|
|
736
|
+
let r = 0
|
|
737
|
+
let g = 0
|
|
738
|
+
let b = 0
|
|
739
|
+
let a = 0
|
|
740
|
+
for (const s of stops) {
|
|
741
|
+
r += s.color[0] * s.color[3]
|
|
742
|
+
g += s.color[1] * s.color[3]
|
|
743
|
+
b += s.color[2] * s.color[3]
|
|
744
|
+
a += s.color[3]
|
|
745
|
+
}
|
|
746
|
+
const n = stops.length
|
|
747
|
+
if (a <= EPS) return [0, 0, 0, 0]
|
|
748
|
+
return [clamp(r / a, 0, 1), clamp(g / a, 0, 1), clamp(b / a, 0, 1), a / n]
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Unroll `repeating-*` across the painted domain. The period is the distance
|
|
753
|
+
* between the first and last stop; copies are laid down until [lo,hi] is covered
|
|
754
|
+
* or the budget is spent, after which the last colour pads and the truncation is
|
|
755
|
+
* reported — `emitted/needed` — rather than left to be discovered visually.
|
|
756
|
+
*/
|
|
757
|
+
function unrollRepeat (stops, lo, hi, notes) {
|
|
758
|
+
const period = stops[stops.length - 1].t - stops[0].t
|
|
759
|
+
if (!(period > EPS)) {
|
|
760
|
+
notes.push('repeating-zero-period-averaged')
|
|
761
|
+
const avg = averageColor(stops)
|
|
762
|
+
return [stop(lo, avg), stop(hi, avg)]
|
|
763
|
+
}
|
|
764
|
+
const kMin = Math.floor((lo - stops[stops.length - 1].t) / period)
|
|
765
|
+
const kMax = Math.ceil((hi - stops[0].t) / period)
|
|
766
|
+
const needed = kMax - kMin + 1
|
|
767
|
+
const cap = Math.min(MAX_REPEATS, Math.max(1, Math.floor(MAX_STOPS / stops.length)))
|
|
768
|
+
let copies = needed
|
|
769
|
+
if (copies > cap) {
|
|
770
|
+
notes.push(`repeating-truncated:${cap}/${needed}`)
|
|
771
|
+
copies = cap
|
|
772
|
+
}
|
|
773
|
+
const out = []
|
|
774
|
+
for (let i = 0; i < copies; i++) {
|
|
775
|
+
const shift = (kMin + i) * period
|
|
776
|
+
for (const s of stops) out.push(stop(s.t + shift, s.color, s.srcCss))
|
|
777
|
+
}
|
|
778
|
+
return out
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Drop stops that carry no information: exact duplicates, and third-and-beyond
|
|
783
|
+
* ties at one position. Two DIFFERENT colours at one position are kept — that
|
|
784
|
+
* is a hard edge, the whole basis of CSS stripes, and also what `red 60%,
|
|
785
|
+
* blue 20%` collapses to after the monotonic fixup.
|
|
786
|
+
*/
|
|
787
|
+
function dedupe (stops) {
|
|
788
|
+
const out = []
|
|
789
|
+
for (const s of stops) {
|
|
790
|
+
const n = out.length
|
|
791
|
+
if (n && Math.abs(out[n - 1].t - s.t) < 1e-7) {
|
|
792
|
+
if (sameColor(out[n - 1].color, s.color)) continue
|
|
793
|
+
if (n > 1 && Math.abs(out[n - 2].t - s.t) < 1e-7) { out[n - 1] = s; continue }
|
|
794
|
+
}
|
|
795
|
+
out.push(s)
|
|
796
|
+
}
|
|
797
|
+
// A lone stop is a constant colour; spanning it is the only form every backend reads.
|
|
798
|
+
if (out.length === 1) return [stop(0, out[0].color, out[0].srcCss), stop(1, out[0].color, out[0].srcCss)]
|
|
799
|
+
return out
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ————————————————————————————————————————————————————————————————
|
|
803
|
+
// Preludes
|
|
804
|
+
// ————————————————————————————————————————————————————————————————
|
|
805
|
+
|
|
806
|
+
/** Legacy `-webkit-` gradients name the START side and measure angles CCW from east. */
|
|
807
|
+
function legacyLinearAngle (tokens, w, h) {
|
|
808
|
+
if (tokens.length === 1 && looksNumeric(tokens[0])) {
|
|
809
|
+
const a = resolveAngle(parseDim(tokens[0]))
|
|
810
|
+
return a === null ? null : ((90 - a) % 360 + 360) % 360
|
|
811
|
+
}
|
|
812
|
+
const flip = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
|
|
813
|
+
const flipped = tokens.map((t) => flip[t.toLowerCase()])
|
|
814
|
+
if (flipped.some((t) => t === undefined)) return null
|
|
815
|
+
return sideOrCornerAngle(flipped, w, h)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function radialPrelude (tokens, w, h, notes) {
|
|
819
|
+
let shape = null
|
|
820
|
+
let sizeKeyword = null
|
|
821
|
+
const dims = []
|
|
822
|
+
let at = null
|
|
823
|
+
|
|
824
|
+
const atIndex = tokens.findIndex((t) => t.toLowerCase() === 'at')
|
|
825
|
+
const head = atIndex === -1 ? tokens : tokens.slice(0, atIndex)
|
|
826
|
+
if (atIndex !== -1) {
|
|
827
|
+
at = parsePosition(tokens.slice(atIndex + 1), w, h)
|
|
828
|
+
if (!at) { notes.push('unparsed-position'); at = { x: w / 2, y: h / 2 } }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (const tok of head) {
|
|
832
|
+
const k = tok.toLowerCase()
|
|
833
|
+
if (SHAPE_KEYWORDS.has(k)) { shape = k; continue }
|
|
834
|
+
if (SIZE_KEYWORDS.has(k)) { sizeKeyword = k; continue }
|
|
835
|
+
if (looksNumeric(tok)) {
|
|
836
|
+
const d = parseDim(tok)
|
|
837
|
+
if (d) dims.push(d)
|
|
838
|
+
else notes.push(`unsupported-value:${tok}`)
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
notes.push(`unknown-radial-token:${tok}`)
|
|
842
|
+
}
|
|
843
|
+
return { shape, sizeKeyword, dims, at }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const HUE_METHODS = new Set(['shorter', 'longer', 'increasing', 'decreasing'])
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Remove `in <colorspace> [<hue> hue]` from a prelude. The gradient is still
|
|
850
|
+
* interpolated in sRGB — matching a non-sRGB ramp would need the whole stop list
|
|
851
|
+
* resampled through that space, which is a different job — but the DIRECTION has
|
|
852
|
+
* to survive: dropping the prelude wholesale would turn `45deg in oklab` into the
|
|
853
|
+
* default `to bottom`, a geometry error on top of a colour one.
|
|
854
|
+
*/
|
|
855
|
+
function stripInterpolationMethod (tokens, notes) {
|
|
856
|
+
const at = tokens.findIndex((t) => t.toLowerCase() === 'in')
|
|
857
|
+
if (at === -1) return null
|
|
858
|
+
let end = at + 1
|
|
859
|
+
const space = tokens[end] || ''
|
|
860
|
+
if (space) end++
|
|
861
|
+
if (HUE_METHODS.has((tokens[end] || '').toLowerCase())) {
|
|
862
|
+
end++
|
|
863
|
+
if ((tokens[end] || '').toLowerCase() === 'hue') end++
|
|
864
|
+
}
|
|
865
|
+
notes.push(`interpolation-space-ignored:${space || '?'}`)
|
|
866
|
+
return tokens.slice(0, at).concat(tokens.slice(end))
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A first argument is a prelude only when it cannot be a colour stop. */
|
|
870
|
+
function isRadialPrelude (part) {
|
|
871
|
+
const tokens = splitTop(part, ' ')
|
|
872
|
+
if (tokens.length === 0) return false
|
|
873
|
+
for (const t of tokens) {
|
|
874
|
+
const k = t.toLowerCase()
|
|
875
|
+
if (k === 'at' || SHAPE_KEYWORDS.has(k) || SIZE_KEYWORDS.has(k)) return true
|
|
876
|
+
}
|
|
877
|
+
return tokens.length <= 2 && tokens.every(looksNumeric)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ————————————————————————————————————————————————————————————————
|
|
881
|
+
// Entry point
|
|
882
|
+
// ————————————————————————————————————————————————————————————————
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* @typedef {Object} GradientStop
|
|
886
|
+
* @property {number} t 0..1 along the gradient's own parameter
|
|
887
|
+
* @property {number[]} color [r,g,b,a] floats 0..1, sRGB
|
|
888
|
+
* @property {string} srcCss round-trip form
|
|
889
|
+
*/
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* @typedef {Object} Paint
|
|
893
|
+
* @property {'linear'|'radial'|'angular'} type
|
|
894
|
+
* @property {Object} unit canonical geometry in the unit square (see file header)
|
|
895
|
+
* @property {number[]} transform [a,b,c,d,e,f] mapping unit space -> painting box px
|
|
896
|
+
* @property {GradientStop[]} stops non-decreasing in `t`, all within 0..1
|
|
897
|
+
* @property {string[]} notes every approximation, named
|
|
898
|
+
*/
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Parse one `background-image` layer into an SVD gradient paint.
|
|
902
|
+
*
|
|
903
|
+
* @param {string} css one background-image layer, e.g. "linear-gradient(...)"
|
|
904
|
+
* @param {{w:number,h:number}} box the painting box in px
|
|
905
|
+
* @returns {Paint|null} null when the layer is not a gradient
|
|
906
|
+
*/
|
|
907
|
+
export function parseGradient (css, box) {
|
|
908
|
+
if (typeof css !== 'string') return null
|
|
909
|
+
const g = matchGradient(css)
|
|
910
|
+
if (!g) return null
|
|
911
|
+
|
|
912
|
+
const notes = []
|
|
913
|
+
if (g.prefixed) notes.push('legacy-prefixed-syntax')
|
|
914
|
+
|
|
915
|
+
let w = box && Number.isFinite(box.w) ? box.w : 0
|
|
916
|
+
let h = box && Number.isFinite(box.h) ? box.h : 0
|
|
917
|
+
if (!(w > EPS) || !(h > EPS)) {
|
|
918
|
+
notes.push('degenerate-box')
|
|
919
|
+
w = Math.max(w, 1)
|
|
920
|
+
h = Math.max(h, 1)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const parts = splitTop(g.args, ',')
|
|
924
|
+
if (parts.length === 0) return null
|
|
925
|
+
if (parts.length > 1) {
|
|
926
|
+
const stripped = stripInterpolationMethod(splitTop(parts[0], ' '), notes)
|
|
927
|
+
if (stripped) {
|
|
928
|
+
if (stripped.length === 0) parts.shift()
|
|
929
|
+
else parts[0] = stripped.join(' ')
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ——— geometry ———
|
|
934
|
+
let type
|
|
935
|
+
let unit
|
|
936
|
+
let transform
|
|
937
|
+
let resolvePos // dimension -> t
|
|
938
|
+
let domainHi = 1
|
|
939
|
+
let radial = null
|
|
940
|
+
let consumed = 0
|
|
941
|
+
|
|
942
|
+
if (g.kind === 'linear') {
|
|
943
|
+
const tokens = splitTop(parts[0], ' ')
|
|
944
|
+
let angle = null
|
|
945
|
+
if (tokens.length && tokens[0].toLowerCase() === 'to') {
|
|
946
|
+
angle = sideOrCornerAngle(tokens.slice(1), w, h)
|
|
947
|
+
consumed = 1
|
|
948
|
+
} else if (g.prefixed && tokens.length <= 2 &&
|
|
949
|
+
(looksNumeric(tokens[0]) || POS_KEYWORDS.has(tokens[0].toLowerCase()))) {
|
|
950
|
+
angle = legacyLinearAngle(tokens, w, h)
|
|
951
|
+
consumed = 1
|
|
952
|
+
} else if (tokens.length === 1 && looksNumeric(tokens[0])) {
|
|
953
|
+
angle = resolveAngle(parseDim(tokens[0]))
|
|
954
|
+
consumed = 1
|
|
955
|
+
}
|
|
956
|
+
if (consumed && angle === null) {
|
|
957
|
+
notes.push(`unparsed-direction:${parts[0]}`)
|
|
958
|
+
angle = 180
|
|
959
|
+
}
|
|
960
|
+
if (angle === null) angle = 180 // `to bottom`
|
|
961
|
+
angle = ((angle % 360) + 360) % 360
|
|
962
|
+
|
|
963
|
+
const line = linearLine(angle, w, h)
|
|
964
|
+
type = 'linear'
|
|
965
|
+
unit = { p0: [0, 0], p1: [1, 0], angle }
|
|
966
|
+
const ax = line.p1[0] - line.p0[0]
|
|
967
|
+
const ay = line.p1[1] - line.p0[1]
|
|
968
|
+
// Second basis vector = perpendicular of the first, same length: determinant
|
|
969
|
+
// L² > 0, so the matrix always inverts.
|
|
970
|
+
transform = [ax, ay, -ay, ax, line.p0[0], line.p0[1]]
|
|
971
|
+
resolvePos = (d) => {
|
|
972
|
+
const px = resolveLength(d, line.L)
|
|
973
|
+
return px === null ? null : (line.L > EPS ? px / line.L : 0)
|
|
974
|
+
}
|
|
975
|
+
} else if (g.kind === 'radial') {
|
|
976
|
+
let prelude = { shape: null, sizeKeyword: null, dims: [], at: null }
|
|
977
|
+
if (isRadialPrelude(parts[0])) {
|
|
978
|
+
prelude = radialPrelude(splitTop(parts[0], ' '), w, h, notes)
|
|
979
|
+
consumed = 1
|
|
980
|
+
// `-webkit-radial-gradient(<position>, <shape size>, …)` puts the position
|
|
981
|
+
// first, in its own argument, with no `at`.
|
|
982
|
+
if (g.prefixed && !prelude.at && prelude.dims.length && !prelude.shape && !prelude.sizeKeyword) {
|
|
983
|
+
const pos = parsePosition(splitTop(parts[0], ' '), w, h)
|
|
984
|
+
if (pos && parts.length > 2 && isRadialPrelude(parts[1])) {
|
|
985
|
+
const second = radialPrelude(splitTop(parts[1], ' '), w, h, notes)
|
|
986
|
+
prelude = { ...second, at: pos }
|
|
987
|
+
consumed = 2
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const at = prelude.at || { x: w / 2, y: h / 2 }
|
|
992
|
+
let shape = prelude.shape
|
|
993
|
+
if (!shape) shape = prelude.dims.length === 1 ? 'circle' : 'ellipse'
|
|
994
|
+
|
|
995
|
+
let rx
|
|
996
|
+
let ry
|
|
997
|
+
if (prelude.dims.length >= 1) {
|
|
998
|
+
if (prelude.sizeKeyword) notes.push('radial-size-keyword-and-length')
|
|
999
|
+
const dx = resolveLength(prelude.dims[0], w)
|
|
1000
|
+
const dy = prelude.dims.length > 1 ? resolveLength(prelude.dims[1], h) : dx
|
|
1001
|
+
if (dx === null || dy === null) {
|
|
1002
|
+
notes.push('unresolvable-radial-size')
|
|
1003
|
+
const s = radialSize('farthest-corner', shape, at.x, at.y, w, h)
|
|
1004
|
+
rx = s.rx
|
|
1005
|
+
ry = s.ry
|
|
1006
|
+
} else {
|
|
1007
|
+
rx = Math.abs(dx)
|
|
1008
|
+
ry = shape === 'circle' ? Math.abs(dx) : Math.abs(dy)
|
|
1009
|
+
}
|
|
1010
|
+
} else {
|
|
1011
|
+
const s = radialSize(prelude.sizeKeyword || 'farthest-corner', shape, at.x, at.y, w, h)
|
|
1012
|
+
rx = s.rx
|
|
1013
|
+
ry = s.ry
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (!(rx > EPS) || !(ry > EPS)) {
|
|
1017
|
+
// A zero-size ending shape paints the last stop's colour everywhere. Doing
|
|
1018
|
+
// that by shrinking the matrix would hand the backend a singular transform,
|
|
1019
|
+
// so the shape is inflated instead and the ramp collapsed further down.
|
|
1020
|
+
notes.push('degenerate-shape-solid')
|
|
1021
|
+
radial = { at, rx: Math.max(w, h), ry: Math.max(w, h), solid: true }
|
|
1022
|
+
} else {
|
|
1023
|
+
radial = { at, rx, ry, solid: false }
|
|
1024
|
+
}
|
|
1025
|
+
type = 'radial'
|
|
1026
|
+
unit = { center: [0.5, 0.5], radius: 0.5, shape }
|
|
1027
|
+
domainHi = radial.solid ? 1 : radialCornerT(at.x, at.y, radial.rx, radial.ry, w, h)
|
|
1028
|
+
resolvePos = (d) => {
|
|
1029
|
+
const px = resolveLength(d, radial.rx)
|
|
1030
|
+
return px === null ? null : (radial.rx > EPS ? px / radial.rx : 0)
|
|
1031
|
+
}
|
|
1032
|
+
transform = null // built after scaling
|
|
1033
|
+
} else {
|
|
1034
|
+
let from = 0
|
|
1035
|
+
let at = null
|
|
1036
|
+
if (parts.length > 1) {
|
|
1037
|
+
const tokens = splitTop(parts[0], ' ')
|
|
1038
|
+
const k0 = tokens[0] ? tokens[0].toLowerCase() : ''
|
|
1039
|
+
if (k0 === 'from' || k0 === 'at') {
|
|
1040
|
+
consumed = 1
|
|
1041
|
+
let i = 0
|
|
1042
|
+
if (tokens[0].toLowerCase() === 'from') {
|
|
1043
|
+
const a = resolveAngle(parseDim(tokens[1] || ''))
|
|
1044
|
+
if (a === null) notes.push(`unparsed-angle:${tokens[1] || ''}`)
|
|
1045
|
+
else from = a
|
|
1046
|
+
i = 2
|
|
1047
|
+
}
|
|
1048
|
+
if (tokens[i] && tokens[i].toLowerCase() === 'at') {
|
|
1049
|
+
at = parsePosition(tokens.slice(i + 1), w, h)
|
|
1050
|
+
if (!at) notes.push('unparsed-position')
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const c = at || { x: w / 2, y: h / 2 }
|
|
1055
|
+
// Uniform scale only: a conic gradient's angles are true screen angles, so
|
|
1056
|
+
// any anisotropy in the matrix would bend them. The square is sized to cover
|
|
1057
|
+
// the box from wherever the centre sits.
|
|
1058
|
+
const reach = Math.max(
|
|
1059
|
+
Math.hypot(c.x, c.y), Math.hypot(w - c.x, c.y),
|
|
1060
|
+
Math.hypot(c.x, h - c.y), Math.hypot(w - c.x, h - c.y)) || Math.max(w, h)
|
|
1061
|
+
const d = 2 * reach
|
|
1062
|
+
type = 'angular'
|
|
1063
|
+
unit = { center: [0.5, 0.5], startAngle: ((from % 360) + 360) % 360 }
|
|
1064
|
+
transform = [d, 0, 0, d, c.x - d / 2, c.y - d / 2]
|
|
1065
|
+
resolvePos = (dm) => {
|
|
1066
|
+
const a = resolveAngle(dm)
|
|
1067
|
+
return a === null ? null : a / 360
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ——— stops ———
|
|
1072
|
+
const raw = []
|
|
1073
|
+
for (let i = consumed; i < parts.length; i++) {
|
|
1074
|
+
for (const e of parseStopPart(parts[i], notes)) raw.push(e)
|
|
1075
|
+
}
|
|
1076
|
+
for (const e of raw) {
|
|
1077
|
+
if (e.d) {
|
|
1078
|
+
const t = resolvePos(e.d)
|
|
1079
|
+
if (t === null) notes.push('unresolvable-stop-position')
|
|
1080
|
+
else e.t = t
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
while (raw.length && raw[0].hint) { raw.shift(); notes.push('hint-orphan') }
|
|
1084
|
+
while (raw.length && raw[raw.length - 1].hint) { raw.pop(); notes.push('hint-orphan') }
|
|
1085
|
+
if (raw.length === 0) {
|
|
1086
|
+
notes.push('no-parsable-stops')
|
|
1087
|
+
raw.push({ color: [0, 0, 0, 0], srcCss: 'rgba(0, 0, 0, 0)', d: null, t: 0 })
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
fixupPositions(raw)
|
|
1091
|
+
const fixed = fixTransparentStops(raw, notes)
|
|
1092
|
+
let stops = applyHints(fixed, notes)
|
|
1093
|
+
if (stops.length === 0) stops = [stop(0, [0, 0, 0, 0])]
|
|
1094
|
+
|
|
1095
|
+
if (g.repeating && stops.length > 1) stops = unrollRepeat(stops, 0, domainHi, notes)
|
|
1096
|
+
stops = clampDomain(stops, 0, domainHi)
|
|
1097
|
+
|
|
1098
|
+
// ——— radial: absorb any overshoot into the shape so every `t` lands in 0..1 ———
|
|
1099
|
+
if (type === 'radial') {
|
|
1100
|
+
if (radial.solid) {
|
|
1101
|
+
const c = stops[stops.length - 1].color
|
|
1102
|
+
stops = [stop(0, c), stop(1, c)]
|
|
1103
|
+
}
|
|
1104
|
+
const maxT = stops[stops.length - 1].t
|
|
1105
|
+
const s = maxT > 1 + EPS ? maxT : 1
|
|
1106
|
+
if (s !== 1) for (const st of stops) st.t /= s
|
|
1107
|
+
const rx = radial.rx * s
|
|
1108
|
+
const ry = radial.ry * s
|
|
1109
|
+
transform = [2 * rx, 0, 0, 2 * ry, radial.at.x - rx, radial.at.y - ry]
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Unrolling adds `k * period` to every position and the radial overshoot divides
|
|
1113
|
+
// them all by `s`; both accumulate float error, so two stops emitted in order can
|
|
1114
|
+
// come back 1e-16 apart the WRONG way and fail the schema's monotonicity check.
|
|
1115
|
+
// The order is real, the epsilon is not — so the order is what is kept. This runs
|
|
1116
|
+
// before `dedupe` so stops that become equal here still collapse.
|
|
1117
|
+
let prev = 0
|
|
1118
|
+
for (const st of stops) {
|
|
1119
|
+
st.t = clamp(st.t, 0, 1)
|
|
1120
|
+
if (st.t < prev) st.t = prev
|
|
1121
|
+
prev = st.t
|
|
1122
|
+
}
|
|
1123
|
+
stops = dedupe(stops)
|
|
1124
|
+
|
|
1125
|
+
return { type, unit, transform, stops, notes }
|
|
1126
|
+
}
|