Skip to content

Commit 4bdfcc0

Browse files
committed
optimize
1 parent b22c04c commit 4bdfcc0

9 files changed

Lines changed: 154 additions & 3 deletions

src/builder/rect.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,28 @@ export default async function rect(
256256
})
257257
defs += backdropDefinitions
258258

259+
// When the element is a single plain shape (one background fill, no border,
260+
// box shadow, backdrop filter, mask or CSS filter), `opacity` is visually
261+
// equivalent to `fill-opacity` since there is nothing to composite as an
262+
// isolated group. Using `fill-opacity` avoids emitting a `<g opacity>`
263+
// wrapper, which forces rasterizers such as librsvg and resvg to allocate
264+
// and composite a temporary surface.
265+
const useFillOpacity =
266+
opacity !== 1 &&
267+
!isImage &&
268+
fills.length === 1 &&
269+
!backdropShape &&
270+
!backgroundShapes &&
271+
!cssFilter &&
272+
!maskId &&
273+
!style.boxShadow &&
274+
!(
275+
style.borderTopWidth ||
276+
style.borderRightWidth ||
277+
style.borderBottomWidth ||
278+
style.borderLeftWidth
279+
)
280+
259281
// Each background generates a new rectangle.
260282
// @TODO: Not sure if this is the best way to do it, maybe <pattern> with
261283
// multiple <image>s is better.
@@ -267,6 +289,7 @@ export default async function rect(
267289
width,
268290
height,
269291
fill,
292+
'fill-opacity': useFillOpacity ? opacity : undefined,
270293
d: path ? path : undefined,
271294
transform: matrix ? matrix : undefined,
272295
'clip-path': style.transform ? undefined : currentClipPath,
@@ -538,7 +561,7 @@ export default async function rect(
538561
(shadow ? shadow[0] : '') +
539562
(imageBorderRadius ? imageBorderRadius[0] : '') +
540563
clip +
541-
(opacity !== 1 ? `<g opacity="${opacity}">` : '') +
564+
(opacity !== 1 && !useFillOpacity ? `<g opacity="${opacity}">` : '') +
542565
(style.transform && (currentClipPath || maskId)
543566
? `<g${currentClipPath ? ` clip-path="${currentClipPath}"` : ''}${
544567
maskId ? ` mask="${maskId}"` : ''
@@ -547,7 +570,7 @@ export default async function rect(
547570
backdropShape +
548571
(backgroundShapes || shape) +
549572
(style.transform && (currentClipPath || maskId) ? '</g>' : '') +
550-
(opacity !== 1 ? `</g>` : '') +
573+
(opacity !== 1 && !useFillOpacity ? `</g>` : '') +
551574
(shadow ? shadow[1] : '') +
552575
extra
553576
)

src/text/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,15 @@ export default async function* buildTextNodes(
906906
: parentStyle.color,
907907
d: mergedPath,
908908
transform: matrix ? matrix : undefined,
909-
opacity: opacity !== 1 ? opacity : undefined,
909+
// A single path is one fill operation, so `fill-opacity` is
910+
// visually identical to `opacity` when there is no stroke or
911+
// filter, and avoids the isolated-group compositing surface in
912+
// rasterizers. With a filter (e.g. text-shadow), `fill-opacity`
913+
// applies before filtering while `opacity` applies after, so we
914+
// must keep `opacity` there.
915+
[inheritedStyle.WebkitTextStrokeWidth || cssFilter || filter
916+
? 'opacity'
917+
: 'fill-opacity']: opacity !== 1 ? opacity : undefined,
910918
style: cssFilter ? `filter:${cssFilter}` : undefined,
911919
'stroke-width': inheritedStyle.WebkitTextStrokeWidth
912920
? `${inheritedStyle.WebkitTextStrokeWidth}px`
Loading
Loading

test/fill-opacity.test.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { it, describe, expect } from 'vitest'
2+
3+
import { initFonts, toImage } from './utils.js'
4+
import satori from '../src/index.js'
5+
6+
describe('Opacity to fill-opacity optimization', () => {
7+
let fonts
8+
initFonts((f) => (fonts = f))
9+
10+
it('should use fill-opacity for a plain shape without an isolated group', async () => {
11+
const svg = await satori(
12+
<div
13+
style={{
14+
width: 50,
15+
height: 50,
16+
background: 'red',
17+
opacity: 0.5,
18+
}}
19+
/>,
20+
{ width: 100, height: 100, fonts }
21+
)
22+
expect(svg).toContain('fill-opacity="0.5"')
23+
expect(svg).not.toContain('<g opacity=')
24+
expect(toImage(svg, 100)).toMatchImageSnapshot()
25+
})
26+
27+
it('should keep group opacity when the element has a border', async () => {
28+
const svg = await satori(
29+
<div
30+
style={{
31+
width: 50,
32+
height: 50,
33+
background: 'red',
34+
border: '2px solid blue',
35+
opacity: 0.5,
36+
}}
37+
/>,
38+
{ width: 100, height: 100, fonts }
39+
)
40+
expect(svg).toContain('<g opacity="0.5">')
41+
expect(svg).not.toContain('fill-opacity')
42+
expect(toImage(svg, 100)).toMatchImageSnapshot()
43+
})
44+
45+
it('should keep group opacity when the element has a box shadow', async () => {
46+
const svg = await satori(
47+
<div
48+
style={{
49+
width: 50,
50+
height: 50,
51+
background: 'red',
52+
boxShadow: '0 0 4px black',
53+
opacity: 0.5,
54+
}}
55+
/>,
56+
{ width: 100, height: 100, fonts }
57+
)
58+
expect(svg).toContain('<g opacity="0.5">')
59+
expect(toImage(svg, 100)).toMatchImageSnapshot()
60+
})
61+
62+
it('should keep group opacity when the element has multiple backgrounds', async () => {
63+
const svg = await satori(
64+
<div
65+
style={{
66+
width: 50,
67+
height: 50,
68+
backgroundColor: 'red',
69+
backgroundImage: 'linear-gradient(to right, blue, green)',
70+
opacity: 0.5,
71+
}}
72+
/>,
73+
{ width: 100, height: 100, fonts }
74+
)
75+
expect(svg).toContain('<g opacity="0.5">')
76+
expect(toImage(svg, 100)).toMatchImageSnapshot()
77+
})
78+
79+
it('should use fill-opacity for text without stroke or filter', async () => {
80+
const svg = await satori(
81+
<div
82+
style={{
83+
fontSize: 40,
84+
color: 'black',
85+
opacity: 0.5,
86+
}}
87+
>
88+
Hello
89+
</div>,
90+
{ width: 200, height: 100, fonts }
91+
)
92+
expect(svg).toContain('fill-opacity="0.5"')
93+
expect(toImage(svg, 200)).toMatchImageSnapshot()
94+
})
95+
96+
it('should render nested opacity multiplicatively', async () => {
97+
const svg = await satori(
98+
<div
99+
style={{
100+
display: 'flex',
101+
width: 100,
102+
height: 100,
103+
opacity: 0.5,
104+
}}
105+
>
106+
<div
107+
style={{
108+
width: 50,
109+
height: 50,
110+
background: 'red',
111+
opacity: 0.5,
112+
}}
113+
/>
114+
</div>,
115+
{ width: 100, height: 100, fonts }
116+
)
117+
expect(svg).toContain('fill-opacity="0.25"')
118+
expect(toImage(svg, 100)).toMatchImageSnapshot()
119+
})
120+
})

0 commit comments

Comments
 (0)