Skip to content

Commit ff80448

Browse files
authored
feat: support repeating-linear-gradient (#630)
Now that #624 has been merged, let me bring`repeating-linear-gradient` to satori~ ❤️ Close #554 Note about the algorithm: <hr> Option 1: `0 < deg < 90` define ```math r=(h/w)^2 ``` then, calculate the intersection point of the last two lines ```math y = - r / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x + h ``` Finally, we can get `(x1, y1)`, `(x2, y2)` about length: ```math y = - 1 / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x + h ``` then, we can get a point: `(a, b)`, so length is $`2 ·\sqrt{(a - w/2)^2 + (b - h/2)^2}`$ <hr> Option 2: `90 < deg < 180` define ```math r=(h/w)^2 ``` then, calculate the intersection point of the last two lines ```math y = - r / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x ``` Finally, we can get `(x1, y1)`, `(x2, y2)` about length: ```math y = - 1 / tan(angle) ·x + w / 2 + h/2+r·w/ (2·tan(angle)) ``` ```math y=tan(angle) ·x ``` then, we can get a point: `(a, b)`, so length is $`2 ·\sqrt{(a - w/2)^2 + (b - h/2)^2}`$ Actually, I didn't find any spec of the algorithm on calculating the points. I just came across the algorithm accidentally. It turns out it shows the same result just like chrome renders.
1 parent fe2534a commit ff80448

14 files changed

+274
-79
lines changed

src/builder/background-image.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ export default async function backgroundImage(
8888
defaultY: 0,
8989
})
9090

91-
if (image.startsWith('linear-gradient(')) {
91+
if (
92+
image.startsWith('linear-gradient(') ||
93+
image.startsWith('repeating-linear-gradient(')
94+
) {
9295
return buildLinearGradient(
9396
{ id, width, height, repeatX, repeatY },
9497
image,

src/builder/gradient/linear.ts

+157-76
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { parseLinearGradient } from 'css-gradient-parser'
1+
import { parseLinearGradient, ColorStop } from 'css-gradient-parser'
22
import { normalizeStops } from './utils.js'
3-
import { buildXMLString, calcDegree } from '../../utils.js'
3+
import { buildXMLString, calcDegree, lengthToNumber } from '../../utils.js'
44

55
export function buildLinearGradient(
66
{
@@ -24,90 +24,44 @@ export function buildLinearGradient(
2424
) {
2525
const parsed = parseLinearGradient(image)
2626
const [imageWidth, imageHeight] = dimensions
27+
const repeating = image.startsWith('repeating')
2728

2829
// Calculate the direction.
29-
let x1, y1, x2, y2, length
30+
let points, length, xys
3031

3132
if (parsed.orientation.type === 'directional') {
32-
;[x1, y1, x2, y2] = resolveXYFromDirection(parsed.orientation.value)
33+
points = resolveXYFromDirection(parsed.orientation.value)
3334

3435
length = Math.sqrt(
35-
Math.pow((x2 - x1) * imageWidth, 2) + Math.pow((y2 - y1) * imageHeight, 2)
36+
Math.pow((points.x2 - points.x1) * imageWidth, 2) +
37+
Math.pow((points.y2 - points.y1) * imageHeight, 2)
3638
)
3739
} else if (parsed.orientation.type === 'angular') {
38-
const EPS = 0.000001
39-
const r = imageWidth / imageHeight
40-
41-
function calc(angle) {
42-
angle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
43-
44-
if (Math.abs(angle - Math.PI / 2) < EPS) {
45-
x1 = 0
46-
y1 = 0
47-
x2 = 1
48-
y2 = 0
49-
length = imageWidth
50-
return
51-
} else if (Math.abs(angle) < EPS) {
52-
x1 = 0
53-
y1 = 1
54-
x2 = 0
55-
y2 = 0
56-
length = imageHeight
57-
return
58-
}
59-
60-
// Assuming 0 <= angle < PI / 2.
61-
if (angle >= Math.PI / 2 && angle < Math.PI) {
62-
calc(Math.PI - angle)
63-
y1 = 1 - y1
64-
y2 = 1 - y2
65-
return
66-
} else if (angle >= Math.PI) {
67-
calc(angle - Math.PI)
68-
let tmp = x1
69-
x1 = x2
70-
x2 = tmp
71-
tmp = y1
72-
y1 = y2
73-
y2 = tmp
74-
return
75-
}
76-
77-
// Remap SVG distortion
78-
const tan = Math.tan(angle)
79-
const tanTexture = tan * r
80-
const angleTexture = Math.atan(tanTexture)
81-
const l = Math.sqrt(2) * Math.cos(Math.PI / 4 - angleTexture)
82-
x1 = 0
83-
y1 = 1
84-
x2 = Math.sin(angleTexture) * l
85-
y2 = 1 - Math.cos(angleTexture) * l
86-
87-
// Get the angle between the distored gradient direction and diagonal.
88-
const x = 1
89-
const y = 1 / tan
90-
const cosA = Math.abs(
91-
(x * r + y) / Math.sqrt(x * x + y * y) / Math.sqrt(r * r + 1)
92-
)
93-
94-
// Get the distored gradient length.
95-
const diagonal = Math.sqrt(
96-
imageWidth * imageWidth + imageHeight * imageHeight
97-
)
98-
length = diagonal * cosA
99-
}
100-
101-
calc(
40+
const { length: l, ...p } = calcNormalPoint(
10241
(calcDegree(
10342
`${parsed.orientation.value.value}${parsed.orientation.value.unit}`
10443
) /
10544
180) *
106-
Math.PI
45+
Math.PI,
46+
imageWidth,
47+
imageHeight
10748
)
49+
50+
length = l
51+
points = p
10852
}
10953

110-
const stops = normalizeStops(length, parsed.stops, inheritableStyle, from)
54+
xys = repeating
55+
? calcPercentage(parsed.stops, length, points, inheritableStyle)
56+
: points
57+
58+
const stops = normalizeStops(
59+
length,
60+
parsed.stops,
61+
inheritableStyle,
62+
repeating,
63+
from
64+
)
11165

11266
const gradientId = `satori_bi${id}`
11367
const patternId = `satori_pattern_${id}`
@@ -126,10 +80,8 @@ export function buildLinearGradient(
12680
'linearGradient',
12781
{
12882
id: gradientId,
129-
x1,
130-
y1,
131-
x2,
132-
y2,
83+
...xys,
84+
spreadMethod: repeating ? 'repeat' : 'pad',
13385
},
13486
stops
13587
.map((stop) =>
@@ -173,5 +125,134 @@ function resolveXYFromDirection(dir: string) {
173125
y1 = 1
174126
}
175127

176-
return [x1, y1, x2, y2]
128+
return { x1, y1, x2, y2 }
129+
}
130+
131+
/**
132+
* calc start point and end point of linear gradient
133+
*/
134+
function calcNormalPoint(v: number, w: number, h: number) {
135+
const r = Math.pow(h / w, 2)
136+
137+
// make sure angle is 0 <= angle <= 360
138+
v = ((v % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
139+
140+
let x1, y1, x2, y2, length, tmp, a, b
141+
142+
const dfs = (angle: number) => {
143+
if (angle === 0) {
144+
x1 = 0
145+
y1 = h
146+
x2 = 0
147+
y2 = 0
148+
length = h
149+
return
150+
} else if (angle === Math.PI / 2) {
151+
x1 = 0
152+
y1 = 0
153+
x2 = w
154+
y2 = 0
155+
length = w
156+
return
157+
}
158+
if (angle > 0 && angle < Math.PI / 2) {
159+
x1 =
160+
((r * w) / 2 / Math.tan(angle) - h / 2) /
161+
(Math.tan(angle) + r / Math.tan(angle))
162+
y1 = Math.tan(angle) * x1 + h
163+
x2 = Math.abs(w / 2 - x1) + w / 2
164+
y2 = h / 2 - Math.abs(y1 - h / 2)
165+
length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
166+
// y = -1 / tan * x = h / 2 +1 / tan * w/2
167+
// y = tan * x + h
168+
a =
169+
(w / 2 / Math.tan(angle) - h / 2) /
170+
(Math.tan(angle) + 1 / Math.tan(angle))
171+
b = Math.tan(angle) * a + h
172+
length = 2 * Math.sqrt(Math.pow(w / 2 - a, 2) + Math.pow(h / 2 - b, 2))
173+
return
174+
} else if (angle > Math.PI / 2 && angle < Math.PI) {
175+
x1 =
176+
(h / 2 + (r * w) / 2 / Math.tan(angle)) /
177+
(Math.tan(angle) + r / Math.tan(angle))
178+
y1 = Math.tan(angle) * x1
179+
x2 = Math.abs(w / 2 - x1) + w / 2
180+
y2 = h / 2 + Math.abs(y1 - h / 2)
181+
// y = -1 / tan * x + h / 2 + 1 / tan * w / 2
182+
// y = tan * x
183+
a =
184+
(w / 2 / Math.tan(angle) + h / 2) /
185+
(Math.tan(angle) + 1 / Math.tan(angle))
186+
b = Math.tan(angle) * a
187+
length = 2 * Math.sqrt(Math.pow(w / 2 - a, 2) + Math.pow(h / 2 - b, 2))
188+
return
189+
} else if (angle >= Math.PI) {
190+
dfs(angle - Math.PI)
191+
192+
tmp = x1
193+
x1 = x2
194+
x2 = tmp
195+
tmp = y1
196+
y1 = y2
197+
y2 = tmp
198+
}
199+
}
200+
201+
dfs(v)
202+
203+
return {
204+
x1: x1 / w,
205+
y1: y1 / h,
206+
x2: x2 / w,
207+
y2: y2 / h,
208+
length,
209+
}
210+
}
211+
212+
function calcPercentage(
213+
stops: ColorStop[],
214+
length: number,
215+
points: {
216+
x1: number
217+
y1: number
218+
x2: number
219+
y2: number
220+
},
221+
inheritableStyle: Record<string, string | number>
222+
) {
223+
const { x1, x2, y1, y2 } = points
224+
const p1 = !stops[0].offset
225+
? 0
226+
: stops[0].offset.unit === '%'
227+
? Number(stops[0].offset.value) / 100
228+
: lengthToNumber(
229+
`${stops[0].offset.value}${stops[0].offset.unit}`,
230+
inheritableStyle.fontSize as number,
231+
length,
232+
inheritableStyle,
233+
true
234+
) / length
235+
const p2 = !stops.at(-1).offset
236+
? 1
237+
: stops.at(-1).offset.unit === '%'
238+
? Number(stops.at(-1).offset.value) / 100
239+
: lengthToNumber(
240+
`${stops.at(-1).offset.value}${stops.at(-1).offset.unit}`,
241+
inheritableStyle.fontSize as number,
242+
length,
243+
inheritableStyle,
244+
true
245+
) / length
246+
247+
const sx = (x2 - x1) * p1 + x1
248+
const sy = (y2 - y1) * p1 + y1
249+
const ex = (x2 - x1) * p2 + x1
250+
const ey = (y2 - y1) * p2 + y1
251+
252+
return {
253+
x1: sx,
254+
y1: sy,
255+
x2: ex,
256+
y2: ey,
257+
}
177258
}

src/builder/gradient/radial.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function buildRadialGradient(
4848
cx = pos.x
4949
cy = pos.y
5050

51-
const stops = normalizeStops(width, colorStops, inheritableStyle, from)
51+
const stops = normalizeStops(width, colorStops, inheritableStyle, false, from)
5252

5353
const gradientId = `satori_radial_${id}`
5454
const patternId = `satori_pattern_${id}`

src/builder/gradient/utils.ts

+6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function normalizeStops(
1111
totalLength: number,
1212
colorStops: ColorStop[],
1313
inheritedStyle: Record<string, string | number>,
14+
repeating: boolean,
1415
from?: 'background' | 'mask'
1516
) {
1617
// Resolve the color stops based on the spec:
@@ -61,6 +62,11 @@ export function normalizeStops(
6162
if (lastStop.offset !== 1) {
6263
if (typeof lastStop.offset === 'undefined') {
6364
lastStop.offset = 1
65+
} else if (repeating) {
66+
stops[stops.length - 1] = {
67+
offset: 1,
68+
color: lastStop.color,
69+
}
6470
} else {
6571
stops.push({
6672
offset: 1,

src/handler/expand.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ function handleSpecialCase(
164164

165165
if (name === 'background') {
166166
value = value.toString().trim()
167-
if (/^(linear-gradient|radial-gradient|url)\(/.test(value)) {
167+
if (
168+
/^(linear-gradient|radial-gradient|url|repeating-linear-gradient)\(/.test(
169+
value
170+
)
171+
) {
168172
return getStylesForProperty('backgroundImage', value, true)
169173
}
170174
return getStylesForProperty('background', value, true)

0 commit comments

Comments
 (0)