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
+2 -2
View File
@@ -34,8 +34,8 @@ frontend/playwright-report/
# Tauri/desktop builds # Tauri/desktop builds
src-tauri/target/ src-tauri/target/
src-tauri/dist/ src-tauri/dist/
frontend/src-tauri/target/ frontend/editor/src-tauri/target/
frontend/src-tauri/dist/ frontend/editor/src-tauri/dist/
# IDE and editor # IDE and editor
.idea/ .idea/
+2 -2
View File
@@ -69,8 +69,8 @@ labels:
- label: 'Tauri' - label: 'Tauri'
files: files:
- 'frontend/src-tauri/**' - 'frontend/editor/src-tauri/**'
- 'frontend/src-tauri/.*' - 'frontend/editor/src-tauri/.*'
- label: 'engine' - label: 'engine'
files: files:
+4
View File
@@ -4,6 +4,10 @@ name: Build Tauri Applications
# Linux). Called from build.yml on PRs that touch desktop sources (gated # Linux). Called from build.yml on PRs that touch desktop sources (gated
# via the `tauri` filter in .github/config/.files.yaml). Also runnable # via the `tauri` filter in .github/config/.files.yaml). Also runnable
# on demand via workflow_dispatch with a per-platform selector. # on demand via workflow_dispatch with a per-platform selector.
#
# Note: editing this file is itself enough to make the `tauri` path filter
# match, which is how non-desktop PRs (e.g. backend-only fixes) opt into a
# desktop smoke build.
on: on:
workflow_call: workflow_call:
inputs: inputs:
+1 -1
View File
@@ -157,7 +157,7 @@ app/proprietary/build
common/build common/build
proprietary/build proprietary/build
stirling-pdf/build stirling-pdf/build
frontend/src-tauri/provisioner/target frontend/editor/src-tauri/provisioner/target
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
+2 -2
View File
@@ -60,7 +60,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson' exclude group: 'com.google.code.gson', module: 'gson'
} }
api 'com.stirling:jpdfium:1.0.0' api 'com.stirling:jpdfium:1.0.1'
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64> // -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,7 +75,7 @@ dependencies {
} }
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}") logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform -> jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.0" runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1"
} }
// ArchUnit: enforces module dependency direction (see ArchitectureTest) // ArchUnit: enforces module dependency direction (see ArchitectureTest)
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
@@ -30,12 +31,13 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils; import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfSplit;
@GeneralApi @GeneralApi
@Slf4j @Slf4j
@@ -91,10 +93,14 @@ public class SplitPDFController {
try (ZipOutputStream zipOut = try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) { new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
if (hasForm) { if (hasForm) {
writeSplitsViaReload( // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary, which
// breaks Fill Forms downstream. Fall back to the PDFBox load-and-remove
// path so the AcroForm (with only fields whose widgets remain on kept
// pages) is preserved.
writeSplitsViaPdfBox(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut); sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut);
} else { } else {
writeSplitsViaSharedSource( writeSplitsViaJpdfium(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut); sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut);
} }
} }
@@ -109,7 +115,26 @@ public class SplitPDFController {
} }
} }
private void writeSplitsViaReload( private void writeSplitsViaJpdfium(
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
throws IOException {
try (PdfDocument sourceDoc = PdfDocument.open(source.toPath())) {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
try (PdfDocument splitDoc =
PdfSplit.extractPageRange(sourceDoc, previousPageNumber, splitPoint)) {
splitDoc.save(splitTemp.getPath());
}
writeEntry(zipOut, baseFilename, splitIndex + 1, splitTemp.getPath());
}
previousPageNumber = splitPoint + 1;
}
}
}
private void writeSplitsViaPdfBox(
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut) File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
throws IOException { throws IOException {
int previousPageNumber = 0; int previousPageNumber = 0;
@@ -129,33 +154,15 @@ public class SplitPDFController {
} }
FormUtils.pruneOrphanedFormFields(splitDoc); FormUtils.pruneOrphanedFormFields(splitDoc);
writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc); writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
} }
} }
} }
private void writeSplitsViaSharedSource( private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, Path pdfPath)
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
throws IOException { throws IOException {
try (PDDocument sourceDoc = pdfDocumentFactory.load(source)) { zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + index + ".pdf"));
int previousPageNumber = 0; Files.copy(pdfPath, zipOut);
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) { zipOut.closeEntry();
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;
}
}
}
} }
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc) private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc)
@@ -1,6 +1,10 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -8,9 +12,7 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
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.core.io.Resource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -35,10 +37,13 @@ import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfSplit;
@GeneralApi @GeneralApi
@Slf4j @Slf4j
@@ -51,72 +56,36 @@ public class SplitPdfByChaptersController {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private static List<Bookmark> extractOutlineItems( private static void collectBookmarks(
PDDocument sourceDocument, List<stirling.software.jpdfium.doc.Bookmark> source,
PDOutlineItem current, List<Bookmark> out,
List<Bookmark> bookmarks,
PDOutlineItem nextParent,
int level, int level,
int maxLevel) int maxLevel) {
throws Exception { for (stirling.software.jpdfium.doc.Bookmark bm : source) {
if (!bm.isInternal()) {
while (current != null) { continue;
}
String currentTitle = current.getTitle().replace("/", ""); String title = bm.title() == null ? "" : bm.title().replace("/", "");
int firstPage = int firstPage = Math.max(0, bm.pageIndex());
sourceDocument.getPages().indexOf(current.findDestinationPage(sourceDocument)); out.add(new Bookmark(title, firstPage, -2));
PDOutlineItem child = current.getFirstChild(); if (bm.hasChildren() && level < maxLevel) {
PDOutlineItem nextSibling = current.getNextSibling(); collectBookmarks(bm.children(), out, level + 1, maxLevel);
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
*/
} }
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);
} }
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; 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);
} }
return bookmarks;
} }
@AutoJobPostMapping( @AutoJobPostMapping(
@@ -134,46 +103,41 @@ public class SplitPdfByChaptersController {
MultipartFile file = request.getFileInput(); MultipartFile file = request.getFileInput();
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata()); boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel = Integer bookmarkLevel = request.getBookmarkLevel();
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) { if (bookmarkLevel < 0) {
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level"); "error.invalidArgument", "Invalid argument: {0}", "bookmark level");
} }
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) { try (TempFile sourceTempFile = new TempFile(tempFileManager, ".pdf")) {
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline(); Files.copy(
file.getInputStream(),
sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING);
if (outline == null) { List<Bookmark> bookmarks = new ArrayList<>();
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()); log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound", "No PDF bookmarks/outline found in document"); "error.pdfBookmarksNotFound",
"No PDF bookmarks/outline found in document");
} }
List<Bookmark> bookmarks = new ArrayList<>(); collectBookmarks(roots, bookmarks, 0, bookmarkLevel);
try { if (bookmarks.isEmpty()) {
bookmarks = log.warn("No outline found for {}", file.getOriginalFilename());
extractOutlineItems( throw ExceptionUtils.createIllegalArgumentException(
sourceDocument, "error.pdfBookmarksNotFound",
outline.getFirstChild(), "No PDF bookmarks/outline found in document");
bookmarks, }
outline.getFirstChild().getNextSibling(), assignEndPages(bookmarks, totalPages);
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;
} }
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates()); boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
if (!allowDuplicates) { 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); bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks);
} }
for (Bookmark bookmark : bookmarks) { for (Bookmark bookmark : bookmarks) {
@@ -184,7 +148,23 @@ public class SplitPdfByChaptersController {
bookmark.getEndPage()); bookmark.getEndPage());
} }
TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata); PdfMetadata metadata = null;
boolean hasForm = false;
if (includeMetadata) {
try (PDDocument metaDoc = pdfDocumentFactory.load(sourceTempFile.getFile())) {
metadata = pdfMetadataService.extractMetadataFromPdf(metaDoc);
PDAcroForm acroForm = metaDoc.getDocumentCatalog().getAcroForm(null);
hasForm = acroForm != null;
}
} else {
try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) {
hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null;
}
}
TempFile zipTempFile =
createZipFile(
sourceTempFile.getFile(), bookmarks, metadata, totalPages, hasForm);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), ""); String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip"); return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
} }
@@ -216,43 +196,40 @@ public class SplitPdfByChaptersController {
} }
private TempFile createZipFile( private TempFile createZipFile(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata) File sourceFile,
List<Bookmark> bookmarks,
PdfMetadata metadata,
int totalPages,
boolean hasForm)
throws Exception { throws Exception {
PdfMetadata metadata =
includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null;
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d "; String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
TempFile zipTempFile = new TempFile(tempFileManager, ".zip"); TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
try (ZipOutputStream zipOut = try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) { new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
if (hasForm) {
// JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary. For form
// PDFs, do the per-chapter extract via PDFBox so form fields survive the split.
for (int i = 0; i < bookmarks.size(); i++) { for (int i = 0; i < bookmarks.size(); i++) {
Bookmark bookmark = bookmarks.get(i); writeChapterViaPdfBox(
try (PDDocument splitDocument = new PDDocument()) { sourceFile,
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage()); bookmarks.get(i),
i,
for (int pg = bookmark.getStartPage(); fileNumberFormatter,
pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0); metadata,
pg++) { zipOut,
PDPage page = sourceDocument.getPage(pg); totalPages);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", pg);
} }
if (includeMetadata) { } else {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata); try (PdfDocument sourceDocument = PdfDocument.open(sourceFile.toPath())) {
} for (int i = 0; i < bookmarks.size(); i++) {
writeChapterViaJpdfium(
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf" sourceDocument,
String fileName = bookmarks.get(i),
String.format(Locale.ROOT, fileNumberFormatter, i) i,
+ bookmark.getTitle() fileNumberFormatter,
+ ".pdf"; metadata,
zipOut.putNextEntry(new ZipEntry(fileName)); zipOut,
splitDocument.save(zipOut); totalPages);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
} }
} }
} }
@@ -265,6 +242,97 @@ public class SplitPdfByChaptersController {
throw e; throw e;
} }
} }
private void writeChapterViaJpdfium(
PdfDocument sourceDocument,
Bookmark bookmark,
int index,
String fileNumberFormatter,
PdfMetadata metadata,
ZipOutputStream zipOut,
int totalPages)
throws Exception {
int[] range = clampRange(bookmark, totalPages);
int from = range[0];
int to = range[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();
}
writeZipEntry(zipOut, fileNumberFormatter, index, bookmark.getTitle(), finalPath);
} finally {
if (metaTemp != null) {
metaTemp.close();
}
}
}
}
private void writeChapterViaPdfBox(
File sourceFile,
Bookmark bookmark,
int index,
String fileNumberFormatter,
PdfMetadata metadata,
ZipOutputStream zipOut,
int totalPages)
throws Exception {
int[] range = clampRange(bookmark, totalPages);
int from = range[0];
int to = range[1];
try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) {
for (int p = doc.getNumberOfPages() - 1; p >= 0; p--) {
if (p < from || p > to) {
doc.removePage(p);
}
}
FormUtils.pruneOrphanedFormFields(doc);
if (metadata != null) {
pdfMetadataService.setMetadataToPdf(doc, metadata);
}
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, index)
+ bookmark.getTitle()
+ ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
doc.save(zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
}
}
private void writeZipEntry(
ZipOutputStream zipOut,
String fileNumberFormatter,
int index,
String title,
Path pdfPath)
throws IOException {
String fileName = String.format(Locale.ROOT, fileNumberFormatter, index) + title + ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
Files.copy(pdfPath, zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
}
private static int[] clampRange(Bookmark bookmark, int totalPages) {
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);
return new int[] {from, to};
}
} }
@Data @Data
@@ -1,9 +1,9 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
@@ -13,7 +13,6 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -37,6 +36,8 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfSplit;
@GeneralApi @GeneralApi
@Slf4j @Slf4j
@@ -75,23 +76,27 @@ public class SplitPdfBySizeController {
sourceTempFile.getPath(), sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING); StandardCopyOption.REPLACE_EXISTING);
try (PDDocument sourceDocument = boolean hasForm;
pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) {
boolean hasForm = sourceDocument.getDocumentCatalog().getAcroForm(null) != null; hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null;
List<List<Integer>> ranges = computeRanges(request, sourceDocument); }
try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) {
List<int[]> ranges = computeRanges(request, sourceDocument);
int fileIndex = 1; int fileIndex = 1;
for (List<Integer> range : ranges) { for (int[] range : ranges) {
if (range.isEmpty()) { if (range.length == 0) {
continue; continue;
} }
if (hasForm) { writeRange(
writeRangeViaReload( sourceDocument,
sourceTempFile.getFile(), range, zipOut, filename, fileIndex++); sourceTempFile.getFile(),
} else { range,
writeRangeViaSharedSource( zipOut,
sourceDocument, range, zipOut, filename, fileIndex++); filename,
} fileIndex++,
hasForm);
} }
} }
} }
@@ -104,29 +109,54 @@ public class SplitPdfBySizeController {
} }
} }
private List<List<Integer>> computeRanges( private List<int[]> computeRanges(SplitPdfBySizeOrCountRequest request, PdfDocument sourceDoc)
SplitPdfBySizeOrCountRequest request, PDDocument sourceDocument) throws IOException { throws IOException {
int type = request.getSplitType(); int type = request.getSplitType();
String value = request.getSplitValue(); String value = request.getSplitValue();
if (type == 0) { if (type == 0) {
return computeSizeRanges(sourceDocument, GeneralUtils.convertSizeToBytes(value)); return computeSizeRanges(sourceDoc, GeneralUtils.convertSizeToBytes(value));
} else if (type == 1) { } else if (type == 1) {
return computePageCountRanges(sourceDocument, Integer.parseInt(value)); return computePageCountRanges(sourceDoc, Integer.parseInt(value));
} else if (type == 2) { } else if (type == 2) {
return computeDocCountRanges(sourceDocument, Integer.parseInt(value)); return computeDocCountRanges(sourceDoc, Integer.parseInt(value));
} }
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "split type: " + type); "error.invalidArgument", "Invalid argument: {0}", "split type: " + type);
} }
private void writeRangeViaReload( private void writeRange(
PdfDocument sourceDoc,
File sourceFile, File sourceFile,
List<Integer> keepIndices, int[] range,
ZipOutputStream zipOut,
String baseFilename,
int fileIndex,
boolean hasForm)
throws IOException {
if (hasForm) {
// JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary, breaking form
// fields downstream. For form-bearing PDFs, do the extract via PDFBox so the
// AcroForm survives (pruneOrphanedFormFields removes references to dropped pages).
writeRangeViaPdfBox(sourceFile, range, zipOut, baseFilename, fileIndex);
} else {
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
extractRangeToFile(sourceDoc, range, splitTemp.getPath());
writeEntry(zipOut, baseFilename, fileIndex, splitTemp.getPath());
}
}
}
private void writeRangeViaPdfBox(
File sourceFile,
int[] range,
ZipOutputStream zipOut, ZipOutputStream zipOut,
String baseFilename, String baseFilename,
int fileIndex) int fileIndex)
throws IOException { throws IOException {
Set<Integer> keep = new HashSet<>(keepIndices); Set<Integer> keep = new HashSet<>();
for (int p : range) {
keep.add(p);
}
try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) { try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) {
for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) { for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) {
if (!keep.contains(i)) { if (!keep.contains(i)) {
@@ -134,50 +164,41 @@ public class SplitPdfBySizeController {
} }
} }
FormUtils.pruneOrphanedFormFields(doc); FormUtils.pruneOrphanedFormFields(doc);
writeEntry(zipOut, baseFilename, fileIndex, doc);
}
}
private void writeRangeViaSharedSource(
PDDocument sourceDocument,
List<Integer> keepIndices,
ZipOutputStream zipOut,
String baseFilename,
int fileIndex)
throws IOException {
try (PDDocument doc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
for (int p : keepIndices) {
doc.addPage(sourceDocument.getPage(p));
}
writeEntry(zipOut, baseFilename, fileIndex, doc);
}
}
private void writeEntry(
ZipOutputStream zipOut, String baseFilename, int fileIndex, PDDocument doc)
throws IOException {
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf")); zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf"));
doc.save(zipOut); doc.save(zipOut);
zipOut.closeEntry(); zipOut.closeEntry();
} }
}
/** Page-index ranges each output should contain. AcroForm overhead isn't modeled. */ private void extractRangeToFile(PdfDocument sourceDoc, int[] range, Path outputPath)
private List<List<Integer>> computeSizeRanges(PDDocument sourceDocument, long maxBytes)
throws IOException { throws IOException {
List<List<Integer>> ranges = new ArrayList<>(); int from = range[0];
List<Integer> currentRange = new ArrayList<>(); int to = range[range.length - 1];
int totalPages = sourceDocument.getNumberOfPages(); try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) {
split.save(outputPath);
}
}
private void writeEntry(
ZipOutputStream zipOut, String baseFilename, int fileIndex, Path pdfPath)
throws IOException {
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf"));
Files.copy(pdfPath, zipOut);
zipOut.closeEntry();
}
/** 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; int baseCheckFrequency = 5;
int rangeStart = 0;
PDDocument scratch = new PDDocument(); int rangeEnd = -1;
try { try (TempFile probe = new TempFile(tempFileManager, ".pdf")) {
File probeFile = probe.getFile();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage page = sourceDocument.getPage(pageIndex); rangeEnd = pageIndex;
scratch.addPage(new PDPage(page.getCOSObject())); int pageAdded = rangeEnd - rangeStart + 1;
currentRange.add(pageIndex);
int pageAdded = currentRange.size();
boolean shouldCheckSize = boolean shouldCheckSize =
(pageAdded % baseCheckFrequency == 0) (pageAdded % baseCheckFrequency == 0)
|| (pageIndex == totalPages - 1) || (pageIndex == totalPages - 1)
@@ -185,117 +206,110 @@ public class SplitPdfBySizeController {
if (!shouldCheckSize) { if (!shouldCheckSize) {
continue; continue;
} }
long actualSize = saveRange(sourceDoc, rangeStart, rangeEnd, probeFile);
long actualSize;
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
scratch.save(out);
actualSize = out.size();
}
if (actualSize > maxBytes) { if (actualSize > maxBytes) {
if (scratch.getNumberOfPages() > 1) { if (pageAdded > 1) {
scratch.removePage(scratch.getNumberOfPages() - 1); rangeEnd = pageIndex - 1;
currentRange.remove(currentRange.size() - 1); pageIndex--;
pageIndex--; // retry this page in the next chunk
} }
ranges.add(new ArrayList<>(currentRange)); ranges.add(buildRange(rangeStart, rangeEnd));
currentRange.clear(); rangeStart = rangeEnd + 1;
scratch.close(); rangeEnd = rangeStart - 1;
scratch = new PDDocument();
} else if (pageIndex < totalPages - 1 && actualSize < maxBytes * 0.75) { } else if (pageIndex < totalPages - 1 && actualSize < maxBytes * 0.75) {
int extraPagesAdded = int extra =
lookAheadFit(scratch, sourceDocument, pageIndex, maxBytes); lookAheadFit(
for (int i = 0; i < extraPagesAdded; i++) { sourceDoc,
int extra = pageIndex + 1 + i; rangeStart,
scratch.addPage(new PDPage(sourceDocument.getPage(extra).getCOSObject())); pageIndex,
currentRange.add(extra); maxBytes,
} totalPages,
pageIndex += extraPagesAdded; probeFile);
pageIndex += extra;
rangeEnd = pageIndex;
} }
} }
if (!currentRange.isEmpty()) {
ranges.add(new ArrayList<>(currentRange));
} }
} finally { if (rangeEnd >= rangeStart) {
scratch.close(); ranges.add(buildRange(rangeStart, rangeEnd));
} }
return ranges; return ranges;
} }
/** Speculatively tries up to 5 next pages; returns how many fit under {@code maxBytes}. */ private long saveRange(PdfDocument sourceDoc, int from, int to, File output)
private int lookAheadFit(PDDocument scratch, PDDocument source, int pageIndex, long maxBytes)
throws IOException { throws IOException {
int totalPages = source.getNumberOfPages(); try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) {
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1); split.save(output.toPath());
if (pagesToLookAhead == 0) { }
return 0; return output.length();
} }
int extraPagesAdded = 0; private int lookAheadFit(
try (PDDocument testDoc = new PDDocument()) { PdfDocument sourceDoc,
for (int i = 0; i < scratch.getNumberOfPages(); i++) { int rangeStart,
testDoc.addPage(new PDPage(scratch.getPage(i).getCOSObject())); 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++) { for (int i = 0; i < pagesToLookAhead; i++) {
testDoc.addPage(new PDPage(source.getPage(pageIndex + 1 + i).getCOSObject())); int trialEnd = currentEnd + 1 + i;
long testSize; long size = saveRange(sourceDoc, rangeStart, trialEnd, probeFile);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { if (size > maxBytes) {
testDoc.save(out);
testSize = out.size();
}
if (testSize > maxBytes) {
break; break;
} }
extraPagesAdded++; extra++;
} }
} return extra;
return extraPagesAdded;
} }
private List<List<Integer>> computePageCountRanges(PDDocument sourceDocument, int pageCount) { private List<int[]> computePageCountRanges(PdfDocument sourceDoc, int pageCount) {
if (pageCount <= 0) { if (pageCount <= 0) {
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount); "error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount);
} }
int totalPages = sourceDocument.getNumberOfPages(); int totalPages = sourceDoc.pageCount();
List<List<Integer>> ranges = new ArrayList<>(); List<int[]> ranges = new ArrayList<>();
List<Integer> current = new ArrayList<>(pageCount); int start = 0;
for (int i = 0; i < totalPages; i++) { while (start < totalPages) {
current.add(i); int end = Math.min(start + pageCount - 1, totalPages - 1);
if (current.size() == pageCount) { ranges.add(buildRange(start, end));
ranges.add(current); start = end + 1;
current = new ArrayList<>(pageCount);
}
}
if (!current.isEmpty()) {
ranges.add(current);
} }
return ranges; return ranges;
} }
private List<List<Integer>> computeDocCountRanges( private List<int[]> computeDocCountRanges(PdfDocument sourceDoc, int documentCount) {
PDDocument sourceDocument, int documentCount) {
if (documentCount <= 0) { if (documentCount <= 0) {
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "error.invalidArgument",
"Invalid argument: {0}", "Invalid argument: {0}",
"document count: " + documentCount); "document count: " + documentCount);
} }
int totalPages = sourceDocument.getNumberOfPages(); int totalPages = sourceDoc.pageCount();
int pagesPerDocument = totalPages / documentCount; int pagesPerDocument = totalPages / documentCount;
int extraPages = totalPages % documentCount; int extraPages = totalPages % documentCount;
List<int[]> ranges = new ArrayList<>();
List<List<Integer>> ranges = new ArrayList<>();
int cursor = 0; int cursor = 0;
for (int i = 0; i < documentCount; i++) { for (int i = 0; i < documentCount; i++) {
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
List<Integer> range = new ArrayList<>(pagesToAdd); if (pagesToAdd == 0) {
for (int j = 0; j < pagesToAdd; j++) { continue;
range.add(cursor++);
} }
ranges.add(range); int end = cursor + pagesToAdd - 1;
ranges.add(buildRange(cursor, end));
cursor = end + 1;
} }
return ranges; 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;
}
} }
@@ -4,17 +4,28 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.Loader;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle; 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.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -23,8 +34,12 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.SplitPagesRequest; import stirling.software.SPDF.model.api.SplitPagesRequest;
@@ -32,6 +47,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPDFControllerTest { class SplitPDFControllerTest {
@TempDir Path tempDir; @TempDir Path tempDir;
@@ -47,6 +63,12 @@ class SplitPDFControllerTest {
String suffix = invocation.getArgument(0); String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile(); 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 { private byte[] createPdf(int numPages) throws IOException {
@@ -60,17 +82,79 @@ class SplitPDFControllerTest {
} }
} }
private void setupFactory() throws IOException { private byte[] createPdfWithForm(int numPages) throws IOException {
when(pdfDocumentFactory.load(any(File.class), eq(true))) try (PDDocument doc = new PDDocument()) {
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); PDAcroForm acroForm = new PDAcroForm(doc);
when(pdfDocumentFactory.load(any(File.class))) doc.getDocumentCatalog().setAcroForm(acroForm);
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); for (int i = 0; i < numPages; i++) {
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) PDPage page = new PDPage(PDRectangle.A4);
.thenAnswer(inv -> new PDDocument()); 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 @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 { void shouldSplitAtPage3() throws Exception {
byte[] pdfBytes = createPdf(6); byte[] pdfBytes = createPdf(6);
MockMultipartFile file = MockMultipartFile file =
@@ -81,11 +165,12 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("3"); request.setPageNumbers("3");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(3, 3);
} }
@Test @Test
@@ -100,11 +185,12 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("1,2,3"); request.setPageNumbers("1,2,3");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
} }
@Test @Test
@@ -119,15 +205,16 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("1"); request.setPageNumbers("1");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(pageCountsOf(outputs)).containsExactly(1);
} }
@Test @Test
@DisplayName("Should split with range notation") @DisplayName("Should split with multiple split points")
void shouldSplitWithRange() throws Exception { void shouldSplitWithRange() throws Exception {
byte[] pdfBytes = createPdf(10); byte[] pdfBytes = createPdf(10);
MockMultipartFile file = MockMultipartFile file =
@@ -138,11 +225,12 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("3,7"); request.setPageNumbers("3,7");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(3, 4, 3);
} }
@Test @Test
@@ -157,13 +245,14 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("2"); request.setPageNumbers("2");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType()) assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM); .isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
} }
@Test @Test
@@ -178,11 +267,12 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("5"); request.setPageNumbers("5");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(pageCountsOf(outputs)).containsExactly(5);
} }
@Test @Test
@@ -197,29 +287,43 @@ class SplitPDFControllerTest {
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("all"); request.setPageNumbers("all");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
} }
@Test @Test
@DisplayName("Should handle file without extension in original name") @DisplayName("Should preserve AcroForm and per-page widgets when splitting form PDF")
void shouldHandleFileWithoutExtension() throws Exception { void shouldSplitFormPdf() throws Exception {
byte[] pdfBytes = createPdf(2); byte[] pdfBytes = createPdfWithForm(4);
MockMultipartFile file = MockMultipartFile file =
new MockMultipartFile( new MockMultipartFile(
"fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes); "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPagesRequest request = new SplitPagesRequest(); SplitPagesRequest request = new SplitPagesRequest();
request.setFileInput(file); request.setFileInput(file);
request.setPageNumbers("1"); request.setPageNumbers("2");
setupFactory(); ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); 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.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; 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 static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.Loader;
import org.apache.pdfbox.pdmodel.PDDocument; 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.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness; import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest; import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -55,6 +64,12 @@ class SplitPdfByChaptersControllerTest {
String suffix = inv.getArgument(0); String suffix = inv.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile(); 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 { 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 @Test
@DisplayName("Should split PDF by chapters") @DisplayName("Should split PDF by chapters")
void shouldSplitByChapters() throws Exception { void shouldSplitByChapters() throws Exception {
@@ -97,12 +135,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false); request.setIncludeMetadata(false);
request.setAllowDuplicates(false); request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class))) ResponseEntity<Resource> response = controller.splitPdf(request);
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(totalPagesOf(outputs)).isEqualTo(6);
} }
@Test @Test
@@ -119,12 +157,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false); request.setIncludeMetadata(false);
request.setAllowDuplicates(true); request.setAllowDuplicates(true);
when(pdfDocumentFactory.load(any(MultipartFile.class))) ResponseEntity<Resource> response = controller.splitPdf(request);
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(totalPagesOf(outputs)).isEqualTo(4);
} }
@Test @Test
@@ -163,10 +201,6 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false); request.setIncludeMetadata(false);
request.setAllowDuplicates(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)); assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request));
} }
} }
@@ -185,12 +219,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false); request.setIncludeMetadata(false);
request.setAllowDuplicates(false); request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class))) ResponseEntity<Resource> response = controller.splitPdf(request);
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(totalPagesOf(outputs)).isEqualTo(3);
} }
@Test @Test
@@ -207,14 +241,15 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(true); request.setIncludeMetadata(true);
request.setAllowDuplicates(false); request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class))) lenient()
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); .when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
.thenReturn(new stirling.software.common.model.PdfMetadata()); .thenReturn(new stirling.software.common.model.PdfMetadata());
var response = controller.splitPdf(request); ResponseEntity<Resource> response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(totalPagesOf(outputs)).isEqualTo(4);
} }
@Test @Test
@@ -231,12 +266,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false); request.setIncludeMetadata(false);
request.setAllowDuplicates(false); request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class))) ResponseEntity<Resource> response = controller.splitPdf(request);
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(totalPagesOf(outputs)).isEqualTo(6);
} }
@Test @Test
@@ -253,11 +288,11 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false); request.setIncludeMetadata(false);
request.setAllowDuplicates(true); request.setAllowDuplicates(true);
when(pdfDocumentFactory.load(any(MultipartFile.class))) ResponseEntity<Resource> response = controller.splitPdf(request);
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); 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.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.Loader;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle; 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.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -23,6 +33,9 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -33,6 +46,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPdfBySizeControllerTest { class SplitPdfBySizeControllerTest {
@TempDir Path tempDir; @TempDir Path tempDir;
@@ -48,69 +62,189 @@ class SplitPdfBySizeControllerTest {
String suffix = invocation.getArgument(0); String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile(); 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 private byte[] createPdf(int numPages) throws IOException {
@DisplayName("Should split by page count successfully")
void shouldSplitByPageCount() throws Exception {
byte[] pdfBytes;
try (PDDocument doc = new PDDocument()) { 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)); doc.addPage(new PDPage(PDRectangle.A4));
} }
Path pdfPath = tempDir.resolve("input.pdf"); Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile()); 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 = MockMultipartFile file =
new MockMultipartFile( new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file); request.setFileInput(file);
request.setSplitType(1); // Page count request.setSplitType(1);
request.setSplitValue("2"); request.setSplitValue("2");
when(pdfDocumentFactory.load(any(File.class), eq(true))) ResponseEntity<Resource> response = controller.autoSplitPdf(request);
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<?> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType()) assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM); .isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 1);
} }
@Test @Test
@DisplayName("Should split by document count successfully") @DisplayName("Should split by document count into 3 even documents")
void shouldSplitByDocCount() throws Exception { void shouldSplitByDocCount() throws Exception {
byte[] pdfBytes; byte[] pdfBytes = createPdf(6);
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);
}
MockMultipartFile file = MockMultipartFile file =
new MockMultipartFile( new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file); request.setFileInput(file);
request.setSplitType(2); // Document count request.setSplitType(2);
request.setSplitValue("3"); // Split into 3 docs (2 pages each) request.setSplitValue("3");
when(pdfDocumentFactory.load(any(File.class), eq(true))) ResponseEntity<Resource> response = controller.autoSplitPdf(request);
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<?> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); 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);
} }
} }
@@ -11,7 +11,7 @@
# X64_JAVA_HOME path to an x86_64 JDK with jmods/ # X64_JAVA_HOME path to an x86_64 JDK with jmods/
# JLINK_MODULES comma-separated module list (matches desktop.yml) # JLINK_MODULES comma-separated module list (matches desktop.yml)
# OUTPUT_DIR target directory (will be wiped); defaults to # OUTPUT_DIR target directory (will be wiped); defaults to
# frontend/src-tauri/runtime/jre # frontend/editor/src-tauri/runtime/jre
set -euo pipefail set -euo pipefail
@@ -19,7 +19,7 @@ set -euo pipefail
: "${X64_JAVA_HOME:?X64_JAVA_HOME must be set}" : "${X64_JAVA_HOME:?X64_JAVA_HOME must be set}"
: "${JLINK_MODULES:?JLINK_MODULES must be set}" : "${JLINK_MODULES:?JLINK_MODULES must be set}"
OUTPUT_DIR="${OUTPUT_DIR:-frontend/src-tauri/runtime/jre}" OUTPUT_DIR="${OUTPUT_DIR:-frontend/editor/src-tauri/runtime/jre}"
if [[ "$(uname -s)" != "Darwin" ]]; then if [[ "$(uname -s)" != "Darwin" ]]; then
echo "build-universal-mac-jre.sh only runs on macOS" >&2 echo "build-universal-mac-jre.sh only runs on macOS" >&2
@@ -51,7 +51,7 @@ npm run tauri-build
To build the DLL standalone: To build the DLL standalone:
```bash ```bash
cd frontend/src-tauri/thumbnail-handler cd frontend/editor/src-tauri/thumbnail-handler
cargo build --release cargo build --release
# Output: target/release/stirling_thumbnail_handler.dll # Output: target/release/stirling_thumbnail_handler.dll
``` ```
@@ -31,14 +31,14 @@ if [ -n "${1:-}" ]; then
BOOTJARS+=("$1") BOOTJARS+=("$1")
else else
for cand in app/core/build/libs/stirling-pdf-*.jar \ for cand in app/core/build/libs/stirling-pdf-*.jar \
frontend/src-tauri/libs/stirling-pdf-*.jar; do frontend/editor/src-tauri/libs/stirling-pdf-*.jar; do
[ -f "$cand" ] || continue [ -f "$cand" ] || continue
BOOTJARS+=("$cand") BOOTJARS+=("$cand")
done done
fi fi
if [ "${#BOOTJARS[@]:-0}" = 0 ]; then if [ "${#BOOTJARS[@]:-0}" = 0 ]; then
echo "bootJar not found (expected app/core/build/libs/stirling-pdf-*.jar" \ echo "bootJar not found (expected app/core/build/libs/stirling-pdf-*.jar" \
"or frontend/src-tauri/libs/stirling-pdf-*.jar)" "or frontend/editor/src-tauri/libs/stirling-pdf-*.jar)"
exit 0 exit 0
fi fi
+3 -2
View File
@@ -442,9 +442,10 @@ compare_file_lists() {
echo "New files created during test:" echo "New files created during test:"
cat "${diff_file}.added" | sed 's/^> //' cat "${diff_file}.added" | sed 's/^> //'
# Exclude JPDFium native cache (deleteOnExit-registered, not a leak). # Exclude JPDFium native cache + merge seed temp files
# (both deleteOnExit-registered, not leaks).
grep -i "tmp\|temp" "${diff_file}.added" \ grep -i "tmp\|temp" "${diff_file}.added" \
| grep -v '/jpdfium-[0-9]\+/' \ | grep -v '/jpdfium-' \
> "${diff_file}.tmp" || true > "${diff_file}.tmp" || true
if [ -s "${diff_file}.tmp" ]; then if [ -s "${diff_file}.tmp" ]; then
echo "WARNING: Temporary files detected:" echo "WARNING: Temporary files detected:"