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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na

<tr>
<td colspan="2"><code>objectFit</code></td>
<td><code>contain</code>, <code>cover</code>, <code>none</code>, default to <code>none</code></td>
<td></td>
<td>Supported</td>
<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>
</tr>

<tr>
Expand Down
72 changes: 62 additions & 10 deletions src/builder/rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,64 @@ export default async function rect(
}
}
const alignment = `x${xAlign}Y${yAlign}`
const preserveAspectRatio =
style.objectFit === 'contain'
? alignment
: style.objectFit === 'cover'
? `${alignment} slice`
: 'none'

// Calculate objectFit behavior
let preserveAspectRatio: string
let imageWidth = width - offsetLeft - offsetRight
let imageHeight = height - offsetTop - offsetBottom
let imageX = left + offsetLeft
let imageY = top + offsetTop

if (style.objectFit === 'contain') {
preserveAspectRatio = alignment
} else if (style.objectFit === 'cover') {
preserveAspectRatio = `${alignment} slice`
} else if (style.objectFit === 'fill') {
preserveAspectRatio = 'none'
} else if (style.objectFit === 'scale-down') {
// Get natural dimensions
const naturalWidth = style.__naturalWidth as number
const naturalHeight = style.__naturalHeight as number

if (naturalWidth && naturalHeight) {
// Calculate if we need to scale down
const containerWidth = width - offsetLeft - offsetRight
const containerHeight = height - offsetTop - offsetBottom
const scaleX = containerWidth / naturalWidth
const scaleY = containerHeight / naturalHeight
const minScale = Math.min(scaleX, scaleY)

if (minScale >= 1) {
// Image is smaller than or equal to container
// Use natural size (don't scale up)
imageWidth = naturalWidth
imageHeight = naturalHeight
preserveAspectRatio = 'none'

// Center according to objectPosition
const extraWidth = containerWidth - naturalWidth
const extraHeight = containerHeight - naturalHeight

// Apply objectPosition alignment
if (xAlign === 'Min') imageX += 0
else if (xAlign === 'Max') imageX += extraWidth
else imageX += extraWidth / 2 // Mid

if (yAlign === 'Min') imageY += 0
else if (yAlign === 'Max') imageY += extraHeight
else imageY += extraHeight / 2 // Mid
} else {
// Image is larger than container, scale down like 'contain'
preserveAspectRatio = alignment
}
} else {
// Fall back to 'contain' behavior if natural dimensions are unavailable
preserveAspectRatio = alignment
}
} else {
// Default/none: fill (stretch)
preserveAspectRatio = 'none'
}

if (style.transform) {
imageBorderRadius = getBorderRadiusClipPath(
Expand All @@ -266,10 +318,10 @@ export default async function rect(
}

shape += buildXMLString('image', {
x: left + offsetLeft,
y: top + offsetTop,
width: width - offsetLeft - offsetRight,
height: height - offsetTop - offsetBottom,
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
href: src,
preserveAspectRatio,
transform: matrix ? matrix : undefined,
Expand Down
2 changes: 2 additions & 0 deletions src/handler/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export default async function compute(
? (contentBoxHeight as number) + extraVertical
: contentBoxHeight
style.__src = resolvedSrc
style.__naturalWidth = imageWidth
style.__naturalHeight = imageHeight
}

if (type === 'svg') {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
118 changes: 118 additions & 0 deletions test/image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -979,4 +979,122 @@ describe('objectFit and objectPosition', () => {
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

describe('objectFit: fill', () => {
it('should stretch image to fill container (aspect ratio not preserved)', async () => {
const svg = await satori(
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
<img
src={PNG_SAMPLE}
style={{
width: 100,
height: 100,
objectFit: 'fill',
backgroundColor: 'green',
}}
/>
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

it('should stretch with fill on non-square container', async () => {
const svg = await satori(
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
<img
src={PNG_SAMPLE}
style={{
width: 150,
height: 100,
objectFit: 'fill',
backgroundColor: 'green',
}}
/>
</div>,
{ width: 150, height: 100, fonts }
)
expect(toImage(svg, 150)).toMatchImageSnapshot()
})
})

describe('objectFit: scale-down', () => {
it('should not scale up when image is smaller than container', async () => {
// PNG_SAMPLE is 20x15, container is 100x100
// Should show image at 20x15, centered
const svg = await satori(
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
<img
src={PNG_SAMPLE}
style={{
width: 100,
height: 100,
objectFit: 'scale-down',
backgroundColor: 'green',
}}
/>
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

it('should scale down when image is larger than container', async () => {
// PNG_SAMPLE is 20x15, container is 10x10
// Should scale down like 'contain'
const svg = await satori(
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
<img
src={PNG_SAMPLE}
style={{
width: 10,
height: 10,
objectFit: 'scale-down',
backgroundColor: 'green',
}}
/>
</div>,
{ width: 10, height: 10, fonts }
)
expect(toImage(svg, 10)).toMatchImageSnapshot()
})

it('should respect objectPosition with scale-down', async () => {
const svg = await satori(
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
<img
src={PNG_SAMPLE}
style={{
width: 100,
height: 100,
objectFit: 'scale-down',
objectPosition: 'top left',
backgroundColor: 'green',
}}
/>
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})

it('should respect objectPosition bottom right with scale-down', async () => {
const svg = await satori(
<div style={{ width: '100%', height: '100%', display: 'flex' }}>
<img
src={PNG_SAMPLE}
style={{
width: 100,
height: 100,
objectFit: 'scale-down',
objectPosition: 'bottom right',
backgroundColor: 'green',
}}
/>
</div>,
{ width: 100, height: 100, fonts }
)
expect(toImage(svg, 100)).toMatchImageSnapshot()
})
})
})