diff --git a/README.md b/README.md
index 78ed9761..b7ebe8f5 100644
--- a/README.md
+++ b/README.md
@@ -206,7 +206,7 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
backgroundColor | Supported, single value | |
backgroundImage | linear-gradient, repeating-linear-gradient, radial-gradient, repeating-radial-gradient, url, single value | |
backgroundPosition | Support single value | |
-backgroundSize | Support two-value size i.e. 10px 20% | |
+backgroundSize | Support cover, contain, auto, and two-value sizes i.e. 10px 20% | Example |
backgroundClip | border-box, text | |
backgroundRepeat | repeat, repeat-x, repeat-y, no-repeat, defaults to repeat | |
diff --git a/src/builder/background-image.ts b/src/builder/background-image.ts
index 28831baa..66ce875d 100644
--- a/src/builder/background-image.ts
+++ b/src/builder/background-image.ts
@@ -24,6 +24,61 @@ function toAbsoluteValue(v: string | number, base: number) {
return +v
}
+function calculateKeywordSize(
+ keyword: string,
+ containerWidth: number,
+ containerHeight: number,
+ imageWidth: number,
+ imageHeight: number
+): [number, number] {
+ if (!imageWidth || !imageHeight) {
+ return [containerWidth, containerHeight]
+ }
+
+ if (keyword === 'cover') {
+ // Scale to cover the container (use max scale to ensure it covers)
+ const scaleX = containerWidth / imageWidth
+ const scaleY = containerHeight / imageHeight
+ const scale = Math.max(scaleX, scaleY)
+ return [imageWidth * scale, imageHeight * scale]
+ }
+
+ if (keyword === 'contain') {
+ // Scale to fit within the container (use min scale to ensure it fits)
+ const scaleX = containerWidth / imageWidth
+ const scaleY = containerHeight / imageHeight
+ const scale = Math.min(scaleX, scaleY)
+ return [imageWidth * scale, imageHeight * scale]
+ }
+
+ // For 'auto' or other values, handle auto
+ if (keyword === 'auto' || keyword.includes('auto')) {
+ const parts = keyword.split(' ')
+ const widthPart = parts[0] || 'auto'
+ const heightPart = parts[1] || parts[0] || 'auto'
+
+ let finalWidth = imageWidth
+ let finalHeight = imageHeight
+
+ if (widthPart === 'auto' && heightPart !== 'auto') {
+ // Width is auto, height is specified
+ const parsedHeight = toAbsoluteValue(heightPart, containerHeight)
+ finalHeight = parsedHeight
+ finalWidth = (imageWidth / imageHeight) * parsedHeight
+ } else if (heightPart === 'auto' && widthPart !== 'auto') {
+ // Height is auto, width is specified
+ const parsedWidth = toAbsoluteValue(widthPart, containerWidth)
+ finalWidth = parsedWidth
+ finalHeight = (imageHeight / imageWidth) * parsedWidth
+ }
+ // If both are auto, use intrinsic dimensions
+
+ return [finalWidth, finalHeight]
+ }
+
+ return [containerWidth, containerHeight]
+}
+
function parseLengthPairs(
str: string,
{
@@ -76,12 +131,35 @@ export default async function backgroundImage(
const repeatX = repeat === 'repeat-x' || repeat === 'repeat'
const repeatY = repeat === 'repeat-y' || repeat === 'repeat'
- const dimensions = parseLengthPairs(size, {
- x: width,
- y: height,
- defaultX: width,
- defaultY: height,
- })
+ // Check if size is a keyword (cover, contain, auto) that needs to be calculated later
+ const isKeywordSize =
+ size &&
+ (size === 'cover' ||
+ size === 'contain' ||
+ size === 'auto' ||
+ size.includes('auto'))
+
+ // For gradients, keyword sizes (cover, contain, auto) resolve to the
+ // container dimensions since gradients have no intrinsic size.
+ // For url() images, keyword sizes are calculated later using the image's
+ // intrinsic dimensions.
+ const isGradient =
+ image.startsWith('linear-gradient(') ||
+ image.startsWith('repeating-linear-gradient(') ||
+ image.startsWith('radial-gradient(') ||
+ image.startsWith('repeating-radial-gradient(')
+
+ const dimensions =
+ isKeywordSize && isGradient
+ ? [width, height] // Gradients have no intrinsic size; keyword sizes resolve to container
+ : isKeywordSize
+ ? [0, 0] // Will be calculated later when we have image dimensions
+ : parseLengthPairs(size, {
+ x: width,
+ y: height,
+ defaultX: width,
+ defaultY: height,
+ })
const offsets = parseLengthPairs(position, {
x: width,
y: height,
@@ -118,23 +196,41 @@ export default async function backgroundImage(
}
if (image.startsWith('url(')) {
- const dimensionsWithoutFallback = parseLengthPairs(size, {
- x: width,
- y: height,
- defaultX: 0,
- defaultY: 0,
- })
const [src, imageWidth, imageHeight] = await resolveImageData(
image.slice(4, -1)
)
- const resolvedWidth =
- from === 'mask'
- ? imageWidth || dimensionsWithoutFallback[0]
- : dimensionsWithoutFallback[0] || imageWidth
- const resolvedHeight =
- from === 'mask'
- ? imageHeight || dimensionsWithoutFallback[1]
- : dimensionsWithoutFallback[1] || imageHeight
+
+ let resolvedWidth: number
+ let resolvedHeight: number
+
+ if (isKeywordSize) {
+ // Calculate dimensions based on keyword (cover, contain, auto)
+ const [calcWidth, calcHeight] = calculateKeywordSize(
+ size,
+ width,
+ height,
+ imageWidth,
+ imageHeight
+ )
+ resolvedWidth = calcWidth
+ resolvedHeight = calcHeight
+ } else {
+ // Use the previously parsed dimensions
+ const dimensionsWithoutFallback = parseLengthPairs(size, {
+ x: width,
+ y: height,
+ defaultX: 0,
+ defaultY: 0,
+ })
+ resolvedWidth =
+ from === 'mask'
+ ? imageWidth || dimensionsWithoutFallback[0]
+ : dimensionsWithoutFallback[0] || imageWidth
+ resolvedHeight =
+ from === 'mask'
+ ? imageHeight || dimensionsWithoutFallback[1]
+ : dimensionsWithoutFallback[1] || imageHeight
+ }
return [
`satori_bi${id}`,
diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-auto-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-auto-1-snap.png
new file mode 100644
index 00000000..ab68f444
Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-auto-1-snap.png differ
diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-contain-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-contain-1-snap.png
new file mode 100644
index 00000000..bdd10530
Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-contain-1-snap.png differ
diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-cover-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-cover-1-snap.png
new file mode 100644
index 00000000..7c815d94
Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-cover-1-snap.png differ
diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-cover-with-non-square-container-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-cover-with-non-square-container-1-snap.png
new file mode 100644
index 00000000..8766c36a
Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-background-image-url-should-support-background-size-cover-with-non-square-container-1-snap.png differ
diff --git a/test/image.test.tsx b/test/image.test.tsx
index 102766a3..5cc69e47 100644
--- a/test/image.test.tsx
+++ b/test/image.test.tsx
@@ -664,6 +664,74 @@ describe('background-image: url()', () => {
expect(toImage(svg, 100)).toMatchImageSnapshot()
})
+ it('should support background-size: cover', async () => {
+ const svg = await satori(
+ ,
+ { width: 100, height: 100, fonts }
+ )
+
+ expect(toImage(svg, 100)).toMatchImageSnapshot()
+ })
+
+ it('should support background-size: contain', async () => {
+ const svg = await satori(
+ ,
+ { width: 100, height: 100, fonts }
+ )
+
+ expect(toImage(svg, 100)).toMatchImageSnapshot()
+ })
+
+ it('should support background-size: auto', async () => {
+ const svg = await satori(
+ ,
+ { width: 100, height: 100, fonts }
+ )
+
+ expect(toImage(svg, 100)).toMatchImageSnapshot()
+ })
+
+ it('should support background-size: cover with non-square container', async () => {
+ const svg = await satori(
+ ,
+ { width: 200, height: 100, fonts }
+ )
+
+ expect(toImage(svg, 200)).toMatchImageSnapshot()
+ })
+
it('should correctly position the background pattern', async () => {
const svg = await satori(