Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
<td></td>
</tr>

<tr>
<td colspan="2"><code>backdropFilter</code></td>
<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>
<td></td>
</tr>

<tr>
<td colspan="2"><code>clipPath</code></td>
<td>Supported</td>
Expand Down
220 changes: 220 additions & 0 deletions src/builder/backdrop-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type { BackdropFilter } from '../parser/backdrop-filter.js'
import { buildXMLString } from '../utils.js'

export function backdropFilter({
id,
left,
top,
width,
height,
path,
type,
matrix,
currentClipPath,
mask,
filters,
}: {
id: string
left: number
top: number
width: number
height: number
path: string
type: 'rect' | 'path'
matrix: string | undefined
currentClipPath: string | undefined
mask: string | undefined
filters: BackdropFilter[]
}) {
if (!filters.length) return ['', ''] as const

const filterId = `satori_bf-${id}`
const clipId = `satori_bfc-${id}`
const expansion = getExpansion(filters)
let input = 'BackgroundImage'
let primitives = ''

filters.forEach((filter, index) => {
const result = `${filterId}-${index}`
primitives += buildFilterPrimitive(filter, input, result)
input = result
})

const definitions =
buildXMLString(
'filter',
{
id: filterId,
x: left - expansion.left,
y: top - expansion.top,
width: width + expansion.left + expansion.right,
height: height + expansion.top + expansion.bottom,
filterUnits: 'userSpaceOnUse',
'color-interpolation-filters': 'sRGB',
},
primitives
) +
buildXMLString(
'clipPath',
{
id: clipId,
'clip-path': currentClipPath,
},
buildXMLString(type, {
x: left,
y: top,
width,
height,
d: path || undefined,
transform: matrix || undefined,
})
)

const shape = buildXMLString(type, {
x: left,
y: top,
width,
height,
d: path || undefined,
fill: '#000',
transform: matrix || undefined,
filter: `url(#${filterId})`,
'clip-path': `url(#${clipId})`,
mask,
})

return [definitions, shape] as const
}

function buildFilterPrimitive(
filter: BackdropFilter,
input: string,
result: string
) {
if (filter.type === 'blur') {
return buildXMLString('feGaussianBlur', {
in: input,
stdDeviation: filter.value,
result,
})
}

if (filter.type === 'hue-rotate') {
return buildXMLString('feColorMatrix', {
in: input,
type: 'hueRotate',
values: filter.value,
result,
})
}

if (filter.type === 'saturate' || filter.type === 'grayscale') {
return buildXMLString('feColorMatrix', {
in: input,
type: 'saturate',
values: filter.type === 'grayscale' ? 1 - filter.value : filter.value,
result,
})
}

if (filter.type === 'sepia') {
const amount = filter.value
const inverse = 1 - amount
return buildXMLString('feColorMatrix', {
in: input,
type: 'matrix',
values: [
inverse + 0.393 * amount,
0.769 * amount,
0.189 * amount,
0,
0,
0.349 * amount,
inverse + 0.686 * amount,
0.168 * amount,
0,
0,
0.272 * amount,
0.534 * amount,
inverse + 0.131 * amount,
0,
0,
0,
0,
0,
1,
0,
].join(' '),
result,
})
}

if (filter.type === 'drop-shadow') {
const clippedInput = `${result}-input`
return (
buildXMLString('feComposite', {
in: input,
in2: 'SourceAlpha',
operator: 'in',
result: clippedInput,
}) +
buildXMLString('feDropShadow', {
in: clippedInput,
dx: filter.offsetX,
dy: filter.offsetY,
stdDeviation: filter.blurRadius,
'flood-color': filter.color,
result,
})
)
}

const amount = filter.value
const attributes =
filter.type === 'brightness'
? { slope: amount }
: filter.type === 'contrast'
? { slope: amount, intercept: 0.5 - 0.5 * amount }
: filter.type === 'invert'
? { slope: 1 - 2 * amount, intercept: amount }
: { slope: amount }
const channels = filter.type === 'opacity' ? ['A'] : ['R', 'G', 'B']

return buildXMLString(
'feComponentTransfer',
{ in: input, result },
channels
.map((channel) =>
buildXMLString(`feFunc${channel}`, {
type: 'linear',
...attributes,
})
)
.join('')
)
}

function getExpansion(filters: BackdropFilter[]) {
let left = 0
let top = 0
let right = 0
let bottom = 0

for (const filter of filters) {
if (filter.type === 'blur') {
const grow = filter.value * 3
left = Math.max(left, grow)
top = Math.max(top, grow)
right = Math.max(right, grow)
bottom = Math.max(bottom, grow)
} else if (filter.type === 'drop-shadow') {
const grow = filter.blurRadius * 3
left = Math.max(left, grow - filter.offsetX)
top = Math.max(top, grow - filter.offsetY)
right = Math.max(right, grow + filter.offsetX)
bottom = Math.max(bottom, grow + filter.offsetY)
}
}

return { left, top, right, bottom }
}
18 changes: 18 additions & 0 deletions src/builder/rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { buildXMLString } from '../utils.js'
import border, { getBorderClipPath } from './border.js'
import { genClipPath } from './clip-path.js'
import buildMaskImage from './mask-image.js'
import { backdropFilter } from './backdrop-filter.js'
import type { BackdropFilter } from '../parser/backdrop-filter.js'
import CssDimension from '../vendor/parse-css-dimension/index.js'

/**
Expand Down Expand Up @@ -239,6 +241,21 @@ export default async function rect(
inheritableStyle
)

const [backdropDefinitions, backdropShape] = backdropFilter({
id,
left,
top,
width,
height,
path,
type,
matrix: matrix || undefined,
currentClipPath,
mask: maskId,
filters: (style._backdropFilters as unknown as BackdropFilter[]) || [],
})
defs += backdropDefinitions

// Each background generates a new rectangle.
// @TODO: Not sure if this is the best way to do it, maybe <pattern> with
// multiple <image>s is better.
Expand Down Expand Up @@ -527,6 +544,7 @@ export default async function rect(
maskId ? ` mask="${maskId}"` : ''
}>`
: '') +
backdropShape +
(backgroundShapes || shape) +
(style.transform && (currentClipPath || maskId) ? '</g>' : '') +
(opacity !== 1 ? `</g>` : '') +
Expand Down
3 changes: 3 additions & 0 deletions src/builder/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default function svg({
height,
viewBox: `0 0 ${width} ${height}`,
xmlns: 'http://www.w3.org/2000/svg',
'enable-background': content.includes('in="BackgroundImage"')
? 'new'
: undefined,
},
content
)
Expand Down
16 changes: 14 additions & 2 deletions src/handler/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import parseTransformOrigin, {
import { isString, lengthToNumber, v, splitEffects } from '../utils.js'
import { MaskProperty, parseMask } from '../parser/mask.js'
import { splitCornerShapeValues } from '../parser/corner-shape.js'
import { parseBackdropFilter } from '../parser/backdrop-filter.js'
import { FontWeight, FontStyle } from '../font.js'
import {
extractCustomProperties,
Expand Down Expand Up @@ -64,7 +65,8 @@ function purify(name: string, value?: string | number) {
function handleSpecialCase(
name: string,
value: string | number,
currentColor: string
currentColor: string,
inheritedStyle: SerializedStyle
) {
if (name === 'zIndex') {
console.warn('`z-index` is currently not supported.')
Expand Down Expand Up @@ -199,6 +201,16 @@ function handleSpecialCase(
}
}

if (name === 'backdropFilter' || name === 'WebkitBackdropFilter') {
return {
_backdropFilters: parseBackdropFilter(
value,
inheritedStyle,
currentColor
),
}
}

if (name === 'transform') {
if (typeof value !== 'string') throw new Error('Invalid `transform` value.')
// To support percentages in transform (which is not supported in RN), we
Expand Down Expand Up @@ -423,7 +435,7 @@ export default function expand(

try {
const resolvedStyle =
handleSpecialCase(name, value, currentColor) ||
handleSpecialCase(name, value, currentColor, inheritedStyle) ||
handleFallbackColor(
name,
getStylesForProperty(name, purify(name, value), true),
Expand Down
Loading
Loading