feat(backend): add ThumbnailService for PDF and image thumbnails

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 21:46:08 +02:00
parent 0bb18c6789
commit 955c497ba0
2 changed files with 391 additions and 0 deletions

View File

@@ -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<String> 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();
}
}

View File

@@ -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<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
ArgumentCaptor<RequestBody> 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<RequestBody> 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<RequestBody> 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();
}
}
}