impl migration to pdfium for decompress

This commit is contained in:
Anthony Stirling
2026-05-22 09:31:11 +01:00
parent 2f1fe2c80c
commit ec67f7e431
2 changed files with 73 additions and 26 deletions
@@ -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<COSBase> 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<COSBase> 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<COSBase> 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<COSBase> 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
}
}
}
@@ -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<Resource> 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");