From ec67f7e431653d8156e4ef8ea47963054ada1124 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Fri, 22 May 2026 09:31:11 +0100 Subject: [PATCH] impl migration to pdfium for decompress --- .../api/misc/DecompressPdfController.java | 61 +++++++++++-------- .../api/misc/DecompressPdfControllerTest.java | 38 ++++++++++++ 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java index a867937c38..7eb3202f29 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.controller.api.misc; +import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.util.HashSet; @@ -30,6 +31,7 @@ import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; @MiscApi @Slf4j @@ -51,23 +53,41 @@ public class DecompressPdfController { MultipartFile file = request.getFileInput(); - try (PDDocument document = pdfDocumentFactory.load(file)) { - // Process all objects in document - processAllObjects(document); - - // Save with explicit no compression to a temp file - TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); - try { - document.save(tempOut.getFile(), CompressParameters.NO_COMPRESSION); - } catch (IOException e) { - tempOut.close(); - throw e; + // JPDFium fast pre-validate: catches corrupt PDFs cheaply before the expensive PDFBox walk. + // JPDFium's FPDF_SaveAsCopy has no "uncompress streams" flag, so PDFBox does the actual + // work. + File inputTemp = null; + try { + inputTemp = tempFileManager.convertMultipartFileToFile(file); + try (PdfDocument ignored = PdfDocument.open(inputTemp.toPath())) { + // pre-validate only + } catch (Exception e) { + log.debug( + "JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage()); } - // Return the PDF as a streaming response - return WebResponseUtils.pdfFileToWebResponse( - tempOut, - GeneralUtils.generateFilename(file.getOriginalFilename(), "_decompressed.pdf")); + try (PDDocument document = pdfDocumentFactory.load(file)) { + // Walk every object and strip stream filters so PDFBox writes raw bytes + processAllObjects(document); + + // Hybrid fallback: PDFium cannot save uncompressed, so use PDFBox NO_COMPRESSION + TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); + try { + document.save(tempOut.getFile(), CompressParameters.NO_COMPRESSION); + } catch (IOException e) { + tempOut.close(); + throw e; + } + + return WebResponseUtils.pdfFileToWebResponse( + tempOut, + GeneralUtils.generateFilename( + file.getOriginalFilename(), "_decompressed.pdf")); + } + } finally { + if (inputTemp != null) { + tempFileManager.deleteTempFile(inputTemp); + } } } @@ -75,7 +95,6 @@ public class DecompressPdfController { Set processed = new HashSet<>(); COSDocument cosDoc = document.getDocument(); - // Process all objects in the document for (COSObjectKey key : cosDoc.getXrefTable().keySet()) { COSObject obj = cosDoc.getObjectFromPool(key); processObject(obj, processed); @@ -83,7 +102,6 @@ public class DecompressPdfController { } private void processObject(COSBase obj, Set processed) { - // Skip null objects or already processed objects to avoid infinite recursion if (obj == null || processed.contains(obj)) return; processed.add(obj); @@ -97,19 +115,16 @@ public class DecompressPdfController { } private void processDictionary(COSDictionary dict, Set processed) { - // Process all dictionary entries for (COSName key : dict.keySet()) { processObject(dict.getDictionaryObject(key), processed); } - // If this is a stream, decompress it if (dict instanceof COSStream stream) { decompressStream(stream); } } private void processArray(COSArray array, Set processed) { - // Process all array elements for (int i = 0; i < array.size(); i++) { processObject(array.get(i), processed); } @@ -119,33 +134,27 @@ public class DecompressPdfController { try { log.debug("Processing stream: {}", stream); - // Only remove filter information if it exists if (stream.containsKey(COSName.FILTER) || stream.containsKey(COSName.DECODE_PARMS) || stream.containsKey(COSName.D)) { - // Read the decompressed content first byte[] decompressedBytes; try (COSInputStream is = stream.createInputStream()) { decompressedBytes = IOUtils.toByteArray(is); } - // Now remove filter information stream.removeItem(COSName.FILTER); stream.removeItem(COSName.DECODE_PARMS); stream.removeItem(COSName.D); - // Write the raw content back try (OutputStream out = stream.createRawOutputStream()) { out.write(decompressedBytes); } - // Set the Length to reflect the new stream size stream.setInt(COSName.LENGTH, decompressedBytes.length); } } catch (IOException e) { ExceptionUtils.logException("stream decompression", e); - // Continue processing other streams even if this one fails } } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/DecompressPdfControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/DecompressPdfControllerTest.java index 413b96f662..6a44cda77a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/DecompressPdfControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/DecompressPdfControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.controller.api.misc; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -68,6 +69,16 @@ class DecompressPdfControllerTest { lenient().when(tf.getPath()).thenReturn(f.toPath()); return tf; }); + // Stub multipart-to-file conversion for the JPDFium pre-validate step + lenient() + .when(tempFileManager.convertMultipartFileToFile(any())) + .thenAnswer( + inv -> { + org.springframework.web.multipart.MultipartFile mf = inv.getArgument(0); + File f = Files.createTempFile("input", ".pdf").toFile(); + java.nio.file.Files.write(f.toPath(), mf.getBytes()); + return f; + }); } private MockMultipartFile createRealPdf(String content) throws IOException { @@ -209,6 +220,33 @@ class DecompressPdfControllerTest { assertThat(drainBody(response).length).isGreaterThan(0); } + @Test + void decompressPdf_oracleNoFilterEntries() throws IOException { + // PDFBox oracle: load the decompressed output and verify every COSStream has no /Filter + MockMultipartFile file = createRealPdf("Oracle decompression check"); + PDFFile request = new PDFFile(); + request.setFileInput(file); + + PDDocument doc = Loader.loadPDF(file.getBytes()); + when(pdfDocumentFactory.load(file)).thenReturn(doc); + + ResponseEntity response = controller.decompressPdf(request); + byte[] body = drainBody(response); + + try (PDDocument result = Loader.loadPDF(body)) { + org.apache.pdfbox.cos.COSDocument cosDoc = result.getDocument(); + for (org.apache.pdfbox.cos.COSObjectKey key : cosDoc.getXrefTable().keySet()) { + org.apache.pdfbox.cos.COSObject obj = cosDoc.getObjectFromPool(key); + org.apache.pdfbox.cos.COSBase base = obj.getObject(); + if (base instanceof org.apache.pdfbox.cos.COSStream stream) { + assertThat(stream.containsKey(org.apache.pdfbox.cos.COSName.FILTER)) + .as("stream %s should have no /Filter", key) + .isFalse(); + } + } + } + } + @Test void decompressPdf_returnsOkContentType() throws IOException { MockMultipartFile file = createRealPdf("test");