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 backgroundColorSupported, single value backgroundImagelinear-gradient, repeating-linear-gradient, radial-gradient, repeating-radial-gradient, url, single value backgroundPositionSupport single value -backgroundSizeSupport two-value size i.e. 10px 20% +backgroundSizeSupport cover, contain, auto, and two-value sizes i.e. 10px 20%Example backgroundClipborder-box, text backgroundRepeatrepeat, 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(