mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
+2
-2
@@ -34,8 +34,8 @@ frontend/playwright-report/
|
||||
# Tauri/desktop builds
|
||||
src-tauri/target/
|
||||
src-tauri/dist/
|
||||
frontend/src-tauri/target/
|
||||
frontend/src-tauri/dist/
|
||||
frontend/editor/src-tauri/target/
|
||||
frontend/editor/src-tauri/dist/
|
||||
|
||||
# IDE and editor
|
||||
.idea/
|
||||
|
||||
@@ -69,8 +69,8 @@ labels:
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src-tauri/.*'
|
||||
- 'frontend/editor/src-tauri/**'
|
||||
- 'frontend/editor/src-tauri/.*'
|
||||
|
||||
- label: 'engine'
|
||||
files:
|
||||
|
||||
@@ -4,6 +4,10 @@ name: Build Tauri Applications
|
||||
# Linux). Called from build.yml on PRs that touch desktop sources (gated
|
||||
# via the `tauri` filter in .github/config/.files.yaml). Also runnable
|
||||
# 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:
|
||||
workflow_call:
|
||||
inputs:
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
frontend/src-tauri/provisioner/target
|
||||
frontend/editor/src-tauri/provisioner/target
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -60,7 +60,7 @@ dependencies {
|
||||
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>
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
@@ -75,7 +75,7 @@ dependencies {
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
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)
|
||||
|
||||
+33
-26
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -30,12 +31,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
|
||||
@@ -91,10 +93,14 @@ public class SplitPDFController {
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
|
||||
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);
|
||||
} else {
|
||||
writeSplitsViaSharedSource(
|
||||
writeSplitsViaJpdfium(
|
||||
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)
|
||||
throws IOException {
|
||||
int previousPageNumber = 0;
|
||||
@@ -129,33 +154,15 @@ public class SplitPDFController {
|
||||
}
|
||||
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)
|
||||
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, Path pdfPath)
|
||||
throws IOException {
|
||||
try (PDDocument sourceDoc = pdfDocumentFactory.load(source)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + index + ".pdf"));
|
||||
Files.copy(pdfPath, zipOut);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
|
||||
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc)
|
||||
|
||||
+196
-128
@@ -1,6 +1,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.List;
|
||||
import java.util.Locale;
|
||||
@@ -8,9 +12,7 @@ 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.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
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.PdfMetadataService;
|
||||
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
|
||||
@@ -51,72 +56,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 +103,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 +148,23 @@ public class SplitPdfByChaptersController {
|
||||
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(), "");
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
|
||||
}
|
||||
@@ -216,43 +196,40 @@ public class SplitPdfByChaptersController {
|
||||
}
|
||||
|
||||
private TempFile createZipFile(
|
||||
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
|
||||
File sourceFile,
|
||||
List<Bookmark> bookmarks,
|
||||
PdfMetadata metadata,
|
||||
int totalPages,
|
||||
boolean hasForm)
|
||||
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()))) {
|
||||
try (ZipOutputStream zipOut =
|
||||
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++) {
|
||||
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;
|
||||
writeChapterViaPdfBox(
|
||||
sourceFile,
|
||||
bookmarks.get(i),
|
||||
i,
|
||||
fileNumberFormatter,
|
||||
metadata,
|
||||
zipOut,
|
||||
totalPages);
|
||||
}
|
||||
} else {
|
||||
try (PdfDocument sourceDocument = PdfDocument.open(sourceFile.toPath())) {
|
||||
for (int i = 0; i < bookmarks.size(); i++) {
|
||||
writeChapterViaJpdfium(
|
||||
sourceDocument,
|
||||
bookmarks.get(i),
|
||||
i,
|
||||
fileNumberFormatter,
|
||||
metadata,
|
||||
zipOut,
|
||||
totalPages);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +242,97 @@ public class SplitPdfByChaptersController {
|
||||
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
|
||||
|
||||
+142
-128
@@ -1,9 +1,9 @@
|
||||
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;
|
||||
@@ -13,7 +13,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.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
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.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfSplit;
|
||||
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@@ -75,23 +76,27 @@ 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,
|
||||
sourceTempFile.getFile(),
|
||||
range,
|
||||
zipOut,
|
||||
filename,
|
||||
fileIndex++,
|
||||
hasForm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,29 +109,54 @@ 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(
|
||||
private void writeRange(
|
||||
PdfDocument sourceDoc,
|
||||
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,
|
||||
String baseFilename,
|
||||
int fileIndex)
|
||||
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)) {
|
||||
for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
if (!keep.contains(i)) {
|
||||
@@ -134,50 +164,41 @@ public class SplitPdfBySizeController {
|
||||
}
|
||||
}
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
writeEntry(zipOut, baseFilename, fileIndex, doc);
|
||||
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf"));
|
||||
doc.save(zipOut);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
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 +206,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;
|
||||
}
|
||||
}
|
||||
|
||||
+142
-38
@@ -4,17 +4,28 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -23,8 +34,12 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.SplitPagesRequest;
|
||||
@@ -32,6 +47,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class SplitPDFControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@@ -47,6 +63,12 @@ class SplitPDFControllerTest {
|
||||
String suffix = invocation.getArgument(0);
|
||||
return Files.createTempFile(tempDir, "test", suffix).toFile();
|
||||
});
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
}
|
||||
|
||||
private byte[] createPdf(int numPages) throws IOException {
|
||||
@@ -60,17 +82,79 @@ class SplitPDFControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupFactory() throws IOException {
|
||||
when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
private byte[] createPdfWithForm(int numPages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm acroForm = new PDAcroForm(doc);
|
||||
doc.getDocumentCatalog().setAcroForm(acroForm);
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDTextField field = new PDTextField(acroForm);
|
||||
field.setPartialName("text_p" + (i + 1));
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
|
||||
widget.setPage(page);
|
||||
field.setWidgets(java.util.List.of(widget));
|
||||
page.getAnnotations().add(widget);
|
||||
acroForm.getFields().add(field);
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
return Files.readAllBytes(pdfPath);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> fieldNamesOf(byte[] pdfBytes) throws IOException {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null);
|
||||
if (acroForm == null) {
|
||||
return names;
|
||||
}
|
||||
for (PDField field : acroForm.getFields()) {
|
||||
names.add(field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private int widgetCountOnPage(byte[] pdfBytes, int pageIndex) throws IOException {
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
int count = 0;
|
||||
for (PDAnnotation a : doc.getPage(pageIndex).getAnnotations()) {
|
||||
if (a instanceof PDAnnotationWidget) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private List<byte[]> unzip(Resource zipResource) throws IOException {
|
||||
List<byte[]> entries = new ArrayList<>();
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries.add(zis.readAllBytes());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private int[] pageCountsOf(List<byte[]> entries) throws IOException {
|
||||
int[] counts = new int[entries.size()];
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
try (PDDocument doc = Loader.loadPDF(entries.get(i))) {
|
||||
counts[i] = doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split 6-page PDF at page 3")
|
||||
@DisplayName("Should split 6-page PDF at page 3 into 2 parts")
|
||||
void shouldSplitAtPage3() throws Exception {
|
||||
byte[] pdfBytes = createPdf(6);
|
||||
MockMultipartFile file =
|
||||
@@ -81,11 +165,12 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("3");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(3, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -100,11 +185,12 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("1,2,3");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,15 +205,16 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("1");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(1);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split with range notation")
|
||||
@DisplayName("Should split with multiple split points")
|
||||
void shouldSplitWithRange() throws Exception {
|
||||
byte[] pdfBytes = createPdf(10);
|
||||
MockMultipartFile file =
|
||||
@@ -138,11 +225,12 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("3,7");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(3, 4, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,13 +245,14 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("2");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -178,11 +267,12 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("5");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(1);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -197,29 +287,43 @@ class SplitPDFControllerTest {
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("all");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle file without extension in original name")
|
||||
void shouldHandleFileWithoutExtension() throws Exception {
|
||||
byte[] pdfBytes = createPdf(2);
|
||||
@DisplayName("Should preserve AcroForm and per-page widgets when splitting form PDF")
|
||||
void shouldSplitFormPdf() throws Exception {
|
||||
byte[] pdfBytes = createPdfWithForm(4);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
|
||||
SplitPagesRequest request = new SplitPagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("1");
|
||||
request.setPageNumbers("2");
|
||||
|
||||
setupFactory();
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
|
||||
|
||||
assertThat(fieldNamesOf(outputs.get(0)))
|
||||
.as("first split keeps fields whose widgets are on pages 1-2")
|
||||
.containsExactlyInAnyOrder("text_p1", "text_p2");
|
||||
assertThat(fieldNamesOf(outputs.get(1)))
|
||||
.as("second split keeps fields whose widgets are on pages 3-4")
|
||||
.containsExactlyInAnyOrder("text_p3", "text_p4");
|
||||
|
||||
assertThat(widgetCountOnPage(outputs.get(0), 0)).isEqualTo(1);
|
||||
assertThat(widgetCountOnPage(outputs.get(0), 1)).isEqualTo(1);
|
||||
assertThat(widgetCountOnPage(outputs.get(1), 0)).isEqualTo(1);
|
||||
assertThat(widgetCountOnPage(outputs.get(1), 1)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
+64
-29
@@ -4,11 +4,19 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -27,10 +35,11 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -55,6 +64,12 @@ class SplitPdfByChaptersControllerTest {
|
||||
String suffix = inv.getArgument(0);
|
||||
return Files.createTempFile(tempDir, "test", suffix).toFile();
|
||||
});
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
}
|
||||
|
||||
private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException {
|
||||
@@ -83,6 +98,29 @@ class SplitPdfByChaptersControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private List<byte[]> unzip(Resource zipResource) throws IOException {
|
||||
List<byte[]> entries = new ArrayList<>();
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries.add(zis.readAllBytes());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private int totalPagesOf(List<byte[]> entries) throws IOException {
|
||||
int total = 0;
|
||||
for (byte[] data : entries) {
|
||||
try (PDDocument doc = Loader.loadPDF(data)) {
|
||||
total += doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split PDF by chapters")
|
||||
void shouldSplitByChapters() throws Exception {
|
||||
@@ -97,12 +135,12 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(false);
|
||||
request.setAllowDuplicates(false);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(totalPagesOf(outputs)).isEqualTo(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,12 +157,12 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(false);
|
||||
request.setAllowDuplicates(true);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(totalPagesOf(outputs)).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -163,10 +201,6 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(false);
|
||||
request.setAllowDuplicates(false);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request));
|
||||
}
|
||||
}
|
||||
@@ -185,12 +219,12 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(false);
|
||||
request.setAllowDuplicates(false);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(1);
|
||||
assertThat(totalPagesOf(outputs)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -207,14 +241,15 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(true);
|
||||
request.setAllowDuplicates(false);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
|
||||
lenient()
|
||||
.when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
|
||||
.thenReturn(new stirling.software.common.model.PdfMetadata());
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(totalPagesOf(outputs)).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -231,12 +266,12 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(false);
|
||||
request.setAllowDuplicates(false);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(totalPagesOf(outputs)).isEqualTo(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -253,11 +288,11 @@ class SplitPdfByChaptersControllerTest {
|
||||
request.setIncludeMetadata(false);
|
||||
request.setAllowDuplicates(true);
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
var response = controller.splitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(5);
|
||||
assertThat(totalPagesOf(outputs)).isEqualTo(10);
|
||||
}
|
||||
}
|
||||
|
||||
+166
-32
@@ -4,17 +4,27 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -23,6 +33,9 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -33,6 +46,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class SplitPdfBySizeControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@@ -48,69 +62,189 @@ class SplitPdfBySizeControllerTest {
|
||||
String suffix = invocation.getArgument(0);
|
||||
return Files.createTempFile(tempDir, "test", suffix).toFile();
|
||||
});
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by page count successfully")
|
||||
void shouldSplitByPageCount() throws Exception {
|
||||
byte[] pdfBytes;
|
||||
private byte[] createPdf(int numPages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
pdfBytes = Files.readAllBytes(pdfPath);
|
||||
return Files.readAllBytes(pdfPath);
|
||||
}
|
||||
}
|
||||
|
||||
private List<byte[]> unzip(Resource zipResource) throws IOException {
|
||||
List<byte[]> entries = new ArrayList<>();
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries.add(zis.readAllBytes());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private int[] pageCountsOf(List<byte[]> entries) throws IOException {
|
||||
int[] counts = new int[entries.size()];
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
try (PDDocument doc = Loader.loadPDF(entries.get(i))) {
|
||||
counts[i] = doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by page count into 2-page chunks")
|
||||
void shouldSplitByPageCount() throws Exception {
|
||||
byte[] pdfBytes = createPdf(5);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(1); // Page count
|
||||
request.setSplitType(1);
|
||||
request.setSplitValue("2");
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
ResponseEntity<?> response = controller.autoSplitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by document count successfully")
|
||||
@DisplayName("Should split by document count into 3 even documents")
|
||||
void shouldSplitByDocCount() throws Exception {
|
||||
byte[] pdfBytes;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 6; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
pdfBytes = Files.readAllBytes(pdfPath);
|
||||
}
|
||||
|
||||
byte[] pdfBytes = createPdf(6);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(2); // Document count
|
||||
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
|
||||
request.setSplitType(2);
|
||||
request.setSplitValue("3");
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
ResponseEntity<?> response = controller.autoSplitPdf(request);
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by document count distributing extras")
|
||||
void shouldSplitByDocCountWithRemainder() throws Exception {
|
||||
byte[] pdfBytes = createPdf(7);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(2);
|
||||
request.setSplitValue("3");
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(3);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(3, 2, 2);
|
||||
}
|
||||
|
||||
private byte[] createPdfWithForm(int numPages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm acroForm = new PDAcroForm(doc);
|
||||
doc.getDocumentCatalog().setAcroForm(acroForm);
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDTextField field = new PDTextField(acroForm);
|
||||
field.setPartialName("text_p" + (i + 1));
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
|
||||
widget.setPage(page);
|
||||
field.setWidgets(java.util.List.of(widget));
|
||||
page.getAnnotations().add(widget);
|
||||
acroForm.getFields().add(field);
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
return Files.readAllBytes(pdfPath);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> fieldNamesOf(byte[] pdfBytes) throws IOException {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null);
|
||||
if (acroForm == null) {
|
||||
return names;
|
||||
}
|
||||
for (PDField field : acroForm.getFields()) {
|
||||
names.add(field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve AcroForm when splitting form PDF by page count")
|
||||
void shouldPreserveFormFieldsWhenSplitting() throws Exception {
|
||||
byte[] pdfBytes = createPdfWithForm(4);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(1);
|
||||
request.setSplitValue("2");
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
|
||||
assertThat(fieldNamesOf(outputs.get(0))).containsExactlyInAnyOrder("text_p1", "text_p2");
|
||||
assertThat(fieldNamesOf(outputs.get(1))).containsExactlyInAnyOrder("text_p3", "text_p4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by size into multiple files")
|
||||
void shouldSplitBySize() throws Exception {
|
||||
byte[] pdfBytes = createPdf(20);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(0);
|
||||
request.setSplitValue("3KB");
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).isNotEmpty();
|
||||
int total = 0;
|
||||
for (int count : pageCountsOf(outputs)) {
|
||||
total += count;
|
||||
}
|
||||
assertThat(total).isEqualTo(20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# X64_JAVA_HOME path to an x86_64 JDK with jmods/
|
||||
# JLINK_MODULES comma-separated module list (matches desktop.yml)
|
||||
# OUTPUT_DIR target directory (will be wiped); defaults to
|
||||
# frontend/src-tauri/runtime/jre
|
||||
# frontend/editor/src-tauri/runtime/jre
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -19,7 +19,7 @@ set -euo pipefail
|
||||
: "${X64_JAVA_HOME:?X64_JAVA_HOME 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
|
||||
echo "build-universal-mac-jre.sh only runs on macOS" >&2
|
||||
|
||||
@@ -51,7 +51,7 @@ npm run tauri-build
|
||||
To build the DLL standalone:
|
||||
|
||||
```bash
|
||||
cd frontend/src-tauri/thumbnail-handler
|
||||
cd frontend/editor/src-tauri/thumbnail-handler
|
||||
cargo build --release
|
||||
# Output: target/release/stirling_thumbnail_handler.dll
|
||||
```
|
||||
|
||||
@@ -31,14 +31,14 @@ if [ -n "${1:-}" ]; then
|
||||
BOOTJARS+=("$1")
|
||||
else
|
||||
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
|
||||
BOOTJARS+=("$cand")
|
||||
done
|
||||
fi
|
||||
if [ "${#BOOTJARS[@]:-0}" = 0 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
+3
-2
@@ -442,9 +442,10 @@ compare_file_lists() {
|
||||
echo "New files created during test:"
|
||||
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 -v '/jpdfium-[0-9]\+/' \
|
||||
| grep -v '/jpdfium-' \
|
||||
> "${diff_file}.tmp" || true
|
||||
if [ -s "${diff_file}.tmp" ]; then
|
||||
echo "WARNING: Temporary files detected:"
|
||||
|
||||
Reference in New Issue
Block a user