Skip to content

Commit 44ce1a7

Browse files
authoredApr 22, 2023
feat: Complete radial-gradient support (#454)
- radial-gradient should support default value when missed according to [reference](https://w3c.github.io/csswg-drafts/css-images/#radial-gradients) - radial-gradient `<position>` should support unit such as `vw`, `vh`,`em`, `rem`, `%` etc - support `rg-extent-keyword` such as `closest-corner`, `farthest-side`,`closest-side`, `farthest-corner` - support explicitly set rg-size like `radial-gradient(20% 20% at top left, yellow, blue)` Close #453 Close #222 Close #456 you can check [it](https://satori-playground-git-fork-jackie1210-fix-453.vercel.sh/?share=XVLLbuMwDPwVgYtFdgE30TaOsxXSHPYBtIeeWqAXX-SIsdXKkiHLeTTIv5ey66LtSeQMySEpnmDjFIKAldK73DLWhqPB69Mp2oxVqMsqCDb5xfn3STKAe61C9QVTum2MPBK6NXgY0Wj_0x43QTtL3MaZrrYjK40u7W3Auo0U2oB-pJ66Nujt8a8j0Eb9z3QhN8-ld51Vt7UsUUy8VFqaizK-FPnDo0pY6RFtwgrT4c_3jqjivX5BweaXH6DHtzkzznv0fM7tOhqrdld-mPo6h-UihwHZadz_cQfCOONsuWDZO7XVxhD-jXM-QuNiWS19qeM2Yk5zmPRijPVyJNjIUDFFyXfz5XRxNb1cmHk2vaLq6c00SwfnIktfclivZjF6aHRGnQ4W_eRXtQfXCJZyklrfoDEuYY_OG7WaUSwljS8k4Jr4Vy2IE_QTg_hNO4HhEECk0VFYdCWIrTQtJoC1e9IPxyZeUdj3HtWJW_1fF6hABN_hOYEgC4qoovw-isP5FQ) while it can render now, but it seems diffs from result in html in opacity? worth to dig it.
1 parent 16d8b99 commit 44ce1a7

16 files changed

+429
-75
lines changed
 

‎src/builder/background-image.ts

+203-23
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import CssDimension from '../vendor/parse-css-dimension/index.js'
2-
import { buildXMLString } from '../utils.js'
2+
import { buildXMLString, lengthToNumber } from '../utils.js'
33

44
import gradient from '../vendor/gradient-parser/index.js'
55
import { resolveImageData } from '../handler/image.js'
@@ -167,7 +167,8 @@ export default async function backgroundImage(
167167
left,
168168
top,
169169
}: { id: string; width: number; height: number; left: number; top: number },
170-
{ image, size, position, repeat }: Background
170+
{ image, size, position, repeat }: Background,
171+
inheritableStyle: Record<string, number | string>
171172
): Promise<string[]> {
172173
// Default to `repeat`.
173174
repeat = repeat || 'repeat'
@@ -327,8 +328,16 @@ export default async function backgroundImage(
327328
if (!orientation.at) {
328329
// Defaults to center.
329330
} else if (orientation.at.type === 'position') {
330-
cx = orientation.at.value.x.value
331-
cy = orientation.at.value.y.value
331+
const pos = calcRadialGradient(
332+
orientation.at.value.x,
333+
orientation.at.value.y,
334+
xDelta,
335+
yDelta,
336+
inheritableStyle.fontSize as number,
337+
inheritableStyle
338+
)
339+
cx = pos.x
340+
cy = pos.y
332341
} else {
333342
throw new Error(
334343
'orientation.at.type not implemented: ' + orientation.at.type
@@ -346,25 +355,14 @@ export default async function backgroundImage(
346355

347356
// We currently only support `farthest-corner`:
348357
// https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/radial-gradient()#values
349-
const spread: Record<string, number> = {}
350-
351-
// Farest corner.
352-
const fx = Math.max(Math.abs(xDelta - cx), Math.abs(cx))
353-
const fy = Math.max(Math.abs(yDelta - cy), Math.abs(cy))
354-
if (shape === 'circle') {
355-
spread.r = Math.sqrt(fx * fx + fy * fy)
356-
} else if (shape === 'ellipse') {
357-
// Spec: https://drafts.csswg.org/css-images/#typedef-size
358-
// Get the aspect ratio of the closest-side size.
359-
const ratio = fy !== 0 ? fx / fy : 1
360-
361-
// fx^2/a^2 + fy^2/b^2 = 1
362-
// fx^2/(b*ratio)^2 + fy^2/b^2 = 1
363-
// (fx^2+fy^2*ratio^2) = (b*ratio)^2
364-
// b = sqrt(fx^2+fy^2*ratio^2)/ratio
365-
spread.ry = Math.sqrt(fx * fx + fy * fy * ratio * ratio) / ratio
366-
spread.rx = spread.ry * ratio
367-
}
358+
const spread = calcRadius(
359+
shape as Shape,
360+
orientation.style,
361+
inheritableStyle.fontSize as number,
362+
{ x: cx, y: cy },
363+
[xDelta, yDelta],
364+
inheritableStyle
365+
)
368366

369367
// TODO: check for repeat-x/repeat-y
370368
const defs = buildXMLString(
@@ -404,6 +402,13 @@ export default async function backgroundImage(
404402
fill: '#fff',
405403
})
406404
) +
405+
buildXMLString('rect', {
406+
x: 0,
407+
y: 0,
408+
width: xDelta,
409+
height: yDelta,
410+
fill: stops.at(-1).color,
411+
}) +
407412
buildXMLString(shape, {
408413
cx: cx,
409414
cy: cy,
@@ -459,3 +464,178 @@ export default async function backgroundImage(
459464

460465
throw new Error(`Invalid background image: "${image}"`)
461466
}
467+
468+
type PositionKeyWord = 'center' | 'left' | 'right' | 'top' | 'bottom'
469+
interface Position {
470+
type: string
471+
value: PositionKeyWord
472+
}
473+
474+
function calcRadialGradient(
475+
cx: Position,
476+
cy: Position,
477+
xDelta: number,
478+
yDelta: number,
479+
baseFontSize: number,
480+
style: Record<string, string | number>
481+
) {
482+
const pos: { x: number; y: number } = { x: xDelta / 2, y: yDelta / 2 }
483+
if (cx.type === 'position-keyword') {
484+
Object.assign(pos, calcPos(cx.value, xDelta, yDelta, 'x'))
485+
} else {
486+
pos.x = lengthToNumber(
487+
`${cx.value}${cx.type}`,
488+
baseFontSize,
489+
xDelta,
490+
style,
491+
true
492+
)
493+
}
494+
495+
if (cy.type === 'position-keyword') {
496+
Object.assign(pos, calcPos(cy.value, xDelta, yDelta, 'y'))
497+
} else {
498+
pos.y = lengthToNumber(
499+
`${cy.value}${cy.type}`,
500+
baseFontSize,
501+
yDelta,
502+
style,
503+
true
504+
)
505+
}
506+
507+
return pos
508+
}
509+
510+
function calcPos(
511+
key: PositionKeyWord,
512+
xDelta: number,
513+
yDelta: number,
514+
dir: 'x' | 'y'
515+
) {
516+
switch (key) {
517+
case 'center':
518+
return { [dir]: dir === 'x' ? xDelta / 2 : yDelta / 2 }
519+
case 'left':
520+
return { x: 0 }
521+
case 'top':
522+
return { y: 0 }
523+
case 'right':
524+
return { x: xDelta }
525+
case 'bottom':
526+
return { y: yDelta }
527+
}
528+
}
529+
530+
type Shape = 'circle' | 'ellipse'
531+
function calcRadius(
532+
shape: Shape,
533+
endingShape: Array<{ type: string; value: string }>,
534+
baseFontSize: number,
535+
centerAxis: { x: number; y: number },
536+
length: [number, number],
537+
inheritableStyle: Record<string, string | number>
538+
) {
539+
const [xDelta, yDelta] = length
540+
const { x: cx, y: cy } = centerAxis
541+
const spread: Record<string, number> = {}
542+
let fx = 0
543+
let fy = 0
544+
const isExtentKeyWord = endingShape.some((v) => v.type === 'extent-keyword')
545+
546+
if (!isExtentKeyWord) {
547+
if (endingShape.some((v) => v.value.startsWith('-'))) {
548+
throw new Error(
549+
'disallow setting negative values to the size of the shape. Check https://w3c.github.io/csswg-drafts/css-images/#valdef-rg-size-length-0'
550+
)
551+
}
552+
if (shape === 'circle') {
553+
return {
554+
r: lengthToNumber(
555+
`${endingShape[0].value}${endingShape[0].type}`,
556+
baseFontSize,
557+
xDelta,
558+
inheritableStyle,
559+
true
560+
),
561+
}
562+
} else {
563+
return {
564+
rx: lengthToNumber(
565+
`${endingShape[0].value}${endingShape[0].type}`,
566+
baseFontSize,
567+
xDelta,
568+
inheritableStyle,
569+
true
570+
),
571+
ry: lengthToNumber(
572+
`${endingShape[1].value}${endingShape[1].type}`,
573+
baseFontSize,
574+
yDelta,
575+
inheritableStyle,
576+
true
577+
),
578+
}
579+
}
580+
}
581+
582+
switch (endingShape[0].value) {
583+
case 'farthest-corner':
584+
fx = Math.max(Math.abs(xDelta - cx), Math.abs(cx))
585+
fy = Math.max(Math.abs(yDelta - cy), Math.abs(cy))
586+
break
587+
case 'closest-corner':
588+
fx = Math.min(Math.abs(xDelta - cx), Math.abs(cx))
589+
fy = Math.min(Math.abs(yDelta - cy), Math.abs(cy))
590+
break
591+
case 'farthest-side':
592+
if (shape === 'circle') {
593+
spread.r = Math.max(
594+
Math.abs(xDelta - cx),
595+
Math.abs(cx),
596+
Math.abs(yDelta - cy),
597+
Math.abs(cy)
598+
)
599+
} else {
600+
spread.rx = Math.max(Math.abs(xDelta - cx), Math.abs(cx))
601+
spread.ry = Math.max(Math.abs(yDelta - cy), Math.abs(cy))
602+
}
603+
return spread
604+
case 'closest-side':
605+
if (shape === 'circle') {
606+
spread.r = Math.min(
607+
Math.abs(xDelta - cx),
608+
Math.abs(cx),
609+
Math.abs(yDelta - cy),
610+
Math.abs(cy)
611+
)
612+
} else {
613+
spread.rx = Math.min(Math.abs(xDelta - cx), Math.abs(cx))
614+
spread.ry = Math.min(Math.abs(yDelta - cy), Math.abs(cy))
615+
}
616+
617+
return spread
618+
}
619+
if (shape === 'circle') {
620+
spread.r = Math.sqrt(fx * fx + fy * fy)
621+
} else {
622+
// Spec: https://drafts.csswg.org/css-images/#typedef-size
623+
// Get the aspect ratio of the closest-side size.
624+
const ratio = fy !== 0 ? fx / fy : 1
625+
626+
if (fx === 0) {
627+
spread.rx = 0
628+
spread.ry = 0
629+
} else {
630+
// fx^2/a^2 + fy^2/b^2 = 1
631+
// fx^2/(b*ratio)^2 + fy^2/b^2 = 1
632+
// (fx^2+fy^2*ratio^2) = (b*ratio)^2
633+
// b = sqrt(fx^2+fy^2*ratio^2)/ratio
634+
635+
spread.ry = Math.sqrt(fx * fx + fy * fy * ratio * ratio) / ratio
636+
spread.rx = spread.ry * ratio
637+
}
638+
}
639+
640+
return spread
641+
}

‎src/builder/rect.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export default async function rect(
2828
src?: string
2929
debug?: boolean
3030
},
31-
style: Record<string, number | string>
31+
style: Record<string, number | string>,
32+
inheritableStyle: Record<string, number | string>
3233
) {
3334
if (style.display === 'none') return ''
3435

@@ -75,7 +76,8 @@ export default async function rect(
7576
const background = (style.backgroundImage as any)[index]
7677
const image = await backgroundImage(
7778
{ id: id + '_' + index, width, height, left, top },
78-
background
79+
background,
80+
inheritableStyle
7981
)
8082
if (image) {
8183
// Background images that come first in the array are rendered last.

‎src/layout.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ export default async function* layout(
196196
isInheritingTransform,
197197
debug,
198198
},
199-
computedStyle
199+
computedStyle,
200+
newInheritableStyle
200201
)
201202
} else if (type === 'svg') {
202203
// When entering a <svg> node, we need to convert it to a <img> with the
@@ -214,7 +215,8 @@ export default async function* layout(
214215
isInheritingTransform,
215216
debug,
216217
},
217-
computedStyle
218+
computedStyle,
219+
newInheritableStyle
218220
)
219221
} else {
220222
const display = style?.display
@@ -231,7 +233,8 @@ export default async function* layout(
231233
}
232234
baseRenderResult = await rect(
233235
{ id, left, top, width, height, isInheritingTransform, debug },
234-
computedStyle
236+
computedStyle,
237+
newInheritableStyle
235238
)
236239
}
237240

‎src/vendor/gradient-parser/index.js

+76-47
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ GradientParser.parse = (function () {
2323
positionKeywords: /^(left|center|right|top|bottom)/i,
2424
pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
2525
percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))\%/,
26-
emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
26+
emLikeValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))(r?em|vw|vh)/,
2727
angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
2828
zeroValue: /[0]/,
2929
startCall: /^\(/,
@@ -84,6 +84,42 @@ GradientParser.parse = (function () {
8484
)
8585
}
8686

87+
function getDefaultRadialGradient(orientation = {}) {
88+
const res = { ...orientation }
89+
90+
Object.assign(res, {
91+
style: (res.style || []).length > 0
92+
? res.style
93+
: [{ type: 'extent-keyword', value: 'farthest-corner' }],
94+
at: {
95+
type: 'position',
96+
value: {
97+
x: {
98+
type: 'position-keyword',
99+
value: 'center',
100+
...(res.at?.value?.x || {})
101+
},
102+
y: {
103+
type: 'position-keyword',
104+
value: 'center',
105+
...(res.at?.value?.y || {})
106+
}
107+
}
108+
}}
109+
)
110+
111+
if (!orientation.value) {
112+
Object.assign(res, {
113+
type: 'shape',
114+
value: res.style.some(v => ['%', 'extent-keyword'].includes(v.type))
115+
? 'ellipse'
116+
: 'circle'
117+
})
118+
}
119+
120+
return res
121+
}
122+
87123
function matchGradient(
88124
gradientType,
89125
pattern,
@@ -102,7 +138,9 @@ GradientParser.parse = (function () {
102138

103139
return {
104140
type: gradientType,
105-
orientation: orientation,
141+
orientation: gradientType.endsWith('radial-gradient')
142+
? orientation?.map(v => getDefaultRadialGradient(v)) ?? [getDefaultRadialGradient()]
143+
: orientation,
106144
colorStops: matchListing(matchColorStop),
107145
}
108146
})
@@ -166,59 +204,40 @@ GradientParser.parse = (function () {
166204
}
167205

168206
function matchRadialOrientation() {
169-
var radialType = matchCircle() || matchEllipse()
170-
if (radialType) {
171-
radialType.at = matchAtPosition()
172-
} else {
173-
var extent = matchExtentKeyword()
174-
if (extent) {
175-
radialType = extent
176-
var positionAt = matchAtPosition()
177-
if (positionAt) {
178-
radialType.at = positionAt
179-
}
180-
} else {
181-
// If unspecified, it defaults to ellipse.
182-
var positionAt = matchAtPosition()
183-
if (positionAt) {
184-
radialType = {
185-
type: 'shape',
186-
value: 'ellipse',
187-
at: positionAt
188-
}
189-
} else {
190-
var defaultPosition = matchPositioning()
191-
if (defaultPosition) {
192-
radialType = {
193-
type: 'default-radial',
194-
at: defaultPosition,
195-
}
196-
}
197-
}
198-
}
199-
}
207+
const pre = preMatchRadialOrientation()
208+
const at = matchAtPosition()
209+
210+
if (!pre && !at) return
200211

201-
return radialType
212+
return { ...pre, at }
202213
}
203214

204-
function matchCircle() {
205-
var circle = match('shape', /^(circle)/i, 0)
215+
function preMatchRadialOrientation() {
216+
let rgEndingShape = matchCircle() || matchEllipse()
217+
let rgSize = matchExtentKeyword() || matchLength() || matchDistance()
218+
const extra = match('%', tokens.percentageValue, 1)
206219

207-
if (circle) {
208-
circle.style = matchLength() || matchExtentKeyword()
220+
if (rgEndingShape) {
221+
return {
222+
...rgEndingShape,
223+
style: [rgSize, extra].filter(v => v)
224+
}
225+
} else if (rgSize){
226+
return {
227+
style: [rgSize, extra].filter(v => v),
228+
...(matchCircle() || matchEllipse())
229+
}
230+
} else {
231+
//
209232
}
233+
}
210234

211-
return circle
235+
function matchCircle() {
236+
return match('shape', /^(circle)/i, 0)
212237
}
213238

214239
function matchEllipse() {
215-
var ellipse = match('shape', /^(ellipse)/i, 0)
216-
217-
if (ellipse) {
218-
ellipse.style = matchDistance() || matchExtentKeyword()
219-
}
220-
221-
return ellipse
240+
return match('shape', /^(ellipse)/i, 0)
222241
}
223242

224243
function matchExtentKeyword() {
@@ -337,7 +356,17 @@ GradientParser.parse = (function () {
337356
}
338357

339358
function matchLength() {
340-
return match('px', tokens.pixelValue, 1) || match('em', tokens.emValue, 1)
359+
return match('px', tokens.pixelValue, 1) || matchRelativeLength(tokens.emLikeValue, 1)
360+
}
361+
362+
function matchRelativeLength(pattern, captureIndex) {
363+
var captures = scan(pattern)
364+
if (captures) {
365+
return {
366+
type: captures[5],
367+
value: captures[captureIndex],
368+
}
369+
}
341370
}
342371

343372
function match(type, pattern, captureIndex) {

‎test/error.test.tsx

+28
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,32 @@ describe('Error', () => {
9595
})
9696
expect(typeof svg).toBe('string')
9797
})
98+
99+
it('should not allowed to set negative value to rg-size', async () => {
100+
const result = satori(
101+
<div
102+
style={{
103+
height: '100%',
104+
width: '100%',
105+
display: 'flex',
106+
flexDirection: 'column',
107+
alignItems: 'center',
108+
justifyContent: 'center',
109+
backgroundImage:
110+
'radial-gradient(-20% 20% at top left, yellow, blue)',
111+
fontSize: 32,
112+
fontWeight: 600,
113+
}}
114+
></div>,
115+
{
116+
width: 100,
117+
height: 100,
118+
fonts,
119+
}
120+
)
121+
122+
expect(result).rejects.toThrowError(
123+
'disallow setting negative values to the size of the shape. Check https://w3c.github.io/csswg-drafts/css-images/#valdef-rg-size-length-0'
124+
)
125+
})
98126
})

‎test/gradient.test.tsx

+112
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,118 @@ describe('Gradient', () => {
172172
)
173173
expect(toImage(svg, 100)).toMatchImageSnapshot()
174174
})
175+
176+
it('should support default value', async () => {
177+
const svg = await satori(
178+
<div
179+
style={{
180+
backgroundColor: 'white',
181+
backgroundImage: 'radial-gradient(blue, red)',
182+
backgroundSize: '100px 100px',
183+
height: '100%',
184+
width: '100%',
185+
}}
186+
></div>,
187+
{
188+
width: 100,
189+
height: 100,
190+
fonts,
191+
}
192+
)
193+
expect(toImage(svg, 100)).toMatchImageSnapshot()
194+
})
195+
196+
it('should support releative unit', async () => {
197+
const svgs = await Promise.all(
198+
[
199+
'radial-gradient(ellipse at 1em 25px,blue, red)',
200+
'radial-gradient(circle at 1rem 25px,blue, red)',
201+
'radial-gradient(circle at 2vw 25px,blue, red)',
202+
'radial-gradient(circle at 1vh 50%,blue, red)',
203+
].map((backgroundImage) =>
204+
satori(
205+
<div
206+
style={{
207+
backgroundColor: 'white',
208+
backgroundImage,
209+
backgroundSize: '100px 100px',
210+
height: '100%',
211+
width: '100%',
212+
}}
213+
></div>,
214+
{
215+
width: 100,
216+
height: 100,
217+
fonts,
218+
}
219+
)
220+
)
221+
)
222+
svgs.forEach((svg) => {
223+
expect(toImage(svg, 100)).toMatchImageSnapshot()
224+
})
225+
})
226+
227+
it('should support rg-size with rg-extent-keyword', async () => {
228+
const svgs = await Promise.all(
229+
[
230+
'radial-gradient(closest-corner at 50% 50%, yellow, green)',
231+
'radial-gradient(farthest-side at left bottom, red, yellow 50px, green)',
232+
'radial-gradient(closest-side at 20px 30px, red, yellow, green)',
233+
].map((backgroundImage) =>
234+
satori(
235+
<div
236+
style={{
237+
backgroundColor: 'white',
238+
backgroundImage,
239+
backgroundSize: '100px 100px',
240+
height: '100%',
241+
width: '100%',
242+
}}
243+
></div>,
244+
{
245+
width: 100,
246+
height: 100,
247+
fonts,
248+
}
249+
)
250+
)
251+
)
252+
253+
svgs.forEach((svg) => {
254+
expect(toImage(svg, 100)).toMatchImageSnapshot()
255+
})
256+
})
257+
258+
it('should support explicitly setting rg-size', async () => {
259+
const svgs = await Promise.all(
260+
[
261+
'radial-gradient(20% 20% at top left, yellow, blue)',
262+
'radial-gradient(30px at top left, yellow, blue)',
263+
].map((backgroundImage) =>
264+
satori(
265+
<div
266+
style={{
267+
backgroundColor: 'white',
268+
backgroundImage,
269+
backgroundSize: '100px 100px',
270+
height: '100%',
271+
width: '100%',
272+
}}
273+
></div>,
274+
{
275+
width: 100,
276+
height: 100,
277+
fonts,
278+
}
279+
)
280+
)
281+
)
282+
283+
svgs.forEach((svg) => {
284+
expect(toImage(svg, 100)).toMatchImageSnapshot()
285+
})
286+
})
175287
})
176288

177289
it('should support advanced usage', async () => {

1 commit comments

Comments
 (1)

vercel[bot] commented on Apr 22, 2023

@vercel[bot]
Please sign in to comment.