Skip to content

Commit c55e4da

Browse files
authored
fix: clipping behavior of children with transform (#635)
1 parent ff80448 commit c55e4da

9 files changed

+147
-9
lines changed

README.md

+2-3
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,7 @@ Note:
279279
2. There is no `z-index` support in SVG. Elements that come later in the document will be painted on top.
280280
3. `box-sizing` is set to `border-box` for all elements.
281281
4. `calc` isn't supported.
282-
5. `overflow: hidden` and `transform` can't be used together.
283-
6. `currentcolor` support is only available for the `color` property.
282+
5. `currentcolor` support is only available for the `color` property.
284283

285284
### Language and Typography
286285

@@ -346,7 +345,7 @@ await satori(
346345
)
347346
```
348347

349-
Same characters can be rendered differently in different locales, you can specify the locale when necessary to force it to render with a specific font and locale. Check out [this example](https://og-playground.vercel.app/?share=nVLdSsMwFH6VcEC86VgdXoyweTMVpyiCA296kzWnbWaalCZ160rfwAcRH8Bn0rcwWVdQEYTdnJzz_ZyEnNNArDkChQkXz5EixNha4rRpfE4IF6aQrKbkOJG4OQ461OfnosTYCq0cF2tZ5apnMxRpZh18EoZHPbgW3Ga_sIJxLlS6Q4sNGbnQU0yKVM0t5sa3R2Wx7KlVZaxI6pl2oPLX_KQTh1-yXEj_6LlnAhLBLXOJYJLMY61MBN_VD2KLlIzGe2jJ4qe01JXiMy116bqsM2Gxc7Stj2edcmIKpohkKp1GsGKD6_sI9hQhn2-vHy_ve-HQK_9ybbPB7O4Q1-LxENfVzX-uydDtgTshAF348RqgDeymB3QchgF04wV66guOyyoFmjBpMADM9Uos6sLvk13vKtfH__FFvkQO1JYVtu0X) to learn more.
348+
Same characters can be rendered differently in different locales, you can specify the locale when necessary to force it to render with a specific font and locale. Check out [this example](https://og-playground.vercel.app/?share=nVLdSsMwFH6VcEC86VgdXoyweTMVpyiCA296kzWnbWaalCZ160rfwAcRH8Bn0rcwWVdQEYTdnJzz_ZyEnNNArDkChQkXz5EixNha4rRpfE4IF6aQrKbkOJG4OQ461OfnosTYCq0cF2tZ5apnMxRpZh18EoZHPbgW3Ga_sIJxLlS6Q4sNGbnQU0yKVM0t5sa3R2Wx7KlVZaxI6pl2oPLX_KQTh1-yXEj_6LlnAhLBLXOJYJLMY61MBN_VD2KLlIzGe2jJ4qe01JXiMy116bqsM2Gxc7Stj2edcmIKpohkKp1GsGKD6_sI9hQhn2-vHy_ve-HQK_9ybbPB7O4Q1-LxENfVzX-uydDtgTshAF348RqgDeymB3QchgF04wV66guOyyoFmjBpMADM9Uos6sLvk13vKtfH__FFvkQO1JYVtu0X) to learn more.
350349

351350
Supported locales are exported as the `Locale` enum type.
352351

src/builder/border-radius.ts

+39-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// TODO: Support the `border-radius: 10px / 20px` syntax.
66
// https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius
77

8-
import { lengthToNumber } from '../utils.js'
8+
import { buildXMLString, lengthToNumber } from '../utils.js'
99

1010
// Getting the intersection of a 45deg ray with the elliptical arc x^2/rx^2 + y^2/ry^2 = 1.
1111
// Reference:
@@ -66,6 +66,44 @@ function resolveRadius(
6666
const radiusZeroOrNull = (_radius?: [number, number]) =>
6767
_radius && _radius[0] !== 0 && _radius[1] !== 0
6868

69+
export function getBorderRadiusClipPath(
70+
{
71+
id,
72+
borderRadiusPath,
73+
borderType,
74+
left,
75+
top,
76+
width,
77+
height,
78+
}: {
79+
id: string
80+
borderRadiusPath?: string
81+
borderType?: 'rect' | 'path'
82+
left: number
83+
top: number
84+
width: number
85+
height: number
86+
},
87+
style: Record<string, number | string>
88+
) {
89+
const rectClipId = `satori_brc-${id}`
90+
const defs = buildXMLString(
91+
'clipPath',
92+
{
93+
id: rectClipId,
94+
},
95+
buildXMLString(borderType, {
96+
x: left,
97+
y: top,
98+
width,
99+
height,
100+
d: borderRadiusPath ? borderRadiusPath : undefined,
101+
})
102+
)
103+
104+
return [defs, rectClipId]
105+
}
106+
69107
export default function radius(
70108
{
71109
left,

src/builder/content-mask.ts

+7
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export default function contentMask(
5353
buildXMLString('rect', {
5454
...contentArea,
5555
fill: '#fff',
56+
// add transformation matrix to mask if overflow is hidden AND a
57+
// transformation style is defined, otherwise children will be clipped
58+
// incorrectly
59+
transform:
60+
style.overflow === 'hidden' && style.transform && matrix
61+
? matrix
62+
: undefined,
5663
mask: style._inheritedMaskId
5764
? `url(#${style._inheritedMaskId})`
5865
: undefined,

src/builder/overflow.ts

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ export default function overflow(
5858
width,
5959
height,
6060
d: path ? path : undefined,
61+
// add transformation matrix to clip path if overflow is hidden AND a
62+
// transformation style is defined, otherwise children will be clipped
63+
// relative to the parent's original plane instead of the transformed
64+
// plane
65+
transform:
66+
style.overflow === 'hidden' && style.transform && matrix
67+
? matrix
68+
: undefined,
6169
})
6270
)
6371
}

src/builder/rect.ts

+36-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ParsedTransformOrigin } from '../transform-origin.js'
22

33
import backgroundImage from './background-image.js'
4-
import radius from './border-radius.js'
4+
import radius, { getBorderRadiusClipPath } from './border-radius.js'
55
import { boxShadow } from './shadow.js'
66
import transform from './transform.js'
77
import overflow from './overflow.js'
@@ -163,9 +163,9 @@ export default async function rect(
163163
fill,
164164
d: path ? path : undefined,
165165
transform: matrix ? matrix : undefined,
166-
'clip-path': currentClipPath,
166+
'clip-path': style.transform ? undefined : currentClipPath,
167167
style: cssFilter ? `filter:${cssFilter}` : undefined,
168-
mask: maskId,
168+
mask: style.transform ? undefined : maskId,
169169
})
170170
)
171171
.join('')
@@ -184,6 +184,9 @@ export default async function rect(
184184
style
185185
)
186186

187+
// border radius for images with transform property
188+
let imageBorderRadius = undefined
189+
187190
// If it's an image (<img>) tag, we add an extra layer of the image itself.
188191
if (isImage) {
189192
// We need to subtract the border and padding sizes from the image size.
@@ -207,6 +210,21 @@ export default async function rect(
207210
? 'xMidYMid slice'
208211
: 'none'
209212

213+
if (style.transform) {
214+
imageBorderRadius = getBorderRadiusClipPath(
215+
{
216+
id,
217+
borderRadiusPath: path,
218+
borderType: type,
219+
left,
220+
top,
221+
width,
222+
height,
223+
},
224+
style
225+
)
226+
}
227+
210228
shape += buildXMLString('image', {
211229
x: left + offsetLeft,
212230
y: top + offsetTop,
@@ -216,8 +234,16 @@ export default async function rect(
216234
preserveAspectRatio,
217235
transform: matrix ? matrix : undefined,
218236
style: cssFilter ? `filter:${cssFilter}` : undefined,
219-
'clip-path': `url(#satori_cp-${id})`,
220-
mask: miId ? `url(#${miId})` : `url(#satori_om-${id})`,
237+
'clip-path': style.transform
238+
? imageBorderRadius
239+
? `url(#${imageBorderRadius[1]})`
240+
: undefined
241+
: `url(#satori_cp-${id})`,
242+
mask: style.transform
243+
? undefined
244+
: miId
245+
? `url(#${miId})`
246+
: `url(#satori_om-${id})`,
221247
})
222248
}
223249

@@ -269,9 +295,14 @@ export default async function rect(
269295
return (
270296
(defs ? buildXMLString('defs', {}, defs) : '') +
271297
(shadow ? shadow[0] : '') +
298+
(imageBorderRadius ? imageBorderRadius[0] : '') +
272299
clip +
273300
(opacity !== 1 ? `<g opacity="${opacity}">` : '') +
301+
(style.transform && currentClipPath && maskId
302+
? `<g clip-path="${currentClipPath}" mask="${maskId}">`
303+
: '') +
274304
(backgroundShapes || shape) +
305+
(style.transform && currentClipPath && maskId ? '</g>' : '') +
275306
(opacity !== 1 ? `</g>` : '') +
276307
(shadow ? shadow[1] : '') +
277308
extra

test/image.test.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,31 @@ describe('Image', () => {
367367
expect(toImage(svg, 100)).toMatchImageSnapshot()
368368
})
369369

370+
it('should have a separate border radius clip path when transform is used', async () => {
371+
const svg = await satori(
372+
<div
373+
style={{
374+
width: '100%',
375+
height: '100%',
376+
display: 'flex',
377+
overflow: 'hidden',
378+
}}
379+
>
380+
<img
381+
width='100%'
382+
height='100%'
383+
src='https://via.placeholder.com/150'
384+
style={{
385+
transform: 'rotate(45deg) translate(30px, 15px)',
386+
borderRadius: '20px',
387+
}}
388+
/>
389+
</div>,
390+
{ width: 100, height: 100, fonts }
391+
)
392+
expect(toImage(svg, 100)).toMatchImageSnapshot()
393+
})
394+
370395
it('should support transparent image with background', async () => {
371396
const svg = await satori(
372397
<div

test/transform.test.tsx

+30
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,34 @@ describe('transform', () => {
176176
expect(toImage(svg, 100)).toMatchImageSnapshot()
177177
})
178178
})
179+
180+
describe('behavior with parent overflow', () => {
181+
it('should not inherit parent clip-path', async () => {
182+
const svg = await satori(
183+
<div
184+
style={{
185+
display: 'flex',
186+
width: 20,
187+
height: 20,
188+
overflow: 'hidden',
189+
}}
190+
>
191+
<div
192+
style={{
193+
width: 15,
194+
height: 15,
195+
backgroundColor: 'red',
196+
transform: 'rotate(45deg) translate(15px, 5px)',
197+
}}
198+
/>
199+
</div>,
200+
{
201+
width: 100,
202+
height: 100,
203+
fonts,
204+
}
205+
)
206+
expect(toImage(svg, 100)).toMatchImageSnapshot()
207+
})
208+
})
179209
})

0 commit comments

Comments
 (0)