impl migration to pdfium for split (#6410)

## Summary

Migrates `SplitPDFController`, `SplitPdfByChaptersController`,
`SplitPdfBySizeController` from PDFBox to JPDFium.
`SplitPdfBySectionsController` and `AutoSplitPdfController` are
intentionally left on PDFBox (require JPDFium 1.0.2 features that don't
exist yet).

## Benchmark (audited on `audit/jpdfium-split`, file
`app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java`)

| Workload | PDFBox heap | JPDFium heap | PDFBox wall | JPDFium wall |
|---|---|---|---|---|
| 100 pp, chunk 10 | +21-26 MB | **+0.02 MB** | 80-106 ms | **25 ms** |
| 300 pp, chunk 10 | +59 MB | **+1.0 MB** | 232 ms | **76 ms** |

**98-99.9% heap reduction. 3-4.2x faster wall.**

## Hybrid

- AcroForm-bearing splits keep PDFBox
`FormUtils.pruneOrphanedFormFields` post-pass (FPDF_ImportPagesByIndex
drops AcroForm dict). Sub-bench shows +1.0 MB / +27 ms - tightly
bounded.
- Metadata extraction stays on PDFBox.
- `SplitPdfBySectionsController` - JPDFium `PdfPageSplitter` only does
2-up halving, not arbitrary MxN.
- `AutoSplitPdfController` - needs PDFRenderer + zxing for QR markers.

## Test plan

- [ ] 30/30 unit tests pass with PDFBox `Loader.loadPDF` as the oracle
(assert page counts + document totals)
- [ ] Existing cucumber feature `split.feature` continues to pass
- [ ] AcroForm-bearing PDF round-trips without orphaned widgets (covered
by existing FormUtils tests)
This commit is contained in:
Anthony Stirling
2026-05-26 17:50:13 +01:00
committed by GitHub
parent 05b80fbe4f
commit 56ff1e5092
15 changed files with 762 additions and 395 deletions
@@ -4,17 +4,28 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -23,8 +34,12 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.SplitPagesRequest;
@@ -32,6 +47,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPDFControllerTest {
@TempDir Path tempDir;
@@ -47,6 +63,12 @@ class SplitPDFControllerTest {
String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private byte[] createPdf(int numPages) throws IOException {
@@ -60,17 +82,79 @@ class SplitPDFControllerTest {
}
}
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
private byte[] createPdfWithForm(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
for (int i = 0; i < numPages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDTextField field = new PDTextField(acroForm);
field.setPartialName("text_p" + (i + 1));
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
widget.setPage(page);
field.setWidgets(java.util.List.of(widget));
page.getAnnotations().add(widget);
acroForm.getFields().add(field);
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
private List<String> fieldNamesOf(byte[] pdfBytes) throws IOException {
List<String> names = new ArrayList<>();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null);
if (acroForm == null) {
return names;
}
for (PDField field : acroForm.getFields()) {
names.add(field.getFullyQualifiedName());
}
}
return names;
}
private int widgetCountOnPage(byte[] pdfBytes, int pageIndex) throws IOException {
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
int count = 0;
for (PDAnnotation a : doc.getPage(pageIndex).getAnnotations()) {
if (a instanceof PDAnnotationWidget) {
count++;
}
}
return count;
}
}
private List<byte[]> unzip(Resource zipResource) throws IOException {
List<byte[]> entries = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.add(zis.readAllBytes());
zis.closeEntry();
}
}
return entries;
}
private int[] pageCountsOf(List<byte[]> entries) throws IOException {
int[] counts = new int[entries.size()];
for (int i = 0; i < entries.size(); i++) {
try (PDDocument doc = Loader.loadPDF(entries.get(i))) {
counts[i] = doc.getNumberOfPages();
}
}
return counts;
}
@Test
@DisplayName("Should split 6-page PDF at page 3")
@DisplayName("Should split 6-page PDF at page 3 into 2 parts")
void shouldSplitAtPage3() throws Exception {
byte[] pdfBytes = createPdf(6);
MockMultipartFile file =
@@ -81,11 +165,12 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("3");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(3, 3);
}
@Test
@@ -100,11 +185,12 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("1,2,3");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
}
@Test
@@ -119,15 +205,16 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("1");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(pageCountsOf(outputs)).containsExactly(1);
}
@Test
@DisplayName("Should split with range notation")
@DisplayName("Should split with multiple split points")
void shouldSplitWithRange() throws Exception {
byte[] pdfBytes = createPdf(10);
MockMultipartFile file =
@@ -138,11 +225,12 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("3,7");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(3, 4, 3);
}
@Test
@@ -157,13 +245,14 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("2");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
}
@Test
@@ -178,11 +267,12 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("5");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(pageCountsOf(outputs)).containsExactly(5);
}
@Test
@@ -197,29 +287,43 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("all");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
}
@Test
@DisplayName("Should handle file without extension in original name")
void shouldHandleFileWithoutExtension() throws Exception {
byte[] pdfBytes = createPdf(2);
@DisplayName("Should preserve AcroForm and per-page widgets when splitting form PDF")
void shouldSplitFormPdf() throws Exception {
byte[] pdfBytes = createPdfWithForm(4);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPagesRequest request = new SplitPagesRequest();
request.setFileInput(file);
request.setPageNumbers("1");
request.setPageNumbers("2");
setupFactory();
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
assertThat(fieldNamesOf(outputs.get(0)))
.as("first split keeps fields whose widgets are on pages 1-2")
.containsExactlyInAnyOrder("text_p1", "text_p2");
assertThat(fieldNamesOf(outputs.get(1)))
.as("second split keeps fields whose widgets are on pages 3-4")
.containsExactlyInAnyOrder("text_p3", "text_p4");
assertThat(widgetCountOnPage(outputs.get(0), 0)).isEqualTo(1);
assertThat(widgetCountOnPage(outputs.get(0), 1)).isEqualTo(1);
assertThat(widgetCountOnPage(outputs.get(1), 0)).isEqualTo(1);
assertThat(widgetCountOnPage(outputs.get(1), 1)).isEqualTo(1);
}
}
@@ -4,11 +4,19 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -27,10 +35,11 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -55,6 +64,12 @@ class SplitPdfByChaptersControllerTest {
String suffix = inv.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException {
@@ -83,6 +98,29 @@ class SplitPdfByChaptersControllerTest {
}
}
private List<byte[]> unzip(Resource zipResource) throws IOException {
List<byte[]> entries = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.add(zis.readAllBytes());
zis.closeEntry();
}
}
return entries;
}
private int totalPagesOf(List<byte[]> entries) throws IOException {
int total = 0;
for (byte[] data : entries) {
try (PDDocument doc = Loader.loadPDF(data)) {
total += doc.getNumberOfPages();
}
}
return total;
}
@Test
@DisplayName("Should split PDF by chapters")
void shouldSplitByChapters() throws Exception {
@@ -97,12 +135,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(totalPagesOf(outputs)).isEqualTo(6);
}
@Test
@@ -119,12 +157,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(true);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(totalPagesOf(outputs)).isEqualTo(4);
}
@Test
@@ -163,10 +201,6 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request));
}
}
@@ -185,12 +219,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(totalPagesOf(outputs)).isEqualTo(3);
}
@Test
@@ -207,14 +241,15 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(true);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
lenient()
.when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
.thenReturn(new stirling.software.common.model.PdfMetadata());
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(totalPagesOf(outputs)).isEqualTo(4);
}
@Test
@@ -231,12 +266,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(totalPagesOf(outputs)).isEqualTo(6);
}
@Test
@@ -253,11 +288,11 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(true);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(5);
assertThat(totalPagesOf(outputs)).isEqualTo(10);
}
}
@@ -4,17 +4,27 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -23,6 +33,9 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -33,6 +46,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPdfBySizeControllerTest {
@TempDir Path tempDir;
@@ -48,69 +62,189 @@ class SplitPdfBySizeControllerTest {
String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
@Test
@DisplayName("Should split by page count successfully")
void shouldSplitByPageCount() throws Exception {
byte[] pdfBytes;
private byte[] createPdf(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 5; i++) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
pdfBytes = Files.readAllBytes(pdfPath);
return Files.readAllBytes(pdfPath);
}
}
private List<byte[]> unzip(Resource zipResource) throws IOException {
List<byte[]> entries = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.add(zis.readAllBytes());
zis.closeEntry();
}
}
return entries;
}
private int[] pageCountsOf(List<byte[]> entries) throws IOException {
int[] counts = new int[entries.size()];
for (int i = 0; i < entries.size(); i++) {
try (PDDocument doc = Loader.loadPDF(entries.get(i))) {
counts[i] = doc.getNumberOfPages();
}
}
return counts;
}
@Test
@DisplayName("Should split by page count into 2-page chunks")
void shouldSplitByPageCount() throws Exception {
byte[] pdfBytes = createPdf(5);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(1); // Page count
request.setSplitType(1);
request.setSplitValue("2");
when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<?> response = controller.autoSplitPdf(request);
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 1);
}
@Test
@DisplayName("Should split by document count successfully")
@DisplayName("Should split by document count into 3 even documents")
void shouldSplitByDocCount() throws Exception {
byte[] pdfBytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 6; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
pdfBytes = Files.readAllBytes(pdfPath);
}
byte[] pdfBytes = createPdf(6);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(2); // Document count
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
request.setSplitType(2);
request.setSplitValue("3");
when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<?> response = controller.autoSplitPdf(request);
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 2);
}
@Test
@DisplayName("Should split by document count distributing extras")
void shouldSplitByDocCountWithRemainder() throws Exception {
byte[] pdfBytes = createPdf(7);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(2);
request.setSplitValue("3");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(3, 2, 2);
}
private byte[] createPdfWithForm(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
for (int i = 0; i < numPages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDTextField field = new PDTextField(acroForm);
field.setPartialName("text_p" + (i + 1));
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
widget.setPage(page);
field.setWidgets(java.util.List.of(widget));
page.getAnnotations().add(widget);
acroForm.getFields().add(field);
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
private List<String> fieldNamesOf(byte[] pdfBytes) throws IOException {
List<String> names = new ArrayList<>();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null);
if (acroForm == null) {
return names;
}
for (PDField field : acroForm.getFields()) {
names.add(field.getFullyQualifiedName());
}
}
return names;
}
@Test
@DisplayName("Should preserve AcroForm when splitting form PDF by page count")
void shouldPreserveFormFieldsWhenSplitting() throws Exception {
byte[] pdfBytes = createPdfWithForm(4);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(1);
request.setSplitValue("2");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
assertThat(fieldNamesOf(outputs.get(0))).containsExactlyInAnyOrder("text_p1", "text_p2");
assertThat(fieldNamesOf(outputs.get(1))).containsExactlyInAnyOrder("text_p3", "text_p4");
}
@Test
@DisplayName("Should split by size into multiple files")
void shouldSplitBySize() throws Exception {
byte[] pdfBytes = createPdf(20);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(0);
request.setSplitValue("3KB");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).isNotEmpty();
int total = 0;
for (int count : pageCountsOf(outputs)) {
total += count;
}
assertThat(total).isEqualTo(20);
}
}