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:
@@ -86,20 +86,21 @@ public class ThumbnailService {
|
|||||||
return Outcome.SKIPPED;
|
return Outcome.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
BufferedImage source = readSourceImage(doc, contentType);
|
SourcePreview preview = readSourcePreview(doc, contentType);
|
||||||
if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) {
|
if (preview == null
|
||||||
|
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
|
||||||
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
|
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
|
||||||
return Outcome.FAILED;
|
return Outcome.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] jpeg = encodeThumbnail(source, doc.getId());
|
byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
|
||||||
if (jpeg == null) return Outcome.FAILED;
|
if (jpeg == null) return Outcome.FAILED;
|
||||||
|
|
||||||
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
||||||
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
||||||
|
|
||||||
ThumbnailAspect aspect = aspectOf(source);
|
ThumbnailAspect aspect = aspectOf(preview.image());
|
||||||
return persistThumbnailMetadata(doc, thumbnailKey, aspect);
|
return persistThumbnailMetadata(doc, thumbnailKey, aspect, preview.pageCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ThumbnailAspect aspectOf(BufferedImage source) {
|
private static ThumbnailAspect aspectOf(BufferedImage source) {
|
||||||
@@ -107,15 +108,19 @@ public class ThumbnailService {
|
|||||||
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
|
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) {
|
private static String thumbnailKeyFor(UUID documentId) {
|
||||||
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BufferedImage readSourceImage(Document doc, String contentType) {
|
private SourcePreview readSourcePreview(Document doc, String contentType) {
|
||||||
try {
|
try {
|
||||||
return PDF_CONTENT_TYPE.equals(contentType)
|
return PDF_CONTENT_TYPE.equals(contentType)
|
||||||
? renderPdfFirstPage(doc.getFilePath())
|
? renderPdfFirstPage(doc.getFilePath())
|
||||||
: readImage(doc.getFilePath());
|
: new SourcePreview(readImage(doc.getFilePath()), 1);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Thumbnail source read failed for doc={} reason={}",
|
log.warn("Thumbnail source read failed for doc={} reason={}",
|
||||||
doc.getId(), e.getMessage());
|
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 {
|
try {
|
||||||
doc.setThumbnailKey(thumbnailKey);
|
doc.setThumbnailKey(thumbnailKey);
|
||||||
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
||||||
doc.setThumbnailAspect(aspect);
|
doc.setThumbnailAspect(aspect);
|
||||||
|
doc.setPageCount(pageCount);
|
||||||
documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
return Outcome.SUCCESS;
|
return Outcome.SUCCESS;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -174,11 +181,12 @@ public class ThumbnailService {
|
|||||||
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
|
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);
|
try (InputStream in = fileService.downloadFileStream(s3Key);
|
||||||
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
|
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
|
||||||
PDFRenderer renderer = new PDFRenderer(pdf);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,31 @@ class ThumbnailServiceTest {
|
|||||||
verify(documentRepository, never()).save(any());
|
verify(documentRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
|
||||||
|
// Image uploads are always a single page from the user's perspective.
|
||||||
|
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);
|
||||||
|
assertThat(doc.getPageCount()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPageCount_fromPdfDocument() throws IOException {
|
||||||
|
Document doc = makeDoc("application/pdf", "documents/multi.pdf");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSamplePdf(3)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getPageCount()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
|
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
|
||||||
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
|
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
|
||||||
@@ -288,15 +313,21 @@ class ThumbnailServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] createSamplePdf() throws IOException {
|
private static byte[] createSamplePdf() throws IOException {
|
||||||
|
return createSamplePdf(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] createSamplePdf(int pageCount) throws IOException {
|
||||||
try (PDDocument doc = new PDDocument()) {
|
try (PDDocument doc = new PDDocument()) {
|
||||||
PDPage page = new PDPage(PDRectangle.A4);
|
for (int i = 0; i < pageCount; i++) {
|
||||||
doc.addPage(page);
|
PDPage page = new PDPage(PDRectangle.A4);
|
||||||
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
doc.addPage(page);
|
||||||
content.beginText();
|
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
||||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
content.beginText();
|
||||||
content.newLineAtOffset(100, 700);
|
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
||||||
content.showText("Lieber Hans,");
|
content.newLineAtOffset(100, 700);
|
||||||
content.endText();
|
content.showText("Lieber Hans,");
|
||||||
|
content.endText();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
doc.save(bos);
|
doc.save(bos);
|
||||||
|
|||||||
Reference in New Issue
Block a user