diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java index 3b4cb48e..8fbf8d92 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -7,6 +7,7 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -45,6 +46,9 @@ public class ThumbnailService { private static final int THUMBNAIL_WIDTH = 240; private static final float JPEG_QUALITY = 0.85f; private static final int PDF_RENDER_DPI = 100; + // Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should + // render in the portrait tile rather than flipping to landscape at 1.01. + private static final float LANDSCAPE_THRESHOLD = 1.1f; private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final Set IMAGE_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/tiff"); @@ -83,7 +87,10 @@ public class ThumbnailService { } BufferedImage source = readSourceImage(doc, contentType); - if (source == null) return Outcome.FAILED; + if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) { + log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId()); + return Outcome.FAILED; + } byte[] jpeg = encodeThumbnail(source, doc.getId()); if (jpeg == null) return Outcome.FAILED; @@ -91,7 +98,13 @@ public class ThumbnailService { String thumbnailKey = thumbnailKeyFor(doc.getId()); if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED; - return persistThumbnailMetadata(doc, thumbnailKey); + ThumbnailAspect aspect = aspectOf(source); + return persistThumbnailMetadata(doc, thumbnailKey, aspect); + } + + private static ThumbnailAspect aspectOf(BufferedImage source) { + float ratio = (float) source.getWidth() / source.getHeight(); + return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT; } private static String thumbnailKeyFor(UUID documentId) { @@ -138,10 +151,11 @@ public class ThumbnailService { } } - private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) { + private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey, ThumbnailAspect aspect) { try { doc.setThumbnailKey(thumbnailKey); doc.setThumbnailGeneratedAt(LocalDateTime.now()); + doc.setThumbnailAspect(aspect); documentRepository.save(doc); return Outcome.SUCCESS; } catch (Exception e) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index a2074891..df1f62b0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.test.util.ReflectionTestUtils; import software.amazon.awssdk.core.sync.RequestBody; @@ -167,6 +168,59 @@ class ThumbnailServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException { + // 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT. + Document doc = makeDoc("image/png", "documents/portrait.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException { + // 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE. + Document doc = makeDoc("image/jpeg", "documents/wide.jpg"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE); + } + + @Test + void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException { + // 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often + // come in at near-square and we want them to live in the portrait tile). + Document doc = makeDoc("image/png", "documents/square.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(500, 500))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException { + // 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT. + Document doc = makeDoc("image/png", "documents/near_threshold.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + @Test void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException { // Truncated JPEG header — ImageIO returns null rather than throwing.