Skip to content

Commit

Permalink
feat: support repeating-linear-gradient (#630)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Jackie1210 authored Sep 14, 2024
1 parent fe2534a commit ff80448
Show file tree
Hide file tree
Showing 14 changed files with 274 additions and 79 deletions.
5 changes: 4 additions & 1 deletion src/builder/background-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ export default async function backgroundImage(
defaultY: 0,
})

if (image.startsWith('linear-gradient(')) {
if (
image.startsWith('linear-gradient(') ||
image.startsWith('repeating-linear-gradient(')
) {
return buildLinearGradient(
{ id, width, height, repeatX, repeatY },
image,
Expand Down
233 changes: 157 additions & 76 deletions src/builder/gradient/linear.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { parseLinearGradient } from 'css-gradient-parser'
import { parseLinearGradient, ColorStop } from 'css-gradient-parser'
import { normalizeStops } from './utils.js'
import { buildXMLString, calcDegree } from '../../utils.js'
import { buildXMLString, calcDegree, lengthToNumber } from '../../utils.js'

export function buildLinearGradient(
{
Expand All @@ -24,90 +24,44 @@ export function buildLinearGradient(
) {
const parsed = parseLinearGradient(image)
const [imageWidth, imageHeight] = dimensions
const repeating = image.startsWith('repeating')

// Calculate the direction.
let x1, y1, x2, y2, length
let points, length, xys

if (parsed.orientation.type === 'directional') {
;[x1, y1, x2, y2] = resolveXYFromDirection(parsed.orientation.value)
points = resolveXYFromDirection(parsed.orientation.value)

length = Math.sqrt(
Math.pow((x2 - x1) * imageWidth, 2) + Math.pow((y2 - y1) * imageHeight, 2)
Math.pow((points.x2 - points.x1) * imageWidth, 2) +
Math.pow((points.y2 - points.y1) * imageHeight, 2)
)
} else if (parsed.orientation.type === 'angular') {
const EPS = 0.000001
const r = imageWidth / imageHeight

function calc(angle) {
angle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)

if (Math.abs(angle - Math.PI / 2) < EPS) {
x1 = 0
y1 = 0
x2 = 1
y2 = 0
length = imageWidth
return
} else if (Math.abs(angle) < EPS) {
x1 = 0
y1 = 1
x2 = 0
y2 = 0
length = imageHeight
return
}

// Assuming 0 <= angle < PI / 2.
if (angle >= Math.PI / 2 && angle < Math.PI) {
calc(Math.PI - angle)
y1 = 1 - y1
y2 = 1 - y2
return
} else if (angle >= Math.PI) {
calc(angle - Math.PI)
let tmp = x1
x1 = x2
x2 = tmp
tmp = y1
y1 = y2
y2 = tmp
return
}

// Remap SVG distortion
const tan = Math.tan(angle)
const tanTexture = tan * r
const angleTexture = Math.atan(tanTexture)
const l = Math.sqrt(2) * Math.cos(Math.PI / 4 - angleTexture)
x1 = 0
y1 = 1
x2 = Math.sin(angleTexture) * l
y2 = 1 - Math.cos(angleTexture) * l

// Get the angle between the distored gradient direction and diagonal.
const x = 1
const y = 1 / tan
const cosA = Math.abs(
(x * r + y) / Math.sqrt(x * x + y * y) / Math.sqrt(r * r + 1)
)

// Get the distored gradient length.
const diagonal = Math.sqrt(
imageWidth * imageWidth + imageHeight * imageHeight
)
length = diagonal * cosA
}

calc(
const { length: l, ...p } = calcNormalPoint(
(calcDegree(
`${parsed.orientation.value.value}${parsed.orientation.value.unit}`
) /
180) *
Math.PI
Math.PI,
imageWidth,
imageHeight
)

length = l
points = p
}

const stops = normalizeStops(length, parsed.stops, inheritableStyle, from)
xys = repeating
? calcPercentage(parsed.stops, length, points, inheritableStyle)
: points

const stops = normalizeStops(
length,
parsed.stops,
inheritableStyle,
repeating,
from
)

const gradientId = `satori_bi${id}`
const patternId = `satori_pattern_${id}`
Expand All @@ -126,10 +80,8 @@ export function buildLinearGradient(
'linearGradient',
{
id: gradientId,
x1,
y1,
x2,
y2,
...xys,
spreadMethod: repeating ? 'repeat' : 'pad',
},
stops
.map((stop) =>
Expand Down Expand Up @@ -173,5 +125,134 @@ function resolveXYFromDirection(dir: string) {
y1 = 1
}

return [x1, y1, x2, y2]
return { x1, y1, x2, y2 }
}

/**
* calc start point and end point of linear gradient
*/
function calcNormalPoint(v: number, w: number, h: number) {
const r = Math.pow(h / w, 2)

// make sure angle is 0 <= angle <= 360
v = ((v % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)

let x1, y1, x2, y2, length, tmp, a, b

const dfs = (angle: number) => {
if (angle === 0) {
x1 = 0
y1 = h
x2 = 0
y2 = 0
length = h
return
} else if (angle === Math.PI / 2) {
x1 = 0
y1 = 0
x2 = w
y2 = 0
length = w
return
}
if (angle > 0 && angle < Math.PI / 2) {
x1 =
((r * w) / 2 / Math.tan(angle) - h / 2) /
(Math.tan(angle) + r / Math.tan(angle))
y1 = Math.tan(angle) * x1 + h
x2 = Math.abs(w / 2 - x1) + w / 2
y2 = h / 2 - Math.abs(y1 - h / 2)
length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
// y = -1 / tan * x = h / 2 +1 / tan * w/2
// y = tan * x + h
a =
(w / 2 / Math.tan(angle) - h / 2) /
(Math.tan(angle) + 1 / Math.tan(angle))
b = Math.tan(angle) * a + h
length = 2 * Math.sqrt(Math.pow(w / 2 - a, 2) + Math.pow(h / 2 - b, 2))
return
} else if (angle > Math.PI / 2 && angle < Math.PI) {
x1 =
(h / 2 + (r * w) / 2 / Math.tan(angle)) /
(Math.tan(angle) + r / Math.tan(angle))
y1 = Math.tan(angle) * x1
x2 = Math.abs(w / 2 - x1) + w / 2
y2 = h / 2 + Math.abs(y1 - h / 2)
// y = -1 / tan * x + h / 2 + 1 / tan * w / 2
// y = tan * x
a =
(w / 2 / Math.tan(angle) + h / 2) /
(Math.tan(angle) + 1 / Math.tan(angle))
b = Math.tan(angle) * a
length = 2 * Math.sqrt(Math.pow(w / 2 - a, 2) + Math.pow(h / 2 - b, 2))
return
} else if (angle >= Math.PI) {
dfs(angle - Math.PI)

tmp = x1
x1 = x2
x2 = tmp
tmp = y1
y1 = y2
y2 = tmp
}
}

dfs(v)

return {
x1: x1 / w,
y1: y1 / h,
x2: x2 / w,
y2: y2 / h,
length,
}
}

function calcPercentage(
stops: ColorStop[],
length: number,
points: {
x1: number
y1: number
x2: number
y2: number
},
inheritableStyle: Record<string, string | number>
) {
const { x1, x2, y1, y2 } = points
const p1 = !stops[0].offset
? 0
: stops[0].offset.unit === '%'
? Number(stops[0].offset.value) / 100
: lengthToNumber(
`${stops[0].offset.value}${stops[0].offset.unit}`,
inheritableStyle.fontSize as number,
length,
inheritableStyle,
true
) / length
const p2 = !stops.at(-1).offset
? 1
: stops.at(-1).offset.unit === '%'
? Number(stops.at(-1).offset.value) / 100
: lengthToNumber(
`${stops.at(-1).offset.value}${stops.at(-1).offset.unit}`,
inheritableStyle.fontSize as number,
length,
inheritableStyle,
true
) / length

const sx = (x2 - x1) * p1 + x1
const sy = (y2 - y1) * p1 + y1
const ex = (x2 - x1) * p2 + x1
const ey = (y2 - y1) * p2 + y1

return {
x1: sx,
y1: sy,
x2: ex,
y2: ey,
}
}
2 changes: 1 addition & 1 deletion src/builder/gradient/radial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function buildRadialGradient(
cx = pos.x
cy = pos.y

const stops = normalizeStops(width, colorStops, inheritableStyle, from)
const stops = normalizeStops(width, colorStops, inheritableStyle, false, from)

const gradientId = `satori_radial_${id}`
const patternId = `satori_pattern_${id}`
Expand Down
6 changes: 6 additions & 0 deletions src/builder/gradient/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function normalizeStops(
totalLength: number,
colorStops: ColorStop[],
inheritedStyle: Record<string, string | number>,
repeating: boolean,
from?: 'background' | 'mask'
) {
// Resolve the color stops based on the spec:
Expand Down Expand Up @@ -61,6 +62,11 @@ export function normalizeStops(
if (lastStop.offset !== 1) {
if (typeof lastStop.offset === 'undefined') {
lastStop.offset = 1
} else if (repeating) {
stops[stops.length - 1] = {
offset: 1,
color: lastStop.color,
}
} else {
stops.push({
offset: 1,
Expand Down
6 changes: 5 additions & 1 deletion src/handler/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,11 @@ function handleSpecialCase(

if (name === 'background') {
value = value.toString().trim()
if (/^(linear-gradient|radial-gradient|url)\(/.test(value)) {
if (
/^(linear-gradient|radial-gradient|url|repeating-linear-gradient)\(/.test(
value
)
) {
return getStylesForProperty('backgroundImage', value, true)
}
return getStylesForProperty('background', value, true)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit ff80448

Please sign in to comment.