feat(thumbnails): persist thumbnailAspect from source image dimensions
Computes aspect at generate-time from the loaded BufferedImage: w/h above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than flipping to landscape on a rounding error. Also hardens the pipeline with an explicit dimension guard so width=0 / height=0 edge cases fail cleanly instead of dividing by zero when the aspect is computed. Refs #305 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> 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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user