diff --git a/.dockerignore b/.dockerignore index 4ecd420aa..85355537d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 6fe125c37..9eb38bde3 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -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: diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 8828d00d1..ee0b28c88 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -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: diff --git a/.gitignore b/.gitignore index 5c3bd57cc..40be55155 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/app/common/build.gradle b/app/common/build.gradle index 95f555759..508a97825 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -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| 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) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java index 0d37cea55..d4516c0ed 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java @@ -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 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 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 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) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java index 27d533082..6ad85d0c9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java @@ -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 extractOutlineItems( - PDDocument sourceDocument, - PDOutlineItem current, - List bookmarks, - PDOutlineItem nextParent, + private static void collectBookmarks( + List source, + List 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 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 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 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 bookmarks, boolean includeMetadata) + File sourceFile, + List 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 diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java index ead2f12e1..e8ff5d9e2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java @@ -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> 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 ranges = computeRanges(request, sourceDocument); int fileIndex = 1; - for (List 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> computeRanges( - SplitPdfBySizeOrCountRequest request, PDDocument sourceDocument) throws IOException { + private List 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 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 keep = new HashSet<>(keepIndices); + Set 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 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> computeSizeRanges(PDDocument sourceDocument, long maxBytes) - throws IOException { - List> ranges = new ArrayList<>(); - List currentRange = new ArrayList<>(); - int totalPages = sourceDocument.getNumberOfPages(); + /** Returns contiguous page-index ranges fitting within {@code maxBytes}. */ + private List computeSizeRanges(PdfDocument sourceDoc, long maxBytes) throws IOException { + List 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> 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 computePageCountRanges(PdfDocument sourceDoc, int pageCount) { if (pageCount <= 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount); } - int totalPages = sourceDocument.getNumberOfPages(); - List> ranges = new ArrayList<>(); - List 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 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> computeDocCountRanges( - PDDocument sourceDocument, int documentCount) { + private List 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> ranges = new ArrayList<>(); + List ranges = new ArrayList<>(); int cursor = 0; for (int i = 0; i < documentCount; i++) { int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); - List 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; + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java index 49a8d8bfc..1cceefadc 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java @@ -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 fieldNamesOf(byte[] pdfBytes) throws IOException { + List 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 unzip(Resource zipResource) throws IOException { + List 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 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java index 39e711b31..c9c9b618f 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java @@ -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 unzip(Resource zipResource) throws IOException { + List 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 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(5); + assertThat(totalPagesOf(outputs)).isEqualTo(10); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java index 1e4765218..51523eced 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java @@ -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 unzip(Resource zipResource) throws IOException { + List 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 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 response = controller.autoSplitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + List 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 response = controller.autoSplitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 fieldNamesOf(byte[] pdfBytes) throws IOException { + List 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 response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List 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 response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).isNotEmpty(); + int total = 0; + for (int count : pageCountsOf(outputs)) { + total += count; + } + assertThat(total).isEqualTo(20); } } diff --git a/frontend/editor/scripts/build-universal-mac-jre.sh b/frontend/editor/scripts/build-universal-mac-jre.sh index a8fa6ce4d..baf66016b 100755 --- a/frontend/editor/scripts/build-universal-mac-jre.sh +++ b/frontend/editor/scripts/build-universal-mac-jre.sh @@ -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 diff --git a/frontend/editor/src-tauri/thumbnail-handler/README.md b/frontend/editor/src-tauri/thumbnail-handler/README.md index 3deffaab9..27c1ffdfc 100644 --- a/frontend/editor/src-tauri/thumbnail-handler/README.md +++ b/frontend/editor/src-tauri/thumbnail-handler/README.md @@ -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 ``` diff --git a/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh b/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh index f65d02f83..bed635a47 100644 --- a/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh +++ b/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh @@ -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 diff --git a/testing/test.sh b/testing/test.sh index a9146c06c..1ab271704 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -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:"