Compare commits

..
Author SHA1 Message Date
Anthony Stirling 89db09a4e6 Fix Linux crash 2026-05-18 23:37:05 +01:00
290 changed files with 2668 additions and 22761 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
name: Backend build, format check, and coverage
# Reusable workflow called from build.yml. Runs the backend build matrix
# (JDK 25 × spring-security on/off), Spotless formatting check, JUnit, and
# Reusable workflow called from build.yml. Runs the full backend build matrix
# (JDK 21/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: [25]
jdk-version: [21, 25]
spring-security: [true, false]
steps:
- name: Harden Runner
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "Dependency Review"
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
config-file: "./.github/config/dependency-review-config.yml"
+7 -9
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","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"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
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
;;
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","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
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
fi
build-jars:
@@ -256,17 +256,15 @@ 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
@@ -640,7 +638,7 @@ jobs:
run: ls -R ./artifacts
- name: Upload binaries to Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
+1 -1
View File
@@ -100,7 +100,7 @@ jobs:
sbom: true
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v2.4.1"
+2 -2
View File
@@ -96,13 +96,13 @@ jobs:
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v2.4.1"
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v2.4.1"
+1 -1
View File
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3.29.5
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v3.29.5
with:
sarif_file: results.sarif
+12 -10
View File
@@ -47,10 +47,12 @@ 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","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"}'
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"}'
# 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") ;;
@@ -110,6 +112,10 @@ 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
@@ -136,21 +142,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
@@ -263,10 +269,6 @@ 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: |
+1 -1
View File
@@ -165,7 +165,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
with:
egress-policy: audit
-2
View File
@@ -265,8 +265,6 @@ docs/type3/signatures/
# Type3 sample PDFs (development only)
**/type3/samples/
**/application-dev-local.properties
# Claude
.claude/
+15 -66
View File
@@ -1,19 +1,7 @@
version: '3'
# Gradle invocation strategy:
# - Linux/macOS: `./gradlew` runs the POSIX shell wrapper natively.
# - Windows: `cmd /c ".\gradlew.bat ..."` invokes the .bat wrapper through
# cmd.exe so it inherits the user's Windows-side `JAVA_HOME`
# and PATH. Routing through `bash gradlew` on Windows ends up
# under WSL or Git-Bash, neither of which inherits
# Adoptium/Temurin's default Windows-only Java env - gradlew
# then errors with "JAVA_HOME is not set and no 'java' command
# could be found".
#
# The entire `.\gradlew.bat ...` payload is double-quoted so
# the leading `.\` survives mvdan/sh's POSIX backslash
# stripping; cmd.exe also requires `.\` (not bare `gradlew.bat`)
# because modern Windows excludes cwd from cmd's search path.
# All gradle invocations go through `bash gradlew` (the sh wrapper) so they work
# uniformly on Linux/macOS (native bash) and Windows-with-Git-Bash.
tasks:
dev:
@@ -25,67 +13,43 @@ tasks:
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
- '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}bash gradlew :stirling-pdf:bootRun'
dev:bundled:
desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)"
ignore_error: true
cmds:
- cmd: cmd /c ".\gradlew.bat clean bootRun -PbuildWithFrontend=true"
platforms: [windows]
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
platforms: [linux, darwin]
- bash gradlew clean bootRun -PbuildWithFrontend=true
build:
desc: "Full backend build"
cmds:
- cmd: cmd /c ".\gradlew.bat clean build"
platforms: [windows]
- cmd: ./gradlew clean build
platforms: [linux, darwin]
- bash gradlew clean build
build:fast:
desc: "Build without tests"
cmds:
- cmd: cmd /c ".\gradlew.bat clean build -x test"
platforms: [windows]
- cmd: ./gradlew clean build -x test
platforms: [linux, darwin]
- bash gradlew clean build -x test
build:ci:
desc: "Build for CI (formatting checked separately)"
cmds:
- cmd: cmd /c ".\gradlew.bat build -PnoSpotless"
platforms: [windows]
- cmd: ./gradlew build -PnoSpotless
platforms: [linux, darwin]
- bash gradlew build -PnoSpotless
test:
desc: "Run backend tests"
cmds:
- cmd: cmd /c ".\gradlew.bat test"
platforms: [windows]
- cmd: ./gradlew test
platforms: [linux, darwin]
- bash gradlew test
format:
desc: "Auto-fix code formatting"
cmds:
- cmd: cmd /c ".\gradlew.bat spotlessApply"
platforms: [windows]
- cmd: ./gradlew spotlessApply
platforms: [linux, darwin]
- bash gradlew spotlessApply
format:check:
desc: "Check code formatting"
cmds:
- cmd: cmd /c ".\gradlew.bat spotlessCheck"
platforms: [windows]
- cmd: ./gradlew spotlessCheck
platforms: [linux, darwin]
- bash gradlew spotlessCheck
fix:
desc: "Auto-fix backend"
@@ -95,10 +59,7 @@ tasks:
swagger:
desc: "Generate OpenAPI docs"
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc"
platforms: [windows]
- cmd: ./gradlew :stirling-pdf:copySwaggerDoc
platforms: [linux, darwin]
- bash gradlew :stirling-pdf:copySwaggerDoc
sources:
- app/core/src/main/java/**/*.java
- app/proprietary/src/main/java/**/*.java
@@ -116,31 +77,19 @@ tasks:
desc: "Print project version"
silent: true
cmds:
- cmd: cmd /c ".\gradlew.bat printVersion --quiet" | tail -1
platforms: [windows]
- cmd: ./gradlew printVersion --quiet | tail -1
platforms: [linux, darwin]
- bash gradlew printVersion --quiet | tail -1
licenses:check:
desc: "Check dependency licenses"
cmds:
- cmd: cmd /c ".\gradlew.bat checkLicense --no-parallel"
platforms: [windows]
- cmd: ./gradlew checkLicense --no-parallel
platforms: [linux, darwin]
- bash gradlew checkLicense --no-parallel
licenses:generate:
desc: "Check and generate dependency license report"
cmds:
- cmd: cmd /c ".\gradlew.bat checkLicense generateLicenseReport --no-parallel"
platforms: [windows]
- cmd: ./gradlew checkLicense generateLicenseReport --no-parallel
platforms: [linux, darwin]
- bash gradlew checkLicense generateLicenseReport --no-parallel
clean:
desc: "Clean build artifacts"
cmds:
- cmd: cmd /c ".\gradlew.bat clean"
platforms: [windows]
- cmd: ./gradlew clean
platforms: [linux, darwin]
- bash gradlew clean
+3 -20
View File
@@ -3,22 +3,6 @@ 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"
@@ -87,16 +71,15 @@ tasks:
deps: [jlink:jar, jlink:runtime]
jlink:jar:
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
desc: "Build backend JAR for Tauri bundling"
run: once
dir: ..
env:
DISABLE_ADDITIONAL_FEATURES: "true"
cmds:
- echo "Building bootJar with JPDFium natives for {{.JPDFIUM_PLATFORMS}}"
- cmd: cmd /c gradlew.bat bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
- cmd: cmd /c gradlew.bat bootJar --no-daemon
platforms: [windows]
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
- cmd: ./gradlew bootJar --no-daemon
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**: Requires JDK 25.
- **Java Version**: Minimum JDK 21, supports and recommends 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 (requires JDK 25)
- Spring Boot (Java 21+, JDK 25 recommended)
- 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 25
- Java JDK 21 or later (JDK 25 recommended)
- 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 25 if not already installed.
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
-2
View File
@@ -6,8 +6,6 @@ Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* All content that resides under the "app/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
+3 -3
View File
@@ -3,7 +3,7 @@ version: '3'
output: prefixed
vars:
FIND_FREE_PORT_PS: powershell -NoProfile -File scripts/find-free-port.ps1
FIND_FREE_PORT_PS: powershell -NoProfile -File scripts\find-free-port.ps1 -Preferred
FIND_FREE_PORT_SH: bash scripts/find-free-port.sh
includes:
@@ -45,7 +45,7 @@ tasks:
desc: "Start backend + frontend concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
@@ -62,7 +62,7 @@ tasks:
desc: "Start backend + frontend + engine concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173,5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
ENGINE_PORT: '{{index (splitList "\n" .PORTS) 2}}'
-21
View File
@@ -59,25 +59,4 @@ dependencies {
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
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'
}
@@ -75,8 +75,8 @@ public @interface AutoJobPostMapping {
boolean queueable() default false;
/**
* Relative resource weight (1-100). See {@link
* stirling.software.common.enumeration.ResourceWeight} for the standard tiers.
* Relative resource weight (1100) used by the scheduler to prioritise / throttle jobs. Values
* below 1 are clamped to&nbsp;1, values above 100 to&nbsp;100.
*/
int resourceWeight() default 1;
int resourceWeight() default 50;
}
@@ -1,22 +0,0 @@
package stirling.software.common.enumeration;
/**
* Standard resource-weight tiers for {@link
* stirling.software.common.annotations.AutoJobPostMapping#resourceWeight()}.
*/
public final class ResourceWeight {
/** Lightweight: rotate, page numbers, extract pages, permissions, etc. */
public static final int SMALL_WEIGHT = 1;
/** Medium: merge, split, multi-tool batch. */
public static final int MEDIUM_WEIGHT = 3;
/** Heavy: compress, OCR (small), conversions, raster. */
public static final int LARGE_WEIGHT = 5;
/** Extra heavy: OCR on large files, full-document re-render, AI-assisted edits. */
public static final int XLARGE_WEIGHT = 10;
private ResourceWeight() {}
}
@@ -97,14 +97,7 @@ public class ApplicationProperties {
EncodedResource encodedResource = new EncodedResource(resource);
PropertySource<?> propertySource =
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
boolean saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
if (saasActive) {
// Saas-pinned values in application-saas.properties must beat settings.yml.
environment.getPropertySources().addLast(propertySource);
} else {
environment.getPropertySources().addFirst(propertySource);
}
environment.getPropertySources().addFirst(propertySource);
log.debug("Loaded properties: {}", propertySource.getSource());
@@ -1,26 +0,0 @@
package stirling.software.common.model.enumeration;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/** Team invitation status */
@Getter
@RequiredArgsConstructor
public enum InvitationStatus {
PENDING("PENDING"),
ACCEPTED("ACCEPTED"),
REJECTED("REJECTED"),
CANCELLED("CANCELLED"),
EXPIRED("EXPIRED");
private final String statusName;
public static InvitationStatus fromString(String statusName) {
for (InvitationStatus status : InvitationStatus.values()) {
if (status.getStatusName().equalsIgnoreCase(statusName)) {
return status;
}
}
throw new IllegalArgumentException("No InvitationStatus defined for name: " + statusName);
}
}
@@ -16,9 +16,6 @@ public enum Role {
// Unlimited access
USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"),
// Paid tier; set by Stripe webhooks under saas mode.
PRO_USER("ROLE_PRO_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.proUser"),
// 40 API calls Per Day, 40 web calls
LIMITED_API_USER("ROLE_LIMITED_API_USER", 40, 40, "adminUserSettings.apiUser"),
@@ -1,26 +0,0 @@
package stirling.software.common.model.enumeration;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Team membership roles LEADER: Can invite/remove members, manage settings, view usage, manage
* billing MEMBER: Regular team member with standard access
*/
@Getter
@RequiredArgsConstructor
public enum TeamRole {
LEADER("LEADER"),
MEMBER("MEMBER");
private final String roleName;
public static TeamRole fromString(String roleName) {
for (TeamRole role : TeamRole.values()) {
if (role.getRoleName().equalsIgnoreCase(roleName)) {
return role;
}
}
throw new IllegalArgumentException("No TeamRole defined for name: " + roleName);
}
}
@@ -1,59 +0,0 @@
package stirling.software.common.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import org.junit.jupiter.api.Test;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchRule;
/**
* Module dependency-direction guardrails. Allowed direction: {@code saas → proprietary → common}
* and {@code stirling-pdf → proprietary → common}.
*/
class ArchitectureTest {
private static final JavaClasses commonClasses =
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("stirling.software.common");
@Test
void commonDoesNotDependOnCore() {
ArchRule rule =
noClasses()
.that()
.resideInAPackage("stirling.software.common..")
.should()
.dependOnClassesThat()
.resideInAPackage("stirling.software.SPDF..");
rule.check(commonClasses);
}
@Test
void commonDoesNotDependOnProprietary() {
ArchRule rule =
noClasses()
.that()
.resideInAPackage("stirling.software.common..")
.should()
.dependOnClassesThat()
.resideInAPackage("stirling.software.proprietary..");
rule.check(commonClasses);
}
@Test
void commonDoesNotDependOnSaas() {
ArchRule rule =
noClasses()
.that()
.resideInAPackage("stirling.software.common..")
.should()
.dependOnClassesThat()
.resideInAPackage("stirling.software.saas..");
rule.check(commonClasses);
}
}
@@ -1,32 +0,0 @@
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");
}
}
}
+8 -45
View File
@@ -39,14 +39,12 @@ spotless {
}
dependencies {
if (!gradle.ext.disableAdditional) {
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
implementation project(':proprietary')
}
if (gradle.ext.enableSaas) {
implementation project(':saas')
}
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
@@ -136,17 +134,6 @@ sourceSets {
}
// Forward bench-related system properties through to the test JVM so
// `-Dsplit.bench=true` (and similar) flags reach Boolean.getBoolean().
tasks.named('test', Test) {
['split.bench', 'split.bench.pages', 'split.bench.chunk',
'split.bench.imgW', 'split.bench.imgH'].each { key ->
def v = System.getProperty(key)
if (v != null) systemProperty(key, v)
}
maxHeapSize = '2g'
}
// Disable regular jar
jar {
@@ -173,8 +160,7 @@ bootJar {
manifest {
attributes(
'Implementation-Title': 'Stirling-PDF',
'Implementation-Version': project.version,
'Enable-Native-Access': 'ALL-UNNAMED'
'Implementation-Version': project.version
)
}
}
@@ -187,23 +173,6 @@ springBoot {
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
// Vite mode: -PprototypesMode > -PfrontendMode > enableSaas > disableAdditional > proprietary.
def frontendModeOverride = project.findProperty('frontendMode')?.toString()?.toLowerCase()
def frontendMode
if (buildPrototypes) {
frontendMode = 'prototypes'
} else if (frontendModeOverride) {
frontendMode = frontendModeOverride
} else if (gradle.ext.enableSaas) {
frontendMode = 'saas'
} else if (gradle.ext.disableAdditional) {
frontendMode = 'core'
} else {
frontendMode = 'proprietary'
}
def frontendBuildTask = "frontend:build:${frontendMode}"
def frontendDir = file('../../frontend')
def frontendDistDir = file('../../frontend/dist')
def resourcesStaticDir = file('src/main/resources/static')
@@ -272,7 +241,7 @@ tasks.register('npmBuild', Exec) {
group = 'frontend'
description = 'Build frontend application'
workingDir file('../..')
commandLine = ['task', frontendBuildTask]
commandLine = buildPrototypes ? ['task', 'frontend:build:prototypes'] : ['task', 'frontend:build']
inputs.dir(new File(frontendDir, 'src'))
inputs.dir(new File(frontendDir, 'public'))
inputs.file(new File(frontendDir, 'package.json'))
@@ -287,7 +256,7 @@ tasks.register('npmBuild', Exec) {
environment 'VITE_API_BASE_URL', '/'
doFirst {
println "Building frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
println "Building frontend application for production (VITE_API_BASE_URL=/)"
}
}
@@ -296,7 +265,6 @@ tasks.register('copyFrontendAssets', Copy) {
group = 'frontend'
description = 'Copy frontend build to static resources'
dependsOn npmBuild
dependsOn cleanFrontendAssets
from(frontendDistDir) {
// Exclude files that conflict with backend static resources
exclude 'robots.txt' // Backend already has this
@@ -337,7 +305,7 @@ tasks.named('copyFrontendAssets').configure {
}
if (buildWithFrontend) {
println "Frontend build enabled - JAR will include React frontend (mode=${frontendMode})"
println "Frontend build enabled - JAR will include React frontend"
processResources.dependsOn copyFrontendAssets
} else {
println "Frontend build disabled - JAR will be backend-only with API landing page"
@@ -346,9 +314,4 @@ if (buildWithFrontend) {
}
bootJar.dependsOn ':common:jar'
if (!gradle.ext.disableAdditional) {
bootJar.dependsOn ':proprietary:jar'
}
if (gradle.ext.enableSaas) {
bootJar.dependsOn ':saas:jar'
}
bootJar.dependsOn ':proprietary:jar'
@@ -35,8 +35,7 @@ import stirling.software.common.model.ApplicationProperties;
scanBasePackages = {
"stirling.software.SPDF",
"stirling.software.common",
"stirling.software.proprietary",
"stirling.software.saas"
"stirling.software.proprietary"
})
public class SPDFApplication {
@@ -206,22 +205,15 @@ public class SPDFApplication {
}
}
// 2. Detect classpath shape and pick the matching profile chain.
boolean hasSaas = isClassPresent("stirling.software.saas.security.SupabaseSecurityConfig");
boolean hasSecurity =
isClassPresent(
"stirling.software.proprietary.security.configuration.SecurityConfiguration");
if (hasSaas) {
log.info("SaaS features in jar");
return new String[] {"security", "saas"};
}
if (hasSecurity) {
// 2. Detect if SecurityConfiguration is present on classpath
if (isClassPresent(
"stirling.software.proprietary.security.configuration.SecurityConfiguration")) {
log.info("Additional features in jar");
return new String[] {"security"};
} else {
log.info("Without additional features in jar");
return new String[] {"default"};
}
log.info("Without additional features in jar");
return new String[] {"default"};
}
private static boolean isClassPresent(String className) {
@@ -23,7 +23,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.AnalysisApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -33,10 +32,7 @@ public class AnalysisController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(
value = "/page-count",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/page-count", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get PDF page count",
@@ -47,10 +43,7 @@ public class AnalysisController {
}
}
@AutoJobPostMapping(
value = "/basic-info",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/basic-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get basic PDF information",
@@ -67,8 +60,7 @@ public class AnalysisController {
@AutoJobPostMapping(
value = "/document-properties",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get PDF document properties",
@@ -98,10 +90,7 @@ public class AnalysisController {
}
}
@AutoJobPostMapping(
value = "/page-dimensions",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/page-dimensions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get page dimensions for all pages",
@@ -121,10 +110,7 @@ public class AnalysisController {
}
}
@AutoJobPostMapping(
value = "/form-fields",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/form-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get form field information",
@@ -148,10 +134,7 @@ public class AnalysisController {
}
}
@AutoJobPostMapping(
value = "/annotation-info",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/annotation-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get annotation information",
@@ -176,10 +159,7 @@ public class AnalysisController {
}
}
@AutoJobPostMapping(
value = "/font-info",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/font-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get font information",
@@ -205,10 +185,7 @@ public class AnalysisController {
}
}
@AutoJobPostMapping(
value = "/security-info",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/security-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Get security information",
@@ -28,7 +28,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
@@ -45,8 +44,7 @@ public class BookletImpositionController {
@AutoJobPostMapping(
value = "/booklet-imposition",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Create a booklet with proper page imposition",
description =
@@ -26,7 +26,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
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;
@@ -127,10 +126,7 @@ public class CropController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(
value = "/crop",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Crops a PDF document",
description =
@@ -25,7 +25,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
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.GeneralUtils;
import stirling.software.common.util.TempFileManager;
@@ -45,8 +44,7 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@@ -149,8 +147,7 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/edit-table-of-contents",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.")
@@ -3,14 +3,13 @@ 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;
@@ -40,7 +39,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
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;
@@ -48,11 +46,6 @@ 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
@@ -63,6 +56,7 @@ 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;
@@ -81,8 +75,11 @@ 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()
@@ -109,6 +106,7 @@ 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" ->
@@ -156,16 +154,18 @@ public class MergeController {
return 0;
}
};
case "orderProvided" -> (file1, file2) -> 0;
default -> (file1, file2) -> 0;
case "orderProvided" -> (file1, file2) -> 0; // Default is the order provided
default -> (file1, file2) -> 0; // Default is the order provided
};
}
// 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();
@@ -185,29 +185,39 @@ 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;
int pageIndex = 0; // Current page index in the merged document
// Iterate through the original files
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++;
pageIndex++; // Increment by at least one if we can't determine page count
}
}
}
@@ -225,6 +235,7 @@ 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()) {
@@ -260,10 +271,7 @@ public class MergeController {
return -1;
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/merge-pdfs",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/merge-pdfs")
@StandardPdfResponse
@Operation(
summary = "Merge multiple PDF files into one",
@@ -275,7 +283,7 @@ public class MergeController {
@ModelAttribute MergePdfsRequest request,
@RequestParam(value = "fileOrder", required = false) String fileOrder)
throws IOException {
List<File> filesToDelete = new ArrayList<>();
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
TempFile outputTempFile = null;
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
@@ -286,35 +294,48 @@ 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()));
Arrays.sort(
files,
getSortComparator(
request.getSortType())); // Sort files based on requested sort type
}
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
List<Path> inputPaths = new ArrayList<>(files.length);
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
List<Integer> invalidIndexes = new ArrayList<>();
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile);
filesToDelete.add(tempFile);
inputPaths.add(tempFile.toPath());
totalSize += multipartFile.getSize();
File tempFile =
tempFileManager.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
try (PdfDocument ignored = PdfDocument.open(tempFile.toPath())) {
} catch (Exception e) {
// 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) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
int[] pageCounts;
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
try {
pageCounts =
mergeWithJpdfium(inputPaths, files, generateToc, mt.getFile().toPath());
mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(
totalSize)); // Merge the documents
} catch (IOException e) {
ExceptionUtils.logException("PDF merge", e);
if (PdfErrorUtils.isCorruptedPdfError(e)) {
@@ -323,26 +344,10 @@ public class MergeController {
throw e;
}
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())) {
// 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) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
@@ -350,26 +355,24 @@ public class MergeController {
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(fieldsToRemove, false);
acroForm.flatten(
fieldsToRemove,
false); // Flatten the fields, effectively removing them
}
}
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
mergedDocument.save(outputTempFile.getFile());
} catch (Exception e) {
outputTempFile.close();
outputTempFile = null;
throw e;
}
}
} else {
// Add table of contents if generateToc is true
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a temporary file
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
Files.copy(
mt.getFile().toPath(),
outputTempFile.getFile().toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
mergedDocument.save(outputTempFile.getFile());
} catch (Exception e) {
outputTempFile.close();
outputTempFile = null;
@@ -388,7 +391,7 @@ public class MergeController {
throw ex;
} finally {
for (File file : filesToDelete) {
tempFileManager.deleteTempFile(file);
tempFileManager.deleteTempFile(file); // Delete temporary files
}
}
@@ -398,113 +401,4 @@ 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);
}
}
}
@@ -24,7 +24,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
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.GeneralFormCopyUtils;
@@ -42,8 +41,7 @@ public class MultiPageLayoutController {
@AutoJobPostMapping(
value = "/multi-page-layout",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Merge multiple pages of a PDF document into a single page",
description =
@@ -25,7 +25,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
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;
@@ -40,10 +39,7 @@ public class PdfOverlayController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/overlay-pdfs",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(value = "/overlay-pdfs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
summary = "Overlay PDF files in various modes",
@@ -28,7 +28,6 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
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;
@@ -44,10 +43,7 @@ public class PosterPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/split-for-poster-print",
consumes = "multipart/form-data",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(value = "/split-for-poster-print", consumes = "multipart/form-data")
@MultiFileResponse
@Operation(
summary = "Split large PDF pages into smaller printable chunks",
@@ -28,7 +28,6 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
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.FormUtils;
@@ -44,10 +43,7 @@ public class RearrangePagesPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/remove-pages",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-pages")
@StandardPdfResponse
@Operation(
summary = "Remove pages from a PDF file",
@@ -228,10 +224,7 @@ public class RearrangePagesPDFController {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rearrange-pages",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/rearrange-pages")
@StandardPdfResponse
@Operation(
summary = "Rearrange pages in a PDF file",
@@ -19,7 +19,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
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;
@@ -33,10 +32,7 @@ public class RotationController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rotate-pdf",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/rotate-pdf")
@StandardPdfResponse
@Operation(
summary = "Rotate a PDF file",
@@ -25,7 +25,6 @@ import lombok.extern.slf4j.Slf4j;
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;
@@ -115,10 +114,7 @@ public class ScalePagesController {
return sizeMap;
}
@AutoJobPostMapping(
value = "/scale-pages",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Change the size of a PDF page/document",
description =
@@ -16,7 +16,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SettingsApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
@@ -28,9 +27,7 @@ public class SettingsController {
private final ApplicationProperties applicationProperties;
private final EndpointConfiguration endpointConfiguration;
@AutoJobPostMapping(
value = "/update-enable-analytics",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestParam Boolean enabled)
throws IOException {
@@ -3,10 +3,11 @@ package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -27,15 +28,13 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.SplitPagesRequest;
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.FormUtils;
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.PdfSplit;
@GeneralApi
@Slf4j
@@ -45,10 +44,7 @@ public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/split-pages",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/split-pages")
@MultiFileResponse
@Operation(
summary = "Split a PDF file into separate documents",
@@ -90,8 +86,13 @@ public class SplitPDFController {
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
writeSplits(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut, hasForm);
if (hasForm) {
writeSplitsViaReload(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut);
} else {
writeSplitsViaSharedSource(
sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut);
}
}
}
@@ -104,71 +105,59 @@ public class SplitPDFController {
}
}
private void writeSplits(
File source,
List<Integer> pageNumbers,
String baseFilename,
ZipOutputStream zipOut,
boolean hasForm)
private void writeSplitsViaReload(
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
throws IOException {
try (PdfDocument sourceDoc = PdfDocument.open(source.toPath())) {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
Set<Integer> keep = new HashSet<>();
for (int i = previousPageNumber; i <= splitPoint; i++) {
keep.add(i);
}
previousPageNumber = splitPoint + 1;
try (PDDocument splitDoc = pdfDocumentFactory.load(source)) {
for (int p = splitDoc.getNumberOfPages() - 1; p >= 0; p--) {
if (!keep.contains(p)) {
splitDoc.removePage(p);
}
}
FormUtils.pruneOrphanedFormFields(splitDoc);
writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
}
private void writeSplitsViaSharedSource(
File source, List<Integer> pageNumbers, String baseFilename, ZipOutputStream zipOut)
throws IOException {
try (PDDocument sourceDoc = pdfDocumentFactory.load(source)) {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
writeSplit(
sourceDoc,
previousPageNumber,
splitPoint,
baseFilename,
splitIndex + 1,
zipOut,
hasForm);
previousPageNumber = splitPoint + 1;
}
}
}
private void writeSplit(
PdfDocument sourceDoc,
int fromIndex,
int toIndex,
String baseFilename,
int splitNumber,
ZipOutputStream zipOut,
boolean hasForm)
throws IOException {
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
try (PdfDocument splitDoc = PdfSplit.extractPageRange(sourceDoc, fromIndex, toIndex)) {
splitDoc.save(splitTemp.getPath());
}
Path finalPath = splitTemp.getPath();
TempFile prunedTemp = null;
try {
if (hasForm) {
prunedTemp = new TempFile(tempFileManager, ".pdf");
pruneForms(splitTemp.getFile(), prunedTemp.getFile());
finalPath = prunedTemp.getPath();
}
writeEntry(zipOut, baseFilename, splitNumber, finalPath);
} finally {
if (prunedTemp != null) {
prunedTemp.close();
try (PDDocument splitDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
splitDoc.addPage(sourceDoc.getPage(i));
}
previousPageNumber = splitPoint + 1;
writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
}
}
private void pruneForms(File splitFile, File outputFile) throws IOException {
try (PDDocument doc = pdfDocumentFactory.load(splitFile)) {
FormUtils.pruneOrphanedFormFields(doc);
doc.save(outputFile);
}
}
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, Path pdfPath)
private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc)
throws IOException {
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + index + ".pdf"));
Files.copy(pdfPath, zipOut);
doc.save(zipOut);
zipOut.closeEntry();
}
}
@@ -1,9 +1,6 @@
package stirling.software.SPDF.controller.api;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -11,6 +8,9 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -30,7 +30,6 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
@@ -39,8 +38,6 @@ 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.PdfSplit;
@GeneralApi
@Slf4j
@@ -53,42 +50,77 @@ public class SplitPdfByChaptersController {
private final TempFileManager tempFileManager;
private static void collectBookmarks(
List<stirling.software.jpdfium.doc.Bookmark> source,
List<Bookmark> out,
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
PDOutlineItem current,
List<Bookmark> bookmarks,
PDOutlineItem nextParent,
int level,
int maxLevel) {
for (stirling.software.jpdfium.doc.Bookmark bm : source) {
if (!bm.isInternal()) {
continue;
}
String title = bm.title() == null ? "" : bm.title().replace("/", "");
int firstPage = Math.max(0, bm.pageIndex());
out.add(new Bookmark(title, firstPage, -2));
if (bm.hasChildren() && level < maxLevel) {
collectBookmarks(bm.children(), out, level + 1, maxLevel);
}
}
}
int maxLevel)
throws Exception {
private static void assignEndPages(List<Bookmark> bookmarks, int totalPages) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark current = bookmarks.get(i);
int next = -1;
for (int j = i + 1; j < bookmarks.size(); j++) {
if (bookmarks.get(j).getStartPage() >= current.getStartPage()) {
next = bookmarks.get(j).getStartPage();
break;
}
while (current != null) {
String currentTitle = current.getTitle().replace("/", "");
int firstPage =
sourceDocument.getPages().indexOf(current.findDestinationPage(sourceDocument));
PDOutlineItem child = current.getFirstChild();
PDOutlineItem nextSibling = current.getNextSibling();
int endPage;
if (child != null && level < maxLevel) {
endPage =
sourceDocument
.getPages()
.indexOf(child.findDestinationPage(sourceDocument));
} else if (nextSibling != null) {
endPage =
sourceDocument
.getPages()
.indexOf(nextSibling.findDestinationPage(sourceDocument));
} else if (nextParent != null) {
endPage =
sourceDocument
.getPages()
.indexOf(nextParent.findDestinationPage(sourceDocument));
} else {
endPage = -2;
/*
happens when we have something like this:
Outline Item 2
Outline Item 2.1
Outline Item 2.1.1
Outline Item 2.2
Outline 2.2.1
Outline 2.2.2 <--- this item neither has an immediate next parent nor an immediate next sibling
Outline Item 3
*/
}
current.setEndPage(next == -1 ? totalPages : next);
if (!bookmarks.isEmpty()
&& bookmarks.get(bookmarks.size() - 1).getEndPage() == -2
&& firstPage
>= bookmarks
.get(bookmarks.size() - 1)
.getStartPage()) { // for handling the above-mentioned case
Bookmark previousBookmark = bookmarks.get(bookmarks.size() - 1);
previousBookmark.setEndPage(firstPage);
}
bookmarks.add(new Bookmark(currentTitle, firstPage, endPage));
// Recursively process children
if (child != null && level < maxLevel) {
extractOutlineItems(
sourceDocument, child, bookmarks, nextSibling, level + 1, maxLevel);
}
current = nextSibling;
}
return bookmarks;
}
@AutoJobPostMapping(
value = "/split-pdf-by-chapters",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@MultiFileResponse
@Operation(
summary = "Split PDFs by Chapters",
@@ -100,41 +132,46 @@ public class SplitPdfByChaptersController {
MultipartFile file = request.getFileInput();
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel = request.getBookmarkLevel();
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
}
try (TempFile sourceTempFile = new TempFile(tempFileManager, ".pdf")) {
Files.copy(
file.getInputStream(),
sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound", "No PDF bookmarks/outline found in document");
}
List<Bookmark> bookmarks = new ArrayList<>();
int totalPages;
try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) {
totalPages = sourceDocument.pageCount();
List<stirling.software.jpdfium.doc.Bookmark> roots = sourceDocument.bookmarks();
if (roots == null || roots.isEmpty()) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound",
"No PDF bookmarks/outline found in document");
}
collectBookmarks(roots, bookmarks, 0, bookmarkLevel);
if (bookmarks.isEmpty()) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound",
"No PDF bookmarks/outline found in document");
}
assignEndPages(bookmarks, totalPages);
try {
bookmarks =
extractOutlineItems(
sourceDocument,
outline.getFirstChild(),
bookmarks,
outline.getFirstChild().getNextSibling(),
0,
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
} catch (Exception e) {
ExceptionUtils.logException("outline extraction", e);
throw e;
}
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
if (!allowDuplicates) {
/*
duplicates are generated when multiple bookmarks correspond to the same page,
if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all
the bookmarks that correspond to the same page, and treat them as a single bookmark
*/
bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks);
}
for (Bookmark bookmark : bookmarks) {
@@ -145,15 +182,7 @@ public class SplitPdfByChaptersController {
bookmark.getEndPage());
}
PdfMetadata metadata = null;
if (includeMetadata) {
try (PDDocument metaDoc = pdfDocumentFactory.load(sourceTempFile.getFile())) {
metadata = pdfMetadataService.extractMetadataFromPdf(metaDoc);
}
}
TempFile zipTempFile =
createZipFile(sourceTempFile.getFile(), bookmarks, metadata, totalPages);
TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
}
@@ -185,23 +214,45 @@ public class SplitPdfByChaptersController {
}
private TempFile createZipFile(
File sourceFile, List<Bookmark> bookmarks, PdfMetadata metadata, int totalPages)
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
throws Exception {
PdfMetadata metadata =
includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null;
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (PdfDocument sourceDocument = PdfDocument.open(sourceFile.toPath());
ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark bookmark = bookmarks.get(i);
writeChapter(
sourceDocument,
bookmark,
i,
fileNumberFormatter,
metadata,
zipOut,
totalPages);
try {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark bookmark = bookmarks.get(i);
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
for (int pg = bookmark.getStartPage();
pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
pg++) {
PDPage page = sourceDocument.getPage(pg);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", pg);
}
if (includeMetadata) {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
}
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, i)
+ bookmark.getTitle()
+ ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
splitDocument.save(zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
}
log.info(
"Successfully created zip file with split documents: {}",
@@ -212,50 +263,6 @@ public class SplitPdfByChaptersController {
throw e;
}
}
private void writeChapter(
PdfDocument sourceDocument,
Bookmark bookmark,
int index,
String fileNumberFormatter,
PdfMetadata metadata,
ZipOutputStream zipOut,
int totalPages)
throws Exception {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
int from = Math.min(Math.max(0, bookmark.getStartPage()), totalPages - 1);
int rawEnd = isSinglePage ? bookmark.getEndPage() : bookmark.getEndPage() - 1;
int to = Math.min(Math.max(from, rawEnd), totalPages - 1);
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
try (PdfDocument splitDoc = PdfSplit.extractPageRange(sourceDocument, from, to)) {
splitDoc.save(splitTemp.getPath());
}
Path finalPath = splitTemp.getPath();
TempFile metaTemp = null;
try {
if (metadata != null) {
metaTemp = new TempFile(tempFileManager, ".pdf");
try (PDDocument doc = pdfDocumentFactory.load(splitTemp.getFile())) {
pdfMetadataService.setMetadataToPdf(doc, metadata);
doc.save(metaTemp.getFile());
}
finalPath = metaTemp.getPath();
}
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, index)
+ bookmark.getTitle()
+ ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
Files.copy(finalPath, zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} finally {
if (metaTemp != null) {
metaTemp.close();
}
}
}
}
}
@Data
@@ -33,7 +33,6 @@ import stirling.software.SPDF.model.SplitTypes;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
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;
@@ -51,8 +50,7 @@ public class SplitPdfBySectionsController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/split-pdf-by-sections",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
value = "/split-pdf-by-sections")
@MultiFileResponse
@Operation(
summary = "Split PDF pages into smaller sections",
@@ -1,16 +1,19 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -26,7 +29,6 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
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.FormUtils;
@@ -34,8 +36,6 @@ 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.PdfSplit;
@GeneralApi
@Slf4j
@@ -47,8 +47,7 @@ public class SplitPdfBySizeController {
@AutoJobPostMapping(
value = "/split-by-size-or-count",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@MultiFileResponse
@Operation(
summary = "Auto split PDF pages into separate documents based on size or count",
@@ -74,20 +73,23 @@ public class SplitPdfBySizeController {
sourceTempFile.getPath(),
StandardCopyOption.REPLACE_EXISTING);
boolean hasForm;
try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) {
hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null;
}
try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) {
List<int[]> ranges = computeRanges(request, sourceDocument);
try (PDDocument sourceDocument =
pdfDocumentFactory.load(sourceTempFile.getFile(), true)) {
boolean hasForm = sourceDocument.getDocumentCatalog().getAcroForm(null) != null;
List<List<Integer>> ranges = computeRanges(request, sourceDocument);
int fileIndex = 1;
for (int[] range : ranges) {
if (range.length == 0) {
for (List<Integer> range : ranges) {
if (range.isEmpty()) {
continue;
}
writeRange(sourceDocument, range, zipOut, filename, fileIndex++, hasForm);
if (hasForm) {
writeRangeViaReload(
sourceTempFile.getFile(), range, zipOut, filename, fileIndex++);
} else {
writeRangeViaSharedSource(
sourceDocument, range, zipOut, filename, fileIndex++);
}
}
}
}
@@ -100,80 +102,80 @@ public class SplitPdfBySizeController {
}
}
private List<int[]> computeRanges(SplitPdfBySizeOrCountRequest request, PdfDocument sourceDoc)
throws IOException {
private List<List<Integer>> computeRanges(
SplitPdfBySizeOrCountRequest request, PDDocument sourceDocument) throws IOException {
int type = request.getSplitType();
String value = request.getSplitValue();
if (type == 0) {
return computeSizeRanges(sourceDoc, GeneralUtils.convertSizeToBytes(value));
return computeSizeRanges(sourceDocument, GeneralUtils.convertSizeToBytes(value));
} else if (type == 1) {
return computePageCountRanges(sourceDoc, Integer.parseInt(value));
return computePageCountRanges(sourceDocument, Integer.parseInt(value));
} else if (type == 2) {
return computeDocCountRanges(sourceDoc, Integer.parseInt(value));
return computeDocCountRanges(sourceDocument, Integer.parseInt(value));
}
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "split type: " + type);
}
private void writeRange(
PdfDocument sourceDoc,
int[] range,
private void writeRangeViaReload(
File sourceFile,
List<Integer> keepIndices,
ZipOutputStream zipOut,
String baseFilename,
int fileIndex,
boolean hasForm)
int fileIndex)
throws IOException {
try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) {
extractRangeToFile(sourceDoc, range, splitTemp.getPath());
Path finalPath = splitTemp.getPath();
TempFile prunedTemp = null;
try {
if (hasForm) {
prunedTemp = new TempFile(tempFileManager, ".pdf");
try (PDDocument doc = pdfDocumentFactory.load(splitTemp.getFile())) {
FormUtils.pruneOrphanedFormFields(doc);
doc.save(prunedTemp.getFile());
}
finalPath = prunedTemp.getPath();
}
writeEntry(zipOut, baseFilename, fileIndex, finalPath);
} finally {
if (prunedTemp != null) {
prunedTemp.close();
Set<Integer> keep = new HashSet<>(keepIndices);
try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) {
for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) {
if (!keep.contains(i)) {
doc.removePage(i);
}
}
FormUtils.pruneOrphanedFormFields(doc);
writeEntry(zipOut, baseFilename, fileIndex, doc);
}
}
private void extractRangeToFile(PdfDocument sourceDoc, int[] range, Path outputPath)
private void writeRangeViaSharedSource(
PDDocument sourceDocument,
List<Integer> keepIndices,
ZipOutputStream zipOut,
String baseFilename,
int fileIndex)
throws IOException {
int from = range[0];
int to = range[range.length - 1];
try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) {
split.save(outputPath);
try (PDDocument doc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
for (int p : keepIndices) {
doc.addPage(sourceDocument.getPage(p));
}
writeEntry(zipOut, baseFilename, fileIndex, doc);
}
}
private void writeEntry(
ZipOutputStream zipOut, String baseFilename, int fileIndex, Path pdfPath)
ZipOutputStream zipOut, String baseFilename, int fileIndex, PDDocument doc)
throws IOException {
zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf"));
Files.copy(pdfPath, zipOut);
doc.save(zipOut);
zipOut.closeEntry();
}
/** Returns contiguous page-index ranges fitting within {@code maxBytes}. */
private List<int[]> computeSizeRanges(PdfDocument sourceDoc, long maxBytes) throws IOException {
List<int[]> ranges = new ArrayList<>();
int totalPages = sourceDoc.pageCount();
/** Page-index ranges each output should contain. AcroForm overhead isn't modeled. */
private List<List<Integer>> computeSizeRanges(PDDocument sourceDocument, long maxBytes)
throws IOException {
List<List<Integer>> ranges = new ArrayList<>();
List<Integer> currentRange = new ArrayList<>();
int totalPages = sourceDocument.getNumberOfPages();
int baseCheckFrequency = 5;
int rangeStart = 0;
int rangeEnd = -1;
try (TempFile probe = new TempFile(tempFileManager, ".pdf")) {
File probeFile = probe.getFile();
PDDocument scratch = new PDDocument();
try {
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
rangeEnd = pageIndex;
int pageAdded = rangeEnd - rangeStart + 1;
PDPage page = sourceDocument.getPage(pageIndex);
scratch.addPage(new PDPage(page.getCOSObject()));
currentRange.add(pageIndex);
int pageAdded = currentRange.size();
boolean shouldCheckSize =
(pageAdded % baseCheckFrequency == 0)
|| (pageIndex == totalPages - 1)
@@ -181,110 +183,117 @@ public class SplitPdfBySizeController {
if (!shouldCheckSize) {
continue;
}
long actualSize = saveRange(sourceDoc, rangeStart, rangeEnd, probeFile);
long actualSize;
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
scratch.save(out);
actualSize = out.size();
}
if (actualSize > maxBytes) {
if (pageAdded > 1) {
rangeEnd = pageIndex - 1;
pageIndex--;
if (scratch.getNumberOfPages() > 1) {
scratch.removePage(scratch.getNumberOfPages() - 1);
currentRange.remove(currentRange.size() - 1);
pageIndex--; // retry this page in the next chunk
}
ranges.add(buildRange(rangeStart, rangeEnd));
rangeStart = rangeEnd + 1;
rangeEnd = rangeStart - 1;
ranges.add(new ArrayList<>(currentRange));
currentRange.clear();
scratch.close();
scratch = new PDDocument();
} else if (pageIndex < totalPages - 1 && actualSize < maxBytes * 0.75) {
int extra =
lookAheadFit(
sourceDoc,
rangeStart,
pageIndex,
maxBytes,
totalPages,
probeFile);
pageIndex += extra;
rangeEnd = pageIndex;
int extraPagesAdded =
lookAheadFit(scratch, sourceDocument, pageIndex, maxBytes);
for (int i = 0; i < extraPagesAdded; i++) {
int extra = pageIndex + 1 + i;
scratch.addPage(new PDPage(sourceDocument.getPage(extra).getCOSObject()));
currentRange.add(extra);
}
pageIndex += extraPagesAdded;
}
}
}
if (rangeEnd >= rangeStart) {
ranges.add(buildRange(rangeStart, rangeEnd));
if (!currentRange.isEmpty()) {
ranges.add(new ArrayList<>(currentRange));
}
} finally {
scratch.close();
}
return ranges;
}
private long saveRange(PdfDocument sourceDoc, int from, int to, File output)
/** Speculatively tries up to 5 next pages; returns how many fit under {@code maxBytes}. */
private int lookAheadFit(PDDocument scratch, PDDocument source, int pageIndex, long maxBytes)
throws IOException {
try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) {
split.save(output.toPath());
int totalPages = source.getNumberOfPages();
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
if (pagesToLookAhead == 0) {
return 0;
}
return output.length();
}
private int lookAheadFit(
PdfDocument sourceDoc,
int rangeStart,
int currentEnd,
long maxBytes,
int totalPages,
File probeFile)
throws IOException {
int pagesToLookAhead = Math.min(5, totalPages - currentEnd - 1);
int extra = 0;
for (int i = 0; i < pagesToLookAhead; i++) {
int trialEnd = currentEnd + 1 + i;
long size = saveRange(sourceDoc, rangeStart, trialEnd, probeFile);
if (size > maxBytes) {
break;
int extraPagesAdded = 0;
try (PDDocument testDoc = new PDDocument()) {
for (int i = 0; i < scratch.getNumberOfPages(); i++) {
testDoc.addPage(new PDPage(scratch.getPage(i).getCOSObject()));
}
for (int i = 0; i < pagesToLookAhead; i++) {
testDoc.addPage(new PDPage(source.getPage(pageIndex + 1 + i).getCOSObject()));
long testSize;
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
testDoc.save(out);
testSize = out.size();
}
if (testSize > maxBytes) {
break;
}
extraPagesAdded++;
}
extra++;
}
return extra;
return extraPagesAdded;
}
private List<int[]> computePageCountRanges(PdfDocument sourceDoc, int pageCount) {
private List<List<Integer>> computePageCountRanges(PDDocument sourceDocument, int pageCount) {
if (pageCount <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount);
}
int totalPages = sourceDoc.pageCount();
List<int[]> ranges = new ArrayList<>();
int start = 0;
while (start < totalPages) {
int end = Math.min(start + pageCount - 1, totalPages - 1);
ranges.add(buildRange(start, end));
start = end + 1;
int totalPages = sourceDocument.getNumberOfPages();
List<List<Integer>> ranges = new ArrayList<>();
List<Integer> current = new ArrayList<>(pageCount);
for (int i = 0; i < totalPages; i++) {
current.add(i);
if (current.size() == pageCount) {
ranges.add(current);
current = new ArrayList<>(pageCount);
}
}
if (!current.isEmpty()) {
ranges.add(current);
}
return ranges;
}
private List<int[]> computeDocCountRanges(PdfDocument sourceDoc, int documentCount) {
private List<List<Integer>> computeDocCountRanges(
PDDocument sourceDocument, int documentCount) {
if (documentCount <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"document count: " + documentCount);
}
int totalPages = sourceDoc.pageCount();
int totalPages = sourceDocument.getNumberOfPages();
int pagesPerDocument = totalPages / documentCount;
int extraPages = totalPages % documentCount;
List<int[]> ranges = new ArrayList<>();
List<List<Integer>> ranges = new ArrayList<>();
int cursor = 0;
for (int i = 0; i < documentCount; i++) {
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
if (pagesToAdd == 0) {
continue;
List<Integer> range = new ArrayList<>(pagesToAdd);
for (int j = 0; j < pagesToAdd; j++) {
range.add(cursor++);
}
int end = cursor + pagesToAdd - 1;
ranges.add(buildRange(cursor, end));
cursor = end + 1;
ranges.add(range);
}
return ranges;
}
private static int[] buildRange(int start, int end) {
int[] range = new int[end - start + 1];
for (int i = 0; i < range.length; i++) {
range[i] = start + i;
}
return range;
}
}
@@ -20,7 +20,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -36,8 +35,7 @@ public class ToSinglePageController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf-to-single-page",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
value = "/pdf-to-single-page")
@StandardPdfResponse
@Operation(
summary = "Convert a multi-page PDF into a single long page PDF",
@@ -28,7 +28,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -57,10 +56,7 @@ public class ConvertEbookToPDFController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/ebook/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@Operation(
summary = "Convert an eBook file to PDF",
description =
@@ -25,7 +25,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
@@ -44,10 +43,7 @@ public class ConvertEmlToPDF {
private final TempFileManager tempFileManager;
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/eml/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/eml/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert EML/MSG to PDF",
@@ -17,7 +17,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.*;
@@ -34,10 +33,7 @@ public class ConvertHtmlToPDF {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/html/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/html/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
@@ -40,7 +40,6 @@ import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CbrUtils;
import stirling.software.common.util.CbzUtils;
@@ -73,10 +72,7 @@ public class ConvertImgPDFController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/img",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/img")
@MultiFileResponse
@Operation(
summary = "Convert PDF to image(s)",
@@ -243,10 +239,7 @@ public class ConvertImgPDFController {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/img/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/img/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert images to a PDF file",
@@ -26,7 +26,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.*;
@@ -42,10 +41,7 @@ public class ConvertMarkdownToPdf {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/markdown/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/markdown/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert a Markdown file to PDF",
@@ -29,7 +29,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
@@ -201,10 +200,7 @@ public class ConvertOfficeController {
.matches();
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/file/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/file/pdf")
@Operation(
summary = "Convert a file to a PDF using LibreOffice",
description =
@@ -27,7 +27,6 @@ import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.Outpu
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -82,10 +81,7 @@ public class ConvertPDFToEpubController {
return command;
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/epub",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
@Operation(
summary = "Convert PDF to EPUB/AZW3",
description =
@@ -25,7 +25,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -46,10 +45,7 @@ public class ConvertPDFToExcelController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/pdf/xlsx",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(value = "/pdf/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert a PDF to an Excel spreadsheet (XLSX)",
description =
@@ -13,7 +13,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@@ -25,10 +24,7 @@ public class ConvertPDFToHtml {
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/html",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
@Operation(
summary = "Convert PDF to HTML",
description =
@@ -22,7 +22,6 @@ import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -39,10 +38,7 @@ public class ConvertPDFToOffice {
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/presentation",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
@Operation(
summary = "Convert PDF to Presentation format",
description =
@@ -57,10 +53,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/text",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
@Operation(
summary = "Convert PDF to Text or RTF format",
description =
@@ -90,10 +83,7 @@ public class ConvertPDFToOffice {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/word",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
@Operation(
summary = "Convert PDF to Word document",
description =
@@ -107,10 +97,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/xml",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
@Operation(
summary = "Convert PDF to XML",
description =
@@ -90,7 +90,6 @@ import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -573,10 +572,7 @@ public class ConvertPDFToPDFA {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/pdfa",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
@Operation(
summary = "Convert a PDF to a PDF/A or PDF/X",
description =
@@ -32,7 +32,6 @@ import stirling.software.SPDF.model.json.PdfJsonMetadata;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
@@ -55,10 +54,7 @@ public class ConvertPdfJsonController {
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
@AutoJobPostMapping(
consumes = "multipart/form-data",
value = "/pdf/text-editor",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor")
@Operation(
summary = "Convert PDF to Text Editor Format",
description =
@@ -96,10 +92,7 @@ public class ConvertPdfJsonController {
}
}
@AutoJobPostMapping(
consumes = "multipart/form-data",
value = "/text-editor/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/text-editor/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert Text Editor Format to PDF",
@@ -130,10 +123,7 @@ public class ConvertPdfJsonController {
return WebResponseUtils.pdfFileToWebResponse(tempOut, docName);
}
@AutoJobPostMapping(
consumes = "multipart/form-data",
value = "/pdf/text-editor/metadata",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
@Operation(
summary = "Extract PDF metadata for text editor lazy loading",
description =
@@ -175,8 +165,7 @@ public class ConvertPdfJsonController {
@AutoJobPostMapping(
value = "/pdf/text-editor/partial/{jobId}",
consumes = MediaType.APPLICATION_JSON_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.APPLICATION_JSON_VALUE)
@StandardPdfResponse
@Operation(
summary = "Apply incremental edits from text editor to a cached PDF",
@@ -195,6 +184,8 @@ public class ConvertPdfJsonController {
validateJobAccess(jobId);
byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document);
String baseName =
(filename != null && !filename.isBlank())
? FILE_EXTENSION_PATTERN
@@ -206,18 +197,13 @@ public class ConvertPdfJsonController {
.orElse("document");
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = Files.newOutputStream(tempOut.getPath())) {
pdfJsonConversionService.exportUpdatedPages(jobId, document, os);
} catch (Exception e) {
tempOut.close();
throw e;
}
try {
return WebResponseUtils.pdfFileToWebResponse(tempOut, docName);
Files.write(tempOut.getPath(), pdfBytes);
} catch (Exception e) {
tempOut.close();
throw e;
}
return WebResponseUtils.pdfFileToWebResponse(tempOut, docName);
}
@GetMapping(value = "/pdf/text-editor/page/{jobId}/{pageNumber}")
@@ -280,8 +266,7 @@ public class ConvertPdfJsonController {
@AutoJobPostMapping(
value = "/pdf/text-editor/clear-cache/{jobId}",
consumes = MediaType.ALL_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.ALL_VALUE)
@Operation(
summary = "Clear cached PDF document for text editor",
description =
@@ -49,10 +49,7 @@ public class ConvertPdfToVideoController {
// ffmpeg disabled due to raised CVEs
/*
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/video",
resourceWeight = ResourceWeight.XLARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
@Operation(
summary = "Convert PDF to Video Slideshow",
description =
@@ -28,7 +28,6 @@ import stirling.software.SPDF.model.api.converters.SvgToPdfRequest;
import stirling.software.SPDF.utils.SvgToPdf;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.SvgSanitizer;
@@ -45,10 +44,7 @@ public class ConvertSvgToPDF {
private final SvgSanitizer svgSanitizer;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/svg/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/svg/pdf")
@MultiFileResponse
@Operation(
summary = "Convert SVG to PDF",
@@ -32,7 +32,6 @@ import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -58,10 +57,7 @@ public class ConvertWebsiteToPDF {
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/url/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description =
@@ -30,7 +30,6 @@ import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -43,10 +42,7 @@ public class ExtractCSVController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TabulaTableParser tabulaTableParser;
@AutoJobPostMapping(
value = "/pdf/csv",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(value = "/pdf/csv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@CsvConversionResponse
@Operation(
summary = "Extracts a CSV document from a PDF",
@@ -26,7 +26,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -46,10 +45,7 @@ public class PdfVectorExportController {
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/vector/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
@Operation(
summary = "Convert PostScript formats to PDF",
description =
@@ -96,10 +92,7 @@ public class PdfVectorExportController {
return WebResponseUtils.pdfFileToWebResponse(outputTemp, outputName);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/vector",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
@Operation(
summary = "Convert PDF to vector format",
description =
@@ -27,7 +27,6 @@ import stirling.software.SPDF.model.api.filter.PageRotationRequest;
import stirling.software.SPDF.model.api.filter.PageSizeRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.FilterApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
@@ -43,8 +42,7 @@ public class FilterController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-contains-text",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/filter-contains-text")
@Operation(
summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -77,8 +75,7 @@ public class FilterController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-contains-image",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/filter-contains-image")
@Operation(
summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -110,8 +107,7 @@ public class FilterController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-count",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/filter-page-count")
@Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -142,10 +138,7 @@ public class FilterController {
: ResponseEntity.noContent().build();
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-size",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
@Operation(
summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -182,10 +175,7 @@ public class FilterController {
: ResponseEntity.noContent().build();
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-file-size",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
@Operation(
summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -215,8 +205,7 @@ public class FilterController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-rotation",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/filter-page-rotation")
@Operation(
summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -23,7 +23,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -67,10 +66,7 @@ public class AddCommentsController {
private final PdfTextLocator pdfTextLocator;
private final ObjectMapper objectMapper;
@AutoJobPostMapping(
value = "/add-comments",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/add-comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
@@ -28,7 +28,6 @@ import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
import stirling.software.SPDF.service.AttachmentServiceInterface;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -49,10 +48,7 @@ public class AttachmentController {
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/add-attachments",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-attachments")
@StandardPdfResponse
@Operation(
summary = "Add attachments to PDF",
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ExtractHeaderRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFileManager;
@@ -40,10 +39,7 @@ public class AutoRenameController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/auto-rename",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/auto-rename")
@Operation(
summary = "Extract header from PDF file",
description =
@@ -38,7 +38,6 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -268,10 +267,7 @@ public class AutoSplitPdfController {
return QR_DETECTION_DPI;
}
@AutoJobPostMapping(
value = "/auto-split-pdf",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/auto-split-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@MultiFileResponse
@Operation(
summary = "Auto split PDF pages into separate documents",
@@ -31,7 +31,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.RemoveBlankPagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
@@ -82,10 +81,7 @@ public class BlankPageController {
return whitePixelPercentage >= whitePercent;
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/remove-blanks",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-blanks")
@Operation(
summary = "Remove blank pages from a PDF file",
description =
@@ -49,7 +49,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ExceptionUtils;
@@ -923,10 +922,7 @@ public class CompressController {
return Math.min(9, currentLevel + 1);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/compress-pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
@Operation(
summary = "Optimize PDF file",
description =
@@ -22,7 +22,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -39,10 +38,7 @@ public class DecompressPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/decompress-pdf",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(value = "/decompress-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Decompress PDF streams",
description = "Fully decompresses all PDF streams including text content")
@@ -32,7 +32,6 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
@@ -57,8 +56,7 @@ public class ExtractImageScansController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-image-scans",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
value = "/extract-image-scans")
@MultiFileResponse
@Operation(
summary = "Extract image scans from an input file",
@@ -34,7 +34,6 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.PDFExtractImagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -50,10 +49,7 @@ public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-images",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/extract-images")
@MultiFileResponse
@Operation(
summary = "Extract images from a PDF file",
@@ -27,7 +27,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.FlattenRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
@@ -43,10 +42,7 @@ public class FlattenController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/flatten",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/flatten")
@StandardPdfResponse
@Operation(
summary = "Flatten PDF form fields or full page",
@@ -25,7 +25,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.MetadataRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.GeneralUtils;
@@ -57,10 +56,7 @@ public class MetadataController {
binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor());
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/update-metadata",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/update-metadata")
@StandardPdfResponse
@Operation(
summary = "Update metadata of a PDF file",
@@ -38,7 +38,6 @@ import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -83,10 +82,7 @@ public class OCRController {
.toList();
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/ocr-pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
@Operation(
summary = "Process a PDF file with OCR",
description =
@@ -22,7 +22,6 @@ import stirling.software.SPDF.model.api.misc.OverlayImageRequest;
import stirling.software.SPDF.utils.SvgOverlayUtil;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -37,10 +36,7 @@ public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/add-image",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-image")
@Operation(
summary = "Overlay image onto a PDF file",
description =
@@ -26,7 +26,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.AddPageNumbersRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -40,10 +39,7 @@ public class PageNumbersController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/add-page-numbers",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/add-page-numbers", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
summary = "Add page numbers to a PDF document",
@@ -25,7 +25,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -42,10 +41,7 @@ public class RemoveImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/remove-image-pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-image-pdf")
@Operation(
summary = "Remove images from PDF",
description =
@@ -19,7 +19,6 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -47,10 +46,7 @@ public class RepairController {
return endpointConfiguration.isGroupEnabled("qpdf");
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/repair",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/repair")
@StandardPdfResponse
@Operation(
summary = "Repair a PDF file",
@@ -19,7 +19,6 @@ import stirling.software.SPDF.model.api.misc.ReplaceAndInvertColorRequest;
import stirling.software.SPDF.service.misc.ReplaceAndInvertColorService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -34,8 +33,7 @@ public class ReplaceAndInvertColorController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/replace-invert-pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
value = "/replace-invert-pdf")
@Operation(
summary = "Replace-Invert Color PDF",
description =
@@ -46,7 +46,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
@@ -560,10 +559,7 @@ public class ScannerEffectController {
}
}
@AutoJobPostMapping(
value = "/scanner-effect",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(value = "/scanner-effect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Apply scanner effect to PDF",
description =
@@ -21,7 +21,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.JavaScriptResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
@@ -35,10 +34,7 @@ public class ShowJavascript {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/show-javascript",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/show-javascript")
@JavaScriptResponse
@Operation(
summary = "Grabs all JS from a PDF and returns a single JS file with all code",
@@ -46,7 +46,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.misc.AddStampRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -87,10 +86,7 @@ public class StampController {
});
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/add-stamp",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
@Operation(
summary = "Add stamp to a PDF file",
description =
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -43,10 +42,7 @@ public class UnlockPDFFormsController {
this.tempFileManager = tempFileManager;
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/unlock-pdf-forms",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/unlock-pdf-forms")
@StandardPdfResponse
@Operation(
summary = "Remove read-only property from form fields",
@@ -26,7 +26,6 @@ import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.model.api.HandleDataRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.PipelineApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -50,10 +49,7 @@ public class PipelineController {
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/handleData",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@MultiFileResponse
@Operation(
summary = "Execute automated PDF processing pipeline",
@@ -75,7 +75,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.util.ExceptionUtils;
@@ -161,8 +160,7 @@ public class CertSignController {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
},
value = "/cert-sign",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
value = "/cert-sign")
@StandardPdfResponse
@Operation(
summary = "Sign PDF with a Digital Certificate",
@@ -56,7 +56,6 @@ import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -1062,10 +1061,7 @@ public class GetInfoOnPDF {
return stats;
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/get-info-on-pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
@Operation(
summary = "Get comprehensive PDF information",
description =
@@ -20,7 +20,6 @@ import stirling.software.SPDF.model.api.security.AddPasswordRequest;
import stirling.software.SPDF.model.api.security.PDFPasswordRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -34,10 +33,7 @@ public class PasswordController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/remove-password",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-password")
@StandardPdfResponse
@Operation(
summary = "Remove password from a PDF file",
@@ -66,10 +62,7 @@ public class PasswordController {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/add-password",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-password")
@StandardPdfResponse
@Operation(
summary = "Add password to a PDF file",
@@ -59,7 +59,6 @@ import stirling.software.SPDF.utils.text.TextFinderUtils;
import stirling.software.SPDF.utils.text.WidthCalculator;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.security.RedactionArea;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -102,10 +101,7 @@ public class RedactController {
new StringToArrayListPropertyEditor<>(RedactionArea.class));
}
@AutoJobPostMapping(
value = "/redact",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(value = "/redact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
operationId = "redactPdfManual",
@@ -498,10 +494,7 @@ public class RedactController {
return pageNumbers;
}
@AutoJobPostMapping(
value = "/auto-redact",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(value = "/auto-redact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
summary = "Redact PDF automatically",
@@ -20,7 +20,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -34,10 +33,7 @@ public class RemoveCertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/remove-cert-sign",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-cert-sign")
@StandardPdfResponse
@Operation(
summary = "Remove digital signature from PDF",
@@ -39,7 +39,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SanitizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
@@ -53,10 +52,7 @@ public class SanitizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/sanitize-pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/sanitize-pdf")
@StandardPdfResponse
@Operation(
summary = "Sanitize a PDF file",
@@ -43,7 +43,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.TimestampPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -79,10 +78,7 @@ public class TimestampController {
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/timestamp-pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/timestamp-pdf")
@StandardPdfResponse
@Operation(
summary = "Add RFC 3161 document timestamp to a PDF",
@@ -42,7 +42,6 @@ import stirling.software.SPDF.model.api.security.SignatureValidationResult;
import stirling.software.SPDF.service.CertificateValidationService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@@ -75,8 +74,7 @@ public class ValidateSignatureController {
+ " Input:PDF Output:JSON Type:SISO")
@AutoJobPostMapping(
value = "/validate-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<SignatureValidationResult>> validateSignature(
@ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>();
@@ -21,7 +21,6 @@ import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.util.ExceptionUtils;
@SecurityApi
@@ -38,10 +37,7 @@ public class VerifyPDFController {
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
+ "from the document's XMP metadata and validates compliance. "
+ "Input:PDF Output:JSON Type:SISO")
@AutoJobPostMapping(
value = "/verify-pdf",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
@ModelAttribute PDFVerificationRequest request) {
@@ -42,7 +42,6 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
@@ -69,10 +68,7 @@ public class WatermarkController {
});
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/add-watermark",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-watermark")
@StandardPdfResponse
@Operation(
summary = "Add watermark to a PDF file",
@@ -6490,8 +6490,7 @@ public class PdfJsonConversionService {
objectMapper.writeValue(out, pageFonts);
}
public void exportUpdatedPages(String jobId, PdfJsonDocument updates, OutputStream outputStream)
throws IOException {
public byte[] exportUpdatedPages(String jobId, PdfJsonDocument updates) throws IOException {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export");
}
@@ -6514,8 +6513,7 @@ public class PdfJsonConversionService {
log.debug(
"Incremental export requested with no page updates; returning cached PDF for jobId {}",
jobId);
outputStream.write(cached.getPdfBytes());
return;
return cached.getPdfBytes();
}
try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) {
@@ -6582,8 +6580,7 @@ public class PdfJsonConversionService {
log.debug(
"Incremental export for jobId {} resulted in no page updates; returning cached PDF",
jobId);
outputStream.write(cached.getPdfBytes());
return;
return cached.getPdfBytes();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -6606,7 +6603,7 @@ public class PdfJsonConversionService {
"Incremental export complete for jobId {} (pages updated: {})",
jobId,
updatedPages.stream().map(i -> i + 1).sorted().toList());
outputStream.write(updatedBytes);
return updatedBytes;
}
}
@@ -1,365 +0,0 @@
package stirling.software.SPDF.bench;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.FormUtils;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.PdfSplit;
/**
* Apples-to-apples heap + wall-clock benchmark for split: legacy PDFBox path (load + addPage per
* range + save) vs JPDFium path (PdfSplit.extractPageRange + save).
*
* <p>Generates a single ~N-page PDF with one JPEG per page, then performs a "split into chunks of
* 10" run with both backends. Samples heap every 25 ms during execution.
*
* <p>Run only when explicitly requested: -Dsplit.bench=true
*/
public final class SplitBenchmark {
private static final int PAGES = Integer.getInteger("split.bench.pages", 100);
private static final int CHUNK = Integer.getInteger("split.bench.chunk", 10);
private static final int IMAGE_W = Integer.getInteger("split.bench.imgW", 800);
private static final int IMAGE_H = Integer.getInteger("split.bench.imgH", 600);
private static final long SAMPLE_PERIOD_MS = 25L;
@Test
void compareSplitMemoryFootprint() throws Exception {
if (!Boolean.getBoolean("split.bench")) {
System.out.println("Skipping SplitBenchmark (run with -Dsplit.bench=true)");
return;
}
main(new String[0]);
}
public static void main(String[] args) throws Exception {
Path workDir = Files.createTempDirectory("split-bench-");
System.out.println("Work dir: " + workDir);
System.out.printf(
"Generating %d-page input PDF with %dx%d images per page...%n",
PAGES, IMAGE_W, IMAGE_H);
Path input = workDir.resolve("input.pdf");
long t0 = System.nanoTime();
generateTestPdf(input, PAGES);
long buildMs = (System.nanoTime() - t0) / 1_000_000;
System.out.printf(
" built %s: %,d KB (%d pages) in %,d ms%n%n",
input.getFileName(), Files.size(input) / 1024, PAGES, buildMs);
// Warmup - prime classloaders, JIT, native lib.
System.out.println("--- Warmup (results discarded) ---");
runPdfBoxSplit(input, workDir.resolve("warmup-pdfbox"), CHUNK);
runJpdfiumSplit(input, workDir.resolve("warmup-jpdfium"), CHUNK);
forceGcQuiescence();
System.out.println();
long baseline = sampleUsedHeapAfterGc();
System.out.printf("Baseline heap (after GC, before split): %,d KB%n%n", baseline / 1024);
// PDFBox run
System.out.println("--- PDFBox split (load + addPage + save per chunk) ---");
Path outBoxDir = workDir.resolve("out-pdfbox");
BenchResult pdfboxResult = profile(() -> runPdfBoxSplit(input, outBoxDir, CHUNK));
pdfboxResult.printSummary("PDFBox", baseline);
forceGcQuiescence();
System.out.println();
// JPDFium run
System.out.println("--- JPDFium split (PdfSplit.extractPageRange + save) ---");
Path outJpdDir = workDir.resolve("out-jpdfium");
BenchResult jpdfiumResult = profile(() -> runJpdfiumSplit(input, outJpdDir, CHUNK));
jpdfiumResult.printSummary("JPDFium", baseline);
// Compare
System.out.println();
System.out.println("=== Heap delta over baseline ===");
long pdfboxDelta = pdfboxResult.peakHeapBytes - baseline;
long jpdfiumDelta = jpdfiumResult.peakHeapBytes - baseline;
double improvement =
pdfboxDelta == 0 ? 0.0 : (100.0 * (pdfboxDelta - jpdfiumDelta) / pdfboxDelta);
System.out.printf(" PDFBox : +%,d KB%n", pdfboxDelta / 1024);
System.out.printf(" JPDFium : +%,d KB%n", jpdfiumDelta / 1024);
System.out.printf(" JPDFium uses %.1f%% LESS heap than PDFBox%n", improvement);
System.out.println();
System.out.println("=== Wall-clock ===");
System.out.printf(" PDFBox : %,d ms%n", pdfboxResult.wallMs);
System.out.printf(" JPDFium : %,d ms%n", jpdfiumResult.wallMs);
double speedup =
jpdfiumResult.wallMs == 0
? 0.0
: ((double) pdfboxResult.wallMs / jpdfiumResult.wallMs);
System.out.printf(" Speedup : %.2fx%n", speedup);
// Hybrid form-pruning sub-benchmark.
// Quantifies the heap re-introduced by the PDFBox post-pass that
// pruneOrphanedFormFields runs on each JPDFium-produced split.
System.out.println();
System.out.println("=== Hybrid form-pruning sub-benchmark ===");
Path formInput = workDir.resolve("form-input.pdf");
generateFormPdf(formInput, PAGES);
System.out.printf(
" built %s with AcroForm: %,d KB (%d pages)%n",
formInput.getFileName(), Files.size(formInput) / 1024, PAGES);
runJpdfiumSplit(formInput, workDir.resolve("warmup-form-bare"), CHUNK);
runJpdfiumSplitWithFormPrune(formInput, workDir.resolve("warmup-form-pruned"), CHUNK);
forceGcQuiescence();
BenchResult bareForm =
profile(() -> runJpdfiumSplit(formInput, workDir.resolve("out-form-bare"), CHUNK));
bareForm.printSummary("JPDFium-only", sampleUsedHeapAfterGc());
forceGcQuiescence();
BenchResult prunedForm =
profile(
() ->
runJpdfiumSplitWithFormPrune(
formInput, workDir.resolve("out-form-pruned"), CHUNK));
prunedForm.printSummary("JPDFium+PDFBox-prune", sampleUsedHeapAfterGc());
long pruneOverhead = prunedForm.peakHeapBytes - bareForm.peakHeapBytes;
long pruneTimeOverhead = prunedForm.wallMs - bareForm.wallMs;
System.out.printf(
" Hybrid prune overhead: +%,d KB heap, +%,d ms wall (%.1fx slower)%n",
pruneOverhead / 1024,
pruneTimeOverhead,
bareForm.wallMs == 0 ? 0.0 : (double) prunedForm.wallMs / bareForm.wallMs);
}
private static void runJpdfiumSplitWithFormPrune(Path input, Path outDir, int chunk)
throws IOException {
Files.createDirectories(outDir);
try (PdfDocument source = PdfDocument.open(input)) {
int total = source.pageCount();
int index = 0;
for (int start = 0; start < total; start += chunk) {
int end = Math.min(start + chunk - 1, total - 1);
Path raw = outDir.resolve("raw-" + (index + 1) + ".pdf");
try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) {
split.save(raw);
}
Path pruned = outDir.resolve("pruned-" + (++index) + ".pdf");
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(raw.toFile())) {
FormUtils.pruneOrphanedFormFields(doc);
doc.save(pruned.toFile());
}
Files.deleteIfExists(raw);
}
}
}
private static void generateFormPdf(Path out, int pages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
}
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
// One text field per page (orphan-prone scenario).
for (int i = 0; i < pages; i++) {
PDTextField field = new PDTextField(acroForm);
field.setPartialName("field_" + i);
acroForm.getFields().add(field);
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(50, 50, 200, 30));
widget.setPage(doc.getPage(i));
doc.getPage(i).getAnnotations().add(widget);
field.getWidgets().add(widget);
}
doc.save(out.toFile());
}
}
private static void runPdfBoxSplit(Path input, Path outDir, int chunk) throws IOException {
Files.createDirectories(outDir);
try (PDDocument source = org.apache.pdfbox.Loader.loadPDF(input.toFile())) {
int total = source.getNumberOfPages();
int index = 0;
for (int start = 0; start < total; start += chunk) {
int end = Math.min(start + chunk - 1, total - 1);
Set<Integer> keep = new HashSet<>();
for (int p = start; p <= end; p++) keep.add(p);
// Match the pre-jpdfium reload path: load fresh, removePage, save.
try (PDDocument split = org.apache.pdfbox.Loader.loadPDF(input.toFile())) {
for (int p = split.getNumberOfPages() - 1; p >= 0; p--) {
if (!keep.contains(p)) split.removePage(p);
}
split.save(outDir.resolve("split-" + (++index) + ".pdf").toFile());
}
}
}
}
private static void runJpdfiumSplit(Path input, Path outDir, int chunk) throws IOException {
Files.createDirectories(outDir);
try (PdfDocument source = PdfDocument.open(input)) {
int total = source.pageCount();
int index = 0;
for (int start = 0; start < total; start += chunk) {
int end = Math.min(start + chunk - 1, total - 1);
try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) {
split.save(outDir.resolve("split-" + (++index) + ".pdf"));
}
}
}
}
private static void generateTestPdf(Path out, int pages) throws IOException {
try (PDDocument doc = new PDDocument()) {
Random rng = new Random(42L);
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
byte[] jpegBytes = generateRandomJpeg(rng, IMAGE_W, IMAGE_H);
PDImageXObject xobj = PDImageXObject.createFromByteArray(doc, jpegBytes, "img");
try (PDPageContentStream cs =
new PDPageContentStream(
doc, page, PDPageContentStream.AppendMode.APPEND, false)) {
float pageW = page.getMediaBox().getWidth();
float pageH = page.getMediaBox().getHeight();
float imgW = pageW - 100;
float imgH = imgW * IMAGE_H / IMAGE_W;
float x = (pageW - imgW) / 2;
float y = pageH - 80 - imgH;
cs.drawImage(xobj, x, y, imgW, imgH);
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
cs.newLineAtOffset(50, 30);
cs.showText("Page " + (i + 1) + " bench fill");
cs.endText();
}
}
doc.save(out.toFile());
}
}
private static byte[] generateRandomJpeg(Random rng, int w, int h) throws IOException {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
try {
float hue = rng.nextFloat();
g.setColor(Color.getHSBColor(hue, 0.4f, 0.95f));
g.fillRect(0, 0, w, h);
for (int i = 0; i < 20; i++) {
g.setColor(Color.getHSBColor(rng.nextFloat(), 0.6f, 0.5f + rng.nextFloat() * 0.5f));
int r = 20 + rng.nextInt(80);
int cx = rng.nextInt(w);
int cy = rng.nextInt(h);
g.fillOval(cx - r, cy - r, r * 2, r * 2);
}
g.setColor(Color.BLACK);
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 32));
g.drawString("bench image", 40, h - 40);
} finally {
g.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "jpg", baos);
return baos.toByteArray();
}
private static BenchResult profile(ThrowingRunnable task) throws Exception {
forceGcQuiescence();
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
AtomicLong peakHeap = new AtomicLong(0);
AtomicLong peakNonHeap = new AtomicLong(0);
AtomicBoolean stop = new AtomicBoolean(false);
Thread sampler =
new Thread(
() -> {
while (!stop.get()) {
MemoryUsage heap = mem.getHeapMemoryUsage();
MemoryUsage nonHeap = mem.getNonHeapMemoryUsage();
peakHeap.updateAndGet(p -> Math.max(p, heap.getUsed()));
peakNonHeap.updateAndGet(p -> Math.max(p, nonHeap.getUsed()));
try {
Thread.sleep(SAMPLE_PERIOD_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
},
"split-bench-sampler");
sampler.setDaemon(true);
sampler.start();
long t0 = System.nanoTime();
try {
task.run();
} finally {
stop.set(true);
sampler.join();
}
long wallMs = (System.nanoTime() - t0) / 1_000_000;
return new BenchResult(peakHeap.get(), peakNonHeap.get(), wallMs);
}
private static void forceGcQuiescence() {
for (int i = 0; i < 3; i++) {
System.gc();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
private static long sampleUsedHeapAfterGc() {
forceGcQuiescence();
return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed();
}
@FunctionalInterface
interface ThrowingRunnable {
void run() throws Exception;
}
private record BenchResult(long peakHeapBytes, long peakNonHeapBytes, long wallMs) {
void printSummary(String label, long baselineBytes) {
System.out.printf(
" %s peak heap : %,d KB (delta over baseline: +%,d KB)%n",
label, peakHeapBytes / 1024, (peakHeapBytes - baselineBytes) / 1024);
System.out.printf(" %s peak nonHeap : %,d KB%n", label, peakNonHeapBytes / 1024);
System.out.printf(" %s wall-clock : %,d ms%n", label, wallMs);
}
}
}
@@ -4,25 +4,17 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -31,12 +23,8 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
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 stirling.software.SPDF.model.api.SplitPagesRequest;
@@ -44,7 +32,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPDFControllerTest {
@TempDir Path tempDir;
@@ -60,12 +47,6 @@ class SplitPDFControllerTest {
String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private byte[] createPdf(int numPages) throws IOException {
@@ -79,47 +60,17 @@ class SplitPDFControllerTest {
}
}
private byte[] createPdfWithForm(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
PDTextField field = new PDTextField(acroForm);
field.setPartialName("testField");
acroForm.getFields().add(field);
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
private List<byte[]> unzip(Resource zipResource) throws IOException {
List<byte[]> entries = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.add(zis.readAllBytes());
zis.closeEntry();
}
}
return entries;
}
private int[] pageCountsOf(List<byte[]> entries) throws IOException {
int[] counts = new int[entries.size()];
for (int i = 0; i < entries.size(); i++) {
try (PDDocument doc = Loader.loadPDF(entries.get(i))) {
counts[i] = doc.getNumberOfPages();
}
}
return counts;
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
}
@Test
@DisplayName("Should split 6-page PDF at page 3 into 2 parts")
@DisplayName("Should split 6-page PDF at page 3")
void shouldSplitAtPage3() throws Exception {
byte[] pdfBytes = createPdf(6);
MockMultipartFile file =
@@ -130,12 +81,11 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("3");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(3, 3);
}
@Test
@@ -150,12 +100,11 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("1,2,3");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
}
@Test
@@ -170,16 +119,15 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("1");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(pageCountsOf(outputs)).containsExactly(1);
}
@Test
@DisplayName("Should split with multiple split points")
@DisplayName("Should split with range notation")
void shouldSplitWithRange() throws Exception {
byte[] pdfBytes = createPdf(10);
MockMultipartFile file =
@@ -190,12 +138,11 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("3,7");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(3, 4, 3);
}
@Test
@@ -210,14 +157,13 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("2");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
}
@Test
@@ -232,12 +178,11 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("5");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(pageCountsOf(outputs)).containsExactly(5);
}
@Test
@@ -252,31 +197,29 @@ class SplitPDFControllerTest {
request.setFileInput(file);
request.setPageNumbers("all");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1);
}
@Test
@DisplayName("Should split PDF with form fields and keep form-aware path")
void shouldSplitFormPdf() throws Exception {
byte[] pdfBytes = createPdfWithForm(4);
@DisplayName("Should handle file without extension in original name")
void shouldHandleFileWithoutExtension() throws Exception {
byte[] pdfBytes = createPdf(2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
"fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPagesRequest request = new SplitPagesRequest();
request.setFileInput(file);
request.setPageNumbers("2");
request.setPageNumbers("1");
ResponseEntity<Resource> response = controller.splitPdf(request);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2);
}
}
@@ -4,18 +4,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -34,11 +27,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
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.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -63,9 +55,6 @@ class SplitPdfByChaptersControllerTest {
String suffix = inv.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException {
@@ -94,29 +83,6 @@ class SplitPdfByChaptersControllerTest {
}
}
private List<byte[]> unzip(Resource zipResource) throws IOException {
List<byte[]> entries = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.add(zis.readAllBytes());
zis.closeEntry();
}
}
return entries;
}
private int totalPagesOf(List<byte[]> entries) throws IOException {
int total = 0;
for (byte[] data : entries) {
try (PDDocument doc = Loader.loadPDF(data)) {
total += doc.getNumberOfPages();
}
}
return total;
}
@Test
@DisplayName("Should split PDF by chapters")
void shouldSplitByChapters() throws Exception {
@@ -131,12 +97,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
ResponseEntity<Resource> response = controller.splitPdf(request);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(totalPagesOf(outputs)).isEqualTo(6);
}
@Test
@@ -153,12 +119,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(true);
ResponseEntity<Resource> response = controller.splitPdf(request);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(totalPagesOf(outputs)).isEqualTo(4);
}
@Test
@@ -197,6 +163,10 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request));
}
}
@@ -215,12 +185,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
ResponseEntity<Resource> response = controller.splitPdf(request);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(1);
assertThat(totalPagesOf(outputs)).isEqualTo(3);
}
@Test
@@ -237,15 +207,14 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(true);
request.setAllowDuplicates(false);
lenient()
.when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
.thenReturn(new stirling.software.common.model.PdfMetadata());
ResponseEntity<Resource> response = controller.splitPdf(request);
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(totalPagesOf(outputs)).isEqualTo(4);
}
@Test
@@ -262,12 +231,12 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
ResponseEntity<Resource> response = controller.splitPdf(request);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(totalPagesOf(outputs)).isEqualTo(6);
}
@Test
@@ -284,11 +253,11 @@ class SplitPdfByChaptersControllerTest {
request.setIncludeMetadata(false);
request.setAllowDuplicates(true);
ResponseEntity<Resource> response = controller.splitPdf(request);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(5);
assertThat(totalPagesOf(outputs)).isEqualTo(10);
}
}
@@ -4,18 +4,12 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -29,9 +23,6 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -42,7 +33,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPdfBySizeControllerTest {
@TempDir Path tempDir;
@@ -58,131 +48,69 @@ class SplitPdfBySizeControllerTest {
String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private byte[] createPdf(int numPages) throws IOException {
@Test
@DisplayName("Should split by page count successfully")
void shouldSplitByPageCount() throws Exception {
byte[] pdfBytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
for (int i = 0; i < 5; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
pdfBytes = Files.readAllBytes(pdfPath);
}
}
private List<byte[]> unzip(Resource zipResource) throws IOException {
List<byte[]> entries = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.add(zis.readAllBytes());
zis.closeEntry();
}
}
return entries;
}
private int[] pageCountsOf(List<byte[]> entries) throws IOException {
int[] counts = new int[entries.size()];
for (int i = 0; i < entries.size(); i++) {
try (PDDocument doc = Loader.loadPDF(entries.get(i))) {
counts[i] = doc.getNumberOfPages();
}
}
return counts;
}
@Test
@DisplayName("Should split by page count into 2-page chunks")
void shouldSplitByPageCount() throws Exception {
byte[] pdfBytes = createPdf(5);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(1);
request.setSplitType(1); // Page count
request.setSplitValue("2");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<?> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 1);
}
@Test
@DisplayName("Should split by document count into 3 even documents")
@DisplayName("Should split by document count successfully")
void shouldSplitByDocCount() throws Exception {
byte[] pdfBytes = createPdf(6);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(2);
request.setSplitValue("3");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 2);
}
@Test
@DisplayName("Should split by document count distributing extras")
void shouldSplitByDocCountWithRemainder() throws Exception {
byte[] pdfBytes = createPdf(7);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(2);
request.setSplitValue("3");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(3);
assertThat(pageCountsOf(outputs)).containsExactly(3, 2, 2);
}
@Test
@DisplayName("Should split by size into multiple files")
void shouldSplitBySize() throws Exception {
byte[] pdfBytes = createPdf(20);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(0);
request.setSplitValue("3KB");
ResponseEntity<Resource> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).isNotEmpty();
int total = 0;
for (int count : pageCountsOf(outputs)) {
total += count;
byte[] pdfBytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 6; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
pdfBytes = Files.readAllBytes(pdfPath);
}
assertThat(total).isEqualTo(20);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(2); // Document count
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<?> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ dependencies {
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
runtimeOnly 'org.postgresql:postgresql:42.7.11'
runtimeOnly 'org.postgresql:postgresql:42.7.10'
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
}
@@ -1,39 +0,0 @@
package stirling.software.proprietary.audit;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Controller
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardWebController {
private final AuditConfigurationProperties auditConfig;
/** Display the audit dashboard. */
@GetMapping("/audit")
@Hidden
public String showDashboard(Model model) {
model.addAttribute("auditEnabled", auditConfig.isEnabled());
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
model.addAttribute("auditLevelInt", auditConfig.getLevel());
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
model.addAttribute("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
model.addAttribute("auditEventTypes", AuditEventType.values());
return "audit/dashboard";
}
}

Some files were not shown because too many files have changed in this diff Show More