diff --git a/README.md b/README.md index b7ebe8f5..f1f1426e 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,8 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na objectFit -contain, cover, none, default to none - +Supported +Example diff --git a/src/builder/rect.ts b/src/builder/rect.ts index 62dc963f..49907031 100644 --- a/src/builder/rect.ts +++ b/src/builder/rect.ts @@ -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( @@ -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, diff --git a/src/handler/compute.ts b/src/handler/compute.ts index 6e46f9b2..80ebf594 100644 --- a/src/handler/compute.ts +++ b/src/handler/compute.ts @@ -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') { diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-fill-should-stretch-image-to-fill-container-aspect-ratio-not-preserved-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-fill-should-stretch-image-to-fill-container-aspect-ratio-not-preserved-1-snap.png new file mode 100644 index 00000000..0200e7d0 Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-fill-should-stretch-image-to-fill-container-aspect-ratio-not-preserved-1-snap.png differ diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-fill-should-stretch-with-fill-on-non-square-container-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-fill-should-stretch-with-fill-on-non-square-container-1-snap.png new file mode 100644 index 00000000..fb6ed3d0 Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-fill-should-stretch-with-fill-on-non-square-container-1-snap.png differ diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-not-scale-up-when-image-is-smaller-than-container-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-not-scale-up-when-image-is-smaller-than-container-1-snap.png new file mode 100644 index 00000000..6d4d670a Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-not-scale-up-when-image-is-smaller-than-container-1-snap.png differ diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-respect-object-position-bottom-right-with-scale-down-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-respect-object-position-bottom-right-with-scale-down-1-snap.png new file mode 100644 index 00000000..dbc2ce46 Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-respect-object-position-bottom-right-with-scale-down-1-snap.png differ diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-respect-object-position-with-scale-down-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-respect-object-position-with-scale-down-1-snap.png new file mode 100644 index 00000000..749d1bbd Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-respect-object-position-with-scale-down-1-snap.png differ diff --git a/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-scale-down-when-image-is-larger-than-container-1-snap.png b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-scale-down-when-image-is-larger-than-container-1-snap.png new file mode 100644 index 00000000..6b4fee18 Binary files /dev/null and b/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-object-fit-and-object-position-object-fit-scale-down-should-scale-down-when-image-is-larger-than-container-1-snap.png differ diff --git a/test/image.test.tsx b/test/image.test.tsx index 5cc69e47..0d8479f1 100644 --- a/test/image.test.tsx +++ b/test/image.test.tsx @@ -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( +
+ +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + + it('should stretch with fill on non-square container', async () => { + const svg = await satori( +
+ +
, + { 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( +
+ +
, + { 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( +
+ +
, + { width: 10, height: 10, fonts } + ) + expect(toImage(svg, 10)).toMatchImageSnapshot() + }) + + it('should respect objectPosition with scale-down', async () => { + const svg = await satori( +
+ +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + + it('should respect objectPosition bottom right with scale-down', async () => { + const svg = await satori( +
+ +
, + { width: 100, height: 100, fonts } + ) + expect(toImage(svg, 100)).toMatchImageSnapshot() + }) + }) })