From ca40fecd4a97c69f504ba811ac059f2369dc7d12 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Fri, 22 May 2026 10:04:53 +0100 Subject: [PATCH] audit: jpdfium split --- app/core/build.gradle | 11 + .../software/SPDF/bench/SplitBenchmark.java | 365 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java diff --git a/app/core/build.gradle b/app/core/build.gradle index 64a1862c99..716292b34e 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -136,6 +136,17 @@ sourceSets { } +// Forward bench-related system properties through to the test JVM so +// `-Dsplit.bench=true` (and similar) flags reach Boolean.getBoolean(). +tasks.named('test', Test) { + ['split.bench', 'split.bench.pages', 'split.bench.chunk', + 'split.bench.imgW', 'split.bench.imgH'].each { key -> + def v = System.getProperty(key) + if (v != null) systemProperty(key, v) + } + maxHeapSize = '2g' +} + // Disable regular jar jar { diff --git a/app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java b/app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java new file mode 100644 index 0000000000..7297c8e8c9 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java @@ -0,0 +1,365 @@ +package stirling.software.SPDF.bench; + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.FormUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; + +/** + * Apples-to-apples heap + wall-clock benchmark for split: legacy PDFBox path (load + addPage per + * range + save) vs JPDFium path (PdfSplit.extractPageRange + save). + * + *

Generates a single ~N-page PDF with one JPEG per page, then performs a "split into chunks of + * 10" run with both backends. Samples heap every 25 ms during execution. + * + *

Run only when explicitly requested: -Dsplit.bench=true + */ +public final class SplitBenchmark { + + private static final int PAGES = Integer.getInteger("split.bench.pages", 100); + private static final int CHUNK = Integer.getInteger("split.bench.chunk", 10); + private static final int IMAGE_W = Integer.getInteger("split.bench.imgW", 800); + private static final int IMAGE_H = Integer.getInteger("split.bench.imgH", 600); + private static final long SAMPLE_PERIOD_MS = 25L; + + @Test + void compareSplitMemoryFootprint() throws Exception { + if (!Boolean.getBoolean("split.bench")) { + System.out.println("Skipping SplitBenchmark (run with -Dsplit.bench=true)"); + return; + } + main(new String[0]); + } + + public static void main(String[] args) throws Exception { + Path workDir = Files.createTempDirectory("split-bench-"); + System.out.println("Work dir: " + workDir); + System.out.printf( + "Generating %d-page input PDF with %dx%d images per page...%n", + PAGES, IMAGE_W, IMAGE_H); + + Path input = workDir.resolve("input.pdf"); + long t0 = System.nanoTime(); + generateTestPdf(input, PAGES); + long buildMs = (System.nanoTime() - t0) / 1_000_000; + System.out.printf( + " built %s: %,d KB (%d pages) in %,d ms%n%n", + input.getFileName(), Files.size(input) / 1024, PAGES, buildMs); + + // Warmup - prime classloaders, JIT, native lib. + System.out.println("--- Warmup (results discarded) ---"); + runPdfBoxSplit(input, workDir.resolve("warmup-pdfbox"), CHUNK); + runJpdfiumSplit(input, workDir.resolve("warmup-jpdfium"), CHUNK); + forceGcQuiescence(); + System.out.println(); + + long baseline = sampleUsedHeapAfterGc(); + System.out.printf("Baseline heap (after GC, before split): %,d KB%n%n", baseline / 1024); + + // PDFBox run + System.out.println("--- PDFBox split (load + addPage + save per chunk) ---"); + Path outBoxDir = workDir.resolve("out-pdfbox"); + BenchResult pdfboxResult = profile(() -> runPdfBoxSplit(input, outBoxDir, CHUNK)); + pdfboxResult.printSummary("PDFBox", baseline); + + forceGcQuiescence(); + System.out.println(); + + // JPDFium run + System.out.println("--- JPDFium split (PdfSplit.extractPageRange + save) ---"); + Path outJpdDir = workDir.resolve("out-jpdfium"); + BenchResult jpdfiumResult = profile(() -> runJpdfiumSplit(input, outJpdDir, CHUNK)); + jpdfiumResult.printSummary("JPDFium", baseline); + + // Compare + System.out.println(); + System.out.println("=== Heap delta over baseline ==="); + long pdfboxDelta = pdfboxResult.peakHeapBytes - baseline; + long jpdfiumDelta = jpdfiumResult.peakHeapBytes - baseline; + double improvement = + pdfboxDelta == 0 ? 0.0 : (100.0 * (pdfboxDelta - jpdfiumDelta) / pdfboxDelta); + System.out.printf(" PDFBox : +%,d KB%n", pdfboxDelta / 1024); + System.out.printf(" JPDFium : +%,d KB%n", jpdfiumDelta / 1024); + System.out.printf(" JPDFium uses %.1f%% LESS heap than PDFBox%n", improvement); + + System.out.println(); + System.out.println("=== Wall-clock ==="); + System.out.printf(" PDFBox : %,d ms%n", pdfboxResult.wallMs); + System.out.printf(" JPDFium : %,d ms%n", jpdfiumResult.wallMs); + double speedup = + jpdfiumResult.wallMs == 0 + ? 0.0 + : ((double) pdfboxResult.wallMs / jpdfiumResult.wallMs); + System.out.printf(" Speedup : %.2fx%n", speedup); + + // Hybrid form-pruning sub-benchmark. + // Quantifies the heap re-introduced by the PDFBox post-pass that + // pruneOrphanedFormFields runs on each JPDFium-produced split. + System.out.println(); + System.out.println("=== Hybrid form-pruning sub-benchmark ==="); + Path formInput = workDir.resolve("form-input.pdf"); + generateFormPdf(formInput, PAGES); + System.out.printf( + " built %s with AcroForm: %,d KB (%d pages)%n", + formInput.getFileName(), Files.size(formInput) / 1024, PAGES); + + runJpdfiumSplit(formInput, workDir.resolve("warmup-form-bare"), CHUNK); + runJpdfiumSplitWithFormPrune(formInput, workDir.resolve("warmup-form-pruned"), CHUNK); + forceGcQuiescence(); + + BenchResult bareForm = + profile(() -> runJpdfiumSplit(formInput, workDir.resolve("out-form-bare"), CHUNK)); + bareForm.printSummary("JPDFium-only", sampleUsedHeapAfterGc()); + + forceGcQuiescence(); + BenchResult prunedForm = + profile( + () -> + runJpdfiumSplitWithFormPrune( + formInput, workDir.resolve("out-form-pruned"), CHUNK)); + prunedForm.printSummary("JPDFium+PDFBox-prune", sampleUsedHeapAfterGc()); + + long pruneOverhead = prunedForm.peakHeapBytes - bareForm.peakHeapBytes; + long pruneTimeOverhead = prunedForm.wallMs - bareForm.wallMs; + System.out.printf( + " Hybrid prune overhead: +%,d KB heap, +%,d ms wall (%.1fx slower)%n", + pruneOverhead / 1024, + pruneTimeOverhead, + bareForm.wallMs == 0 ? 0.0 : (double) prunedForm.wallMs / bareForm.wallMs); + } + + private static void runJpdfiumSplitWithFormPrune(Path input, Path outDir, int chunk) + throws IOException { + Files.createDirectories(outDir); + try (PdfDocument source = PdfDocument.open(input)) { + int total = source.pageCount(); + int index = 0; + for (int start = 0; start < total; start += chunk) { + int end = Math.min(start + chunk - 1, total - 1); + Path raw = outDir.resolve("raw-" + (index + 1) + ".pdf"); + try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) { + split.save(raw); + } + Path pruned = outDir.resolve("pruned-" + (++index) + ".pdf"); + try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(raw.toFile())) { + FormUtils.pruneOrphanedFormFields(doc); + doc.save(pruned.toFile()); + } + Files.deleteIfExists(raw); + } + } + } + + private static void generateFormPdf(Path out, int pages) throws IOException { + try (PDDocument doc = new PDDocument()) { + for (int i = 0; i < pages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + } + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + // One text field per page (orphan-prone scenario). + for (int i = 0; i < pages; i++) { + PDTextField field = new PDTextField(acroForm); + field.setPartialName("field_" + i); + acroForm.getFields().add(field); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(50, 50, 200, 30)); + widget.setPage(doc.getPage(i)); + doc.getPage(i).getAnnotations().add(widget); + field.getWidgets().add(widget); + } + doc.save(out.toFile()); + } + } + + private static void runPdfBoxSplit(Path input, Path outDir, int chunk) throws IOException { + Files.createDirectories(outDir); + try (PDDocument source = org.apache.pdfbox.Loader.loadPDF(input.toFile())) { + int total = source.getNumberOfPages(); + int index = 0; + for (int start = 0; start < total; start += chunk) { + int end = Math.min(start + chunk - 1, total - 1); + Set keep = new HashSet<>(); + for (int p = start; p <= end; p++) keep.add(p); + // Match the pre-jpdfium reload path: load fresh, removePage, save. + try (PDDocument split = org.apache.pdfbox.Loader.loadPDF(input.toFile())) { + for (int p = split.getNumberOfPages() - 1; p >= 0; p--) { + if (!keep.contains(p)) split.removePage(p); + } + split.save(outDir.resolve("split-" + (++index) + ".pdf").toFile()); + } + } + } + } + + private static void runJpdfiumSplit(Path input, Path outDir, int chunk) throws IOException { + Files.createDirectories(outDir); + try (PdfDocument source = PdfDocument.open(input)) { + int total = source.pageCount(); + int index = 0; + for (int start = 0; start < total; start += chunk) { + int end = Math.min(start + chunk - 1, total - 1); + try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) { + split.save(outDir.resolve("split-" + (++index) + ".pdf")); + } + } + } + } + + private static void generateTestPdf(Path out, int pages) throws IOException { + try (PDDocument doc = new PDDocument()) { + Random rng = new Random(42L); + for (int i = 0; i < pages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + byte[] jpegBytes = generateRandomJpeg(rng, IMAGE_W, IMAGE_H); + PDImageXObject xobj = PDImageXObject.createFromByteArray(doc, jpegBytes, "img"); + + try (PDPageContentStream cs = + new PDPageContentStream( + doc, page, PDPageContentStream.AppendMode.APPEND, false)) { + float pageW = page.getMediaBox().getWidth(); + float pageH = page.getMediaBox().getHeight(); + float imgW = pageW - 100; + float imgH = imgW * IMAGE_H / IMAGE_W; + float x = (pageW - imgW) / 2; + float y = pageH - 80 - imgH; + cs.drawImage(xobj, x, y, imgW, imgH); + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14); + cs.newLineAtOffset(50, 30); + cs.showText("Page " + (i + 1) + " bench fill"); + cs.endText(); + } + } + doc.save(out.toFile()); + } + } + + private static byte[] generateRandomJpeg(Random rng, int w, int h) throws IOException { + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + try { + float hue = rng.nextFloat(); + g.setColor(Color.getHSBColor(hue, 0.4f, 0.95f)); + g.fillRect(0, 0, w, h); + for (int i = 0; i < 20; i++) { + g.setColor(Color.getHSBColor(rng.nextFloat(), 0.6f, 0.5f + rng.nextFloat() * 0.5f)); + int r = 20 + rng.nextInt(80); + int cx = rng.nextInt(w); + int cy = rng.nextInt(h); + g.fillOval(cx - r, cy - r, r * 2, r * 2); + } + g.setColor(Color.BLACK); + g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 32)); + g.drawString("bench image", 40, h - 40); + } finally { + g.dispose(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpg", baos); + return baos.toByteArray(); + } + + private static BenchResult profile(ThrowingRunnable task) throws Exception { + forceGcQuiescence(); + MemoryMXBean mem = ManagementFactory.getMemoryMXBean(); + AtomicLong peakHeap = new AtomicLong(0); + AtomicLong peakNonHeap = new AtomicLong(0); + AtomicBoolean stop = new AtomicBoolean(false); + + Thread sampler = + new Thread( + () -> { + while (!stop.get()) { + MemoryUsage heap = mem.getHeapMemoryUsage(); + MemoryUsage nonHeap = mem.getNonHeapMemoryUsage(); + peakHeap.updateAndGet(p -> Math.max(p, heap.getUsed())); + peakNonHeap.updateAndGet(p -> Math.max(p, nonHeap.getUsed())); + try { + Thread.sleep(SAMPLE_PERIOD_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + }, + "split-bench-sampler"); + sampler.setDaemon(true); + sampler.start(); + + long t0 = System.nanoTime(); + try { + task.run(); + } finally { + stop.set(true); + sampler.join(); + } + long wallMs = (System.nanoTime() - t0) / 1_000_000; + return new BenchResult(peakHeap.get(), peakNonHeap.get(), wallMs); + } + + private static void forceGcQuiescence() { + for (int i = 0; i < 3; i++) { + System.gc(); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private static long sampleUsedHeapAfterGc() { + forceGcQuiescence(); + return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(); + } + + @FunctionalInterface + interface ThrowingRunnable { + void run() throws Exception; + } + + private record BenchResult(long peakHeapBytes, long peakNonHeapBytes, long wallMs) { + void printSummary(String label, long baselineBytes) { + System.out.printf( + " %s peak heap : %,d KB (delta over baseline: +%,d KB)%n", + label, peakHeapBytes / 1024, (peakHeapBytes - baselineBytes) / 1024); + System.out.printf(" %s peak nonHeap : %,d KB%n", label, peakNonHeapBytes / 1024); + System.out.printf(" %s wall-clock : %,d ms%n", label, wallMs); + } + } +}