From 955c497ba0f43fb960956e5e594054ab4e19a783 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:46:08 +0200 Subject: [PATCH] feat(backend): add ThumbnailService for PDF and image thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a 240px-wide JPEG (quality 85) from either a PDF first page via PDFBox or a JPEG/PNG/TIFF scan via ImageIO, then uploads to S3 under thumbnails/{docId}.jpg and updates the Document entity. Scaling uses Graphics2D.drawImage with VALUE_INTERPOLATION_BILINEAR (not deprecated Image.getScaledInstance). Source is streamed via FileService.downloadFileStream to avoid buffering 50MB PDFs. Never throws — returns Outcome.SKIPPED for unsupported content types and Outcome.FAILED for rendering/upload errors so the backfill can tally them without aborting the run. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/ThumbnailService.java | 159 ++++++++++++ .../service/ThumbnailServiceTest.java | 232 ++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java new file mode 100644 index 00000000..465ff20d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -0,0 +1,159 @@ +package org.raddatz.familienarchiv.service; + +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.io.RandomAccessReadBuffer; +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.repository.DocumentRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.Set; + +/** + * Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image) + * and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via + * {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for + * historical documents. Explicitly does not throw — failures are returned as + * {@link Outcome#FAILED} so the backfill can account for them without aborting the run. + */ +@Service +@Slf4j +public class ThumbnailService { + + public enum Outcome { SUCCESS, SKIPPED, FAILED } + + private static final int THUMBNAIL_WIDTH = 240; + private static final float JPEG_QUALITY = 0.85f; + private static final int PDF_RENDER_DPI = 100; + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final Set IMAGE_CONTENT_TYPES = + Set.of("image/jpeg", "image/png", "image/tiff"); + + private final FileService fileService; + private final S3Client s3Client; + private final DocumentRepository documentRepository; + + @Value("${app.s3.bucket}") + private String bucketName; + + public ThumbnailService(FileService fileService, S3Client s3Client, + DocumentRepository documentRepository) { + this.fileService = fileService; + this.s3Client = s3Client; + this.documentRepository = documentRepository; + } + + public Outcome generate(Document doc) { + if (doc.getFilePath() == null) { + log.debug("Document {} has no filePath, skipping thumbnail", doc.getId()); + return Outcome.SKIPPED; + } + String contentType = doc.getContentType(); + if (contentType == null || !isSupported(contentType)) { + log.warn("Document {} has unsupported contentType {}, skipping thumbnail", + doc.getId(), contentType); + return Outcome.SKIPPED; + } + + BufferedImage source; + try { + source = PDF_CONTENT_TYPE.equals(contentType) + ? renderPdfFirstPage(doc.getFilePath()) + : readImage(doc.getFilePath()); + } catch (Exception e) { + log.warn("Thumbnail generation failed for doc={} reason={}", + doc.getId(), e.getMessage()); + return Outcome.FAILED; + } + + try { + BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH); + byte[] jpeg = encodeJpeg(scaled, JPEG_QUALITY); + String thumbnailKey = "thumbnails/" + doc.getId() + ".jpg"; + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(thumbnailKey) + .contentType("image/jpeg") + .build(), + RequestBody.fromBytes(jpeg)); + + doc.setThumbnailKey(thumbnailKey); + doc.setThumbnailGeneratedAt(LocalDateTime.now()); + documentRepository.save(doc); + return Outcome.SUCCESS; + } catch (Exception e) { + log.warn("Thumbnail generation failed for doc={} reason={}", + doc.getId(), e.getMessage()); + return Outcome.FAILED; + } + } + + private boolean isSupported(String contentType) { + return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType); + } + + private BufferedImage renderPdfFirstPage(String s3Key) throws IOException { + try (InputStream in = fileService.downloadFileStream(s3Key); + PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) { + PDFRenderer renderer = new PDFRenderer(pdf); + return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); + } + } + + private BufferedImage readImage(String s3Key) throws IOException { + try (InputStream in = fileService.downloadFileStream(s3Key)) { + BufferedImage img = ImageIO.read(in); + if (img == null) { + throw new IOException("No ImageIO reader available for " + s3Key); + } + return img; + } + } + + private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) { + int sourceWidth = source.getWidth(); + int sourceHeight = source.getHeight(); + int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth)); + BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(source, 0, 0, targetWidth, targetHeight, null); + g.dispose(); + return scaled; + } + + private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next(); + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality); + try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) { + writer.setOutput(out); + writer.write(null, new IIOImage(image, null, null), param); + } finally { + writer.dispose(); + } + return bos.toByteArray(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java new file mode 100644 index 00000000..bf6827af --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -0,0 +1,232 @@ +package org.raddatz.familienarchiv.service; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.BeforeEach; +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.repository.DocumentRepository; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class ThumbnailServiceTest { + + private FileService fileService; + private S3Client s3Client; + private DocumentRepository documentRepository; + private ThumbnailService thumbnailService; + + @BeforeEach + void setUp() { + fileService = mock(FileService.class); + s3Client = mock(S3Client.class); + documentRepository = mock(DocumentRepository.class); + thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository); + ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket"); + when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0)); + } + + @Test + void generate_returnsSkipped_whenDocumentHasNoFilePath() { + Document doc = makeDoc("application/pdf", null); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED); + verifyNoInteractions(s3Client); + assertThat(doc.getThumbnailKey()).isNull(); + } + + @Test + void generate_returnsSkipped_forUnsupportedContentType() throws IOException { + Document doc = makeDoc("application/msword", "documents/letter.doc"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3})); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED); + verifyNoInteractions(s3Client); + assertThat(doc.getThumbnailKey()).isNull(); + } + + @Test + void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException { + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + byte[] pdfBytes = createSamplePdf(); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(pdfBytes)); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + + ArgumentCaptor putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture()); + + PutObjectRequest req = putCaptor.getValue(); + assertThat(req.bucket()).isEqualTo("test-bucket"); + assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg"); + assertThat(req.contentType()).isEqualTo("image/jpeg"); + + byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream()); + BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded)); + assertThat(jpg).isNotNull(); + assertThat(jpg.getWidth()).isEqualTo(240); + + assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg"); + assertThat(doc.getThumbnailGeneratedAt()).isNotNull(); + verify(documentRepository).save(doc); + } + + @Test + void generate_rendersPng_uploadsJpegAtWidth240() throws IOException { + Document doc = makeDoc("image/png", "documents/scan.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream()); + BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded)); + assertThat(jpg.getWidth()).isEqualTo(240); + assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320 + } + + @Test + void generate_rendersJpeg_uploadsScaledJpeg() throws IOException { + Document doc = makeDoc("image/jpeg", "documents/photo.jpg"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + BufferedImage jpg = ImageIO.read(new ByteArrayInputStream( + readAll(bodyCaptor.getValue().contentStreamProvider().newStream()))); + assertThat(jpg.getWidth()).isEqualTo(240); + assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120 + } + + @Test + void generate_returnsFailed_whenS3PutThrows() throws IOException { + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePdf())); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build()); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + assertThat(doc.getThumbnailKey()).isNull(); + verify(documentRepository, never()).save(any()); + } + + @Test + void generate_returnsFailed_whenSourceStreamThrows() throws IOException { + Document doc = makeDoc("application/pdf", "documents/letter.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenThrow(new IOException("network blip")); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verifyNoInteractions(s3Client); + verify(documentRepository, never()).save(any()); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private Document makeDoc(String contentType, String filePath) { + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Test Doc") + .originalFilename("test.pdf") + .status(DocumentStatus.UPLOADED) + .contentType(contentType) + .filePath(filePath) + .build(); + doc.setCreatedAt(LocalDateTime.now()); + doc.setUpdatedAt(LocalDateTime.now()); + return doc; + } + + private static byte[] createSamplePdf() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream content = new PDPageContentStream(doc, page)) { + content.beginText(); + content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); + content.newLineAtOffset(100, 700); + content.showText("Lieber Hans,"); + content.endText(); + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + doc.save(bos); + return bos.toByteArray(); + } + } + + private static byte[] createSamplePng(int width, int height) throws IOException { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.LIGHT_GRAY); + g.fillRect(0, 0, width, height); + g.setColor(Color.DARK_GRAY); + g.fillRect(0, 0, width, height / 4); + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", bos); + return bos.toByteArray(); + } + + private static byte[] createSampleJpeg(int width, int height) throws IOException { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, width, height); + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpg", bos); + return bos.toByteArray(); + } + + private static byte[] readAll(InputStream stream) throws IOException { + try (stream) { + return stream.readAllBytes(); + } + } +}