feat(thumbnails): persist pageCount from PDDocument / 1 for images

Groups the first-page BufferedImage and the source's total page count
into a SourcePreview record so both values travel through generate()
together. PDFs get pdf.getNumberOfPages(); image uploads always get 1
(a scan is one page from the user's perspective). The page badge on
the thumbnail row uses this value to show "1 / N" for multi-page
letters without a separate round-trip.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 14:25:29 +02:00
committed by marcel
parent b48533be26
commit e6d55e47b1
2 changed files with 57 additions and 18 deletions

View File

@@ -86,20 +86,21 @@ public class ThumbnailService {
return Outcome.SKIPPED;
}
BufferedImage source = readSourceImage(doc, contentType);
if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) {
SourcePreview preview = readSourcePreview(doc, contentType);
if (preview == null
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
return Outcome.FAILED;
}
byte[] jpeg = encodeThumbnail(source, doc.getId());
byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
if (jpeg == null) return Outcome.FAILED;
String thumbnailKey = thumbnailKeyFor(doc.getId());
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
ThumbnailAspect aspect = aspectOf(source);
return persistThumbnailMetadata(doc, thumbnailKey, aspect);
ThumbnailAspect aspect = aspectOf(preview.image());
return persistThumbnailMetadata(doc, thumbnailKey, aspect, preview.pageCount());
}
private static ThumbnailAspect aspectOf(BufferedImage source) {
@@ -107,15 +108,19 @@ public class ThumbnailService {
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
}
// First-page image + total page count for the source file. Page count is always
// 1 for image uploads; for PDFs it comes straight from PDDocument.
private record SourcePreview(BufferedImage image, int pageCount) {}
private static String thumbnailKeyFor(UUID documentId) {
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
}
private BufferedImage readSourceImage(Document doc, String contentType) {
private SourcePreview readSourcePreview(Document doc, String contentType) {
try {
return PDF_CONTENT_TYPE.equals(contentType)
? renderPdfFirstPage(doc.getFilePath())
: readImage(doc.getFilePath());
: new SourcePreview(readImage(doc.getFilePath()), 1);
} catch (Exception e) {
log.warn("Thumbnail source read failed for doc={} reason={}",
doc.getId(), e.getMessage());
@@ -151,11 +156,13 @@ public class ThumbnailService {
}
}
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey, ThumbnailAspect aspect) {
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey,
ThumbnailAspect aspect, int pageCount) {
try {
doc.setThumbnailKey(thumbnailKey);
doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(aspect);
doc.setPageCount(pageCount);
documentRepository.save(doc);
return Outcome.SUCCESS;
} catch (Exception e) {
@@ -174,11 +181,12 @@ public class ThumbnailService {
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
}
private BufferedImage renderPdfFirstPage(String s3Key) throws IOException {
private SourcePreview 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);
BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
return new SourcePreview(image, pdf.getNumberOfPages());
}
}