Compare commits

...
Author SHA1 Message Date
Anthony Stirling ca40fecd4a audit: jpdfium split 2026-05-22 10:04:53 +01:00
Anthony Stirling 9c2c39256a impl migration to pdfium for split 2026-05-22 09:43:18 +01:00
8 changed files with 942 additions and 419 deletions
+11
View File
@@ -136,6 +136,17 @@ sourceSets {
}
// Forward bench-related system properties through to the test JVM so
// `-Dsplit.bench=true` (and similar) flags reach Boolean.getBoolean().
tasks.named('test', Test) {
['split.bench', 'split.bench.pages', 'split.bench.chunk',
'split.bench.imgW', 'split.bench.imgH'].each { key ->
def v = System.getProperty(key)
if (v != null) systemProperty(key, v)
}
maxHeapSize = '2g'
}
// Disable regular jar
jar {
@@ -3,11 +3,10 @@ package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -30,12 +29,13 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
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;
import stirling.software.jpdfium.PdfSplit;
@GeneralApi
@Slf4j
@@ -90,13 +90,8 @@ public class SplitPDFController {
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
if (hasForm) {
writeSplitsViaReload(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut);
} else {
writeSplitsViaSharedSource(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut);
}
writeSplits(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut, hasForm);
}
}
@@ -109,59 +104,71 @@ public class SplitPDFController {
}
}
private void writeSplitsViaReload(
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
private void writeSplits(
File source,
List<Integer> pageNumbers,
String baseFilename,
ZipOutputStream zipOut,
boolean hasForm)
throws IOException {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
Set<Integer> keep = new HashSet<>();
for (int i = previousPageNumber; i <= splitPoint; i++) {
keep.add(i);
}
previousPageNumber = splitPoint + 1;
try (PDDocument splitDoc = pdfDocumentFactory.load(source)) {
for (int p = splitDoc.getNumberOfPages() - 1; p >= 0; p--) {
if (!keep.contains(p)) {
splitDoc.removePage(p);
}
}
FormUtils.pruneOrphanedFormFields(splitDoc);
writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
}
private void writeSplitsViaSharedSource(
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
throws IOException {
try (PDDocument sourceDoc = pdfDocumentFactory.load(source)) {
try (PdfDocument sourceDoc = PdfDocument.open(source.toPath())) {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
try (PDDocument splitDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
splitDoc.addPage(sourceDoc.getPage(i));
}
previousPageNumber = splitPoint + 1;
writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
writeSplit(
sourceDoc,
previousPageNumber,
splitPoint,
baseFilename,
splitIndex + 1,
zipOut,
hasForm);
previousPageNumber = splitPoint + 1;
}
}
}
private void writeSplit(
PdfDocument sourceDoc,
int fromIndex,
int toIndex,
String baseFilename,
int splitNumber,
ZipOutputStream zipOut,
boolean hasForm)
throws IOException {
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
try (PdfDocument splitDoc = PdfSplit.extractPageRange(sourceDoc, fromIndex, toIndex)) {
splitDoc.save(splitTemp.getPath());
}
Path finalPath = splitTemp.getPath();
TempFile prunedTemp = null;
try {
if (hasForm) {
prunedTemp = new TempFile(tempFileManager, ".pdf");
pruneForms(splitTemp.getFile(), prunedTemp.getFile());
finalPath = prunedTemp.getPath();
}
writeEntry(zipOut, baseFilename, splitNumber, finalPath);
} finally {
if (prunedTemp != null) {
prunedTemp.close();
}
}
}
}
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc)
private void pruneForms(File splitFile, File outputFile) throws IOException {
try (PDDocument doc = pdfDocumentFactory.load(splitFile)) {
FormUtils.pruneOrphanedFormFields(doc);
doc.save(outputFile);
}
}
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, Path pdfPath)
throws IOException {
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + index + ".pdf"));
doc.save(zipOut);
Files.copy(pdfPath, zipOut);
zipOut.closeEntry();
}
}
@@ -1,6 +1,9 @@
package stirling.software.SPDF.controller.api;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -8,9 +11,6 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -39,6 +39,8 @@ 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;
import stirling.software.jpdfium.PdfSplit;
@GeneralApi
@Slf4j
@@ -51,72 +53,36 @@ public class SplitPdfByChaptersController {
private final TempFileManager tempFileManager;
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
PDOutlineItem current,
List<Bookmark> bookmarks,
PDOutlineItem nextParent,
private static void collectBookmarks(
List<stirling.software.jpdfium.doc.Bookmark> source,
List<Bookmark> out,
int level,
int maxLevel)
throws Exception {
while (current != null) {
String currentTitle = current.getTitle().replace("/", "");
int firstPage =
sourceDocument.getPages().indexOf(current.findDestinationPage(sourceDocument));
PDOutlineItem child = current.getFirstChild();
PDOutlineItem nextSibling = current.getNextSibling();
int endPage;
if (child != null && level < maxLevel) {
endPage =
sourceDocument
.getPages()
.indexOf(child.findDestinationPage(sourceDocument));
} else if (nextSibling != null) {
endPage =
sourceDocument
.getPages()
.indexOf(nextSibling.findDestinationPage(sourceDocument));
} else if (nextParent != null) {
endPage =
sourceDocument
.getPages()
.indexOf(nextParent.findDestinationPage(sourceDocument));
} else {
endPage = -2;
/*
happens when we have something like this:
Outline Item 2
Outline Item 2.1
Outline Item 2.1.1
Outline Item 2.2
Outline 2.2.1
Outline 2.2.2 <--- this item neither has an immediate next parent nor an immediate next sibling
Outline Item 3
*/
int maxLevel) {
for (stirling.software.jpdfium.doc.Bookmark bm : source) {
if (!bm.isInternal()) {
continue;
}
if (!bookmarks.isEmpty()
&& bookmarks.get(bookmarks.size() - 1).getEndPage() == -2
&& firstPage
>= bookmarks
.get(bookmarks.size() - 1)
.getStartPage()) { // for handling the above-mentioned case
Bookmark previousBookmark = bookmarks.get(bookmarks.size() - 1);
previousBookmark.setEndPage(firstPage);
String title = bm.title() == null ? "" : bm.title().replace("/", "");
int firstPage = Math.max(0, bm.pageIndex());
out.add(new Bookmark(title, firstPage, -2));
if (bm.hasChildren() && level < maxLevel) {
collectBookmarks(bm.children(), out, level + 1, maxLevel);
}
bookmarks.add(new Bookmark(currentTitle, firstPage, endPage));
// Recursively process children
if (child != null && level < maxLevel) {
extractOutlineItems(
sourceDocument, child, bookmarks, nextSibling, level + 1, maxLevel);
}
current = nextSibling;
}
return bookmarks;
}
private static void assignEndPages(List<Bookmark> bookmarks, int totalPages) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark current = bookmarks.get(i);
int next = -1;
for (int j = i + 1; j < bookmarks.size(); j++) {
if (bookmarks.get(j).getStartPage() >= current.getStartPage()) {
next = bookmarks.get(j).getStartPage();
break;
}
}
current.setEndPage(next == -1 ? totalPages : next);
}
}
@AutoJobPostMapping(
@@ -134,46 +100,41 @@ public class SplitPdfByChaptersController {
MultipartFile file = request.getFileInput();
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
Integer bookmarkLevel = request.getBookmarkLevel();
if (bookmarkLevel < 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
try (TempFile sourceTempFile = new TempFile(tempFileManager, ".pdf")) {
Files.copy(
file.getInputStream(),
sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING);
if (outline == null) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound", "No PDF bookmarks/outline found in document");
}
List<Bookmark> bookmarks = new ArrayList<>();
try {
bookmarks =
extractOutlineItems(
sourceDocument,
outline.getFirstChild(),
bookmarks,
outline.getFirstChild().getNextSibling(),
0,
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
} catch (Exception e) {
ExceptionUtils.logException("outline extraction", e);
throw e;
int totalPages;
try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) {
totalPages = sourceDocument.pageCount();
List<stirling.software.jpdfium.doc.Bookmark> roots = sourceDocument.bookmarks();
if (roots == null || roots.isEmpty()) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound",
"No PDF bookmarks/outline found in document");
}
collectBookmarks(roots, bookmarks, 0, bookmarkLevel);
if (bookmarks.isEmpty()) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound",
"No PDF bookmarks/outline found in document");
}
assignEndPages(bookmarks, totalPages);
}
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
if (!allowDuplicates) {
/*
duplicates are generated when multiple bookmarks correspond to the same page,
if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all
the bookmarks that correspond to the same page, and treat them as a single bookmark
*/
bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks);
}
for (Bookmark bookmark : bookmarks) {
@@ -184,7 +145,15 @@ public class SplitPdfByChaptersController {
bookmark.getEndPage());
}
TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata);
PdfMetadata metadata = null;
if (includeMetadata) {
try (PDDocument metaDoc = pdfDocumentFactory.load(sourceTempFile.getFile())) {
metadata = pdfMetadataService.extractMetadataFromPdf(metaDoc);
}
}
TempFile zipTempFile =
createZipFile(sourceTempFile.getFile(), bookmarks, metadata, totalPages);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
}
@@ -216,45 +185,23 @@ public class SplitPdfByChaptersController {
}
private TempFile createZipFile(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
File sourceFile, List<Bookmark> bookmarks, PdfMetadata metadata, int totalPages)
throws Exception {
PdfMetadata metadata =
includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null;
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark bookmark = bookmarks.get(i);
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
for (int pg = bookmark.getStartPage();
pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
pg++) {
PDPage page = sourceDocument.getPage(pg);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", pg);
}
if (includeMetadata) {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
}
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, i)
+ bookmark.getTitle()
+ ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
splitDocument.save(zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
try (PdfDocument sourceDocument = PdfDocument.open(sourceFile.toPath());
ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark bookmark = bookmarks.get(i);
writeChapter(
sourceDocument,
bookmark,
i,
fileNumberFormatter,
metadata,
zipOut,
totalPages);
}
log.info(
"Successfully created zip file with split documents: {}",
@@ -265,6 +212,50 @@ public class SplitPdfByChaptersController {
throw e;
}
}
private void writeChapter(
PdfDocument sourceDocument,
Bookmark bookmark,
int index,
String fileNumberFormatter,
PdfMetadata metadata,
ZipOutputStream zipOut,
int totalPages)
throws Exception {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
int from = Math.min(Math.max(0, bookmark.getStartPage()), totalPages - 1);
int rawEnd = isSinglePage ? bookmark.getEndPage() : bookmark.getEndPage() - 1;
int to = Math.min(Math.max(from, rawEnd), totalPages - 1);
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
try (PdfDocument splitDoc = PdfSplit.extractPageRange(sourceDocument, from, to)) {
splitDoc.save(splitTemp.getPath());
}
Path finalPath = splitTemp.getPath();
TempFile metaTemp = null;
try {
if (metadata != null) {
metaTemp = new TempFile(tempFileManager, ".pdf");
try (PDDocument doc = pdfDocumentFactory.load(splitTemp.getFile())) {
pdfMetadataService.setMetadataToPdf(doc, metadata);
doc.save(metaTemp.getFile());
}
finalPath = metaTemp.getPath();
}
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, index)
+ bookmark.getTitle()
+ ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
Files.copy(finalPath, zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} finally {
if (metaTemp != null) {
metaTemp.close();
}
}
}
}
}
@Data
@@ -1,19 +1,16 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -37,6 +34,8 @@ 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;
import stirling.software.jpdfium.PdfSplit;
@GeneralApi
@Slf4j
@@ -75,23 +74,20 @@ public class SplitPdfBySizeController {
sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING);
try (PDDocument sourceDocument =
pdfDocumentFactory.load(sourceTempFile.getFile(), true)) {
boolean hasForm = sourceDocument.getDocumentCatalog().getAcroForm(null) != null;
List<List<Integer>> ranges = computeRanges(request, sourceDocument);
boolean hasForm;
try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) {
hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null;
}
try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) {
List<int[]> ranges = computeRanges(request, sourceDocument);
int fileIndex = 1;
for (List<Integer> range : ranges) {
if (range.isEmpty()) {
for (int[] range : ranges) {
if (range.length == 0) {
continue;
}
if (hasForm) {
writeRangeViaReload(
sourceTempFile.getFile(), range, zipOut, filename, fileIndex++);
} else {
writeRangeViaSharedSource(
sourceDocument, range, zipOut, filename, fileIndex++);
}
writeRange(sourceDocument, range, zipOut, filename, fileIndex++, hasForm);
}
}
}
@@ -104,80 +100,80 @@ public class SplitPdfBySizeController {
}
}
private List<List<Integer>> computeRanges(
SplitPdfBySizeOrCountRequest request, PDDocument sourceDocument) throws IOException {
private List<int[]> computeRanges(SplitPdfBySizeOrCountRequest request, PdfDocument sourceDoc)
throws IOException {
int type = request.getSplitType();
String value = request.getSplitValue();
if (type == 0) {
return computeSizeRanges(sourceDocument, GeneralUtils.convertSizeToBytes(value));
return computeSizeRanges(sourceDoc, GeneralUtils.convertSizeToBytes(value));
} else if (type == 1) {
return computePageCountRanges(sourceDocument, Integer.parseInt(value));
return computePageCountRanges(sourceDoc, Integer.parseInt(value));
} else if (type == 2) {
return computeDocCountRanges(sourceDocument, Integer.parseInt(value));
return computeDocCountRanges(sourceDoc, Integer.parseInt(value));
}
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "split type: " + type);
}
private void writeRangeViaReload(
File sourceFile,
List<Integer> keepIndices,
private void writeRange(
PdfDocument sourceDoc,
int[] range,
ZipOutputStream zipOut,
String baseFilename,
int fileIndex)
int fileIndex,
boolean hasForm)
throws IOException {
Set<Integer> keep = new HashSet<>(keepIndices);
try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) {
for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) {
if (!keep.contains(i)) {
doc.removePage(i);
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
extractRangeToFile(sourceDoc, range, splitTemp.getPath());
Path finalPath = splitTemp.getPath();
TempFile prunedTemp = null;
try {
if (hasForm) {
prunedTemp = new TempFile(tempFileManager, ".pdf");
try (PDDocument doc = pdfDocumentFactory.load(splitTemp.getFile())) {
FormUtils.pruneOrphanedFormFields(doc);
doc.save(prunedTemp.getFile());
}
finalPath = prunedTemp.getPath();
}
writeEntry(zipOut, baseFilename, fileIndex, finalPath);
} finally {
if (prunedTemp != null) {
prunedTemp.close();
}
}
FormUtils.pruneOrphanedFormFields(doc);
writeEntry(zipOut, baseFilename, fileIndex, doc);
}
}
private void writeRangeViaSharedSource(
PDDocument sourceDocument,
List<Integer> keepIndices,
ZipOutputStream zipOut,
String baseFilename,
int fileIndex)
private void extractRangeToFile(PdfDocument sourceDoc, int[] range, Path outputPath)
throws IOException {
try (PDDocument doc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
for (int p : keepIndices) {
doc.addPage(sourceDocument.getPage(p));
}
writeEntry(zipOut, baseFilename, fileIndex, doc);
int from = range[0];
int to = range[range.length - 1];
try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) {
split.save(outputPath);
}
}
private void writeEntry(
ZipOutputStream zipOut, String baseFilename, int fileIndex, PDDocument doc)
ZipOutputStream zipOut, String baseFilename, int fileIndex, Path pdfPath)
throws IOException {
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf"));
doc.save(zipOut);
Files.copy(pdfPath, zipOut);
zipOut.closeEntry();
}
/** Page-index ranges each output should contain. AcroForm overhead isn't modeled. */
private List<List<Integer>> computeSizeRanges(PDDocument sourceDocument, long maxBytes)
throws IOException {
List<List<Integer>> ranges = new ArrayList<>();
List<Integer> currentRange = new ArrayList<>();
int totalPages = sourceDocument.getNumberOfPages();
/** Returns contiguous page-index ranges fitting within {@code maxBytes}. */
private List<int[]> computeSizeRanges(PdfDocument sourceDoc, long maxBytes) throws IOException {
List<int[]> ranges = new ArrayList<>();
int totalPages = sourceDoc.pageCount();
int baseCheckFrequency = 5;
PDDocument scratch = new PDDocument();
try {
int rangeStart = 0;
int rangeEnd = -1;
try (TempFile probe = new TempFile(tempFileManager, ".pdf")) {
File probeFile = probe.getFile();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage page = sourceDocument.getPage(pageIndex);
scratch.addPage(new PDPage(page.getCOSObject()));
currentRange.add(pageIndex);
int pageAdded = currentRange.size();
rangeEnd = pageIndex;
int pageAdded = rangeEnd - rangeStart + 1;
boolean shouldCheckSize =
(pageAdded % baseCheckFrequency == 0)
|| (pageIndex == totalPages - 1)
@@ -185,117 +181,110 @@ public class SplitPdfBySizeController {
if (!shouldCheckSize) {
continue;
}
long actualSize;
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
scratch.save(out);
actualSize = out.size();
}
long actualSize = saveRange(sourceDoc, rangeStart, rangeEnd, probeFile);
if (actualSize > maxBytes) {
if (scratch.getNumberOfPages() > 1) {
scratch.removePage(scratch.getNumberOfPages() - 1);
currentRange.remove(currentRange.size() - 1);
pageIndex--; // retry this page in the next chunk
if (pageAdded > 1) {
rangeEnd = pageIndex - 1;
pageIndex--;
}
ranges.add(new ArrayList<>(currentRange));
currentRange.clear();
scratch.close();
scratch = new PDDocument();
ranges.add(buildRange(rangeStart, rangeEnd));
rangeStart = rangeEnd + 1;
rangeEnd = rangeStart - 1;
} else if (pageIndex < totalPages - 1 && actualSize < maxBytes * 0.75) {
int extraPagesAdded =
lookAheadFit(scratch, sourceDocument, pageIndex, maxBytes);
for (int i = 0; i < extraPagesAdded; i++) {
int extra = pageIndex + 1 + i;
scratch.addPage(new PDPage(sourceDocument.getPage(extra).getCOSObject()));
currentRange.add(extra);
}
pageIndex += extraPagesAdded;
int extra =
lookAheadFit(
sourceDoc,
rangeStart,
pageIndex,
maxBytes,
totalPages,
probeFile);
pageIndex += extra;
rangeEnd = pageIndex;
}
}
if (!currentRange.isEmpty()) {
ranges.add(new ArrayList<>(currentRange));
}
} finally {
scratch.close();
}
if (rangeEnd >= rangeStart) {
ranges.add(buildRange(rangeStart, rangeEnd));
}
return ranges;
}
/** Speculatively tries up to 5 next pages; returns how many fit under {@code maxBytes}. */
private int lookAheadFit(PDDocument scratch, PDDocument source, int pageIndex, long maxBytes)
private long saveRange(PdfDocument sourceDoc, int from, int to, File output)
throws IOException {
int totalPages = source.getNumberOfPages();
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
if (pagesToLookAhead == 0) {
return 0;
try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) {
split.save(output.toPath());
}
int extraPagesAdded = 0;
try (PDDocument testDoc = new PDDocument()) {
for (int i = 0; i < scratch.getNumberOfPages(); i++) {
testDoc.addPage(new PDPage(scratch.getPage(i).getCOSObject()));
}
for (int i = 0; i < pagesToLookAhead; i++) {
testDoc.addPage(new PDPage(source.getPage(pageIndex + 1 + i).getCOSObject()));
long testSize;
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
testDoc.save(out);
testSize = out.size();
}
if (testSize > maxBytes) {
break;
}
extraPagesAdded++;
}
}
return extraPagesAdded;
return output.length();
}
private List<List<Integer>> computePageCountRanges(PDDocument sourceDocument, int pageCount) {
private int lookAheadFit(
PdfDocument sourceDoc,
int rangeStart,
int currentEnd,
long maxBytes,
int totalPages,
File probeFile)
throws IOException {
int pagesToLookAhead = Math.min(5, totalPages - currentEnd - 1);
int extra = 0;
for (int i = 0; i < pagesToLookAhead; i++) {
int trialEnd = currentEnd + 1 + i;
long size = saveRange(sourceDoc, rangeStart, trialEnd, probeFile);
if (size > maxBytes) {
break;
}
extra++;
}
return extra;
}
private List<int[]> computePageCountRanges(PdfDocument sourceDoc, int pageCount) {
if (pageCount <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount);
}
int totalPages = sourceDocument.getNumberOfPages();
List<List<Integer>> ranges = new ArrayList<>();
List<Integer> current = new ArrayList<>(pageCount);
for (int i = 0; i < totalPages; i++) {
current.add(i);
if (current.size() == pageCount) {
ranges.add(current);
current = new ArrayList<>(pageCount);
}
}
if (!current.isEmpty()) {
ranges.add(current);
int totalPages = sourceDoc.pageCount();
List<int[]> ranges = new ArrayList<>();
int start = 0;
while (start < totalPages) {
int end = Math.min(start + pageCount - 1, totalPages - 1);
ranges.add(buildRange(start, end));
start = end + 1;
}
return ranges;
}
private List<List<Integer>> computeDocCountRanges(
PDDocument sourceDocument, int documentCount) {
private List<int[]> computeDocCountRanges(PdfDocument sourceDoc, int documentCount) {
if (documentCount <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"document count: " + documentCount);
}
int totalPages = sourceDocument.getNumberOfPages();
int totalPages = sourceDoc.pageCount();
int pagesPerDocument = totalPages / documentCount;
int extraPages = totalPages % documentCount;
List<List<Integer>> ranges = new ArrayList<>();
List<int[]> ranges = new ArrayList<>();
int cursor = 0;
for (int i = 0; i < documentCount; i++) {
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
List<Integer> range = new ArrayList<>(pagesToAdd);
for (int j = 0; j < pagesToAdd; j++) {
range.add(cursor++);
if (pagesToAdd == 0) {
continue;
}
ranges.add(range);
int end = cursor + pagesToAdd - 1;
ranges.add(buildRange(cursor, end));
cursor = end + 1;
}
return ranges;
}
private static int[] buildRange(int start, int end) {
int[] range = new int[end - start + 1];
for (int i = 0; i < range.length; i++) {
range[i] = start + i;
}
return range;
}
}
@@ -0,0 +1,365 @@
package stirling.software.SPDF.bench;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.FormUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfSplit;
/**
* Apples-to-apples heap + wall-clock benchmark for split: legacy PDFBox path (load + addPage per
* range + save) vs JPDFium path (PdfSplit.extractPageRange + save).
*
* <p>Generates a single ~N-page PDF with one JPEG per page, then performs a "split into chunks of
* 10" run with both backends. Samples heap every 25 ms during execution.
*
* <p>Run only when explicitly requested: -Dsplit.bench=true
*/
public final class SplitBenchmark {
private static final int PAGES = Integer.getInteger("split.bench.pages", 100);
private static final int CHUNK = Integer.getInteger("split.bench.chunk", 10);
private static final int IMAGE_W = Integer.getInteger("split.bench.imgW", 800);
private static final int IMAGE_H = Integer.getInteger("split.bench.imgH", 600);
private static final long SAMPLE_PERIOD_MS = 25L;
@Test
void compareSplitMemoryFootprint() throws Exception {
if (!Boolean.getBoolean("split.bench")) {
System.out.println("Skipping SplitBenchmark (run with -Dsplit.bench=true)");
return;
}
main(new String[0]);
}
public static void main(String[] args) throws Exception {
Path workDir = Files.createTempDirectory("split-bench-");
System.out.println("Work dir: " + workDir);
System.out.printf(
"Generating %d-page input PDF with %dx%d images per page...%n",
PAGES, IMAGE_W, IMAGE_H);
Path input = workDir.resolve("input.pdf");
long t0 = System.nanoTime();
generateTestPdf(input, PAGES);
long buildMs = (System.nanoTime() - t0) / 1_000_000;
System.out.printf(
" built %s: %,d KB (%d pages) in %,d ms%n%n",
input.getFileName(), Files.size(input) / 1024, PAGES, buildMs);
// Warmup - prime classloaders, JIT, native lib.
System.out.println("--- Warmup (results discarded) ---");
runPdfBoxSplit(input, workDir.resolve("warmup-pdfbox"), CHUNK);
runJpdfiumSplit(input, workDir.resolve("warmup-jpdfium"), CHUNK);
forceGcQuiescence();
System.out.println();
long baseline = sampleUsedHeapAfterGc();
System.out.printf("Baseline heap (after GC, before split): %,d KB%n%n", baseline / 1024);
// PDFBox run
System.out.println("--- PDFBox split (load + addPage + save per chunk) ---");
Path outBoxDir = workDir.resolve("out-pdfbox");
BenchResult pdfboxResult = profile(() -> runPdfBoxSplit(input, outBoxDir, CHUNK));
pdfboxResult.printSummary("PDFBox", baseline);
forceGcQuiescence();
System.out.println();
// JPDFium run
System.out.println("--- JPDFium split (PdfSplit.extractPageRange + save) ---");
Path outJpdDir = workDir.resolve("out-jpdfium");
BenchResult jpdfiumResult = profile(() -> runJpdfiumSplit(input, outJpdDir, CHUNK));
jpdfiumResult.printSummary("JPDFium", baseline);
// Compare
System.out.println();
System.out.println("=== Heap delta over baseline ===");
long pdfboxDelta = pdfboxResult.peakHeapBytes - baseline;
long jpdfiumDelta = jpdfiumResult.peakHeapBytes - baseline;
double improvement =
pdfboxDelta == 0 ? 0.0 : (100.0 * (pdfboxDelta - jpdfiumDelta) / pdfboxDelta);
System.out.printf(" PDFBox : +%,d KB%n", pdfboxDelta / 1024);
System.out.printf(" JPDFium : +%,d KB%n", jpdfiumDelta / 1024);
System.out.printf(" JPDFium uses %.1f%% LESS heap than PDFBox%n", improvement);
System.out.println();
System.out.println("=== Wall-clock ===");
System.out.printf(" PDFBox : %,d ms%n", pdfboxResult.wallMs);
System.out.printf(" JPDFium : %,d ms%n", jpdfiumResult.wallMs);
double speedup =
jpdfiumResult.wallMs == 0
? 0.0
: ((double) pdfboxResult.wallMs / jpdfiumResult.wallMs);
System.out.printf(" Speedup : %.2fx%n", speedup);
// Hybrid form-pruning sub-benchmark.
// Quantifies the heap re-introduced by the PDFBox post-pass that
// pruneOrphanedFormFields runs on each JPDFium-produced split.
System.out.println();
System.out.println("=== Hybrid form-pruning sub-benchmark ===");
Path formInput = workDir.resolve("form-input.pdf");
generateFormPdf(formInput, PAGES);
System.out.printf(
" built %s with AcroForm: %,d KB (%d pages)%n",
formInput.getFileName(), Files.size(formInput) / 1024, PAGES);
runJpdfiumSplit(formInput, workDir.resolve("warmup-form-bare"), CHUNK);
runJpdfiumSplitWithFormPrune(formInput, workDir.resolve("warmup-form-pruned"), CHUNK);
forceGcQuiescence();
BenchResult bareForm =
profile(() -> runJpdfiumSplit(formInput, workDir.resolve("out-form-bare"), CHUNK));
bareForm.printSummary("JPDFium-only", sampleUsedHeapAfterGc());
forceGcQuiescence();
BenchResult prunedForm =
profile(
() ->
runJpdfiumSplitWithFormPrune(
formInput, workDir.resolve("out-form-pruned"), CHUNK));
prunedForm.printSummary("JPDFium+PDFBox-prune", sampleUsedHeapAfterGc());
long pruneOverhead = prunedForm.peakHeapBytes - bareForm.peakHeapBytes;
long pruneTimeOverhead = prunedForm.wallMs - bareForm.wallMs;
System.out.printf(
" Hybrid prune overhead: +%,d KB heap, +%,d ms wall (%.1fx slower)%n",
pruneOverhead / 1024,
pruneTimeOverhead,
bareForm.wallMs == 0 ? 0.0 : (double) prunedForm.wallMs / bareForm.wallMs);
}
private static void runJpdfiumSplitWithFormPrune(Path input, Path outDir, int chunk)
throws IOException {
Files.createDirectories(outDir);
try (PdfDocument source = PdfDocument.open(input)) {
int total = source.pageCount();
int index = 0;
for (int start = 0; start < total; start += chunk) {
int end = Math.min(start + chunk - 1, total - 1);
Path raw = outDir.resolve("raw-" + (index + 1) + ".pdf");
try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) {
split.save(raw);
}
Path pruned = outDir.resolve("pruned-" + (++index) + ".pdf");
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(raw.toFile())) {
FormUtils.pruneOrphanedFormFields(doc);
doc.save(pruned.toFile());
}
Files.deleteIfExists(raw);
}
}
}
private static void generateFormPdf(Path out, int pages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
}
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
// One text field per page (orphan-prone scenario).
for (int i = 0; i < pages; i++) {
PDTextField field = new PDTextField(acroForm);
field.setPartialName("field_" + i);
acroForm.getFields().add(field);
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(50, 50, 200, 30));
widget.setPage(doc.getPage(i));
doc.getPage(i).getAnnotations().add(widget);
field.getWidgets().add(widget);
}
doc.save(out.toFile());
}
}
private static void runPdfBoxSplit(Path input, Path outDir, int chunk) throws IOException {
Files.createDirectories(outDir);
try (PDDocument source = org.apache.pdfbox.Loader.loadPDF(input.toFile())) {
int total = source.getNumberOfPages();
int index = 0;
for (int start = 0; start < total; start += chunk) {
int end = Math.min(start + chunk - 1, total - 1);
Set<Integer> keep = new HashSet<>();
for (int p = start; p <= end; p++) keep.add(p);
// Match the pre-jpdfium reload path: load fresh, removePage, save.
try (PDDocument split = org.apache.pdfbox.Loader.loadPDF(input.toFile())) {
for (int p = split.getNumberOfPages() - 1; p >= 0; p--) {
if (!keep.contains(p)) split.removePage(p);
}
split.save(outDir.resolve("split-" + (++index) + ".pdf").toFile());
}
}
}
}
private static void runJpdfiumSplit(Path input, Path outDir, int chunk) throws IOException {
Files.createDirectories(outDir);
try (PdfDocument source = PdfDocument.open(input)) {
int total = source.pageCount();
int index = 0;
for (int start = 0; start < total; start += chunk) {
int end = Math.min(start + chunk - 1, total - 1);
try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) {
split.save(outDir.resolve("split-" + (++index) + ".pdf"));
}
}
}
}
private static void generateTestPdf(Path out, int pages) throws IOException {
try (PDDocument doc = new PDDocument()) {
Random rng = new Random(42L);
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
byte[] jpegBytes = generateRandomJpeg(rng, IMAGE_W, IMAGE_H);
PDImageXObject xobj = PDImageXObject.createFromByteArray(doc, jpegBytes, "img");
try (PDPageContentStream cs =
new PDPageContentStream(
doc, page, PDPageContentStream.AppendMode.APPEND, false)) {
float pageW = page.getMediaBox().getWidth();
float pageH = page.getMediaBox().getHeight();
float imgW = pageW - 100;
float imgH = imgW * IMAGE_H / IMAGE_W;
float x = (pageW - imgW) / 2;
float y = pageH - 80 - imgH;
cs.drawImage(xobj, x, y, imgW, imgH);
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
cs.newLineAtOffset(50, 30);
cs.showText("Page " + (i + 1) + " bench fill");
cs.endText();
}
}
doc.save(out.toFile());
}
}
private static byte[] generateRandomJpeg(Random rng, int w, int h) throws IOException {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
try {
float hue = rng.nextFloat();
g.setColor(Color.getHSBColor(hue, 0.4f, 0.95f));
g.fillRect(0, 0, w, h);
for (int i = 0; i < 20; i++) {
g.setColor(Color.getHSBColor(rng.nextFloat(), 0.6f, 0.5f + rng.nextFloat() * 0.5f));
int r = 20 + rng.nextInt(80);
int cx = rng.nextInt(w);
int cy = rng.nextInt(h);
g.fillOval(cx - r, cy - r, r * 2, r * 2);
}
g.setColor(Color.BLACK);
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 32));
g.drawString("bench image", 40, h - 40);
} finally {
g.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "jpg", baos);
return baos.toByteArray();
}
private static BenchResult profile(ThrowingRunnable task) throws Exception {
forceGcQuiescence();
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
AtomicLong peakHeap = new AtomicLong(0);
AtomicLong peakNonHeap = new AtomicLong(0);
AtomicBoolean stop = new AtomicBoolean(false);
Thread sampler =
new Thread(
() -> {
while (!stop.get()) {
MemoryUsage heap = mem.getHeapMemoryUsage();
MemoryUsage nonHeap = mem.getNonHeapMemoryUsage();
peakHeap.updateAndGet(p -> Math.max(p, heap.getUsed()));
peakNonHeap.updateAndGet(p -> Math.max(p, nonHeap.getUsed()));
try {
Thread.sleep(SAMPLE_PERIOD_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
},
"split-bench-sampler");
sampler.setDaemon(true);
sampler.start();
long t0 = System.nanoTime();
try {
task.run();
} finally {
stop.set(true);
sampler.join();
}
long wallMs = (System.nanoTime() - t0) / 1_000_000;
return new BenchResult(peakHeap.get(), peakNonHeap.get(), wallMs);
}
private static void forceGcQuiescence() {
for (int i = 0; i < 3; i++) {
System.gc();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
private static long sampleUsedHeapAfterGc() {
forceGcQuiescence();
return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed();
}
@FunctionalInterface
interface ThrowingRunnable {
void run() throws Exception;
}
private record BenchResult(long peakHeapBytes, long peakNonHeapBytes, long wallMs) {
void printSummary(String label, long baselineBytes) {
System.out.printf(
" %s peak heap : %,d KB (delta over baseline: +%,d KB)%n",
label, peakHeapBytes / 1024, (peakHeapBytes - baselineBytes) / 1024);
System.out.printf(" %s peak nonHeap : %,d KB%n", label, peakNonHeapBytes / 1024);
System.out.printf(" %s wall-clock : %,d ms%n", label, wallMs);
}
}
}
@@ -4,17 +4,25 @@ 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.form.PDAcroForm;
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 +31,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 +44,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 +60,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 +79,47 @@ 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()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
PDTextField field = new PDTextField(acroForm);
field.setPartialName("testField");
acroForm.getFields().add(field);
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
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 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 +130,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 +150,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 +170,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 +190,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 +210,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 +232,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 +252,31 @@ 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 split PDF with form fields and keep form-aware path")
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);
}
}
@@ -4,11 +4,18 @@ 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.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 +34,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 +63,9 @@ 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)));
}
private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException {
@@ -83,6 +94,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 +131,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 +153,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 +197,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 +215,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 +237,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 +262,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 +284,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,12 +4,18 @@ 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;
@@ -23,6 +29,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 +42,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 +58,131 @@ 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);
}
@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);
}
}