Trim verbose comments across JPDFium integration

This commit is contained in:
Anthony Stirling
2026-05-21 19:03:31 +01:00
parent 007c8e17de
commit 29ccbf7ae6
12 changed files with 116 additions and 395 deletions
+2 -6
View File
@@ -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
+10 -27
View File
@@ -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
+3 -7
View File
@@ -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
+3 -18
View File
@@ -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'
@@ -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
+2 -7
View File
@@ -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'
)
}
@@ -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<PDDocument> 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<MultipartFile> 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<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
List<File> 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<Path> inputPaths = new ArrayList<>(files.length);
List<Integer> 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.
*
* <p>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.
*
* <p>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<Path> 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}.
*
* <p>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.
*
* <p>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<Bookmark> stack = new java.util.ArrayDeque<>();
java.util.Set<Bookmark> 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<Bookmark> 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));
}
@@ -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.
*
* <p>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:
* <p>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).
*
* <pre>{@code
* ./gradlew :stirling-pdf:test --tests '*MergeBenchmark*' -i
* ./gradlew :stirling-pdf:test --tests '*MergeBenchmark*' -Dmerge.bench=true
* }</pre>
*
* or invoke directly:
*
* <pre>{@code
* java -cp <classpath> stirling.software.SPDF.bench.MergeBenchmark
* }</pre>
*
* <p>Outputs:
*
* <ul>
* <li>Input PDF sizes
* <li>Pre-merge baseline heap (after forced GC)
* <li>PDFBox peak heap during merge
* <li>JPDFium peak heap during merge
* <li>Memory improvement %
* </ul>
*
* <p>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:
*
* <pre>{@code
* ./gradlew :stirling-pdf:test --tests '*MergeBenchmark*' \
* -Dmerge.bench=true -Dmerge.bench.docs=4 -Dmerge.bench.pages=70
* }</pre>
*
* <p>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:
*
* <ol>
* <li>plain merge (no TOC, no sig removal)
* <li>merge + TOC generation
* <li>merge + signature removal
* </ol>
*
* <p>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<Path> 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<Path> 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.
*
* <p>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<Path> 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<Path> 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<PdfDocument> 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<stirling.software.jpdfium.doc.Bookmark> bookmarks,
+5 -11
View File
@@ -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"
]
}
@@ -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 ) \
+1 -4
View File
@@ -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,
+2 -4
View File
@@ -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-<id>/ 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-<id>/ 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