Skip to content

Commit 453fd91

Browse files
authored
feat: Support for object-fit fill and scale-down (#732)
```jsx () => { function Box({ children }) { return <div style={{ width: 100, height: 100, display: 'flex', background: '#fff', border: '1px solid red' }}> {children} </div> } return ( <div style={{ display: 'flex', width: 400, height: 100 }} > <Box> <img src="https://placehold.co/600x400/png" width={600} height={400} style={{ width: '100%', height: '100%', objectFit: 'fill' }} /> </Box> <Box> <img src="https://placehold.co/600x400/png" width={600} height={400} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> </Box> <Box> <img src="https://placehold.co/600x400/png" width={600} height={400} style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> </Box> <Box> <img src="https://placehold.co/60x40/png" width={60} height={40} style={{ width: '100%', height: '100%', objectFit: 'scale-down' }} /> </Box> </div> ) } ``` Renders: <img width="1600" height="800" alt="image" src="https://github.com/user-attachments/assets/1829bb4c-9e7d-41e0-904b-0194016a811a" /> https://og-playground.vercel.app/?share=7VVNj5swEP0ro6mqJFJaslJVVVbYQ6X2F_TIBewBvHVsZMwmEeK_75BAAvsl7WUPq-WC5j0P896zNLQonSIUuFxBfAttYgHyxsqgnYXf7rBsQZbaKE8WutWZB_AUGm9hq_Q91OFoKG4HBmCvVSgF3Gw26xEqSRdlmGNK15VJjwIWuaHD4oJnqfxfeNdYxdSXPM8nlPOKPMM31QFqZ7RiIWpxprvudjzXjoq7M7KNWOeJZaB_DfKXA83s2PrYzMs6L0Z_TEzNrI5gN8i46NtyrpeCS70rrhVr8DJOsAyhqkUU8XBJpTPqu3TRz83mwPOiyhYJTntOWuKW-WHYVE3ccs8Mf2pzYmjB2r9OjU59PUu67I5k-Kt7PtfGDFcyt98_0TWDaBrCh05Eunvyn5HMI7Eh1fZdQ-FMXonkhUTeK5Bapoa-Kbd_aybX3bZKbAeQWFyjq_r1XaNo8aQOxS9eUnhWg6LfWKgoawoUeWpqWiPt3J3-d6z6P0HYnyr-Ts7X9GeXkUIRfEPdGkOa8YmSjHF7543C7gE
1 parent c8dd6f5 commit 453fd91

10 files changed

+184
-12
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
224224

225225
<tr>
226226
<td colspan="2"><code>objectFit</code></td>
227-
<td><code>contain</code>, <code>cover</code>, <code>none</code>, default to <code>none</code></td>
228-
<td></td>
227+
<td>Supported</td>
228+
<td><a href="https://og-playground.vercel.app/?share=7VVNj5swEP0ro6mqJFJaslJVVVbYQ6X2F_TIBewBvHVsZMwmEeK_75BAAvsl7WUPq-WC5j0P896zNLQonSIUuFxBfAttYgHyxsqgnYXf7rBsQZbaKE8WutWZB_AUGm9hq_Q91OFoKG4HBmCvVSgF3Gw26xEqSRdlmGNK15VJjwIWuaHD4oJnqfxfeNdYxdSXPM8nlPOKPMM31QFqZ7RiIWpxprvudjzXjoq7M7KNWOeJZaB_DfKXA83s2PrYzMs6L0Z_TEzNrI5gN8i46NtyrpeCS70rrhVr8DJOsAyhqkUU8XBJpTPqu3TRz83mwPOiyhYJTntOWuKW-WHYVE3ccs8Mf2pzYmjB2r9OjU59PUu67I5k-Kt7PtfGDFcyt98_0TWDaBrCh05Eunvyn5HMI7Eh1fZdQ-FMXonkhUTeK5Bapoa-Kbd_aybX3bZKbAeQWFyjq_r1XaNo8aQOxS9eUnhWg6LfWKgoawoUeWpqWiPt3J3-d6z6P0HYnyr-Ts7X9GeXkUIRfEPdGkOa8YmSjHF7543C7gE">Example</a></td>
229229
</tr>
230230

231231
<tr>

src/builder/rect.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,64 @@ export default async function rect(
243243
}
244244
}
245245
const alignment = `x${xAlign}Y${yAlign}`
246-
const preserveAspectRatio =
247-
style.objectFit === 'contain'
248-
? alignment
249-
: style.objectFit === 'cover'
250-
? `${alignment} slice`
251-
: 'none'
246+
247+
// Calculate objectFit behavior
248+
let preserveAspectRatio: string
249+
let imageWidth = width - offsetLeft - offsetRight
250+
let imageHeight = height - offsetTop - offsetBottom
251+
let imageX = left + offsetLeft
252+
let imageY = top + offsetTop
253+
254+
if (style.objectFit === 'contain') {
255+
preserveAspectRatio = alignment
256+
} else if (style.objectFit === 'cover') {
257+
preserveAspectRatio = `${alignment} slice`
258+
} else if (style.objectFit === 'fill') {
259+
preserveAspectRatio = 'none'
260+
} else if (style.objectFit === 'scale-down') {
261+
// Get natural dimensions
262+
const naturalWidth = style.__naturalWidth as number
263+
const naturalHeight = style.__naturalHeight as number
264+
265+
if (naturalWidth && naturalHeight) {
266+
// Calculate if we need to scale down
267+
const containerWidth = width - offsetLeft - offsetRight
268+
const containerHeight = height - offsetTop - offsetBottom
269+
const scaleX = containerWidth / naturalWidth
270+
const scaleY = containerHeight / naturalHeight
271+
const minScale = Math.min(scaleX, scaleY)
272+
273+
if (minScale >= 1) {
274+
// Image is smaller than or equal to container
275+
// Use natural size (don't scale up)
276+
imageWidth = naturalWidth
277+
imageHeight = naturalHeight
278+
preserveAspectRatio = 'none'
279+
280+
// Center according to objectPosition
281+
const extraWidth = containerWidth - naturalWidth
282+
const extraHeight = containerHeight - naturalHeight
283+
284+
// Apply objectPosition alignment
285+
if (xAlign === 'Min') imageX += 0
286+
else if (xAlign === 'Max') imageX += extraWidth
287+
else imageX += extraWidth / 2 // Mid
288+
289+
if (yAlign === 'Min') imageY += 0
290+
else if (yAlign === 'Max') imageY += extraHeight
291+
else imageY += extraHeight / 2 // Mid
292+
} else {
293+
// Image is larger than container, scale down like 'contain'
294+
preserveAspectRatio = alignment
295+
}
296+
} else {
297+
// Fall back to 'contain' behavior if natural dimensions are unavailable
298+
preserveAspectRatio = alignment
299+
}
300+
} else {
301+
// Default/none: fill (stretch)
302+
preserveAspectRatio = 'none'
303+
}
252304

253305
if (style.transform) {
254306
imageBorderRadius = getBorderRadiusClipPath(
@@ -266,10 +318,10 @@ export default async function rect(
266318
}
267319

268320
shape += buildXMLString('image', {
269-
x: left + offsetLeft,
270-
y: top + offsetTop,
271-
width: width - offsetLeft - offsetRight,
272-
height: height - offsetTop - offsetBottom,
321+
x: imageX,
322+
y: imageY,
323+
width: imageWidth,
324+
height: imageHeight,
273325
href: src,
274326
preserveAspectRatio,
275327
transform: matrix ? matrix : undefined,

src/handler/compute.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export default async function compute(
111111
? (contentBoxHeight as number) + extraVertical
112112
: contentBoxHeight
113113
style.__src = resolvedSrc
114+
style.__naturalWidth = imageWidth
115+
style.__naturalHeight = imageHeight
114116
}
115117

116118
if (type === 'svg') {
Loading
Loading
Loading

test/image.test.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,4 +979,122 @@ describe('objectFit and objectPosition', () => {
979979
)
980980
expect(toImage(svg, 100)).toMatchImageSnapshot()
981981
})
982+
983+
describe('objectFit: fill', () => {
984+
it('should stretch image to fill container (aspect ratio not preserved)', async () => {
985+
const svg = await satori(
986+
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
987+
<img
988+
src={PNG_SAMPLE}
989+
style={{
990+
width: 100,
991+
height: 100,
992+
objectFit: 'fill',
993+
backgroundColor: 'green',
994+
}}
995+
/>
996+
</div>,
997+
{ width: 100, height: 100, fonts }
998+
)
999+
expect(toImage(svg, 100)).toMatchImageSnapshot()
1000+
})
1001+
1002+
it('should stretch with fill on non-square container', async () => {
1003+
const svg = await satori(
1004+
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
1005+
<img
1006+
src={PNG_SAMPLE}
1007+
style={{
1008+
width: 150,
1009+
height: 100,
1010+
objectFit: 'fill',
1011+
backgroundColor: 'green',
1012+
}}
1013+
/>
1014+
</div>,
1015+
{ width: 150, height: 100, fonts }
1016+
)
1017+
expect(toImage(svg, 150)).toMatchImageSnapshot()
1018+
})
1019+
})
1020+
1021+
describe('objectFit: scale-down', () => {
1022+
it('should not scale up when image is smaller than container', async () => {
1023+
// PNG_SAMPLE is 20x15, container is 100x100
1024+
// Should show image at 20x15, centered
1025+
const svg = await satori(
1026+
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
1027+
<img
1028+
src={PNG_SAMPLE}
1029+
style={{
1030+
width: 100,
1031+
height: 100,
1032+
objectFit: 'scale-down',
1033+
backgroundColor: 'green',
1034+
}}
1035+
/>
1036+
</div>,
1037+
{ width: 100, height: 100, fonts }
1038+
)
1039+
expect(toImage(svg, 100)).toMatchImageSnapshot()
1040+
})
1041+
1042+
it('should scale down when image is larger than container', async () => {
1043+
// PNG_SAMPLE is 20x15, container is 10x10
1044+
// Should scale down like 'contain'
1045+
const svg = await satori(
1046+
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
1047+
<img
1048+
src={PNG_SAMPLE}
1049+
style={{
1050+
width: 10,
1051+
height: 10,
1052+
objectFit: 'scale-down',
1053+
backgroundColor: 'green',
1054+
}}
1055+
/>
1056+
</div>,
1057+
{ width: 10, height: 10, fonts }
1058+
)
1059+
expect(toImage(svg, 10)).toMatchImageSnapshot()
1060+
})
1061+
1062+
it('should respect objectPosition with scale-down', async () => {
1063+
const svg = await satori(
1064+
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
1065+
<img
1066+
src={PNG_SAMPLE}
1067+
style={{
1068+
width: 100,
1069+
height: 100,
1070+
objectFit: 'scale-down',
1071+
objectPosition: 'top left',
1072+
backgroundColor: 'green',
1073+
}}
1074+
/>
1075+
</div>,
1076+
{ width: 100, height: 100, fonts }
1077+
)
1078+
expect(toImage(svg, 100)).toMatchImageSnapshot()
1079+
})
1080+
1081+
it('should respect objectPosition bottom right with scale-down', async () => {
1082+
const svg = await satori(
1083+
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
1084+
<img
1085+
src={PNG_SAMPLE}
1086+
style={{
1087+
width: 100,
1088+
height: 100,
1089+
objectFit: 'scale-down',
1090+
objectPosition: 'bottom right',
1091+
backgroundColor: 'green',
1092+
}}
1093+
/>
1094+
</div>,
1095+
{ width: 100, height: 100, fonts }
1096+
)
1097+
expect(toImage(svg, 100)).toMatchImageSnapshot()
1098+
})
1099+
})
9821100
})

0 commit comments

Comments
 (0)