diff --git a/backend/pom.xml b/backend/pom.xml index f4c4a47..e679e5a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -55,6 +55,16 @@ postgresql runtime + + net.coobird + thumbnailator + 0.4.21 + + + com.twelvemonkeys.imageio + imageio-webp + 3.13.1 + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java b/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java new file mode 100644 index 0000000..3f695a3 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java @@ -0,0 +1,60 @@ +package com.recipeapp.recipe; + +import net.coobird.thumbnailator.Thumbnails; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Base64; + +@Component +public class ImageCompressor { + + private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class); + + private static final int PREVIEW_WIDTH = 400; + private static final double PREVIEW_QUALITY = 0.6; + private static final String DATA_URI_PREFIX = "data:image/"; + private static final String BASE64_MARKER = ";base64,"; + private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,"; + + public String compressToPreview(String dataUri) { + if (dataUri == null || dataUri.isBlank()) return null; + if (!dataUri.startsWith(DATA_URI_PREFIX)) return null; + + int markerIdx = dataUri.indexOf(BASE64_MARKER); + if (markerIdx < 0) return null; + + byte[] imageBytes; + try { + imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length())); + } catch (IllegalArgumentException e) { + return null; + } + + try { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (original == null) { + log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})", + dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40))); + return null; + } + + int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + Thumbnails.of(original) + .width(targetWidth) + .outputFormat("jpeg") + .outputQuality(PREVIEW_QUALITY) + .toOutputStream(bos); + return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray()); + } catch (Exception e) { + log.warn("Failed to generate image preview", e); + return null; + } + } +} diff --git a/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql b/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql new file mode 100644 index 0000000..2b7ac80 --- /dev/null +++ b/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql @@ -0,0 +1 @@ +ALTER TABLE recipe ADD COLUMN hero_image_preview text; diff --git a/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java new file mode 100644 index 0000000..8d2c7c5 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java @@ -0,0 +1,98 @@ +package com.recipeapp.recipe; + +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.*; + +class ImageCompressorTest { + + private final ImageCompressor compressor = new ImageCompressor(); + + @Test + void compressToPreview_returnsJpegDataUri() throws Exception { + String dataUri = makePngDataUri(800, 600); + String result = compressor.compressToPreview(dataUri); + assertThat(result).startsWith("data:image/jpeg;base64,"); + } + + @Test + void compressToPreview_outputIsDecodableJpeg() throws Exception { + String dataUri = makePngDataUri(800, 600); + String result = compressor.compressToPreview(dataUri); + + String base64 = result.substring("data:image/jpeg;base64,".length()); + byte[] bytes = Base64.getDecoder().decode(base64); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes)); + + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isLessThanOrEqualTo(400); + } + + @Test + void compressToPreview_preservesAspectRatio() throws Exception { + String dataUri = makePngDataUri(800, 400); // 2:1 ratio + String result = compressor.compressToPreview(dataUri); + + String base64 = result.substring("data:image/jpeg;base64,".length()); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64))); + + assertThat(img).isNotNull(); + double ratio = (double) img.getWidth() / img.getHeight(); + assertThat(ratio).isCloseTo(2.0, within(0.1)); + } + + @Test + void compressToPreview_doesNotUpscaleSmallImages() throws Exception { + String dataUri = makePngDataUri(200, 150); // smaller than 400px + String result = compressor.compressToPreview(dataUri); + + String base64 = result.substring("data:image/jpeg;base64,".length()); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64))); + + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isLessThanOrEqualTo(200); + } + + @Test + void compressToPreview_returnsNullForNull() { + assertThat(compressor.compressToPreview(null)).isNull(); + } + + @Test + void compressToPreview_returnsNullForBlankString() { + assertThat(compressor.compressToPreview(" ")).isNull(); + } + + @Test + void compressToPreview_returnsNullForNonDataUri() { + assertThat(compressor.compressToPreview("https://example.com/image.jpg")).isNull(); + } + + @Test + void compressToPreview_returnsNullForInvalidBase64() { + assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull(); + } + + // ── helpers ── + + private String makePngDataUri(int width, int height) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + // draw gradient so PNG and JPEG both have non-trivial content + for (int x = 0; x < width; x++) { + g.setColor(new Color(x * 255 / width, (x * 128 / width + height / 2) % 256, 128)); + g.drawLine(x, 0, x, height); + } + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", bos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray()); + } +} diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte index d38249b..67318c8 100644 --- a/frontend/src/lib/recipes/RecipeCard.svelte +++ b/frontend/src/lib/recipes/RecipeCard.svelte @@ -23,8 +23,8 @@ data-testid="image-area" class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}" > - {#if recipe.heroImageUrl} - {recipe.name} + {#if recipe.heroImagePreview} + {recipe.name} {:else}
{ @@ -27,18 +27,18 @@ describe('RecipeCard', () => { expect(screen.getByText(/easy/i)).toBeInTheDocument(); }); - it('shows placeholder when no heroImageUrl', () => { - render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } }); + it('shows placeholder when no heroImagePreview', () => { + render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } }); expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument(); }); - it('shows image when heroImageUrl is provided', () => { + it('shows image when heroImagePreview is provided', () => { render(RecipeCard, { - props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } } + props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } } }); const img = screen.getByRole('img'); - expect(img).toHaveAttribute('src', '/uploads/test.jpg'); + expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc'); expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese'); }); diff --git a/frontend/src/lib/recipes/types.ts b/frontend/src/lib/recipes/types.ts index 90f28b5..b01a061 100644 --- a/frontend/src/lib/recipes/types.ts +++ b/frontend/src/lib/recipes/types.ts @@ -3,7 +3,7 @@ export type RecipeSummary = { name: string; cookTimeMin?: number; effort?: string; - heroImageUrl?: string; + heroImagePreview?: string; }; export type Tag = { diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts index df455b0..3b8bfe8 100644 --- a/frontend/src/routes/(app)/recipes/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ fetch }) => { name: r.name!, cookTimeMin: r.cookTimeMin, effort: r.effort, - heroImageUrl: r.heroImageUrl + heroImagePreview: r.heroImagePreview })); const activePlan =