Trim verbose comments across JPDFium integration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
+38
-111
@@ -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
@@ -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 ) \
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user