Skip to content

Commit de6db71

Browse files
authored
1 parent 9487cbb commit de6db71

9 files changed

Lines changed: 682 additions & 2 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
297297
<td></td>
298298
</tr>
299299

300+
<tr>
301+
<td colspan="2"><code>backdropFilter</code></td>
302+
<td>Supports chained <code>blur()</code>, <code>brightness()</code>, <code>contrast()</code>, <code>drop-shadow()</code>, <code>grayscale()</code>, <code>hue-rotate()</code>, <code>invert()</code>, <code>opacity()</code>, <code>saturate()</code>, and <code>sepia()</code></td>
303+
<td></td>
304+
</tr>
305+
300306
<tr>
301307
<td colspan="2"><code>clipPath</code></td>
302308
<td>Supports <code>circle()</code>, <code>ellipse()</code>, <code>inset()</code>, <code>polygon()</code>, <code>path()</code>, and <code>shape()</code>. <code>shape()</code> supports <code>move</code>, <code>line</code>, <code>hline</code>, <code>vline</code>, <code>curve</code>, <code>smooth</code>, <code>arc</code>, and <code>close</code> commands.</td>

src/builder/backdrop-filter.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import type { BackdropFilter } from '../parser/backdrop-filter.js'
2+
import { buildXMLString } from '../utils.js'
3+
4+
export function backdropFilter({
5+
id,
6+
left,
7+
top,
8+
width,
9+
height,
10+
path,
11+
type,
12+
matrix,
13+
currentClipPath,
14+
mask,
15+
filters,
16+
}: {
17+
id: string
18+
left: number
19+
top: number
20+
width: number
21+
height: number
22+
path: string
23+
type: 'rect' | 'path'
24+
matrix: string | undefined
25+
currentClipPath: string | undefined
26+
mask: string | undefined
27+
filters: BackdropFilter[]
28+
}) {
29+
if (!filters.length) return ['', ''] as const
30+
31+
const filterId = `satori_bf-${id}`
32+
const clipId = `satori_bfc-${id}`
33+
const expansion = getExpansion(filters)
34+
let input = 'BackgroundImage'
35+
let primitives = ''
36+
37+
filters.forEach((filter, index) => {
38+
const result = `${filterId}-${index}`
39+
primitives += buildFilterPrimitive(filter, input, result)
40+
input = result
41+
})
42+
43+
const definitions =
44+
buildXMLString(
45+
'filter',
46+
{
47+
id: filterId,
48+
x: left - expansion.left,
49+
y: top - expansion.top,
50+
width: width + expansion.left + expansion.right,
51+
height: height + expansion.top + expansion.bottom,
52+
filterUnits: 'userSpaceOnUse',
53+
'color-interpolation-filters': 'sRGB',
54+
},
55+
primitives
56+
) +
57+
buildXMLString(
58+
'clipPath',
59+
{
60+
id: clipId,
61+
'clip-path': currentClipPath,
62+
},
63+
buildXMLString(type, {
64+
x: left,
65+
y: top,
66+
width,
67+
height,
68+
d: path || undefined,
69+
transform: matrix || undefined,
70+
})
71+
)
72+
73+
const shape = buildXMLString(type, {
74+
x: left,
75+
y: top,
76+
width,
77+
height,
78+
d: path || undefined,
79+
fill: '#000',
80+
transform: matrix || undefined,
81+
filter: `url(#${filterId})`,
82+
'clip-path': `url(#${clipId})`,
83+
mask,
84+
})
85+
86+
return [definitions, shape] as const
87+
}
88+
89+
function buildFilterPrimitive(
90+
filter: BackdropFilter,
91+
input: string,
92+
result: string
93+
) {
94+
if (filter.type === 'blur') {
95+
return buildXMLString('feGaussianBlur', {
96+
in: input,
97+
stdDeviation: filter.value,
98+
result,
99+
})
100+
}
101+
102+
if (filter.type === 'hue-rotate') {
103+
return buildXMLString('feColorMatrix', {
104+
in: input,
105+
type: 'hueRotate',
106+
values: filter.value,
107+
result,
108+
})
109+
}
110+
111+
if (filter.type === 'saturate' || filter.type === 'grayscale') {
112+
return buildXMLString('feColorMatrix', {
113+
in: input,
114+
type: 'saturate',
115+
values: filter.type === 'grayscale' ? 1 - filter.value : filter.value,
116+
result,
117+
})
118+
}
119+
120+
if (filter.type === 'sepia') {
121+
const amount = filter.value
122+
const inverse = 1 - amount
123+
return buildXMLString('feColorMatrix', {
124+
in: input,
125+
type: 'matrix',
126+
values: [
127+
inverse + 0.393 * amount,
128+
0.769 * amount,
129+
0.189 * amount,
130+
0,
131+
0,
132+
0.349 * amount,
133+
inverse + 0.686 * amount,
134+
0.168 * amount,
135+
0,
136+
0,
137+
0.272 * amount,
138+
0.534 * amount,
139+
inverse + 0.131 * amount,
140+
0,
141+
0,
142+
0,
143+
0,
144+
0,
145+
1,
146+
0,
147+
].join(' '),
148+
result,
149+
})
150+
}
151+
152+
if (filter.type === 'drop-shadow') {
153+
const clippedInput = `${result}-input`
154+
return (
155+
buildXMLString('feComposite', {
156+
in: input,
157+
in2: 'SourceAlpha',
158+
operator: 'in',
159+
result: clippedInput,
160+
}) +
161+
buildXMLString('feDropShadow', {
162+
in: clippedInput,
163+
dx: filter.offsetX,
164+
dy: filter.offsetY,
165+
stdDeviation: filter.blurRadius,
166+
'flood-color': filter.color,
167+
result,
168+
})
169+
)
170+
}
171+
172+
const amount = filter.value
173+
const attributes =
174+
filter.type === 'brightness'
175+
? { slope: amount }
176+
: filter.type === 'contrast'
177+
? { slope: amount, intercept: 0.5 - 0.5 * amount }
178+
: filter.type === 'invert'
179+
? { slope: 1 - 2 * amount, intercept: amount }
180+
: { slope: amount }
181+
const channels = filter.type === 'opacity' ? ['A'] : ['R', 'G', 'B']
182+
183+
return buildXMLString(
184+
'feComponentTransfer',
185+
{ in: input, result },
186+
channels
187+
.map((channel) =>
188+
buildXMLString(`feFunc${channel}`, {
189+
type: 'linear',
190+
...attributes,
191+
})
192+
)
193+
.join('')
194+
)
195+
}
196+
197+
function getExpansion(filters: BackdropFilter[]) {
198+
let left = 0
199+
let top = 0
200+
let right = 0
201+
let bottom = 0
202+
203+
for (const filter of filters) {
204+
if (filter.type === 'blur') {
205+
const grow = filter.value * 3
206+
left = Math.max(left, grow)
207+
top = Math.max(top, grow)
208+
right = Math.max(right, grow)
209+
bottom = Math.max(bottom, grow)
210+
} else if (filter.type === 'drop-shadow') {
211+
const grow = filter.blurRadius * 3
212+
left = Math.max(left, grow - filter.offsetX)
213+
top = Math.max(top, grow - filter.offsetY)
214+
right = Math.max(right, grow + filter.offsetX)
215+
bottom = Math.max(bottom, grow + filter.offsetY)
216+
}
217+
}
218+
219+
return { left, top, right, bottom }
220+
}

src/builder/rect.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { buildXMLString } from '../utils.js'
99
import border, { getBorderClipPath } from './border.js'
1010
import { genClipPath } from './clip-path.js'
1111
import buildMaskImage from './mask-image.js'
12+
import { backdropFilter } from './backdrop-filter.js'
13+
import type { BackdropFilter } from '../parser/backdrop-filter.js'
1214
import CssDimension from '../vendor/parse-css-dimension/index.js'
1315

1416
/**
@@ -239,6 +241,21 @@ export default async function rect(
239241
inheritableStyle
240242
)
241243

244+
const [backdropDefinitions, backdropShape] = backdropFilter({
245+
id,
246+
left,
247+
top,
248+
width,
249+
height,
250+
path,
251+
type,
252+
matrix: matrix || undefined,
253+
currentClipPath,
254+
mask: maskId,
255+
filters: (style._backdropFilters as unknown as BackdropFilter[]) || [],
256+
})
257+
defs += backdropDefinitions
258+
242259
// Each background generates a new rectangle.
243260
// @TODO: Not sure if this is the best way to do it, maybe <pattern> with
244261
// multiple <image>s is better.
@@ -527,6 +544,7 @@ export default async function rect(
527544
maskId ? ` mask="${maskId}"` : ''
528545
}>`
529546
: '') +
547+
backdropShape +
530548
(backgroundShapes || shape) +
531549
(style.transform && (currentClipPath || maskId) ? '</g>' : '') +
532550
(opacity !== 1 ? `</g>` : '') +

src/builder/svg.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export default function svg({
1616
height,
1717
viewBox: `0 0 ${width} ${height}`,
1818
xmlns: 'http://www.w3.org/2000/svg',
19+
'enable-background': content.includes('in="BackgroundImage"')
20+
? 'new'
21+
: undefined,
1922
},
2023
content
2124
)

src/handler/expand.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import parseTransformOrigin, {
1515
import { isString, lengthToNumber, v, splitEffects } from '../utils.js'
1616
import { MaskProperty, parseMask } from '../parser/mask.js'
1717
import { splitCornerShapeValues } from '../parser/corner-shape.js'
18+
import { parseBackdropFilter } from '../parser/backdrop-filter.js'
1819
import { FontWeight, FontStyle } from '../font.js'
1920
import {
2021
extractCustomProperties,
@@ -64,7 +65,8 @@ function purify(name: string, value?: string | number) {
6465
function handleSpecialCase(
6566
name: string,
6667
value: string | number,
67-
currentColor: string
68+
currentColor: string,
69+
inheritedStyle: SerializedStyle
6870
) {
6971
if (name === 'zIndex') {
7072
console.warn('`z-index` is currently not supported.')
@@ -199,6 +201,16 @@ function handleSpecialCase(
199201
}
200202
}
201203

204+
if (name === 'backdropFilter' || name === 'WebkitBackdropFilter') {
205+
return {
206+
_backdropFilters: parseBackdropFilter(
207+
value,
208+
inheritedStyle,
209+
currentColor
210+
),
211+
}
212+
}
213+
202214
if (name === 'transform') {
203215
if (typeof value !== 'string') throw new Error('Invalid `transform` value.')
204216
// To support percentages in transform (which is not supported in RN), we
@@ -423,7 +435,7 @@ export default function expand(
423435

424436
try {
425437
const resolvedStyle =
426-
handleSpecialCase(name, value, currentColor) ||
438+
handleSpecialCase(name, value, currentColor, inheritedStyle) ||
427439
handleFallbackColor(
428440
name,
429441
getStylesForProperty(name, purify(name, value), true),

0 commit comments

Comments
 (0)