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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user