Compare commits

...
Author SHA1 Message Date
Anthony Stirling fd2bcf4854 audit: jpdfium crop scale 2026-05-22 10:09:56 +01:00
Anthony Stirling 548c8a7e06 impl migration to pdfium for crop and scale 2026-05-22 09:45:43 +01:00
Anthony Stirling 2f1fe2c80c Delete MergeBenchmark 2026-05-21 23:00:00 +01:00
Anthony Stirling 1051f28c52 Drop remaining why/how comments 2026-05-21 19:11:52 +01:00
Anthony Stirling 29ccbf7ae6 Trim verbose comments across JPDFium integration 2026-05-21 19:03:31 +01:00
Anthony Stirling 007c8e17de Exclude JPDFium native cache from temp-file regression check 2026-05-21 17:46:46 +01:00
Anthony Stirling 81dbb29be3 Iterative addBookmarkFlat with depth + cycle + node-count guards 2026-05-21 17:17:06 +01:00
Anthony Stirling caef0477a9 Merge remote-tracking branch 'origin/main' into feat/jpdfium-integration
# Conflicts:
#	app/common/build.gradle
2026-05-21 16:55:18 +01:00
Anthony Stirling 7682a0dd54 sign-jpdfium-dylibs: sign both Gradle and Tauri bootJar copies 2026-05-21 16:26:52 +01:00
Anthony Stirling f90ed4657a Apply spotless; bash 3.2-compatible array handling in sign-jpdfium-dylibs 2026-05-21 15:53:28 +01:00
Anthony Stirling a6c7a68242 Move sign-jpdfium-dylibs-in-bootjar.sh into frontend/scripts/ 2026-05-21 15:21:25 +01:00
Anthony Stirling a184b394d6 Strip rationale-history comments from JDK 25 references 2026-05-21 15:20:11 +01:00
Anthony Stirling 302d04c201 Merge remote-tracking branch 'origin/main' into feat/jpdfium-integration
# Conflicts:
#	build.gradle
2026-05-21 15:05:51 +01:00
Anthony Stirling 68ae9d52fb Bump JPDFium dep to 1.0.0; drop snapshot repo 2026-05-21 14:46:10 +01:00
Anthony Stirling 6552ba905c Cleanup: drop local mavenLocal hack; gate MergeBenchmark with -Dmerge.bench=true 2026-05-21 11:58:43 +01:00
Anthony Stirling ee36b6f616 MergeController: preserve source bookmarks through merge (PDFium drops them) 2026-05-21 11:39:59 +01:00
Anthony Stirling 627091f8df MergeController: use JPDFium native merge (94% less heap on 5-doc merge) 2026-05-21 10:37:01 +01:00
Anthony Stirling 67a37d3291 Filter JPDFium natives per Tauri target OS (saves ~45 MB per bundle) 2026-05-21 00:28:31 +01:00
Anthony Stirling 4b6d4885f4 sign-jpdfium-dylibs: resolve native-jar paths via jar tf first
`jar xf <jar> <path>` doesn't expand glob patterns in <path> the way
`unzip` does — paths must be exact. My initial script passed
`BOOT-INF/lib/jpdfium-natives-darwin-x64-*.jar` as a literal path,
which matched nothing, so the script always reported
"No JPDFium darwin natives in bootJar; nothing to sign" and exited
without actually signing anything.

Fix: `jar tf` to LIST the bootJar's contents, grep with a regex for
the snapshot-versioned native-jar paths, then `jar xf` those exact
paths.

The natives jars are normally:
  BOOT-INF/lib/jpdfium-natives-darwin-x64-1.0.0-SNAPSHOT.jar
  BOOT-INF/lib/jpdfium-natives-darwin-arm64-1.0.0-SNAPSHOT.jar
…but the version number floats with the snapshot timestamp, hence
the regex match instead of a fixed path.
2026-05-20 09:46:07 +01:00
Anthony Stirling 2a151b65f7 Move JPDFium dylib signing step AFTER cert import
My first placement put the sign-jpdfium-dylibs-in-bootjar step at
the wrong point in the workflow: BEFORE the "Verify Certificate"
step that sets APPLE_SIGNING_IDENTITY in GITHUB_ENV. So the gate
`if: ... && env.APPLE_SIGNING_IDENTITY != ''` always evaluated to
false and the step silently skipped, leaving the dylibs unsigned
and notarytool still rejecting the .app.

Move it to right after Verify Certificate (which sets the env var
from the keychain identity). Also switch the gate to checking
env.APPLE_CERTIFICATE (the secret that's set at job level and
available from step 1) rather than env.APPLE_SIGNING_IDENTITY (set
mid-workflow via GITHUB_ENV) — the latter is fine in `run:` blocks
but flaky in `if:` evaluation depending on GH Actions evaluation
timing.
2026-05-20 09:08:37 +01:00
Anthony Stirling 9458fcd0e2 Re-run CI against JPDFium with Windows ICU trim + harfbuzz-no-glib 2026-05-20 00:45:22 +01:00
Anthony Stirling 1e8c41425b Sign JPDFium dylibs in bootJar before Tauri notarization (macOS)
Tauri-build macos-universal has been failing notarytool because the
.dylib files inside the JPDFium native jars (jpdfium-natives-darwin-
x64-*.jar / -arm64-*.jar) ship unsigned — JPDFium's publish workflow
has no Apple Developer credentials, so it can't sign during publish.
Apple's notarytool walks INTO nested .jars in the .app and reports:

  "The binary is not signed."
  path: Stirling-PDF.zip/Stirling-PDF.app/Contents/Resources/libs/
   stirling-pdf-*.jar/BOOT-INF/lib/
   jpdfium-natives-darwin-x64-1.0.0-SNAPSHOT.jar/natives/darwin-x64/
   libjpdfium.dylib

Tauri's own codesign walk doesn't open .jars, so the fix has to
happen here before tauri-action runs. New step between
`task desktop:prepare` (builds bootJar) and `tauri-action` (builds
.app + notarizes):

  scripts/sign-jpdfium-dylibs-in-bootjar.sh
    1. jar xf bootJar BOOT-INF/lib/jpdfium-natives-darwin-*.jar
    2. for each native jar, explode it, codesign every .dylib with
       APPLE_SIGNING_IDENTITY + --options runtime + --timestamp
    3. jar cfM0 to repack the natives jar (stored, no deflate —
       matches Spring Boot's preferred layout)
    4. jar uf bootJar to replace the natives jars in the outer
       bootJar with the freshly-signed versions

Gated on macOS-15 + APPLE_SIGNING_IDENTITY being set, so PR builds
from forks (no secret) fall through and the existing
"binary not signed" failure persists — no regression vs current.
2026-05-19 23:32:09 +01:00
Anthony Stirling b3277a18c8 Re-run CI: pick up Rust-LTO-trimmed JPDFium snapshot 2026-05-19 18:56:29 +01:00
Anthony Stirling 5ca1586976 Re-run CI: pick up smaller (ICU+qpdf trim) JPDFium snapshot 2026-05-19 18:18:21 +01:00
Anthony Stirling ce47a4e3af Re-run CI: pick up ICU-trimmed JPDFium snapshot 2026-05-19 17:08:17 +01:00
Anthony Stirling 859a2d97c2 Always re-resolve JPDFium snapshots in CI
PR rerun against new JPDFium snapshots kept using Gradle's cached
versions from earlier runs. Gradle caches 'changing modules' (anything
ending in -SNAPSHOT) for 24h by default, and gh run rerun --failed
reuses the existing run's Gradle home — so even after publishing a
fresh com.stirling:jpdfium-natives-*:1.0.0-SNAPSHOT, the bootJar kept
embedding the old natives.

cacheChangingModulesFor 0 means every Gradle invocation re-checks the
snapshot's maven-metadata.xml and pulls a newer timestamped build if
present. cacheDynamicVersionsFor 0 does the same for non-snapshot
dynamic versions (e.g. '1.+'). Both are scoped to the
subprojects.configurations.all block alongside the existing security
force-versions.

Trade-off: every Gradle invocation in CI does a HEAD against Maven
Central per snapshot dep (4 natives + 1 main jpdfium = 5 small
requests). Negligible compared to a download. Reverts naturally when
JPDFium ships a tagged release.
2026-05-19 15:00:02 +01:00
Anthony Stirling a422deecdb Re-add jpdfium-natives-darwin-x64 (Intel Mac) runtime dep
The publish-github-packages workflow's restored darwin-x64 build now
cross-compiles from macos-14 under Rosetta + a second Homebrew, so the
natives jar is available again. Adding it back to the bootJar classpath
lets the tauri-build macos-15 universal binary cover Intel Macs too.
2026-05-19 10:15:16 +01:00
Anthony Stirling 7f8f09c899 Native-access via bootJar manifest, not CLI flags everywhere
JDK 22+ honors an 'Enable-Native-Access' attribute on the executable
jar's manifest (JEP 472). Setting it once on the Spring Boot bootJar
removes the need to remember --enable-native-access=ALL-UNNAMED in:
  - Docker init script's JAVA_BASE_OPTS injection
  - Tauri Rust launcher's java_options vector
  - Anywhere else somebody runs the bootJar
…and also future-proofs against JDK 26's hard-fail behavior without
each launch point having to track the flag.

app/core/build.gradle: add 'Enable-Native-Access': 'ALL-UNNAMED' to the
bootJar manifest attributes block.

build.gradle (bootRun jvmArgs): keep --enable-native-access=ALL-UNNAMED
because bootRun launches from classfiles, not the bootJar — the
manifest mechanism doesn't apply to that codepath, only to 'java -jar'.

scripts/init-without-ocr.sh: drop the FFM injection.
frontend/src-tauri/src/commands/backend.rs: drop the CLI arg.
2026-05-18 21:35:06 +01:00
Anthony Stirling 96920b1186 build.gradle: modernJavaVersion 21 -> 25
This was the actual culprit behind 'JVM runtime version 21 vs 25' Gradle
variant-attribute mismatch errors during PR CI. modernJavaVersion drives:
- options.release on JavaCompile.configureEach across all subprojects
- restart-helper compile target

Even though sourceCompatibility / targetCompatibility were already bumped
to VERSION_25 in an earlier commit, --release 21 on javac overrode them
and made Gradle's compileClasspath declare org.gradle.jvm.version=21,
which then refused to resolve com.stirling:jpdfium:1.0.0-SNAPSHOT
(published as JVM 25).

Bumping modernJavaVersion to 25 aligns everything.
2026-05-18 19:09:28 +01:00
Anthony Stirling 3626319685 docs+ci: scrub remaining JDK 21 references
JPDFium's published artifacts target JVM 25 and Stirling-PDF's main
sources are now compiled with sourceCompatibility = VERSION_25, so JDK
21 is no longer supported. Update header comments and developer-facing
docs to reflect the hard floor.

- backend-build.yml header: 'JDK 21/25 \xc3\x97 spring-security' -> 'JDK 25 \xc3\x97 spring-security'
- AGENTS.md Important Notes: minimum 21 -> requires 25
- DeveloperGuide.md prerequisites + setup steps: drop 'or later, 25 recommended'
  language, say 'JDK 25' outright.
2026-05-18 19:07:11 +01:00
Anthony Stirling 755f270a31 ci: drop JDK 21 from backend build matrix
JPDFium's published artifacts carry org.gradle.jvm.version=25 (FFM is
finalized at JDK 22+; JPDFium picks 25 as the source target). Gradle's
variant attribute matching refuses to resolve the dep on JDK 21
targets, so the 21 cells in this matrix were guaranteed to fail.

Now that targetCompatibility is bumped to 25 across the project, JDK 25
is the only supported toolchain.
2026-05-18 19:04:59 +01:00
Anthony Stirling 6e1a7454ca Integrate Stirling-Tools JPDFium for PDF operations
This is a bring-up branch to validate JPDFium consumption across all
Stirling-PDF release targets (server jar, Docker, Tauri desktop). Open
as PR for cross-platform CI validation.

Source dep
- com.stirling:jpdfium:1.0.0-SNAPSHOT  (pulled from Maven Central snapshots,
  fully anonymous — no PAT or GitHub Packages auth needed)
- Per-platform natives: linux-x64, linux-arm64, darwin-arm64, windows-x64
  All four are added as runtimeOnly so the bootJar carries every platform
  and NativeLoader picks the right one at startup. The natives jars are
  hermetic — each bundles its bridge, PDFium component libs, and the
  third-party deps (pcre2/freetype/harfbuzz/icu/qpdf/pugixml/libunibreak),
  so no system package install is required at runtime.
- TODO follow-up: jpdfium-natives-darwin-x64 (Intel Mac) — dropped from
  the upstream publish matrix while cross-compile-on-macos-14 setup lands.

JDK 25 enforcement
- Bump root + subprojects from sourceCompatibility / targetCompatibility
  21 -> 25. JPDFium publishes with org.gradle.jvm.version=25 (FFM is final
  in JDK 22+; JPDFium picks 25 as the floor), and a 21-targeted Stirling-PDF
  jar would fail Gradle's variant attribute match at compile time.
- bootRun jvmArgs: add --enable-native-access=ALL-UNNAMED. JDK 25 warns
  on restricted FFM methods without it; JDK 26 will hard-fail.

Docker
- scripts/init-without-ocr.sh: prepend --enable-native-access=ALL-UNNAMED
  to JAVA_BASE_OPTS so the JAVA_TOOL_OPTIONS env var that the container
  sets includes it for the running JVM. Idempotent — won't duplicate if
  already present.

Tauri
- frontend/src-tauri/src/commands/backend.rs: the sidecar Java spawn now
  passes --enable-native-access=ALL-UNNAMED. The bundled jlink JRE's
  module list (desktop.yml JLINK_MODULES) already includes java.desktop
  and java.net.http which is everything JPDFium's module-info requires.

Smoke test
- app/common/src/test/java/stirling/software/common/jpdfium/JPDFiumSmokeTest.java
  Opens the existing common test resource example.pdf via PdfDocument.open
  and asserts a positive page count. Confirms the dep resolves, the right
  natives jar lands on the classpath, the bridge + PDFium component libs
  + third-party deps all load, and the FFM bindings parse a real PDF
  end-to-end. Runs as part of :common:test on each platform's CI runner.
2026-05-18 18:31:37 +01:00
18 changed files with 1136 additions and 736 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
name: Backend build, format check, and coverage
# Reusable workflow called from build.yml. Runs the full backend build matrix
# (JDK 21/25 × spring-security on/off), Spotless formatting check, JUnit, and
# Reusable workflow called from build.yml. Runs the backend build matrix
# (JDK 25 × spring-security on/off), Spotless formatting check, JUnit, and
# posts Jacoco coverage to PRs.
on:
workflow_call:
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
jdk-version: [21, 25]
jdk-version: [25]
spring-security: [true, false]
steps:
- name: Harden Runner
+8 -6
View File
@@ -90,21 +90,21 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For push/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
fi
build-jars:
@@ -256,15 +256,17 @@ jobs:
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
run: task desktop:prepare
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:prepare
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
+10 -12
View File
@@ -47,12 +47,10 @@ jobs:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
PLATFORM: ${{ inputs.platform }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
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".
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS") ;;
@@ -112,10 +110,6 @@ 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.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -142,21 +136,21 @@ 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.
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
run: task desktop:prepare
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
run: task desktop:prepare
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
@@ -269,6 +263,10 @@ jobs:
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Sign JPDFium dylibs inside bootJar (macOS only)
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: bash frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15'
run: |
+20 -3
View File
@@ -3,6 +3,22 @@ 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"
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
JPDFIUM_PLATFORMS:
sh: |
if [ -n "${JPDFIUM_PLATFORMS:-}" ]; then
echo "$JPDFIUM_PLATFORMS"
else
case "{{OS}}-{{ARCH}}" in
darwin-arm64) echo "darwin-arm64";;
darwin-amd64) echo "darwin-x64";;
linux-amd64) echo "linux-x64";;
linux-arm64) echo "linux-arm64";;
windows-amd64) echo "windows-x64";;
*) echo "all";;
esac
fi
tasks:
prepare:
desc: "Prepare desktop build dependencies"
@@ -71,15 +87,16 @@ tasks:
deps: [jlink:jar, jlink:runtime]
jlink:jar:
desc: "Build backend JAR for Tauri bundling"
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
run: once
dir: ..
env:
DISABLE_ADDITIONAL_FEATURES: "true"
cmds:
- cmd: cmd /c gradlew.bat bootJar --no-daemon
- echo "Building bootJar with JPDFium natives for {{.JPDFIUM_PLATFORMS}}"
- cmd: cmd /c gradlew.bat bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
platforms: [windows]
- cmd: ./gradlew bootJar --no-daemon
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
platforms: [linux, darwin]
- mkdir -p frontend/src-tauri/libs
- cp app/core/build/libs/stirling-pdf-*.jar frontend/src-tauri/libs/
+1 -1
View File
@@ -431,7 +431,7 @@ The frontend is organized with a clear separation of concerns:
## Important Notes
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
- **Java Version**: Requires JDK 25.
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
+3 -3
View File
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
**Stirling 2.0** is built using:
**Backend:**
- Spring Boot (Java 21+, JDK 25 recommended)
- Spring Boot (requires JDK 25)
- PDFBox for core PDF operations
- LibreOffice for document conversions
- qpdf for PDF optimization
@@ -45,7 +45,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- [Task](https://taskfile.dev/installation/) — unified command runner (recommended)
- Docker
- Git
- Java JDK 21 or later (JDK 25 recommended)
- Java JDK 25
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
- [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development)
@@ -61,7 +61,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
cd Stirling-PDF
```
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
2. Install Docker and JDK 25 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
+18
View File
@@ -60,6 +60,24 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api 'com.stirling:jpdfium:1.0.0'
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
? jpdfiumAllPlatforms
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
if (jpdfiumInvalid) {
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.0"
}
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
}
@@ -0,0 +1,32 @@
package stirling.software.common.jpdfium;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.jpdfium.PdfDocument;
class JPDFiumSmokeTest {
@Test
void opensExamplePdfAndReadsPageCount(@TempDir Path tmp) throws IOException {
Path pdf = tmp.resolve("example.pdf");
try (InputStream in = getClass().getResourceAsStream("/example.pdf")) {
assertNotNull(in, "example.pdf must exist under src/test/resources");
Files.copy(in, pdf);
}
try (PdfDocument doc = PdfDocument.open(pdf)) {
assertTrue(
doc.pageCount() >= 1,
"PdfDocument should report at least one page for example.pdf");
}
}
}
+12 -1
View File
@@ -137,6 +137,16 @@ sourceSets {
}
// Forward jpdfium.bench / crop.bench.* to the forked test JVM.
tasks.named('test') {
if (System.getProperty('jpdfium.bench')) {
systemProperty 'jpdfium.bench', System.getProperty('jpdfium.bench')
maxHeapSize = System.getProperty('jpdfium.bench.xmx', '4g')
System.properties.findAll { k, v -> k.toString().startsWith('crop.bench.') }
.each { k, v -> systemProperty(k.toString(), v) }
}
}
// Disable regular jar
jar {
enabled = false
@@ -162,7 +172,8 @@ bootJar {
manifest {
attributes(
'Implementation-Title': 'Stirling-PDF',
'Implementation-Version': project.version
'Implementation-Version': project.version,
'Enable-Native-Access': 'ALL-UNNAMED'
)
}
}
@@ -1,21 +1,15 @@
package stirling.software.SPDF.controller.api;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
@@ -27,13 +21,19 @@ import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfImageConverter;
import stirling.software.jpdfium.PdfPage;
import stirling.software.jpdfium.doc.PdfMarginAdjuster;
import stirling.software.jpdfium.model.PageSize;
import stirling.software.jpdfium.model.Rect;
import stirling.software.jpdfium.transform.PdfPageBoxes;
@GeneralApi
@RequiredArgsConstructor
@@ -42,18 +42,15 @@ public class CropController {
private static final int DEFAULT_RENDER_DPI = 150;
private static final int WHITE_THRESHOLD = 250;
private static final String TEMP_INPUT_PREFIX = "crop_input";
private static final String TEMP_OUTPUT_PREFIX = "crop_output";
private static final String PDF_EXTENSION = ".pdf";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private static int[] detectContentBounds(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
// Early exit if image is too small
if (width < 1 || height < 1) {
return new int[] {0, 0, width - 1, height - 1};
}
@@ -109,8 +106,7 @@ public class CropController {
}
}
// Return bounds in format: [left, bottom, right, top]
// Note: Image coordinates are top-down, PDF coordinates are bottom-up
// Image coordinates top-down, PDF coordinates bottom-up
return new int[] {left, height - bottom - 1, right, height - top - 1};
}
@@ -121,8 +117,6 @@ public class CropController {
return r >= threshold && g >= threshold && b >= threshold;
}
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@@ -152,145 +146,124 @@ public class CropController {
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
return cropWithGhostscript(request);
} else {
return cropWithPDFBox(request);
}
return cropWithJpdfium(request);
}
private ResponseEntity<Resource> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
private ResponseEntity<Resource> cropWithAutomaticDetection(CropPdfForm request)
throws IOException {
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
MultipartFile fileInput = request.getFileInput();
File inputFile = tempFileManager.convertMultipartFileToFile(fileInput);
try {
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
int pageCount = doc.pageCount();
for (int i = 0; i < pageCount; i++) {
BufferedImage image = PdfImageConverter.pageToImage(doc, i, DEFAULT_RENDER_DPI);
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
PDFRenderer renderer = new PDFRenderer(sourceDocument);
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
LayerUtility layerUtility = new LayerUtility(newDocument);
PageSize size;
try (PdfPage page = doc.page(i)) {
size = page.size();
}
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle mediaBox = sourcePage.getMediaBox();
BufferedImage image = renderer.renderImageWithDPI(i, DEFAULT_RENDER_DPI);
int[] bounds = detectContentBounds(image);
float scaleX = mediaBox.getWidth() / image.getWidth();
float scaleY = mediaBox.getHeight() / image.getHeight();
float scaleX = size.width() / image.getWidth();
float scaleY = size.height() / image.getHeight();
CropBounds cropBounds = CropBounds.fromPixels(bounds, scaleX, scaleY);
PDPage newPage = new PDPage(mediaBox);
newDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
PDFormXObject formXObject =
layerUtility.importPageAsForm(sourceDocument, i);
contentStream.saveGraphicsState();
contentStream.addRect(
cropBounds.x, cropBounds.y, cropBounds.width, cropBounds.height);
contentStream.clip();
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
}
newPage.setMediaBox(
new PDRectangle(
cropBounds.x,
cropBounds.y,
cropBounds.width,
cropBounds.height));
applyCropMargins(doc, i, size, cropBounds);
}
return WebResponseUtils.pdfDocToWebResponse(
newDocument,
TempFile outputTempFile = new TempFile(tempFileManager, PDF_EXTENSION);
try {
doc.save(outputTempFile.getPath());
} catch (Exception e) {
outputTempFile.close();
throw e;
}
return WebResponseUtils.pdfFileToWebResponse(
outputTempFile,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"),
tempFileManager);
fileInput.getOriginalFilename(), "_cropped.pdf"));
}
} finally {
tempFileManager.deleteTempFile(inputFile);
}
}
private ResponseEntity<Resource> cropWithPDFBox(@ModelAttribute CropPdfForm request)
throws IOException {
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
// Create a new page with the size of the source page
PDPage newPage = new PDPage(sourcePage.getMediaBox());
newDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
// Import the source page as a form XObject
PDFormXObject formXObject =
layerUtility.importPageAsForm(sourceDocument, i);
contentStream.saveGraphicsState();
// Define the crop area
contentStream.addRect(
private ResponseEntity<Resource> cropWithJpdfium(CropPdfForm request) throws IOException {
MultipartFile fileInput = request.getFileInput();
File inputFile = tempFileManager.convertMultipartFileToFile(fileInput);
try {
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
int pageCount = doc.pageCount();
CropBounds bounds =
new CropBounds(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
contentStream.clip();
// Draw the entire formXObject
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
for (int i = 0; i < pageCount; i++) {
PageSize size;
try (PdfPage page = doc.page(i)) {
size = page.size();
}
// Now, set the new page's media box to the cropped size
newPage.setMediaBox(
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight()));
applyCropMargins(doc, i, size, bounds);
}
return WebResponseUtils.pdfDocToWebResponse(
newDocument,
TempFile outputTempFile = new TempFile(tempFileManager, PDF_EXTENSION);
try {
doc.save(outputTempFile.getPath());
} catch (Exception e) {
outputTempFile.close();
throw e;
}
return WebResponseUtils.pdfFileToWebResponse(
outputTempFile,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"),
tempFileManager);
fileInput.getOriginalFilename(), "_cropped.pdf"));
}
} finally {
tempFileManager.deleteTempFile(inputFile);
}
}
private ResponseEntity<Resource> cropWithGhostscript(@ModelAttribute CropPdfForm request)
throws IOException {
// PdfMarginAdjuster.addMargins(doc, page, left, bottom, right, top): translates
// content by (left, bottom), grows page by left+right and bottom+top.
// To crop to (x, y, w, h) shift content to (-x, -y) and end at (w, h).
private static void applyCropMargins(
PdfDocument doc, int pageIndex, PageSize size, CropBounds bounds) {
float left = -bounds.x();
float bottom = -bounds.y();
float right = bounds.width() - size.width() + bounds.x();
float top = bounds.height() - size.height() + bounds.y();
PdfMarginAdjuster.addMargins(doc, pageIndex, left, bottom, right, top);
}
private ResponseEntity<Resource> cropWithGhostscript(CropPdfForm request) throws IOException {
MultipartFile fileInput = request.getFileInput();
File inputFile = tempFileManager.convertMultipartFileToFile(fileInput);
TempFile tempInputFile = null;
TempFile tempOutputFile = null;
try {
tempInputFile = new TempFile(tempFileManager, PDF_EXTENSION);
tempOutputFile = new TempFile(tempFileManager, PDF_EXTENSION);
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
PDPage page = sourceDocument.getPage(i);
PDRectangle cropBox =
new PDRectangle(
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
Rect cropRect =
Rect.of(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
page.setCropBox(cropBox);
int pageCount = doc.pageCount();
for (int i = 0; i < pageCount; i++) {
try (PdfPage page = doc.page(i)) {
PdfPageBoxes.setCropBox(page.rawHandle(), cropRect);
}
}
doc.save(tempInputFile.getPath());
}
tempInputFile = tempFileManager.createManagedTempFile(PDF_EXTENSION);
tempOutputFile = tempFileManager.createManagedTempFile(PDF_EXTENSION);
// Save the source document with crop boxes
sourceDocument.save(tempInputFile.getFile());
// Execute Ghostscript to process the crop boxes
ProcessExecutor processExecutor =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
List<String> command =
@@ -301,16 +274,13 @@ public class CropController {
"-o",
tempOutputFile.getAbsolutePath(),
tempInputFile.getAbsolutePath());
processExecutor.runCommandWithOutputHandling(command);
TempFile out = tempOutputFile;
tempOutputFile = null; // ownership transferred to response Resource
tempOutputFile = null;
return WebResponseUtils.pdfFileToWebResponse(
out,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_cropped.pdf"));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
@@ -321,6 +291,7 @@ public class CropController {
if (tempOutputFile != null) {
tempOutputFile.close();
}
tempFileManager.deleteTempFile(inputFile);
}
}
@@ -3,13 +3,14 @@ package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
@@ -47,6 +48,11 @@ import stirling.software.common.util.PdfErrorUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfMerge;
import stirling.software.jpdfium.doc.Bookmark;
import stirling.software.jpdfium.doc.PdfBookmarkEditor;
import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree;
@GeneralApi
@Slf4j
@@ -57,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;
@@ -76,11 +81,8 @@ 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()
@@ -107,7 +109,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" ->
@@ -155,18 +156,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();
@@ -186,39 +185,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++;
}
}
}
@@ -236,7 +225,6 @@ public class MergeController {
}
}
// Fallback to XMP metadata if Info dates are missing
PDMetadata metadata = doc.getDocumentCatalog().getMetadata();
if (metadata != null) {
try (InputStream is = metadata.createInputStream()) {
@@ -287,7 +275,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());
@@ -298,48 +286,35 @@ public class MergeController {
files = new MultipartFile[0];
}
// If front-end provided explicit visible order, honor it and override backend sorting
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")) {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
List<Path> inputPaths = new ArrayList<>(files.length);
List<Integer> invalidIndexes = new ArrayList<>();
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
totalSize += multipartFile.getSize();
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());
// Pre-validate each PDF so we can report which one(s) are broken
// Use the original MultipartFile to avoid deleting the tempFile during validation
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
// OK
} catch (IOException e) {
try (PdfDocument ignored = PdfDocument.open(tempFile.toPath())) {
} catch (Exception e) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
int[] pageCounts;
try {
mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(
totalSize)); // Merge the documents
pageCounts =
mergeWithJpdfium(inputPaths, files, generateToc, mt.getFile().toPath());
} catch (IOException e) {
ExceptionUtils.logException("PDF merge", e);
if (PdfErrorUtils.isCorruptedPdfError(e)) {
@@ -348,10 +323,26 @@ public class MergeController {
throw e;
}
// Load the merged PDF document and operate on it inside try-with-resources
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
// Remove signatures if removeCertSign is true
if (removeCertSign) {
boolean sigFlattenNeeded = false;
if (removeCertSign) {
try (PdfDocument check = PdfDocument.open(mt.getFile().toPath())) {
sigFlattenNeeded = !check.signatures().isEmpty();
} catch (Exception e) {
log.debug(
"JPDFium signature pre-check failed; falling back to PDFBox flatten:"
+ " {}",
e.getMessage());
sigFlattenNeeded = true;
}
if (!sigFlattenNeeded) {
log.info(
"removeCertSign requested but merged document has no signature"
+ " fields; skipping PDFBox flatten pass");
}
}
if (sigFlattenNeeded) {
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
@@ -359,24 +350,26 @@ public class MergeController {
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(
fieldsToRemove,
false); // Flatten the fields, effectively removing them
acroForm.flatten(fieldsToRemove, false);
}
}
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
mergedDocument.save(outputTempFile.getFile());
} catch (Exception e) {
outputTempFile.close();
outputTempFile = null;
throw e;
}
}
// Add table of contents if generateToc is true
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a temporary file
} else {
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
mergedDocument.save(outputTempFile.getFile());
Files.copy(
mt.getFile().toPath(),
outputTempFile.getFile().toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
outputTempFile.close();
outputTempFile = null;
@@ -395,7 +388,7 @@ public class MergeController {
throw ex;
} finally {
for (File file : filesToDelete) {
tempFileManager.deleteTempFile(file); // Delete temporary files
tempFileManager.deleteTempFile(file);
}
}
@@ -405,4 +398,113 @@ public class MergeController {
return WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
}
private int[] mergeWithJpdfium(
List<Path> inputPaths, MultipartFile[] files, boolean generateToc, Path outputPath)
throws IOException {
if (inputPaths.isEmpty()) {
try (PdfDocument empty = PdfDocument.open(new byte[0])) {
empty.save(outputPath);
} catch (Exception ignored) {
Files.write(outputPath, new byte[0]);
}
return new int[0];
}
List<PdfDocument> docs = new ArrayList<>(inputPaths.size());
int[] pageCounts = new int[inputPaths.size()];
int[] pageOffsets = new int[inputPaths.size()];
List<List<Bookmark>> sourceBookmarks = new ArrayList<>(inputPaths.size());
int runningOffset = 0;
try {
for (int i = 0; i < inputPaths.size(); i++) {
Path p = inputPaths.get(i);
PdfDocument doc = PdfDocument.open(p);
docs.add(doc);
pageCounts[i] = doc.pageCount();
pageOffsets[i] = runningOffset;
sourceBookmarks.add(doc.bookmarks());
runningOffset += pageCounts[i];
}
BookmarkTree combinedTree =
buildCombinedBookmarkTree(files, pageOffsets, sourceBookmarks, generateToc);
try (PdfDocument merged = PdfMerge.merge(docs)) {
if (combinedTree.entries().isEmpty()) {
merged.save(outputPath);
} else {
PdfBookmarkEditor.setBookmarks(merged, combinedTree, outputPath);
}
}
} catch (RuntimeException e) {
throw new IOException("JPDFium merge failed", e);
} finally {
for (PdfDocument doc : docs) {
try {
doc.close();
} catch (Exception ignored) {
}
}
}
return pageCounts;
}
private BookmarkTree buildCombinedBookmarkTree(
MultipartFile[] files,
int[] pageOffsets,
List<List<Bookmark>> sourceBookmarks,
boolean generateToc) {
BookmarkTree.Builder builder = BookmarkTree.builder();
if (generateToc) {
for (int i = 0; i < files.length; i++) {
String filename = files[i].getOriginalFilename();
String title = GeneralUtils.removeExtension(filename);
if (title == null || title.isBlank()) {
title = "Document " + (i + 1);
}
builder.add(title, pageOffsets[i]);
}
}
for (int i = 0; i < sourceBookmarks.size(); i++) {
int offset = pageOffsets[i];
for (Bookmark bm : sourceBookmarks.get(i)) {
addBookmarkFlat(builder, bm, offset);
}
}
return builder.build();
}
private void addBookmarkFlat(BookmarkTree.Builder builder, Bookmark root, int offset) {
final int maxNodes = 100_000;
java.util.Deque<Bookmark> stack = new java.util.ArrayDeque<>();
java.util.Set<Bookmark> visited =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
stack.push(root);
int processed = 0;
while (!stack.isEmpty() && processed < maxNodes) {
Bookmark bm = stack.pop();
if (!visited.add(bm)) {
continue;
}
processed++;
if (bm.isInternal() && bm.title() != null) {
builder.add(bm.title(), offset + bm.pageIndex());
}
if (bm.hasChildren()) {
List<Bookmark> children = bm.children();
for (int i = children.size() - 1; i >= 0; i--) {
stack.push(children.get(i));
}
}
}
if (processed >= maxNodes) {
log.warn(
"Source bookmark traversal hit {}-node cap; remaining bookmarks dropped",
maxNodes);
}
}
}
@@ -1,16 +1,10 @@
package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.apache.pdfbox.multipdf.LayerUtility;
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.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -26,91 +20,88 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfPage;
import stirling.software.jpdfium.doc.PdfPageScaler;
import stirling.software.jpdfium.doc.PdfPosterizer.PaperSize;
import stirling.software.jpdfium.model.PageSize;
import stirling.software.jpdfium.model.Rect;
import stirling.software.jpdfium.transform.PdfPageBoxes;
@GeneralApi
@Slf4j
@RequiredArgsConstructor
public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
// PDFBox PDRectangle dimensions (width, height) in points, inlined so the
// controller has no remaining PDFBox dependency.
private static final float A0_W = 2383.937f;
private static final float A0_H = 3370.3937f;
private static final float A1_W = 1683.7795f;
private static final float A1_H = 2383.937f;
private static final float A2_W = 1190.5511f;
private static final float A2_H = 1683.7795f;
private static final float A3_W = 841.8898f;
private static final float A3_H = 1190.5511f;
private static final float A4_W = 595.27563f;
private static final float A4_H = 841.8898f;
private static final float A5_W = 419.52756f;
private static final float A5_H = 595.27563f;
private static final float A6_W = 297.63782f;
private static final float A6_H = 419.52756f;
private static final float LETTER_W = 612f;
private static final float LETTER_H = 792f;
private static final float LEGAL_W = 612f;
private static final float LEGAL_H = 1008f;
private final TempFileManager tempFileManager;
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
if ("KEEP".equals(targetPDRectangle)) {
if (sourceDocument.getNumberOfPages() == 0) {
private static PageSize getTargetSize(String name, PdfDocument sourceDocument) {
if ("KEEP".equals(name)) {
if (sourceDocument.pageCount() == 0) {
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
}
PDPage sourcePage = sourceDocument.getPage(0);
PDRectangle sourceSize = sourcePage.getMediaBox();
if (sourceSize == null) {
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
try (PdfPage first = sourceDocument.page(0)) {
return first.size();
}
return sourceSize;
}
Map<String, PDRectangle> sizeMap = getSizeMap();
if (sizeMap.containsKey(targetPDRectangle)) {
return sizeMap.get(targetPDRectangle);
Map<String, PageSize> sizeMap = getSizeMap();
PageSize size = sizeMap.get(name);
if (size == null) {
throw ExceptionUtils.createInvalidPageSizeException(name);
}
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
return size;
}
private static Map<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> sizeMap = new HashMap<>();
private static Map<String, PageSize> getSizeMap() {
Map<String, PageSize> sizeMap = new HashMap<>();
// Portrait sizes (A0-A6)
sizeMap.put("A0", PDRectangle.A0);
sizeMap.put("A1", PDRectangle.A1);
sizeMap.put("A2", PDRectangle.A2);
sizeMap.put("A3", PDRectangle.A3);
sizeMap.put("A4", PDRectangle.A4);
sizeMap.put("A5", PDRectangle.A5);
sizeMap.put("A6", PDRectangle.A6);
sizeMap.put("A0", new PageSize(A0_W, A0_H));
sizeMap.put("A1", new PageSize(A1_W, A1_H));
sizeMap.put("A2", new PageSize(A2_W, A2_H));
sizeMap.put("A3", new PageSize(A3_W, A3_H));
sizeMap.put("A4", new PageSize(A4_W, A4_H));
sizeMap.put("A5", new PageSize(A5_W, A5_H));
sizeMap.put("A6", new PageSize(A6_W, A6_H));
// Landscape sizes (A0-A6)
sizeMap.put(
"A0_LANDSCAPE",
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
sizeMap.put(
"A1_LANDSCAPE",
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
sizeMap.put(
"A2_LANDSCAPE",
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
sizeMap.put(
"A3_LANDSCAPE",
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
sizeMap.put(
"A4_LANDSCAPE",
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
sizeMap.put(
"A5_LANDSCAPE",
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
sizeMap.put(
"A6_LANDSCAPE",
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
sizeMap.put("A0_LANDSCAPE", new PageSize(A0_H, A0_W));
sizeMap.put("A1_LANDSCAPE", new PageSize(A1_H, A1_W));
sizeMap.put("A2_LANDSCAPE", new PageSize(A2_H, A2_W));
sizeMap.put("A3_LANDSCAPE", new PageSize(A3_H, A3_W));
sizeMap.put("A4_LANDSCAPE", new PageSize(A4_H, A4_W));
sizeMap.put("A5_LANDSCAPE", new PageSize(A5_H, A5_W));
sizeMap.put("A6_LANDSCAPE", new PageSize(A6_H, A6_W));
// Portrait US sizes
sizeMap.put("LETTER", PDRectangle.LETTER);
sizeMap.put("LEGAL", PDRectangle.LEGAL);
sizeMap.put("LETTER", new PageSize(LETTER_W, LETTER_H));
sizeMap.put("LEGAL", new PageSize(LEGAL_W, LEGAL_H));
// Landscape US sizes
sizeMap.put(
"LETTER_LANDSCAPE",
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
sizeMap.put(
"LEGAL_LANDSCAPE",
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
sizeMap.put("LETTER_LANDSCAPE", new PageSize(LETTER_H, LETTER_W));
sizeMap.put("LEGAL_LANDSCAPE", new PageSize(LEGAL_H, LEGAL_W));
return sizeMap;
}
@@ -130,53 +121,46 @@ public class ScalePagesController {
String targetPDRectangle = request.getPageSize();
float scaleFactor = request.getScaleFactor();
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
File inputFile = tempFileManager.convertMultipartFileToFile(file);
try {
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
PageSize targetSize = getTargetSize(targetPDRectangle, doc);
float targetW = targetSize.width();
float targetH = targetSize.height();
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
// Build a "virtual" paper so PdfPageScaler.scale produces base*scaleFactor
// scaling, then restore the real MediaBox/CropBox to the requested target
// size with the content centered.
float virtualW = targetW * scaleFactor;
float virtualH = targetH * scaleFactor;
PaperSize virtualPaper = new PaperSize(virtualW, virtualH, "virtual");
// Create LayerUtility once outside the loop for better performance
LayerUtility layerUtility = new LayerUtility(outputDocument);
int pageCount = doc.pageCount();
for (int i = 0; i < pageCount; i++) {
PdfPageScaler.scale(doc, i, virtualPaper, PdfPageScaler.FitMode.FIT_PAGE);
int totalPages = sourceDocument.getNumberOfPages();
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle sourceSize = sourcePage.getMediaBox();
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
PDPage newPage = new PDPage(targetSize);
outputDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
outputDocument,
newPage,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
float offsetX = (virtualW - targetW) / 2f;
float offsetY = (virtualH - targetH) / 2f;
Rect mediaBox = Rect.of(offsetX, offsetY, targetW, targetH);
try (PdfPage page = doc.page(i)) {
PdfPageBoxes.setMediaBox(page.rawHandle(), mediaBox);
PdfPageBoxes.setCropBox(page.rawHandle(), mediaBox);
}
}
}
return WebResponseUtils.pdfDocToWebResponse(
outputDocument,
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"),
tempFileManager);
TempFile outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
doc.save(outputTempFile.getPath());
} catch (Exception e) {
outputTempFile.close();
throw e;
}
return WebResponseUtils.pdfFileToWebResponse(
outputTempFile,
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
}
} finally {
tempFileManager.deleteTempFile(inputFile);
}
}
}
@@ -1,6 +1,7 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@@ -27,53 +28,50 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@DisplayName("CropController Tests")
class CropControllerTest {
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream __in = response.getBody().getInputStream()) {
__in.transferTo(baos);
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private CropController cropController;
private TestPdfFactory pdfFactory;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class)))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
MultipartFile mf = inv.getArgument(0);
File f = Files.createTempFile("crop-in", ".pdf").toFile();
Files.write(f.toPath(), mf.getBytes());
return f;
});
lenient()
.when(tempFileManager.createTempFile(anyString()))
.thenAnswer(inv -> Files.createTempFile("crop-out", inv.getArgument(0)).toFile());
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
pdfFactory = new TestPdfFactory();
}
@@ -112,8 +110,10 @@ class CropControllerTest {
private static final PDType1Font HELVETICA =
new PDType1Font(Standard14Fonts.FontName.HELVETICA);
// PDFium TransFormWithClip rejects pages without content streams; always
// add at least a sentinel character so the page has a /Contents entry.
MockMultipartFile createStandardPdf(String filename) throws IOException {
return createPdf(filename, PDRectangle.LETTER, null);
return createPdf(filename, PDRectangle.LETTER, "X");
}
MockMultipartFile createPdfWithContent(String filename, String content) throws IOException {
@@ -121,30 +121,26 @@ class CropControllerTest {
}
MockMultipartFile createPdfWithSize(String filename, PDRectangle size) throws IOException {
return createPdf(filename, size, null);
return createPdf(filename, size, "X");
}
MockMultipartFile createPdf(String filename, PDRectangle pageSize, String content)
throws IOException {
Path testPdfPath = tempDir.resolve(filename);
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(pageSize);
doc.addPage(page);
if (content != null && !content.isEmpty()) {
try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) {
contentStream.beginText();
contentStream.setFont(HELVETICA, 12);
contentStream.newLineAtOffset(50, pageSize.getHeight() - 50);
contentStream.showText(content);
contentStream.endText();
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(HELVETICA, 12);
cs.newLineAtOffset(50, pageSize.getHeight() - 50);
cs.showText(content);
cs.endText();
}
}
doc.save(testPdfPath.toFile());
}
return new MockMultipartFile(
"fileInput",
filename,
@@ -156,26 +152,22 @@ class CropControllerTest {
throws IOException {
Path testPdfPath = tempDir.resolve(filename);
PDRectangle pageSize = PDRectangle.LETTER;
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(pageSize);
doc.addPage(page);
if (content != null && !content.isEmpty()) {
try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) {
contentStream.beginText();
contentStream.setFont(HELVETICA, 12);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(HELVETICA, 12);
float x = pageSize.getWidth() / 2 - 50;
float y = pageSize.getHeight() / 2;
contentStream.newLineAtOffset(x, y);
contentStream.showText(content);
contentStream.endText();
cs.newLineAtOffset(x, y);
cs.showText(content);
cs.endText();
}
}
doc.save(testPdfPath.toFile());
}
return new MockMultipartFile(
"fileInput",
filename,
@@ -185,43 +177,34 @@ class CropControllerTest {
}
@Nested
@DisplayName("Manual Crop with PDFBox")
class ManualCropPDFBoxTests {
@DisplayName("Manual Crop with JPDFium")
@Tag("integration")
class ManualCropTests {
@Test
@DisplayName(
"Should successfully crop PDF using PDFBox when removeDataOutsideCrop is false")
void shouldCropPdfSuccessfullyWithPDFBox() throws IOException {
@DisplayName("Should produce PDF with cropped MediaBox dimensions")
void shouldCropPdfWithExpectedDimensions() throws IOException {
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
float w = 512f;
float h = 692f;
CropPdfForm request =
new CropRequestBuilder()
.withFile(testFile)
.withCoordinates(50f, 50f, 512f, 692f)
.withCoordinates(50f, 50f, w, h)
.withRemoveDataOutsideCrop(false)
.withAutoCrop(false)
.build();
PDDocument mockDocument = mock(PDDocument.class);
PDDocument newDocument = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
.thenReturn(newDocument);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response)
.isNotNull()
.extracting(ResponseEntity::getStatusCode, ResponseEntity::getBody)
.satisfies(
tuple -> {
assertThat(tuple.get(0)).isEqualTo(HttpStatus.OK);
assertThat(tuple.get(1)).isNotNull();
});
verify(pdfDocumentFactory).load(request);
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(mockDocument);
verify(mockDocument, times(1)).close();
verify(newDocument, times(1)).close();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
byte[] out = drainBody(response);
try (PDDocument result = Loader.loadPDF(out)) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
PDRectangle mediaBox = result.getPage(0).getMediaBox();
assertThat(mediaBox.getWidth()).isCloseTo(w, within(0.1f));
assertThat(mediaBox.getHeight()).isCloseTo(h, within(0.1f));
}
}
@ParameterizedTest
@@ -238,21 +221,15 @@ class CropControllerTest {
.withAutoCrop(false)
.build();
PDDocument mockDocument = mock(PDDocument.class);
PDDocument newDocument = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
.thenReturn(newDocument);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(pdfDocumentFactory).load(request);
verify(mockDocument, times(1)).close();
verify(newDocument, times(1)).close();
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
PDRectangle mediaBox = result.getPage(0).getMediaBox();
assertThat(mediaBox.getWidth()).isCloseTo(width, within(0.5f));
assertThat(mediaBox.getHeight()).isCloseTo(height, within(0.5f));
}
}
}
@@ -261,69 +238,84 @@ class CropControllerTest {
@Tag("integration")
class AutoCropTests {
private TestPdfFactory autoCropPdfFactory;
@BeforeEach
void setUp() throws Exception {
autoCropPdfFactory = new TestPdfFactory();
}
@Test
@DisplayName("Should auto-crop PDF with content successfully")
void shouldAutoCropPdfSuccessfully() throws IOException {
MockMultipartFile testFile =
autoCropPdfFactory.createPdfWithCenteredContent(
pdfFactory.createPdfWithCenteredContent(
"test_autocrop.pdf", "Test Content for Auto Crop");
CropPdfForm request =
new CropRequestBuilder().withFile(testFile).withAutoCrop(true).build();
// Mock the pdfDocumentFactory to load real PDFs
try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes());
PDDocument newDoc = new PDDocument()) {
when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
.thenReturn(newDoc);
ResponseEntity<Resource> response = cropController.cropPdf(request);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
PDPage page = result.getPage(0);
assertThat(page).isNotNull();
assertThat(page.getMediaBox()).isNotNull();
}
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
byte[] out = drainBody(response);
assertThat(out).isNotEmpty();
try (PDDocument result = Loader.loadPDF(out)) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
PDPage page = result.getPage(0);
assertThat(page.getMediaBox()).isNotNull();
// Cropped result should be smaller than original LETTER.
assertThat(page.getMediaBox().getWidth()).isLessThan(PDRectangle.LETTER.getWidth());
assertThat(page.getMediaBox().getHeight())
.isLessThan(PDRectangle.LETTER.getHeight());
}
}
@Test
@DisplayName("Should handle PDF with minimal content")
void shouldHandleMinimalContentPdf() throws IOException {
MockMultipartFile testFile =
autoCropPdfFactory.createPdfWithContent("minimal.pdf", "X");
MockMultipartFile testFile = pdfFactory.createPdfWithContent("minimal.pdf", "X");
CropPdfForm request =
new CropRequestBuilder().withFile(testFile).withAutoCrop(true).build();
// Mock the pdfDocumentFactory to load real PDFs
try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes());
PDDocument newDoc = new PDDocument()) {
when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
.thenReturn(newDoc);
ResponseEntity<Resource> response = cropController.cropPdf(request);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
}
}
}
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@Nested
@DisplayName("Ghostscript CropBox Compatibility")
@Tag("integration")
class GhostscriptCropBoxTests {
Assertions.assertNotNull(response.getBody());
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
// gs -dUseCropBox reads the /CropBox dictionary entry on each page.
// Verifies JPDFium's PdfPageBoxes.setCropBox actually persists a /CropBox
// entry that PDFBox (and therefore gs) can read back.
@Test
@DisplayName("Should persist /CropBox entry that PDFBox can read after JPDFium save")
void shouldPersistCropBoxThatGsCanRead() throws IOException {
MockMultipartFile testFile = pdfFactory.createStandardPdf("cropbox.pdf");
Path src = Files.createTempFile(tempDir, "src-", ".pdf");
Files.write(src, testFile.getBytes());
Path out = Files.createTempFile(tempDir, "out-", ".pdf");
float x = 50, y = 60, w = 400, h = 500;
try (stirling.software.jpdfium.PdfDocument doc =
stirling.software.jpdfium.PdfDocument.open(src)) {
int n = doc.pageCount();
for (int i = 0; i < n; i++) {
try (stirling.software.jpdfium.PdfPage p = doc.page(i)) {
stirling.software.jpdfium.transform.PdfPageBoxes.setCropBox(
p.rawHandle(), stirling.software.jpdfium.model.Rect.of(x, y, w, h));
}
}
doc.save(out);
}
try (PDDocument loaded = Loader.loadPDF(out.toFile())) {
PDRectangle cropBox = loaded.getPage(0).getCropBox();
assertThat(cropBox).isNotNull();
assertThat(cropBox.getLowerLeftX()).isCloseTo(x, within(0.5f));
assertThat(cropBox.getLowerLeftY()).isCloseTo(y, within(0.5f));
assertThat(cropBox.getWidth()).isCloseTo(w, within(0.5f));
assertThat(cropBox.getHeight()).isCloseTo(h, within(0.5f));
}
}
}
@@ -384,9 +376,7 @@ class CropControllerTest {
@DisplayName("Should detect full image bounds for all white image")
void shouldDetectFullBoundsForWhiteImage() throws Exception {
BufferedImage whiteImage = createWhiteImage(100, 100);
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, whiteImage);
assertThat(bounds).containsExactly(0, 0, 99, 99);
}
@@ -395,9 +385,7 @@ class CropControllerTest {
void shouldDetectBlackRectangleBounds() throws Exception {
BufferedImage image = createWhiteImage(100, 100);
drawBlackRectangle(image, 25, 25, 75, 75);
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
assertThat(bounds).containsExactly(25, 25, 74, 74);
}
@@ -409,9 +397,7 @@ class CropControllerTest {
image.setRGB(99, 0, 0x000000);
image.setRGB(0, 99, 0x000000);
image.setRGB(99, 99, 0x000000);
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
assertThat(bounds).containsExactly(0, 0, 99, 99);
}
@@ -422,9 +408,7 @@ class CropControllerTest {
image.setRGB(10, 10, 0xF0F0F0);
image.setRGB(90, 90, 0xF0F0F0);
drawBlackRectangle(image, 30, 30, 70, 70);
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
assertThat(bounds).containsExactly(10, 9, 90, 89);
}
@@ -433,9 +417,7 @@ class CropControllerTest {
void shouldTreatGrayPixelsAsContent() throws Exception {
BufferedImage image = createImageFilledWith(50, 50, 0xF0F0F0);
drawDarkerRectangle(image, 20, 20, 30, 30, 0xC0C0C0);
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
assertThat(bounds).containsExactly(0, 0, 49, 49);
}
}
@@ -554,33 +536,6 @@ class CropControllerTest {
@DisplayName("Error Handling")
class ErrorHandlingTests {
@Test
@DisplayName("Should throw exception for corrupt PDF file")
void shouldThrowExceptionForCorruptPdf() throws IOException {
MockMultipartFile corruptFile =
new MockMultipartFile(
"fileInput",
"corrupt.pdf",
MediaType.APPLICATION_PDF_VALUE,
"not a valid pdf content".getBytes());
CropPdfForm request =
new CropRequestBuilder()
.withFile(corruptFile)
.withCoordinates(50f, 50f, 512f, 692f)
.withRemoveDataOutsideCrop(false)
.withAutoCrop(false)
.build();
when(pdfDocumentFactory.load(request)).thenThrow(new IOException("Invalid PDF format"));
assertThatThrownBy(() -> cropController.cropPdf(request))
.isInstanceOf(IOException.class)
.hasMessageContaining("Invalid PDF format");
verify(pdfDocumentFactory).load(request);
}
@Test
@DisplayName("Should throw exception when coordinates are missing for manual crop")
void shouldThrowExceptionForMissingCoordinates() throws IOException {
@@ -593,54 +548,6 @@ class CropControllerTest {
.hasMessage(
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
@Test
@DisplayName("Should handle negative coordinates gracefully")
void shouldHandleNegativeCoordinates() throws IOException {
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
CropPdfForm request =
new CropRequestBuilder()
.withFile(testFile)
.withCoordinates(-10f, 50f, 512f, 692f)
.withRemoveDataOutsideCrop(false)
.withAutoCrop(false)
.build();
PDDocument mockDocument = mock(PDDocument.class);
PDDocument newDocument = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
.thenReturn(newDocument);
assertThatCode(() -> cropController.cropPdf(request)).doesNotThrowAnyException();
verify(mockDocument, times(1)).close();
verify(newDocument, times(1)).close();
}
@Test
@DisplayName("Should handle zero width or height")
void shouldHandleZeroDimensions() throws IOException {
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
CropPdfForm request =
new CropRequestBuilder()
.withFile(testFile)
.withCoordinates(50f, 50f, 0f, 692f)
.withRemoveDataOutsideCrop(false)
.withAutoCrop(false)
.build();
PDDocument mockDocument = mock(PDDocument.class);
PDDocument newDocument = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
.thenReturn(newDocument);
assertThatCode(() -> cropController.cropPdf(request)).doesNotThrowAnyException();
verify(mockDocument, times(1)).close();
verify(newDocument, times(1)).close();
}
}
@Nested
@@ -663,7 +570,6 @@ class CropControllerTest {
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
float expectedWidth = 400f;
float expectedHeight = 500f;
CropPdfForm request =
new CropRequestBuilder()
.withFile(testFile)
@@ -672,16 +578,14 @@ class CropControllerTest {
.withAutoCrop(false)
.build();
PDDocument mockDocument = mock(PDDocument.class);
PDDocument newDocument = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
.thenReturn(newDocument);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
PDRectangle mediaBox = result.getPage(0).getMediaBox();
assertThat(mediaBox.getWidth()).isCloseTo(expectedWidth, within(0.5f));
assertThat(mediaBox.getHeight()).isCloseTo(expectedHeight, within(0.5f));
}
}
@ParameterizedTest
@@ -691,7 +595,6 @@ class CropControllerTest {
throws IOException {
PDRectangle pageSize = getPageSize(pageSizeName);
MockMultipartFile testFile = pdfFactory.createPdfWithSize(filename, pageSize);
CropPdfForm request =
new CropRequestBuilder()
.withFile(testFile)
@@ -700,17 +603,14 @@ class CropControllerTest {
.withAutoCrop(false)
.build();
PDDocument mockDocument = mock(PDDocument.class);
PDDocument newDocument = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
.thenReturn(newDocument);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(mockDocument, times(1)).close();
verify(newDocument, times(1)).close();
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
PDRectangle mediaBox = result.getPage(0).getMediaBox();
assertThat(mediaBox.getWidth()).isCloseTo(300f, within(0.5f));
assertThat(mediaBox.getHeight()).isCloseTo(400f, within(0.5f));
}
}
}
}
@@ -0,0 +1,300 @@
package stirling.software.SPDF.controller.api;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
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.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.util.Matrix;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfPage;
import stirling.software.jpdfium.doc.PdfMarginAdjuster;
import stirling.software.jpdfium.doc.PdfPageScaler;
import stirling.software.jpdfium.doc.PdfPosterizer.PaperSize;
import stirling.software.jpdfium.model.PageSize;
import stirling.software.jpdfium.model.Rect;
import stirling.software.jpdfium.transform.PdfPageBoxes;
/** Bench gated on -Djpdfium.bench=true. Compares JPDFium crop+scale vs PDFBox baselines. */
@EnabledIfSystemProperty(named = "jpdfium.bench", matches = "true")
class CropScaleBench {
private static final int WARMUPS = 2;
private static final int RUNS = 5;
private static final int PAGES = Integer.getInteger("crop.bench.pages", 60);
private static final int IMG_W = Integer.getInteger("crop.bench.imgW", 1200);
private static final int IMG_H = Integer.getInteger("crop.bench.imgH", 1600);
@Test
void cropAndScale(@TempDir Path tmp) throws IOException {
Path imageHeavy = tmp.resolve("image_heavy.pdf");
buildImageHeavyPdf(imageHeavy, PAGES);
long sizeBytes = Files.size(imageHeavy);
for (int i = 0; i < WARMUPS; i++) {
runPdfBoxCrop(imageHeavy);
runJpdfiumCrop(imageHeavy);
runPdfBoxScale(imageHeavy);
runJpdfiumScale(imageHeavy);
}
Stats pdfboxCrop = bench(() -> runPdfBoxCrop(imageHeavy));
Stats jpdfiumCrop = bench(() -> runJpdfiumCrop(imageHeavy));
Stats pdfboxScale = bench(() -> runPdfBoxScale(imageHeavy));
Stats jpdfiumScale = bench(() -> runJpdfiumScale(imageHeavy));
System.out.println("=== CropScaleBench ===");
System.out.printf(
"input: %d KB (%d pages, %dx%d JPEGs)%n", sizeBytes / 1024, PAGES, IMG_W, IMG_H);
System.out.printf("runs: %d after %d warmups%n", RUNS, WARMUPS);
report("PDFBox crop", pdfboxCrop);
report("JPDFium crop", jpdfiumCrop);
report("PDFBox scale", pdfboxScale);
report("JPDFium scale", jpdfiumScale);
System.out.printf(
"crop delta (jpdfium-pdfbox): wall=%+.1fms heap=%+.1fMB%n",
jpdfiumCrop.avgMillis - pdfboxCrop.avgMillis,
jpdfiumCrop.avgPeakHeapMb - pdfboxCrop.avgPeakHeapMb);
System.out.printf(
"scale delta (jpdfium-pdfbox): wall=%+.1fms heap=%+.1fMB%n",
jpdfiumScale.avgMillis - pdfboxScale.avgMillis,
jpdfiumScale.avgPeakHeapMb - pdfboxScale.avgPeakHeapMb);
// Auto-crop renders every page via PdfImageConverter at 150 DPI.
// Canary single run to surface heap pressure on image-heavy input.
System.gc();
long h0 = usedHeap();
long t0 = System.nanoTime();
runJpdfiumAutoCrop(imageHeavy);
long t1 = System.nanoTime();
long h1 = usedHeap();
System.out.printf(
"JPDFium auto-crop (1 run): wall=%.1fms heapDelta=%.1fMB%n",
(t1 - t0) / 1_000_000.0, Math.max(0, h1 - h0) / (1024.0 * 1024.0));
}
private static void report(String label, Stats s) {
System.out.printf(
"%-13s wall=%7.1fms peakHeap=%6.1fMB%n", label, s.avgMillis, s.avgPeakHeapMb);
}
// PDFBox crop matching pre-migration CropController.cropWithPDFBox.
private void runPdfBoxCrop(Path in) throws IOException {
byte[] bytes = Files.readAllBytes(in);
try (PDDocument src = Loader.loadPDF(bytes);
PDDocument dst = new PDDocument()) {
LayerUtility layerUtility = new LayerUtility(dst);
float cropX = 50, cropY = 50, cropW = 500, cropH = 680;
for (int i = 0; i < src.getNumberOfPages(); i++) {
PDPage sourcePage = src.getPage(i);
PDPage newPage = new PDPage(sourcePage.getMediaBox());
dst.addPage(newPage);
try (PDPageContentStream cs =
new PDPageContentStream(dst, newPage, AppendMode.OVERWRITE, true, true)) {
PDFormXObject form = layerUtility.importPageAsForm(src, i);
cs.saveGraphicsState();
cs.addRect(cropX, cropY, cropW, cropH);
cs.clip();
cs.drawForm(form);
cs.restoreGraphicsState();
}
newPage.setMediaBox(new PDRectangle(cropX, cropY, cropW, cropH));
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
dst.save(out);
}
}
private void runJpdfiumCrop(Path in) throws IOException {
float cropX = 50, cropY = 50, cropW = 500, cropH = 680;
try (PdfDocument doc = PdfDocument.open(in)) {
int n = doc.pageCount();
for (int i = 0; i < n; i++) {
PageSize size;
try (PdfPage page = doc.page(i)) {
size = page.size();
}
float left = -cropX;
float bottom = -cropY;
float right = cropW - size.width() + cropX;
float top = cropH - size.height() + cropY;
PdfMarginAdjuster.addMargins(doc, i, left, bottom, right, top);
}
doc.saveBytes();
}
}
// PDFBox scale matching pre-migration ScalePagesController.scalePages.
private void runPdfBoxScale(Path in) throws IOException {
byte[] bytes = Files.readAllBytes(in);
PDRectangle target = PDRectangle.A4;
float scaleFactor = 0.75f;
try (PDDocument src = Loader.loadPDF(bytes);
PDDocument dst = new PDDocument()) {
LayerUtility layerUtility = new LayerUtility(dst);
for (int i = 0; i < src.getNumberOfPages(); i++) {
PDRectangle sourceSize = src.getPage(i).getMediaBox();
float scaleW = target.getWidth() / sourceSize.getWidth();
float scaleH = target.getHeight() / sourceSize.getHeight();
float scale = Math.min(scaleW, scaleH) * scaleFactor;
PDPage newPage = new PDPage(target);
dst.addPage(newPage);
try (PDPageContentStream cs =
new PDPageContentStream(
dst, newPage, PDPageContentStream.AppendMode.APPEND, true, true)) {
float x = (target.getWidth() - sourceSize.getWidth() * scale) / 2;
float y = (target.getHeight() - sourceSize.getHeight() * scale) / 2;
cs.saveGraphicsState();
cs.transform(Matrix.getTranslateInstance(x, y));
cs.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject form = layerUtility.importPageAsForm(src, i);
cs.drawForm(form);
cs.restoreGraphicsState();
}
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
dst.save(out);
}
}
private void runJpdfiumScale(Path in) throws IOException {
float targetW = 595.27563f, targetH = 841.8898f; // A4
float scaleFactor = 0.75f;
try (PdfDocument doc = PdfDocument.open(in)) {
float virtualW = targetW * scaleFactor;
float virtualH = targetH * scaleFactor;
PaperSize virtualPaper = new PaperSize(virtualW, virtualH, "virtual");
int n = doc.pageCount();
for (int i = 0; i < n; i++) {
PdfPageScaler.scale(doc, i, virtualPaper, PdfPageScaler.FitMode.FIT_PAGE);
float offX = (virtualW - targetW) / 2f;
float offY = (virtualH - targetH) / 2f;
Rect box = Rect.of(offX, offY, targetW, targetH);
try (PdfPage page = doc.page(i)) {
PdfPageBoxes.setMediaBox(page.rawHandle(), box);
PdfPageBoxes.setCropBox(page.rawHandle(), box);
}
}
doc.saveBytes();
}
}
// Mirrors the auto-crop hot path: renders each page then mutates margins.
private void runJpdfiumAutoCrop(Path in) throws IOException {
try (PdfDocument doc = PdfDocument.open(in)) {
int n = doc.pageCount();
for (int i = 0; i < n; i++) {
BufferedImage img =
stirling.software.jpdfium.PdfImageConverter.pageToImage(doc, i, 150);
PageSize size;
try (PdfPage page = doc.page(i)) {
size = page.size();
}
// Pretend bounds detection produced a centered crop.
float cx = 20, cy = 20;
float cw = size.width() - 40;
float ch = size.height() - 40;
float left = -cx, bottom = -cy;
float right = cw - size.width() + cx;
float top = ch - size.height() + cy;
PdfMarginAdjuster.addMargins(doc, i, left, bottom, right, top);
img.flush();
}
doc.saveBytes();
}
}
private Stats bench(IoRunnable r) throws IOException {
double sumMs = 0;
double sumHeapMb = 0;
for (int i = 0; i < RUNS; i++) {
System.gc();
long before = usedHeap();
long t0 = System.nanoTime();
r.run();
long t1 = System.nanoTime();
long after = usedHeap();
sumMs += (t1 - t0) / 1_000_000.0;
sumHeapMb += Math.max(0, after - before) / (1024.0 * 1024.0);
}
Stats s = new Stats();
s.avgMillis = sumMs / RUNS;
s.avgPeakHeapMb = sumHeapMb / RUNS;
return s;
}
private static long usedHeap() {
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
// Image-heavy PDF: one JPEG per page so crop/scale touch real content.
private static void buildImageHeavyPdf(Path path, int pages) throws IOException {
byte[] jpeg = makeJpeg(IMG_W, IMG_H);
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
PDImageXObject img = PDImageXObject.createFromByteArray(doc, jpeg, "bg" + i);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(
img,
0,
0,
page.getMediaBox().getWidth(),
page.getMediaBox().getHeight());
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 18);
cs.newLineAtOffset(40, 40);
cs.showText("page " + i);
cs.endText();
}
}
doc.save(path.toFile());
}
}
private static byte[] makeJpeg(int w, int h) throws IOException {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
for (int y = 0; y < h; y += 8) {
// Gradient so the encoded JPEG is not trivially compressible.
g.setColor(new Color((y * 7) & 0xff, (y * 11) & 0xff, (y * 13) & 0xff));
g.fillRect(0, y, w, 8);
}
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "jpg", baos);
return baos.toByteArray();
}
private static class Stats {
double avgMillis;
double avgPeakHeapMb;
}
@FunctionalInterface
private interface IoRunnable {
void run() throws IOException;
}
}
@@ -1,6 +1,6 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@@ -10,17 +10,21 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
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.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -28,264 +32,211 @@ import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@Tag("integration")
class ScalePagesControllerTest {
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream __in = response.getBody().getInputStream()) {
__in.transferTo(baos);
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ScalePagesController controller;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class)))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
MultipartFile mf = inv.getArgument(0);
File f = Files.createTempFile("scale-in", ".pdf").toFile();
Files.write(f.toPath(), mf.getBytes());
return f;
});
lenient()
.when(tempFileManager.createTempFile(anyString()))
.thenAnswer(inv -> Files.createTempFile("scale-out", inv.getArgument(0)).toFile());
}
private byte[] createRealPdf(PDRectangle pageSize, int numPages) throws IOException {
// PDFium TransFormWithClip requires each page to have a content stream; add a
// sentinel character on every page so the test fixtures are not empty.
private MockMultipartFile createRealPdf(PDRectangle pageSize, int numPages) throws IOException {
PDType1Font helv = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(pageSize));
PDPage page = new PDPage(pageSize);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(helv, 12);
cs.newLineAtOffset(50, pageSize.getHeight() - 50);
cs.showText("X");
cs.endText();
}
}
Path pdfPath = tempDir.resolve("input.pdf");
Path pdfPath = tempDir.resolve("input-" + System.nanoTime() + ".pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
return new MockMultipartFile(
"fileInput",
"test.pdf",
MediaType.APPLICATION_PDF_VALUE,
Files.readAllBytes(pdfPath));
}
}
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
inv -> {
byte[] bytes = ((MultipartFile) inv.getArgument(0)).getBytes();
return org.apache.pdfbox.Loader.loadPDF(bytes);
});
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
private ScalePagesRequest buildRequest(MockMultipartFile file, String size, float factor) {
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize(size);
request.setScaleFactor(factor);
return request;
}
@Test
void testScalePages_A4ToA3() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A3", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A3");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(1);
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A3.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A3.getHeight(), within(0.5f));
}
}
@Test
void testScalePages_KeepSize() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 2);
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "KEEP", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("KEEP");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(2);
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f));
}
}
@Test
void testScalePages_WithScaleFactor() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A4", 0.5f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setScaleFactor(0.5f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
// Page size stays A4; content is scaled by 0.5x and centered.
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f));
}
}
@Test
void testScalePages_Letter() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ResponseEntity<Resource> response =
controller.scalePages(buildRequest(file, "LETTER", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("LETTER");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.LETTER.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.LETTER.getHeight(), within(0.5f));
}
}
@Test
void testScalePages_Legal() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ResponseEntity<Resource> response =
controller.scalePages(buildRequest(file, "LEGAL", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("LEGAL");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.LEGAL.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.LEGAL.getHeight(), within(0.5f));
}
}
@Test
void testScalePages_InvalidPageSize() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ScalePagesRequest request = buildRequest(file, "INVALID_SIZE", 1.0f);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("INVALID_SIZE");
request.setScaleFactor(1.0f);
setupFactory();
assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request));
assertThatThrownBy(() -> controller.scalePages(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void testScalePages_MultiplePages() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 5);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 5);
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A5", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A5");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(5);
for (int i = 0; i < 5; i++) {
PDRectangle mb = out.getPage(i).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A5.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A5.getHeight(), within(0.5f));
}
}
}
@Test
void testScalePages_LandscapeSize() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ResponseEntity<Resource> response =
controller.scalePages(buildRequest(file, "A4_LANDSCAPE", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4_LANDSCAPE");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f));
}
}
@Test
void testScalePages_KeepWithEmptyDoc() throws Exception {
// Create a PDF then load it, but mock factory to return empty doc for KEEP check
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
Path pdfPath = tempDir.resolve("empty.pdf");
try (PDDocument doc = new PDDocument()) {
doc.save(pdfPath.toFile());
}
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
"fileInput",
"empty.pdf",
MediaType.APPLICATION_PDF_VALUE,
Files.readAllBytes(pdfPath));
ScalePagesRequest request = buildRequest(file, "KEEP", 1.0f);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("KEEP");
request.setScaleFactor(1.0f);
// Return an empty document to trigger the KEEP exception
when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(new PDDocument());
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request));
assertThatThrownBy(() -> controller.scalePages(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void testScalePages_A0Size() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A0", 1.0f));
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A0");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<Resource> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertThat(response.getStatusCode().value()).isEqualTo(200);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
PDRectangle mb = out.getPage(0).getMediaBox();
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A0.getWidth(), within(0.5f));
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A0.getHeight(), within(0.5f));
}
}
}
+7 -6
View File
@@ -31,12 +31,12 @@ ext {
googleJavaFormatVersion = "1.28.0"
logback = "1.5.32"
// junit-platform-launcher version managed by Spring Boot BOM
modernJavaVersion = 21
modernJavaVersion = 25
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
toolchain {
languageVersion = JavaLanguageVersion.of(project.findProperty('javaVersion')?.toString() ?: '25')
}
@@ -158,8 +158,8 @@ subprojects {
apply plugin: 'jacoco'
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
@@ -444,7 +444,8 @@ subprojects {
"-XX:G1HeapRegionSize=4m",
"-XX:+ExplicitGCInvokesConcurrent",
"-XX:+UseStringDeduplication",
"-XX:+UseCompactObjectHeaders"
"-XX:+UseCompactObjectHeaders",
"--enable-native-access=ALL-UNNAMED"
]
}
}
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Sign every .dylib inside the bootJar's JPDFium native jars.
# Requires APPLE_SIGNING_IDENTITY set to a Developer ID identity in the keychain.
#
# Usage: sign-jpdfium-dylibs-in-bootjar.sh [path/to/stirling-pdf-*.jar]
set -u
echo "sign-jpdfium-dylibs-in-bootjar.sh: start ($(uname -s) $(uname -m))"
case "$(uname -s)" in
Darwin*) ;;
*) echo "Not macOS, skipping"; exit 0;;
esac
if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
echo "APPLE_SIGNING_IDENTITY not set; skipping"
exit 0
fi
if ! command -v codesign >/dev/null 2>&1; then
echo "codesign not on PATH; skipping"
exit 0
fi
if ! command -v jar >/dev/null 2>&1; then
echo "jar not on PATH (need a JDK setup-action earlier); skipping"
exit 0
fi
BOOTJARS=()
if [ -n "${1:-}" ]; then
BOOTJARS+=("$1")
else
for cand in app/core/build/libs/stirling-pdf-*.jar \
frontend/src-tauri/libs/stirling-pdf-*.jar; do
[ -f "$cand" ] || continue
BOOTJARS+=("$cand")
done
fi
if [ "${#BOOTJARS[@]:-0}" = 0 ]; then
echo "bootJar not found (expected app/core/build/libs/stirling-pdf-*.jar" \
"or frontend/src-tauri/libs/stirling-pdf-*.jar)"
exit 0
fi
for BOOTJAR in "${BOOTJARS[@]}"; do
BOOTJAR=$(cd "$(dirname "$BOOTJAR")" && pwd)/$(basename "$BOOTJAR")
echo ""
echo "=== Target bootJar: $BOOTJAR ($(du -h "$BOOTJAR" | cut -f1)) ==="
WORK=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -rf '$WORK'" EXIT
NATIVE_JAR_PATHS=()
while IFS= read -r line; do
[ -n "$line" ] || continue
NATIVE_JAR_PATHS+=("$line")
done < <(jar tf "$BOOTJAR" \
| grep -E '^BOOT-INF/lib/jpdfium-natives-darwin-(x64|arm64)-.*\.jar$' || true)
if [ "${#NATIVE_JAR_PATHS[@]:-0}" = 0 ]; then
echo " No JPDFium darwin natives in this bootJar; skipping"
rm -rf "$WORK"
continue
fi
( cd "$WORK" && jar xf "$BOOTJAR" ${NATIVE_JAR_PATHS[@]+"${NATIVE_JAR_PATHS[@]}"} ) \
|| { echo "jar xf failed to extract natives jars" >&2; exit 1; }
ANY_SIGNED=0
for nat_jar in "$WORK/BOOT-INF/lib"/jpdfium-natives-darwin-*.jar; do
[ -f "$nat_jar" ] || continue
base=$(basename "$nat_jar")
echo " Processing $base"
exp_dir="$WORK/${base%.jar}.expanded"
mkdir -p "$exp_dir"
( cd "$exp_dir" && jar xf "$nat_jar" )
signed=0
while IFS= read -r dylib; do
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
--options runtime --timestamp "$dylib" 2>&1 | sed 's/^/ /'
signed=$((signed + 1))
done < <(find "$exp_dir" -name '*.dylib' -type f)
if [ "$signed" = 0 ]; then
echo " (no .dylibs found)"
continue
fi
echo " signed $signed dylib(s)"
rm -f "$nat_jar"
( cd "$exp_dir" && jar cfM0 "$nat_jar" . )
ANY_SIGNED=1
done
if [ "$ANY_SIGNED" = 0 ]; then
echo " No .dylibs signed; skipping update"
rm -rf "$WORK"
continue
fi
( cd "$WORK" && jar uf "$BOOTJAR" \
BOOT-INF/lib/jpdfium-natives-darwin-x64-*.jar \
BOOT-INF/lib/jpdfium-natives-darwin-arm64-*.jar ) \
2>/dev/null || { echo "jar uf failed" >&2; exit 1; }
echo " Updated: $BOOTJAR ($(du -h "$BOOTJAR" | cut -f1))"
rm -rf "$WORK"
done
+4 -2
View File
@@ -442,8 +442,10 @@ compare_file_lists() {
echo "New files created during test:"
cat "${diff_file}.added" | sed 's/^> //'
# Check for tmp files
grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true
# Exclude JPDFium native cache (deleteOnExit-registered, not a leak).
grep -i "tmp\|temp" "${diff_file}.added" \
| grep -v '/jpdfium-[0-9]\+/' \
> "${diff_file}.tmp" || true
if [ -s "${diff_file}.tmp" ]; then
echo "WARNING: Temporary files detected:"
cat "${diff_file}.tmp"