Skip to content

Commit 50a7948

Browse files
authored
feat: support repeating-radial-gradient (#697)
1 parent 3262159 commit 50a7948

12 files changed

+208
-32
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
204204

205205
<tr><td rowspan="7">Background</td></tr>
206206
<tr><td><code>backgroundColor</code></td><td>Supported, single value</td><td></td></tr>
207-
<tr><td><code>backgroundImage</code></td><td><code>linear-gradient</code>, <code>radial-gradient</code>, <code>url</code>, single value</td><td></td></tr>
207+
<tr><td><code>backgroundImage</code></td><td><code>linear-gradient</code>, <code>repeating-linear-gradient</code>, <code>radial-gradient</code>, <code>repeating-radial-gradient</code>, <code>url</code>, single value</td><td></td></tr>
208208
<tr><td><code>backgroundPosition</code></td><td>Support single value</td><td></td></tr>
209209
<tr><td><code>backgroundSize</code></td><td>Support two-value size i.e. <code>10px 20%</code></td><td></td></tr>
210210
<tr><td><code>backgroundClip</code></td><td><code>border-box</code>, <code>text</code></td><td></td></tr>

src/builder/background-image.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ export default async function backgroundImage(
102102
)
103103
}
104104

105-
if (image.startsWith('radial-gradient(')) {
105+
if (
106+
image.startsWith('radial-gradient(') ||
107+
image.startsWith('repeating-radial-gradient(')
108+
) {
106109
return buildRadialGradient(
107110
{ id, width, height, repeatX, repeatY },
108111
image,

src/builder/gradient/radial.ts

Lines changed: 158 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
parseRadialGradient,
33
RadialResult,
44
RadialPropertyValue,
5+
ColorStop,
56
} from 'css-gradient-parser'
67
import { buildXMLString, lengthToNumber } from '../../utils.js'
78
import { normalizeStops } from './utils.js'
@@ -31,6 +32,7 @@ export function buildRadialGradient(
3132
stops: colorStops,
3233
position,
3334
size,
35+
repeating,
3436
} = parseRadialGradient(image)
3537
const [xDelta, yDelta] = dimensions
3638

@@ -48,7 +50,20 @@ export function buildRadialGradient(
4850
cx = pos.x
4951
cy = pos.y
5052

51-
const stops = normalizeStops(width, colorStops, inheritableStyle, false, from)
53+
const colorStopTotalLength = calcColorStopTotalLength(
54+
width,
55+
colorStops,
56+
repeating,
57+
inheritableStyle
58+
)
59+
60+
const stops = normalizeStops(
61+
colorStopTotalLength,
62+
colorStops,
63+
inheritableStyle,
64+
repeating,
65+
from
66+
)
5267

5368
const gradientId = `satori_radial_${id}`
5469
const patternId = `satori_pattern_${id}`
@@ -61,7 +76,18 @@ export function buildRadialGradient(
6176
inheritableStyle.fontSize as number,
6277
{ x: cx, y: cy },
6378
[xDelta, yDelta],
64-
inheritableStyle
79+
inheritableStyle,
80+
repeating
81+
)
82+
83+
const props = calcRadialGradientProps(
84+
shape as Shape,
85+
inheritableStyle.fontSize as number,
86+
colorStops,
87+
[xDelta, yDelta],
88+
inheritableStyle,
89+
repeating,
90+
spread
6591
)
6692

6793
// TODO: check for repeat-x/repeat-y
@@ -79,6 +105,7 @@ export function buildRadialGradient(
79105
'radialGradient',
80106
{
81107
id: gradientId,
108+
...props,
82109
},
83110
stops
84111
.map((stop) =>
@@ -124,13 +151,28 @@ export function buildRadialGradient(
124151
return result
125152
}
126153

127-
interface Position {
128-
type: 'keyword' | 'length'
129-
value: string
130-
}
131-
132154
type PositionKeyWord = 'center' | 'left' | 'right' | 'top' | 'bottom'
133155

156+
function calcColorStopTotalLength(
157+
width: number,
158+
stops: ColorStop[],
159+
repeating: boolean,
160+
inheritableStyle: Record<string, string | number>
161+
) {
162+
if (!repeating) return width
163+
const lastStop = stops.at(-1)
164+
if (!lastStop || !lastStop.offset || lastStop.offset.unit === '%')
165+
return width
166+
167+
return lengthToNumber(
168+
`${lastStop.offset.value}${lastStop.offset.unit}`,
169+
+inheritableStyle.fontSize,
170+
width,
171+
inheritableStyle,
172+
true
173+
)
174+
}
175+
134176
function calcRadialGradient(
135177
cx: RadialPropertyValue,
136178
cy: RadialPropertyValue,
@@ -196,13 +238,53 @@ function calcPos(
196238
}
197239

198240
type Shape = 'circle' | 'ellipse'
241+
242+
function calcRadialGradientProps(
243+
shape: Shape,
244+
baseFontSize: number,
245+
colorStops: ColorStop[],
246+
[xDelta, yDelta]: [number, number],
247+
inheritableStyle: Record<string, string | number>,
248+
repeating: boolean,
249+
spread: Record<string, number>
250+
) {
251+
const { r, rx, ratio = 1 } = spread
252+
if (!repeating) {
253+
return {
254+
spreadMethod: 'pad',
255+
}
256+
}
257+
const last = colorStops.at(-1)
258+
const radius = shape === 'circle' ? r * 2 : rx * 2
259+
return {
260+
spreadMethod: 'repeat',
261+
cx: '50%',
262+
cy: '50%',
263+
r:
264+
last.offset.unit === '%'
265+
? `${
266+
(Number(last.offset.value) * Math.min(yDelta / xDelta, 1)) / ratio
267+
}%`
268+
: Number(
269+
lengthToNumber(
270+
`${last.offset.value}${last.offset.unit}`,
271+
baseFontSize,
272+
xDelta,
273+
inheritableStyle,
274+
true
275+
) / radius
276+
),
277+
}
278+
}
279+
199280
function calcRadius(
200281
shape: Shape,
201282
endingShape: RadialResult['size'],
202283
baseFontSize: number,
203284
centerAxis: { x: number; y: number },
204285
length: [number, number],
205-
inheritableStyle: Record<string, string | number>
286+
inheritableStyle: Record<string, string | number>,
287+
repeating: boolean
206288
) {
207289
const [xDelta, yDelta] = length
208290
const { x: cx, y: cy } = centerAxis
@@ -217,7 +299,7 @@ function calcRadius(
217299
)
218300
}
219301
if (shape === 'circle') {
220-
return {
302+
Object.assign(spread, {
221303
r: Number(
222304
lengthToNumber(
223305
`${endingShape[0].value.value}${endingShape[0].value.unit}`,
@@ -227,9 +309,9 @@ function calcRadius(
227309
true
228310
)
229311
),
230-
}
312+
})
231313
} else {
232-
return {
314+
Object.assign(spread, {
233315
rx: Number(
234316
lengthToNumber(
235317
`${endingShape[0].value.value}${endingShape[0].value.unit}`,
@@ -248,8 +330,10 @@ function calcRadius(
248330
true
249331
)
250332
),
251-
}
333+
})
252334
}
335+
patchSpread(spread, xDelta, yDelta, cx, cy, repeating, shape)
336+
return spread
253337
}
254338

255339
switch (endingShape[0].value) {
@@ -273,6 +357,7 @@ function calcRadius(
273357
spread.rx = Math.max(Math.abs(xDelta - cx), Math.abs(cx))
274358
spread.ry = Math.max(Math.abs(yDelta - cy), Math.abs(cy))
275359
}
360+
patchSpread(spread, xDelta, yDelta, cx, cy, repeating, shape)
276361
return spread
277362
case 'closest-side':
278363
if (shape === 'circle') {
@@ -286,31 +371,79 @@ function calcRadius(
286371
spread.rx = Math.min(Math.abs(xDelta - cx), Math.abs(cx))
287372
spread.ry = Math.min(Math.abs(yDelta - cy), Math.abs(cy))
288373
}
374+
patchSpread(spread, xDelta, yDelta, cx, cy, repeating, shape)
289375

290376
return spread
291377
}
292378
if (shape === 'circle') {
293379
spread.r = Math.sqrt(fx * fx + fy * fy)
294380
} else {
295-
// Spec: https://drafts.csswg.org/css-images/#typedef-size
296-
// Get the aspect ratio of the closest-side size.
297-
const ratio = fy !== 0 ? fx / fy : 1
381+
Object.assign(spread, f2r(fx, fy))
382+
}
298383

299-
if (fx === 0) {
300-
spread.rx = 0
301-
spread.ry = 0
384+
patchSpread(spread, xDelta, yDelta, cx, cy, repeating, shape)
385+
386+
return spread
387+
}
388+
389+
// compare with farthest-corner to make it cover the whole container
390+
function patchSpread(
391+
spread: Record<string, number>,
392+
xDelta: number,
393+
yDelta: number,
394+
cx: number,
395+
cy: number,
396+
repeating: boolean,
397+
shape: Shape
398+
) {
399+
if (repeating) {
400+
if (shape === 'ellipse') {
401+
const mfx = Math.max(Math.abs(xDelta - cx), Math.abs(cx))
402+
const mfy = Math.max(Math.abs(yDelta - cy), Math.abs(cy))
403+
404+
const { rx: mrx, ry: mry } = f2r(mfx, mfy)
405+
406+
spread.ratio = Math.max(mrx / spread.rx, mry / spread.ry)
407+
if (spread.ratio > 1) {
408+
spread.rx *= spread.ratio
409+
spread.ry *= spread.ratio
410+
}
302411
} else {
303-
// fx^2/a^2 + fy^2/b^2 = 1
304-
// fx^2/(b*ratio)^2 + fy^2/b^2 = 1
305-
// (fx^2+fy^2*ratio^2) = (b*ratio)^2
306-
// b = sqrt(fx^2+fy^2*ratio^2)/ratio
412+
const mfx = Math.max(Math.abs(xDelta - cx), Math.abs(cx))
413+
const mfy = Math.max(Math.abs(yDelta - cy), Math.abs(cy))
414+
415+
const mr = Math.sqrt(mfx * mfx + mfy * mfy)
307416

308-
spread.ry = Math.sqrt(fx * fx + fy * fy * ratio * ratio) / ratio
309-
spread.rx = spread.ry * ratio
417+
spread.ratio = mr / spread.r
418+
if (spread.ratio > 1) {
419+
spread.r = mr
420+
}
310421
}
311422
}
423+
}
312424

313-
return spread
425+
function f2r(fx: number, fy: number) {
426+
// Spec: https://drafts.csswg.org/css-images/#typedef-size
427+
// Get the aspect ratio of the closest-side size.
428+
const ratio = fy !== 0 ? fx / fy : 1
429+
430+
if (fx === 0) {
431+
return {
432+
rx: 0,
433+
ry: 0,
434+
}
435+
} else {
436+
// fx^2/a^2 + fy^2/b^2 = 1
437+
// fx^2/(b*ratio)^2 + fy^2/b^2 = 1
438+
// (fx^2+fy^2*ratio^2) = (b*ratio)^2
439+
// b = sqrt(fx^2+fy^2*ratio^2)/ratio
440+
441+
const ry = Math.sqrt(fx * fx + fy * fy * ratio * ratio) / ratio
442+
return {
443+
ry,
444+
rx: ry * ratio,
445+
}
446+
}
314447
}
315448

316449
function isSizeAllLength(v: RadialPropertyValue[]): v is Array<{

src/builder/gradient/utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ export function normalizeStops(
1717
// Resolve the color stops based on the spec:
1818
// https://drafts.csswg.org/css-images/#color-stop-syntax
1919
const stops: Stop[] = []
20+
const lastColorStop = colorStops.at(-1)
21+
const totalPercentage =
22+
lastColorStop &&
23+
lastColorStop.offset &&
24+
lastColorStop.offset.unit === '%' &&
25+
repeating
26+
? +lastColorStop.offset.value
27+
: 100
2028
for (const stop of colorStops) {
2129
const { color } = stop
2230
if (!stops.length) {
@@ -35,7 +43,7 @@ export function normalizeStops(
3543
typeof stop.offset === 'undefined'
3644
? undefined
3745
: stop.offset.unit === '%'
38-
? +stop.offset.value / 100
46+
? +stop.offset.value / totalPercentage
3947
: Number(
4048
lengthToNumber(
4149
`${stop.offset.value}${stop.offset.unit}`,

src/handler/expand.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ function handleSpecialCase(
6060
currentColor: string
6161
) {
6262
if (name === 'zIndex') {
63-
console.warn(
64-
'`z-index` is currently not supported.'
65-
)
63+
console.warn('`z-index` is currently not supported.')
6664
return { [name]: value }
6765
}
6866

@@ -172,7 +170,7 @@ function handleSpecialCase(
172170
if (name === 'background') {
173171
value = value.toString().trim()
174172
if (
175-
/^(linear-gradient|radial-gradient|url|repeating-linear-gradient)\(/.test(
173+
/^(linear-gradient|radial-gradient|url|repeating-linear-gradient|repeating-radial-gradient)\(/.test(
176174
value
177175
)
178176
) {
Loading
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)