From 29ccbf7ae6fdc14b8ca9a2ca9f84595cd41bec3b Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Thu, 21 May 2026 19:03:31 +0100 Subject: [PATCH] Trim verbose comments across JPDFium integration --- .github/workflows/multiOSReleases.yml | 8 +- .github/workflows/tauri-build.yml | 37 +--- .taskfiles/desktop.yml | 10 +- app/common/build.gradle | 21 +- .../common/jpdfium/JPDFiumSmokeTest.java | 6 +- app/core/build.gradle | 9 +- .../SPDF/controller/api/MergeController.java | 149 ++++---------- .../software/SPDF/bench/MergeBenchmark.java | 188 ++++-------------- build.gradle | 16 +- .../scripts/sign-jpdfium-dylibs-in-bootjar.sh | 56 ++---- frontend/src-tauri/src/commands/backend.rs | 5 +- testing/test.sh | 6 +- 12 files changed, 116 insertions(+), 395 deletions(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 5f7cc124d0..504f60e910 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -256,9 +256,7 @@ jobs: if: matrix.platform == 'macos-15' env: AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }} - # universal-mac transitively triggers jlink:jar; pin the JPDFium - # natives to both darwin arches so the x86_64 half of the - # universal bundle has dlopen-able libs at runtime. + # Both darwin arches for the universal bundle. JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:jlink:universal-mac @@ -268,9 +266,7 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} DISABLE_ADDITIONAL_FEATURES: true - # Tell jlink:jar to bundle only this matrix entry's JPDFium natives - # into the bootJar — saves ~45 MB out of the ~58 MB JPDFium overhead - # per Tauri bundle vs shipping all 5 OSes. + # Bundle only this matrix entry's natives; saves ~45 MB per Tauri bundle. JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:prepare diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index df7c1b255e..2c3dc459ba 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -47,17 +47,13 @@ jobs: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} PLATFORM: ${{ inputs.platform }} run: | - # jpdfium_platforms: comma-separated subset of JPDFium natives to bundle into - # the per-matrix bootJar — Tauri bundles only run on the OS they were built - # for, so shipping foreign-OS natives wastes ~45 MB. macOS uses both - # darwin-arm64 + darwin-x64 because the --target universal-apple-darwin - # bundle is fat (both archs in one .app). + # jpdfium_platforms: subset of JPDFium natives to bundle into this matrix entry's bootJar. + # macOS gets both darwin arches because universal-apple-darwin is a fat bundle. WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}' MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}' LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}' - # Resolve requested platform — populated by either workflow_dispatch - # or workflow_call inputs; both paths default to "all". + # Resolve requested platform (workflow_dispatch or workflow_call inputs; default "all"). case "$PLATFORM" in windows) ENTRIES=("$WINDOWS") ;; macos) ENTRIES=("$MACOS") ;; @@ -117,10 +113,8 @@ jobs: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - # x86_64 JDK is set up first so the aarch64 step below can leave its - # JAVA_HOME as the active one. The macOS universal JRE build needs - # jmods from both arches; the x64 path is captured into the env - # before the second setup-java overwrites JAVA_HOME. + # Capture x86_64 JAVA_HOME before the next setup-java overwrites it; the universal + # JRE build needs jmods from both arches. - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 @@ -147,16 +141,12 @@ jobs: - name: Setup Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - # Build the universal JRE before desktop:prepare so the jlink:runtime - # task short-circuits on its `test -d runtime/jre` status check. + # Build the universal JRE before desktop:prepare so jlink:runtime short-circuits. - name: Build universal macOS JRE if: matrix.platform == 'macos-15' env: AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }} - # universal-mac calls jlink:jar transitively → bootJar build needs - # both darwin natives. Without this it'd default to host-arch only - # and the x86_64 half of the universal bundle would crash on ICU - # / qpdf / harfbuzz dlopen at startup. + # Both darwin arches for the universal bundle. JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:jlink:universal-mac @@ -166,9 +156,7 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} DISABLE_ADDITIONAL_FEATURES: true - # Tell jlink:jar (called transitively) to bundle only this matrix - # entry's natives into the bootJar — saves ~45 MB out of the - # ~58 MB JPDFium overhead per bundle vs shipping all 5 OSes. + # Bundle only this matrix entry's natives; saves ~45 MB per Tauri bundle. JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }} run: task desktop:prepare @@ -284,13 +272,8 @@ jobs: echo "Certificate imported successfully." - name: Sign JPDFium dylibs inside bootJar (macOS only) - # JPDFium's publish workflow has no Apple Developer credentials, so - # the .dylibs it ships in jpdfium-natives-darwin-*.jar are unsigned. - # Apple's notarytool walks into nested .jars inside the .app and - # rejects unsigned binaries. Tauri's own codesign walk doesn't open - # .jars, so we have to re-sign them here, between cert import and - # tauri-action, using this build's Developer ID identity (set in - # GITHUB_ENV by the Verify Certificate step above). + # JPDFium ships unsigned dylibs; notarytool rejects them inside the .app. + # APPLE_SIGNING_IDENTITY is exported by the Verify Certificate step above. if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' run: bash frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index 1f8a157db9..2e7b82b434 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -3,13 +3,9 @@ version: '3' vars: JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" - # JPDFium natives to bundle into the bootJar (and therefore the Tauri - # desktop bundle). The bundle only ever runs on the OS it was built for, - # so default to current-host-only and skip the other ~45 MB of foreign- - # OS natives. Override with JPDFIUM_PLATFORMS env when needed: - # JPDFIUM_PLATFORMS=all # server / portable - # JPDFIUM_PLATFORMS=darwin-arm64,darwin-x64 # universal-apple-darwin - # JPDFIUM_PLATFORMS=windows-x64 # explicit single + # JPDFium natives bundled into the Tauri bootJar. Defaults to current host + # (drops ~45 MB of foreign-OS natives). Override via JPDFIUM_PLATFORMS env + # (comma list of platform keys, or 'all'). JPDFIUM_PLATFORMS: sh: | if [ -n "${JPDFIUM_PLATFORMS:-}" ]; then diff --git a/app/common/build.gradle b/app/common/build.gradle index a6d9da20da..3cdd652ab1 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -60,26 +60,11 @@ dependencies { exclude group: 'com.google.code.gson', module: 'gson' } - // Stirling-Tools JPDFium — Java 25 FFM bindings for the EmbedPDF PDFium fork. - // Provides PDF rendering, text extraction, content-stripping redaction, etc. - // Resolves from Maven Central; per-platform natives jars ship the - // .so/.dylib/.dll bundles, NativeLoader picks the right one at runtime. api 'com.stirling:jpdfium:1.0.0' - // Per-platform JPDFium natives selection. - // - // -PjpdfiumPlatforms=all (default — server jar - // ships every OS, runs - // anywhere) - // -PjpdfiumPlatforms=darwin-arm64 (single platform — Tauri - // macOS arm64 bundle) - // -PjpdfiumPlatforms=darwin-x64,darwin-arm64 (multi — universal macOS) - // - // Each platform's natives jar is STORED into the bootJar (~11-12 MB raw bytes - // each — Spring Boot doesn't recompress nested jars). Building a per-OS Tauri - // bundle with only that OS's natives saves ~45 MB out of the ~58 MB JPDFium - // overhead. Default 'all' keeps the server / Docker / Maven Central jar - // portable across hosts. + // -PjpdfiumPlatforms accepts 'all' (default) or a comma list of platform keys + // (linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64). Per-OS Tauri + // bundles use this to ship only their target's natives jar (~11-12 MB each). def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all' diff --git a/app/common/src/test/java/stirling/software/common/jpdfium/JPDFiumSmokeTest.java b/app/common/src/test/java/stirling/software/common/jpdfium/JPDFiumSmokeTest.java index 2511d41836..83298987ba 100644 --- a/app/common/src/test/java/stirling/software/common/jpdfium/JPDFiumSmokeTest.java +++ b/app/common/src/test/java/stirling/software/common/jpdfium/JPDFiumSmokeTest.java @@ -13,11 +13,7 @@ import org.junit.jupiter.api.io.TempDir; import stirling.software.jpdfium.PdfDocument; -/** - * Validates that the Stirling-Tools JPDFium snapshot artifacts resolve from Maven Central snapshots - * and that the FFM bridge loads and parses a PDF end-to-end. If the test passes, the package is - * consumable by downstream projects with no auth, no PAT, and no extra repo setup. - */ +/** End-to-end smoke check: JPDFium resolves from Maven Central and opens a sample PDF. */ class JPDFiumSmokeTest { @Test diff --git a/app/core/build.gradle b/app/core/build.gradle index f1decefaff..8cdb62f0e2 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -163,13 +163,8 @@ bootJar { attributes( 'Implementation-Title': 'Stirling-PDF', 'Implementation-Version': project.version, - // JDK 22+ honors this manifest attribute on the executable jar: - // grant native-access (FFM restricted methods) without needing - // --enable-native-access on the CLI / JAVA_TOOL_OPTIONS at launch. - // ALL-UNNAMED matches the unnamed module that Spring Boot's fat - // jar runs as (everything on the classpath), which includes - // JPDFium and its FFM-using internals. - // Ref: https://openjdk.org/jeps/472 ('Enable-Native-Access' attr) + // JEP 472: grants FFM native-access to the unnamed module so we + // don't need --enable-native-access on the launch command. 'Enable-Native-Access': 'ALL-UNNAMED' ) } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 43b79e26a0..bfe75979b6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -63,7 +63,6 @@ public class MergeController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final TempFileManager tempFileManager; - // Merges a list of PDDocument objects into a single PDDocument public PDDocument mergeDocuments(List documents) throws IOException { PDDocument mergedDoc = pdfDocumentFactory.createNewDocument(); boolean success = false; @@ -82,11 +81,9 @@ public class MergeController { } } - // Re-order files to match the explicit order provided by the front-end. // fileOrder is newline-delimited original filenames in the desired order. private static MultipartFile[] reorderFilesByProvidedOrder( MultipartFile[] files, String fileOrder) { - // Split by various line endings and trim each entry String[] desired = stirling.software.common.util.RegexPatternUtils.getInstance() .getNewlineSplitPattern() @@ -113,7 +110,6 @@ public class MergeController { return ordered.toArray(new MultipartFile[0]); } - // Returns a comparator for sorting MultipartFile arrays based on the given sort type private Comparator getSortComparator(String sortType) { return switch (sortType) { case "byFileName" -> @@ -161,18 +157,16 @@ public class MergeController { return 0; } }; - case "orderProvided" -> (file1, file2) -> 0; // Default is the order provided - default -> (file1, file2) -> 0; // Default is the order provided + case "orderProvided" -> (file1, file2) -> 0; + default -> (file1, file2) -> 0; }; } - // Parse client file IDs from JSON string private String[] parseClientFileIds(String clientFileIds) { if (clientFileIds == null || clientFileIds.trim().isEmpty()) { return new String[0]; } try { - // Simple JSON array parsing - remove brackets and split by comma String trimmed = clientFileIds.trim(); if (trimmed.startsWith("[") && trimmed.endsWith("]")) { String inside = trimmed.substring(1, trimmed.length() - 1).trim(); @@ -192,39 +186,29 @@ public class MergeController { return new String[0]; } - // Adds a table of contents to the merged document using filenames as chapter titles private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) { - // Create the document outline PDDocumentOutline outline = new PDDocumentOutline(); mergedDocument.getDocumentCatalog().setDocumentOutline(outline); - int pageIndex = 0; // Current page index in the merged document - - // Iterate through the original files + int pageIndex = 0; for (MultipartFile file : files) { - // Get the filename without extension to use as bookmark title String filename = file.getOriginalFilename(); String title = GeneralUtils.removeExtension(filename); - // Create an outline item for this file PDOutlineItem item = new PDOutlineItem(); item.setTitle(title); - // Set the destination to the first page of this file in the merged document if (pageIndex < mergedDocument.getNumberOfPages()) { PDPage page = mergedDocument.getPage(pageIndex); item.setDestination(page); } - - // Add the item to the outline outline.addLast(item); - // Increment page index for the next file try (PDDocument doc = pdfDocumentFactory.load(file)) { pageIndex += doc.getNumberOfPages(); } catch (IOException e) { ExceptionUtils.logException("document loading for TOC generation", e); - pageIndex++; // Increment by at least one if we can't determine page count + pageIndex++; } } } @@ -242,7 +226,7 @@ public class MergeController { } } - // Fallback to XMP metadata if Info dates are missing + // Fall back to XMP if Info dict has no dates. PDMetadata metadata = doc.getDocumentCatalog().getMetadata(); if (metadata != null) { try (InputStream is = metadata.createInputStream()) { @@ -293,7 +277,7 @@ public class MergeController { @ModelAttribute MergePdfsRequest request, @RequestParam(value = "fileOrder", required = false) String fileOrder) throws IOException { - List filesToDelete = new ArrayList<>(); // List of temporary files to delete + List filesToDelete = new ArrayList<>(); TempFile outputTempFile = null; boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign()); @@ -304,57 +288,38 @@ public class MergeController { files = new MultipartFile[0]; } - // If front-end provided explicit visible order, honor it and override backend sorting + // Front-end visible order, when provided, wins over the request's sortType. if (fileOrder != null && !fileOrder.isBlank()) { log.info("Reordering files based on fileOrder parameter"); files = reorderFilesByProvidedOrder(files, fileOrder); } else { log.info("Sorting files based on sortType: {}", request.getSortType()); - Arrays.sort( - files, - getSortComparator( - request.getSortType())); // Sort files based on requested sort type + Arrays.sort(files, getSortComparator(request.getSortType())); } try (TempFile mt = new TempFile(tempFileManager, ".pdf")) { - // Stage each MultipartFile to a real File and pre-validate via JPDFium's - // cheap header-parse open. Pre-validation surfaces which input is corrupted - // BEFORE we attempt to merge, so the error tells the user which file is - // bad rather than a generic "merge failed". + // Stage each upload to disk and pre-validate via JPDFium's header parse so + // a corrupted input gets flagged by index rather than as a generic merge fail. List inputPaths = new ArrayList<>(files.length); List invalidIndexes = new ArrayList<>(); for (int index = 0; index < files.length; index++) { MultipartFile multipartFile = files[index]; - File tempFile = - tempFileManager.convertMultipartFileToFile( - multipartFile); // Convert MultipartFile to File - filesToDelete.add(tempFile); // Add temp file to the list for later deletion + File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile); + filesToDelete.add(tempFile); inputPaths.add(tempFile.toPath()); try (PdfDocument ignored = PdfDocument.open(tempFile.toPath())) { - // OK — header parsed cleanly + // header parsed cleanly } catch (Exception e) { ExceptionUtils.logException("PDF pre-validate", e); invalidIndexes.add(index); } } - // Merge via JPDFium's native PDFium-backed importer. PDFium runs - // entirely off-heap on its own arena allocator, so the Java heap - // footprint stays flat at the size of the bridge handles (KB-scale) - // rather than ballooning with the size of the input PDFs. - // Apache PDFBox's PDFMergerUtility, by contrast, materialises every - // PDF object as a Java COSObject — on a 1.3 GB image-heavy merge - // this is the difference between ~330 MB peak heap and ~1.43 GB. - // - // PDFium's FPDF_ImportPagesByIndex carries PAGES, not the outline - // tree, so we capture each source's bookmarks (with the page - // offset where its pages will land) BEFORE merge, then inject the - // combined outline via the streaming setBookmarks variant. This - // matches PDFBox's default "preserve source bookmarks" behaviour - // without forcing us to load the merged 1.3 GB doc back into - // PDFBox just to add an outline. + // PDFium runs off-heap, so peak Java heap stays roughly constant in input size. + // FPDF_ImportPagesByIndex only carries pages, so we capture+offset bookmarks + // ourselves and inject them via the streaming setBookmarks. int[] pageCounts; try { pageCounts = @@ -367,11 +332,9 @@ public class MergeController { throw e; } - // Signature removal still needs PDFBox's per-field AcroForm flatten - // — JPDFium's flatten is a full-page bake that would also fuse - // non-signature widgets into the content stream. We pre-check via - // JPDFium's signatures() so the PDFBox round-trip only runs when - // the merged document actually contains signature fields. + // Signature flatten still uses PDFBox (JPDFium's flatten fuses ALL widgets, + // not just signature fields). Skip the PDFBox round-trip when there's nothing to + // flatten. boolean sigFlattenNeeded = false; if (removeCertSign) { try (PdfDocument check = PdfDocument.open(mt.getFile().toPath())) { @@ -386,7 +349,7 @@ public class MergeController { if (!sigFlattenNeeded) { log.info( "removeCertSign requested but merged document has no signature" - + " fields — skipping PDFBox flatten pass"); + + " fields; skipping PDFBox flatten pass"); } } @@ -413,9 +376,8 @@ public class MergeController { } } } else { - // Fast path: the merged temp file IS the output. Move it into - // a fresh TempFile handle so the caller's response can close - // it independently of `mt`'s try-with-resources scope. + // Promote the merged temp file to a fresh TempFile so the response + // owns it independently of mt's try-with-resources. outputTempFile = new TempFile(tempFileManager, ".pdf"); try { Files.copy( @@ -440,7 +402,7 @@ public class MergeController { throw ex; } finally { for (File file : filesToDelete) { - tempFileManager.deleteTempFile(file); // Delete temporary files + tempFileManager.deleteTempFile(file); } } @@ -452,38 +414,19 @@ public class MergeController { } /** - * JPDFium-backed merge with bookmark preservation. + * Merge {@code inputPaths} via JPDFium, preserving source bookmarks with page-offset + * translation and optionally prepending a TOC chapter header per source. * - *

Opens every source PDF natively (off-heap PDFium arena allocator), captures each source's - * bookmarks with the page offset where its pages will land in the merged document, runs - * PDFium's page importer to assemble the merged content, builds a combined {@link BookmarkTree} - * (TOC chapter headers when {@code generateToc} is true, followed by the offset-translated - * source bookmarks), and writes the result to {@code outputPath} via the streaming {@link - * PdfBookmarkEditor#setBookmarks(PdfDocument, BookmarkTree, Path)} — which appends the outline - * as an incremental update and never materialises the merged file in heap. - * - *

This restores PDFBox's "source bookmarks survive the merge" behaviour without forcing us - * to load the merged 1.3 GB document back into a PDDocument graph (which would erase the 76% - * heap saving). - * - * @param inputPaths staged source PDF paths in merge order - * @param files original MultipartFiles — used for TOC chapter titles - * @param generateToc when true, prepend a chapter-header bookmark per source (filename without - * extension, points at the first page of that source's contribution) - * @param outputPath where the merged PDF should be written * @return page-count-per-input array, parallel to {@code inputPaths} */ private int[] mergeWithJpdfium( List inputPaths, MultipartFile[] files, boolean generateToc, Path outputPath) throws IOException { if (inputPaths.isEmpty()) { - // No-op merge — write an empty PDF placeholder so callers always get a file. + // Match PDFBox: produce an empty file so callers always get an output. try (PdfDocument empty = PdfDocument.open(new byte[0])) { empty.save(outputPath); } catch (Exception ignored) { - // PdfDocument.open(byte[0]) will likely fail; write a literal - // empty file in that case. PDFBox would also produce an empty - // doc here, so behaviour parity is preserved. Files.write(outputPath, new byte[0]); } return new int[0]; @@ -501,9 +444,7 @@ public class MergeController { docs.add(doc); pageCounts[i] = doc.pageCount(); pageOffsets[i] = runningOffset; - // Capture the source's bookmark tree NOW while the doc is - // open. After merge the source docs get closed, so any - // bookmark traversal has to happen here. + // Bookmarks must be read while the source doc is still open. sourceBookmarks.add(doc.bookmarks()); runningOffset += pageCounts[i]; } @@ -513,12 +454,10 @@ public class MergeController { try (PdfDocument merged = PdfMerge.merge(docs)) { if (combinedTree.entries().isEmpty()) { - // No source bookmarks AND no TOC requested → just save. merged.save(outputPath); } else { - // setBookmarks streams the doc to outputPath then - // appends the outline as an incremental update — - // KB-scale heap regardless of merged-file size. + // Streams the merged doc to disk then appends the outline as + // an incremental update; heap stays KB-scale. PdfBookmarkEditor.setBookmarks(merged, combinedTree, outputPath); } } @@ -529,7 +468,7 @@ public class MergeController { try { doc.close(); } catch (Exception ignored) { - // best-effort close + // best-effort } } } @@ -537,13 +476,9 @@ public class MergeController { } /** - * Combine each source's bookmarks (with page-offset translation) plus the optional TOC chapter - * headers into a single flat {@link BookmarkTree}. - * - *

Hierarchy is flattened: a source bookmark's children become siblings in the merged - * outline. This matches the existing {@link BookmarkTree.Builder} API surface (only {@code add} - * for top-level entries) and covers the common single-level outline case. For deeply-nested - * source outlines, titles are still preserved but parent/child structure is lost. + * Flatten each source's bookmarks (children become siblings, parent/child structure is lost + * because {@link BookmarkTree.Builder} only takes top-level entries) with page-offset + * translation, optionally prepending a TOC chapter header per source. */ private BookmarkTree buildCombinedBookmarkTree( MultipartFile[] files, @@ -574,18 +509,11 @@ public class MergeController { } /** - * Iteratively walk {@code root} and its descendants depth-first using an explicit deque, - * appending each internal (GoTo-page) entry as a top-level bookmark with {@code offset} added - * to the page index. External-URI / launch bookmarks are skipped because they don't have a - * useful destination in the merged doc. - * - *

Iterative (not recursive) so a maliciously deep or cyclically self-referencing outline - * tree can't blow the JVM stack. A visited-set further prevents cycles from looping forever, - * and a hard cap on visited nodes bounds the work for pathological inputs. + * Iterative DFS over {@code root}, appending each GoTo-page bookmark as a top-level entry with + * {@code offset} added. Iterative + visited-set + node cap guard against hostile inputs (deep + * nesting, cycles, huge outlines). */ private void addBookmarkFlat(BookmarkTree.Builder builder, Bookmark root, int offset) { - // Cap roughly matches PDF outline limits in the wild; legitimate - // documents have hundreds to low thousands of entries at most. final int maxNodes = 100_000; java.util.Deque stack = new java.util.ArrayDeque<>(); java.util.Set visited = @@ -595,7 +523,7 @@ public class MergeController { while (!stack.isEmpty() && processed < maxNodes) { Bookmark bm = stack.pop(); if (!visited.add(bm)) { - continue; // cycle guard + continue; } processed++; if (bm.isInternal() && bm.title() != null) { @@ -603,8 +531,7 @@ public class MergeController { } if (bm.hasChildren()) { List children = bm.children(); - // Push in reverse so iteration order matches recursive - // depth-first traversal (left-to-right child order). + // Reverse-push keeps DFS left-to-right. for (int i = children.size() - 1; i >= 0; i--) { stack.push(children.get(i)); } diff --git a/app/core/src/test/java/stirling/software/SPDF/bench/MergeBenchmark.java b/app/core/src/test/java/stirling/software/SPDF/bench/MergeBenchmark.java index d0e187b959..a9c453cfec 100644 --- a/app/core/src/test/java/stirling/software/SPDF/bench/MergeBenchmark.java +++ b/app/core/src/test/java/stirling/software/SPDF/bench/MergeBenchmark.java @@ -46,36 +46,15 @@ import stirling.software.jpdfium.doc.PdfBookmarkEditor; import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree; /** - * Apples-to-apples memory benchmark for the merge tool: PDFBox's {@link PDFMergerUtility} (old - * MergeController path) vs JPDFium's {@link PdfMerge#mergeFiles(List)} (new path). + * Heap + wall-clock benchmark: PDFBox PDFMergerUtility vs JPDFium PdfMerge. * - *

Generates two 100-page test PDFs each with a unique embedded JPEG per page, merges them, - * samples heap usage every 25 ms during the merge, and reports peak heap. Run with: + *

Generates N synthetic image-heavy PDFs, merges them with each engine, samples heap every 25 ms + * during the merge, and reports peak heap (Java heap only - PDFium's off-heap arena is + * intentionally excluded, RSS is reported separately). * *

{@code
- * ./gradlew :stirling-pdf:test --tests '*MergeBenchmark*' -i
+ * ./gradlew :stirling-pdf:test --tests '*MergeBenchmark*' -Dmerge.bench=true
  * }
- * - * or invoke directly: - * - *
{@code
- * java -cp  stirling.software.SPDF.bench.MergeBenchmark
- * }
- * - *

Outputs: - * - *

    - *
  • Input PDF sizes - *
  • Pre-merge baseline heap (after forced GC) - *
  • PDFBox peak heap during merge - *
  • JPDFium peak heap during merge - *
  • Memory improvement % - *
- * - *

Off-heap / native memory used by JPDFium is NOT counted toward "heap peak" — that is the whole - * point of the comparison. PDFium's arena lives outside the JVM heap, so off-heap usage shows up as - * RSS growth which we report separately from {@code /proc/self/status} on Linux or {@code - * GetProcessMemoryInfo} via JMX on Windows. */ public final class MergeBenchmark { @@ -83,16 +62,11 @@ public final class MergeBenchmark { private static final int IMAGE_W = Integer.getInteger("merge.bench.imgW", 800); private static final int IMAGE_H = Integer.getInteger("merge.bench.imgH", 600); private static final int DOC_COUNT = Integer.getInteger("merge.bench.docs", 2); - // Inject this many internal bookmarks per generated input PDF. The - // merged document should contain (DOC_COUNT × INTERNAL_BOOKMARKS) - // entries from sources, plus DOC_COUNT chapter headers added by the - // TOC step. Lets us validate that source bookmarks are preserved - // through the merge. + // Sources will inject this many GoTo-page bookmarks each, so the merged doc + // should have DOC_COUNT*INTERNAL_BOOKMARKS source entries (+DOC_COUNT TOC headers when on). private static final int INTERNAL_BOOKMARKS = Integer.getInteger("merge.bench.internalBookmarks", 0); - // 0.0..1.0 JPEG quality. Default 0.6 keeps benchmarks small; - // bump to 0.95+ with larger image dimensions to push input PDFs into - // the hundreds-of-MB range. + // 0.0..1.0; bump toward 0.95 with larger images to drive inputs into the 100s of MB. private static final float JPEG_QUALITY = Float.parseFloat(System.getProperty("merge.bench.jpegQ", "0.6")); private static final boolean WITH_TOC = @@ -100,60 +74,29 @@ public final class MergeBenchmark { private static final boolean WITH_SIG_REMOVAL = Boolean.parseBoolean(System.getProperty("merge.bench.sigRemoval", "false")); - /** - * When true, the JPDFium engine writes TOC via {@link - * PdfBookmarkEditor#setBookmarks(PdfDocument, BookmarkTree, java.nio.file.Path)} (streaming, - * KB-scale heap) instead of doing a PDFBox load+save post-pass. Enables comparing the two TOC - * strategies in the same benchmark run. - */ + // When true, JPDFium TOC goes via streaming setBookmarks (KB-scale heap) instead of + // load+save through PDFBox. Lets the benchmark contrast the two strategies. private static boolean withJpdfiumToc = Boolean.parseBoolean(System.getProperty("merge.bench.jpdfiumToc", "false")); - // Run each scenario this many times back-to-back, report median heap - // and wall-clock. Cuts noise from GC scheduling + disk cache cold-start - // on the first iteration. Median is ~immune to a single warm-up outlier. + // Median over N iterations to absorb GC + disk cache outliers on the first run. private static final int ITERATIONS = Math.max(1, Integer.getInteger("merge.bench.iterations", 1)); private static final long SAMPLE_PERIOD_MS = 25L; - // Mutable scenario flags so compareAllMergeScenarios() can run the same - // benchmark loop three times without re-generating the 1+ GB of input - // PDFs. The static initialisers above set the defaults from system - // properties; the all-scenarios test toggles these per iteration. + // Mutable so compareAllMergeScenarios() can re-toggle scenarios without + // regenerating the inputs. private static boolean withToc = WITH_TOC; private static boolean withSigRemoval = WITH_SIG_REMOVAL; - /** - * Invoke explicitly with: - * - *

{@code
-     * ./gradlew :stirling-pdf:test --tests '*MergeBenchmark*' \
-     *     -Dmerge.bench=true -Dmerge.bench.docs=4 -Dmerge.bench.pages=70
-     * }
- * - *

Gated behind {@code -Dmerge.bench=true} so it stays out of normal CI runs — generating the - * 1+ GB of input PDFs takes ~60 s and the merges burn another ~30 s, neither of which belongs - * in regular test cycles. - */ + /** Gated by -Dmerge.bench=true (generating the inputs alone takes ~60s). */ @Test @EnabledIfSystemProperty(named = "merge.bench", matches = "true") void compareMergeMemoryFootprint() throws Exception { main(new String[0]); } - /** - * Runs three back-to-back scenarios on a single shared set of inputs: - * - *

    - *
  1. plain merge (no TOC, no sig removal) - *
  2. merge + TOC generation - *
  3. merge + signature removal - *
- * - *

Saves ~10 minutes of wall-clock vs running three separate test invocations because the 1+ - * GB of input PDFs only gets generated once. Gated behind {@code -Dmerge.bench=true} like the - * other benchmark. - */ + /** Plain + TOC + sigRemoval scenarios on a single shared input set. */ @Test @EnabledIfSystemProperty(named = "merge.bench", matches = "true") void compareAllMergeScenarios() throws Exception { @@ -190,8 +133,7 @@ public final class MergeBenchmark { .sum() / 1024); - // One small warmup pass against a single doc — enough to JIT the - // hot merge code paths without paying the full benchmark cost. + // One-doc warmup to JIT the hot paths. System.out.println("--- Warmup pass (1 input, results discarded) ---"); withToc = false; withSigRemoval = false; @@ -238,11 +180,11 @@ public final class MergeBenchmark { } System.out.println(); } finally { - // Leave inputs/outputs on disk for inspection. + // inputs/outputs left on disk for inspection } } - /** Run one scenario (defined by the two flags) on a shared set of inputs. */ + /** One scenario on a shared set of inputs. */ private static ScenarioRow runScenario( String name, List inputs, Path workDir, boolean toc, boolean sig) throws Exception { @@ -253,10 +195,6 @@ public final class MergeBenchmark { System.out.printf("--- Scenario: %s (TOC=%s, removeCertSign=%s) ---%n", name, toc, sig); System.out.printf(" baseline heap: %,d KB%n", baseline / 1024); - // Run each engine ITERATIONS times back-to-back; report median heap - // and median wall-clock. Median filters out a single warm-up outlier - // or a transient GC pause without needing to discard "the first run" - // by hand. Path outPdfbox = workDir.resolve("out-" + name + "-pdfbox.pdf"); IterResult pdfBox = runIterations("PDFBox", () -> runPdfBoxMerge(inputs, outPdfbox), baseline); @@ -271,8 +209,6 @@ public final class MergeBenchmark { int jpdfiumBookmarks = countBookmarks(outJpdfium); printIter("JPDFium", jpdfium, Files.size(outJpdfium), jpdfiumBookmarks); - // Validation: with INTERNAL_BOOKMARKS=10 and DOC_COUNT=4 we expect - // 40 source bookmarks. Adding TOC contributes 4 chapter headers. int expectedFromSources = INTERNAL_BOOKMARKS * inputs.size(); int expectedTocChapters = toc ? inputs.size() : 0; System.out.printf( @@ -347,8 +283,6 @@ public final class MergeBenchmark { inputs.add(workDir.resolve("doc-" + (char) ('a' + i) + ".pdf")); } - // Build inputs in a fresh JVM-warm phase BEFORE the timed runs so - // class loading / JIT warmup doesn't pollute the merge samples. System.out.printf( "Scenario: TOC=%s, removeCertSign=%s, jpegQuality=%.2f%n", withToc, withSigRemoval, JPEG_QUALITY); @@ -365,11 +299,8 @@ public final class MergeBenchmark { long buildMs = (System.nanoTime() - t0) / 1_000_000; System.out.printf(" generation took %,d ms%n%n", buildMs); - // Warmup pass: prime classloaders + native lib load + JIT so the - // FIRST measured run doesn't get blamed for everything that's - // normally amortised across many requests. For big inputs (>50 MB - // per doc) we shrink the warmup to a single input — the JIT only - // needs to see the hot code paths, not run on the full payload. + // Warmup primes classloaders + native lib + JIT. For big inputs + // a single doc is enough to hit the hot paths. long perDocBytes = Files.size(inputs.getFirst()); List warmupInputs = perDocBytes > 50L * 1024 * 1024 ? List.of(inputs.getFirst()) : inputs; @@ -380,32 +311,25 @@ public final class MergeBenchmark { forceGcQuiescence(); System.out.println(); - // Baseline heap snapshot — what the JVM uses with the inputs on - // disk but no merge running. Both runs should start from this - // floor. long baseline = sampleUsedHeapAfterGc(); System.out.printf( "Baseline heap (after GC, before merge): %,d KB%n%n", baseline / 1024); - // === PDFBox run === System.out.println("--- PDFBox PDFMergerUtility ---"); Path outPdfbox = workDir.resolve("out-pdfbox.pdf"); BenchResult pdfboxResult = profile(() -> runPdfBoxMerge(inputs, outPdfbox)); System.out.printf(" output size : %,d KB%n", Files.size(outPdfbox) / 1024); pdfboxResult.printSummary("PDFBox", baseline); - // GC + cooldown between runs so JPDFium's measurement starts clean. forceGcQuiescence(); System.out.println(); - // === JPDFium run === System.out.println("--- JPDFium PdfMerge.mergeFiles ---"); Path outJpdfium = workDir.resolve("out-jpdfium.pdf"); BenchResult jpdfiumResult = profile(() -> runJpdfiumMerge(inputs, outJpdfium)); System.out.printf(" output size : %,d KB%n", Files.size(outJpdfium) / 1024); jpdfiumResult.printSummary("JPDFium", baseline); - // === Compare === System.out.println(); System.out.println("=== Heap delta (over baseline) ==="); long pdfboxDelta = pdfboxResult.peakHeapBytes - baseline; @@ -427,17 +351,11 @@ public final class MergeBenchmark { System.out.println(" " + outPdfbox); System.out.println(" " + outJpdfium); } finally { - // Leave temp files in place so the user can re-inspect — workDir - // is under the OS temp area so it gets cleaned up by the OS - // eventually anyway. + // workDir is under the OS temp area; cleanup happens eventually } } - /** - * Build a PDF with the given page count. Each page gets a unique JPEG image (procedurally - * generated, ~30-40 KB per page) and a small text caption — close enough to a "report with - * figures" workload that the comparison reflects real merge cost. - */ + /** PDF with a unique procedurally-generated JPEG + caption per page. */ private static void generateTestPdf(Path out, int pages) throws IOException { try (PDDocument doc = new PDDocument()) { Random rng = new Random(42L); @@ -451,7 +369,6 @@ public final class MergeBenchmark { try (PDPageContentStream cs = new PDPageContentStream( doc, page, PDPageContentStream.AppendMode.APPEND, false)) { - // Center the image with some margin float pageW = page.getMediaBox().getWidth(); float pageH = page.getMediaBox().getHeight(); float imgRenderW = pageW - 100; @@ -463,14 +380,11 @@ public final class MergeBenchmark { cs.beginText(); cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14); cs.newLineAtOffset(50, 30); - cs.showText("Page " + (i + 1) + " — benchmark fill"); + cs.showText("Page " + (i + 1) + " - benchmark fill"); cs.endText(); } } - // Optional internal bookmarks — distributed evenly across the - // document. With INTERNAL_BOOKMARKS=10 and pages=70, every 7th - // page gets a "Section N" outline entry. We use this to test - // that source bookmarks survive the merge. + // Internal bookmarks evenly spaced - used to assert source bookmarks survive merge. if (INTERNAL_BOOKMARKS > 0 && pages > 0) { PDDocumentOutline outline = new PDDocumentOutline(); doc.getDocumentCatalog().setDocumentOutline(outline); @@ -478,7 +392,7 @@ public final class MergeBenchmark { for (int b = 0; b < INTERNAL_BOOKMARKS; b++) { int pageIdx = Math.min(b * spacing, pages - 1); PDOutlineItem item = new PDOutlineItem(); - item.setTitle("Section " + (b + 1) + " — " + out.getFileName().toString()); + item.setTitle("Section " + (b + 1) + " - " + out.getFileName().toString()); item.setDestination(doc.getPage(pageIdx)); outline.addLast(item); } @@ -487,10 +401,7 @@ public final class MergeBenchmark { } } - /** - * Open the merged PDF with JPDFium and count its bookmarks. Walks children recursively so - * nested outlines (chapter → section → ...) contribute every entry, not just the top level. - */ + /** Total bookmark count, including nested entries. */ private static int countBookmarks(Path pdf) { try (PdfDocument doc = PdfDocument.open(pdf)) { int total = 0; @@ -515,26 +426,16 @@ public final class MergeBenchmark { } /** - * Generate a unique-looking JPEG so PDF object dedup can't quietly collapse all pages into one - * image stream. We sweep hue per page, fill with random-noise pixels (high entropy → poor - * compression → bigger output), and draw some random circles on top. - * - *

Pixel noise fill is the key: a flat-colour rectangle JPEG-compresses to KBs regardless of - * dimensions; per-pixel random noise compresses close to the raw byte count, which is what we - * need to push a 100-page PDF into the hundreds-of-MB range. + * Per-page unique JPEG. Per-pixel random noise resists JPEG compression so output stays large, + * which is what we want for measuring real merge cost. */ private static byte[] generateRandomJpeg(Random rng, int w, int h) throws IOException { BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); - // High-entropy fill: per-pixel random RGB. Beats Graphics2D shapes - // for resisting JPEG compression on the size we want. int[] pixels = new int[w * h]; - // Seed by ImageIdentityHash so each page is unique without holding - // the per-pixel buffer beyond this scope. for (int i = 0; i < pixels.length; i++) { pixels[i] = rng.nextInt(0xFFFFFF); } img.setRGB(0, 0, w, h, pixels, 0, w); - // Overlay shapes + a label so the image is still recognisable. Graphics2D g = img.createGraphics(); try { for (int i = 0; i < 20; i++) { @@ -550,9 +451,8 @@ public final class MergeBenchmark { } finally { g.dispose(); } - // Honour JPEG_QUALITY so callers can dial the per-image size up or - // down; default ImageIO.write uses ~0.75 which is too aggressive - // when we want big inputs. + // Custom JPEG quality (default ImageIO.write ~0.75 is too aggressive when we want big + // inputs). ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageWriter writer = ImageIO.getImageWritersByFormatName("jpeg").next(); try { @@ -570,7 +470,6 @@ public final class MergeBenchmark { } private static void runPdfBoxMerge(List inputs, Path output) throws IOException { - // Stage 1: PDFMergerUtility — fast stream-cache merge to a temp file. Path stage1 = output.resolveSibling(output.getFileName() + ".stage1"); try { PDFMergerUtility merger = new PDFMergerUtility(); @@ -580,9 +479,7 @@ public final class MergeBenchmark { merger.setDestinationFileName(stage1.toAbsolutePath().toString()); merger.mergeDocuments(null); - // Stage 2: post-process (TOC / sig removal) only when needed. - // Mirror MergeController's PRE-jpdfium behaviour: load with PDFBox, - // mutate, save. + // Post-process via load+save only when TOC or sig removal is on. if (withToc || withSigRemoval) { try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(stage1.toFile())) { if (withSigRemoval) { @@ -609,9 +506,8 @@ public final class MergeBenchmark { item.setDestination(doc.getPage(idx)); } outline.addLast(item); - // Hop forward by the source's page count — for this - // synthetic benchmark every input has PAGES_PER_DOC - // pages, so no need to re-open and count. + // Every synthetic input has PAGES_PER_DOC pages, so we can skip a + // re-count. idx += PAGES_PER_DOC; } } @@ -627,12 +523,7 @@ public final class MergeBenchmark { } private static void runJpdfiumMerge(List inputs, Path output) throws IOException { - // Mirror the production MergeController: capture each source's - // bookmarks (with the page offset where its pages will land in the - // merged doc), run the native page merge, then inject a combined - // BookmarkTree via streaming setBookmarks. This preserves source - // outlines (PDFium's FPDF_ImportPagesByIndex only carries pages, - // not the outline tree). + // Mirror MergeController: capture+offset source bookmarks, merge pages, inject outline. List docs = new ArrayList<>(); int[] pageCounts = new int[inputs.size()]; int[] pageOffsets = new int[inputs.size()]; @@ -676,10 +567,7 @@ public final class MergeBenchmark { } } - // Sig removal is the only step still routed through PDFBox: JPDFium's - // flatten is page-wide and would fuse non-signature widgets too. We - // pre-check via JPDFium and skip the PDFBox load+save entirely when - // there are no signature fields to flatten. + // Sig removal still uses PDFBox (JPDFium's flatten is page-wide). Skip when no sigs. boolean sigFlattenNeeded = false; if (withSigRemoval) { try (PdfDocument check = PdfDocument.open(output)) { @@ -710,11 +598,7 @@ public final class MergeBenchmark { java.nio.file.Files.move(post, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } - /** - * Recursively flatten a source's bookmark list into top-level entries of the combined - * BookmarkTree, translating each entry's pageIndex by the offset where its source's pages were - * inserted. - */ + /** Flatten and offset-translate one source's bookmark list onto the combined tree. */ private static void addBookmarkFlat( BookmarkTree.Builder builder, List bookmarks, diff --git a/build.gradle b/build.gradle index 6253035012..149f01e497 100644 --- a/build.gradle +++ b/build.gradle @@ -31,8 +31,7 @@ ext { googleJavaFormatVersion = "1.28.0" logback = "1.5.32" // junit-platform-launcher version managed by Spring Boot BOM - // modernJavaVersion drives javac --release and the restart-helper - // compile target. + // modernJavaVersion drives javac --release and the restart-helper compile target. modernJavaVersion = 25 } @@ -266,13 +265,10 @@ subprojects { tasks.withType(Test).configureEach { useJUnitPlatform() finalizedBy(jacocoReport) - // Forward any -Dmerge.bench.* system properties from the gradle JVM - // into the test JVM so MergeBenchmark can read its overrides. + // Forward -Dmerge.bench.* props into the test JVM and give the bench + // enough heap that PDFBox finishes (we're measuring it, not OOMing). System.properties.findAll { k, v -> k.toString().startsWith("merge.bench") } .each { k, v -> systemProperty(k.toString(), v) } - // When the merge benchmark is requested, give the test JVM enough - // headroom that BOTH engines finish — we want to MEASURE peak heap, - // not OOM PDFBox on the way to that measurement. if (System.properties.any { k, v -> k.toString().startsWith("merge.bench") }) { maxHeapSize = System.properties.getProperty("merge.bench.xmx", "4g") } @@ -457,10 +453,8 @@ subprojects { "-XX:+ExplicitGCInvokesConcurrent", "-XX:+UseStringDeduplication", "-XX:+UseCompactObjectHeaders", - // bootRun launches from classfiles (not the bootJar), so the - // 'Enable-Native-Access' manifest attribute baked into the - // jar (see app/core/build.gradle) doesn't apply here. - // Keep the CLI form for the dev loop. + // bootRun launches from classfiles, not the bootJar, so the + // Enable-Native-Access manifest attribute doesn't apply. "--enable-native-access=ALL-UNNAMED" ] } diff --git a/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh b/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh index 3578744377..12a0bfe5de 100644 --- a/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh +++ b/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh @@ -1,28 +1,10 @@ #!/usr/bin/env bash -# Sign the JPDFium .dylib files nested inside the bootJar's JPDFium native -# jars so Apple's notarytool stops rejecting the Tauri .app build. +# Sign every .dylib inside the bootJar's JPDFium native jars so Apple's +# notarytool accepts the Tauri .app (Tauri's own codesign walk doesn't +# descend into .jar files, JPDFium's published natives are unsigned). # -# Background: tauri-build's macos-universal step calls notarytool, which -# walks INTO nested .jar files inside the .app and validates the signature -# on every binary it finds. The JPDFium native jars -# (jpdfium-natives-darwin-x64-*.jar / -arm64-*.jar) ship their .dylibs -# unsigned because JPDFium's publish workflow has no Apple Developer -# credentials. The result downstream is notarytool logging -# "The binary is not signed" and failing the build. -# -# Tauri's own codesign walk doesn't descend into .jar files (jars are -# opaque to it), so the fix has to happen before the .app is built: open -# the JPDFium native jars, sign each .dylib with this build's Developer -# ID, repack the native jars, repack the bootJar. -# -# Pre: gradle bootJar already produced the fat jar (typically via -# `task desktop:prepare`). -# Pre: APPLE_SIGNING_IDENTITY is set to a -# "Developer ID Application: ..." identity that's been imported -# into the runner's keychain. -# Post: bootJar contains JPDFium native jars whose .dylibs are signed -# with APPLE_SIGNING_IDENTITY + the runtime hardened option + -# a secure timestamp. +# Pre: gradle bootJar has produced the fat jar (e.g. via `task desktop:prepare`). +# Pre: APPLE_SIGNING_IDENTITY points at a Developer ID Application identity in the keychain. # # Usage: sign-jpdfium-dylibs-in-bootjar.sh [path/to/stirling-pdf-*.jar] @@ -48,12 +30,8 @@ if ! command -v jar >/dev/null 2>&1; then exit 0 fi -# Targets: by default sign every stirling-pdf-*.jar in both the Gradle -# output and the Tauri staging copy. task desktop:jlink:jar copies the -# Gradle bootJar into frontend/src-tauri/libs/ BEFORE this script runs, -# so signing only the Gradle copy leaves the Tauri-bundled jar unsigned -# and notarytool rejects the .app. Passing an explicit path overrides -# the default search. +# Sign both the Gradle output AND the Tauri staging copy +# (frontend/src-tauri/libs/), since Tauri bundles the staging copy. BOOTJARS=() if [ -n "${1:-}" ]; then BOOTJARS+=("$1") @@ -79,10 +57,8 @@ for BOOTJAR in "${BOOTJARS[@]}"; do # shellcheck disable=SC2064 trap "rm -rf '$WORK'" EXIT - # Resolve the exact paths of the JPDFium darwin native jars inside - # this bootJar — `jar xf` doesn't support glob patterns in its path - # args, so we have to list-then-extract by exact path. Portable read - # loop (mapfile is bash 4+; macOS ships bash 3.2 at /bin/bash). + # List then extract by exact path (`jar xf` has no glob support). + # Portable while-read loop because macOS ships bash 3.2 (no mapfile). NATIVE_JAR_PATHS=() while IFS= read -r line; do [ -n "$line" ] || continue @@ -96,10 +72,7 @@ for BOOTJAR in "${BOOTJARS[@]}"; do continue fi - # Extract those exact entries to $WORK/BOOT-INF/lib/*.jar. The - # ${ARR[@]+"${ARR[@]}"} guard expands the array only when set — - # works around bash 3.2 treating "${ARR[@]}" as unbound under set -u - # even when the array is empty. + # ${ARR[@]+...} guard: bash 3.2 treats an empty "${ARR[@]}" as unbound under set -u. ( cd "$WORK" && jar xf "$BOOTJAR" ${NATIVE_JAR_PATHS[@]+"${NATIVE_JAR_PATHS[@]}"} ) \ || { echo "jar xf failed to extract natives jars" >&2; exit 1; } @@ -128,9 +101,8 @@ for BOOTJAR in "${BOOTJARS[@]}"; do fi echo " signed $signed dylib(s)" - # Repack the native jar from the exploded tree. -0 stores - # without deflate (dylibs are already incompressible and Spring - # Boot's NestedJarFile prefers stored entries). + # -0 stores without deflate (dylibs don't compress, and Spring Boot's + # NestedJarFile prefers stored entries). rm -f "$nat_jar" ( cd "$exp_dir" && jar cfM0 "$nat_jar" . ) ANY_SIGNED=1 @@ -142,9 +114,7 @@ for BOOTJAR in "${BOOTJARS[@]}"; do continue fi - # Update the original bootJar in place with the freshly-signed - # natives jars. `jar uf` adds/replaces entries by path inside the - # archive. + # Replace the natives jars inside the bootJar in place. ( cd "$WORK" && jar uf "$BOOTJAR" \ BOOT-INF/lib/jpdfium-natives-darwin-x64-*.jar \ BOOT-INF/lib/jpdfium-natives-darwin-arm64-*.jar ) \ diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index f72bef8ee5..92587c37b2 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -205,10 +205,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & let java_options = vec![ "-Xmx2g", - // FFM native access (for JPDFium) is granted by the - // 'Enable-Native-Access: ALL-UNNAMED' manifest attribute baked into - // the Spring Boot bootJar (see app/core/build.gradle). No CLI flag - // needed here. + // FFM access for JPDFium is granted via Enable-Native-Access in the bootJar manifest. "-DBROWSER_OPEN=false", "-DSTIRLING_PDF_TAURI_MODE=true", &log_path_option, diff --git a/testing/test.sh b/testing/test.sh index d7a29d1402..026f7bd885 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -442,10 +442,8 @@ compare_file_lists() { echo "New files created during test:" cat "${diff_file}.added" | sed 's/^> //' - # Check for tmp files. JPDFium's NativeLoader extracts native - # libs (libpdfium.so, libjpdfium.so, ICU, harfbuzz, etc.) into - # /tmp/.../jpdfium-/ on first use and registers deleteOnExit - # so they're cleaned up at JVM shutdown; they're not leaks. + # JPDFium extracts native libs to /tmp/.../jpdfium-/ on first use + # and registers deleteOnExit; not a leak. grep -i "tmp\|temp" "${diff_file}.added" \ | grep -v '/jpdfium-[0-9]\+/' \ > "${diff_file}.tmp" || true