diff --git a/.dockerignore b/.dockerignore
index 50f737668..54dbc4232 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,75 +1,52 @@
-# Node modules and build artifacts
-node_modules
-frontend/node_modules
-frontend/dist
-frontend/build
-frontend/.vite
-frontend/.tauri
-frontend/src-tauri/target
-
-# Gradle build artifacts
-.gradle
-build
-bin
-target
-out
-
-# Git
-.git
+# Version control
+.git/
.gitignore
-# IDE
-.vscode
-.idea
+# Build outputs
+build/
+*/build/
+**/build/
+out/
+target/
+
+# Gradle caches (local, not what's in the container)
+.gradle/
+**/.gradle/
+
+# Node / frontend
+node_modules/
+**/node_modules/
+.npm/
+.yarn/
+
+# IDE and editor
+.idea/
+.vscode/
*.iml
-*.iws
*.ipr
+*.iws
-# Logs
+# Logs and temp files
*.log
-logs
-
-# Environment files
-.env
-.env.*
-!.env.example
-
-# OS files
+*.tmp
+*.pid
.DS_Store
Thumbs.db
-# Java compiled files
-*.class
-*.jar
-*.war
-*.ear
-
-# Test reports
-test-results
-coverage
-
-# Docker
-docker-compose.override.yml
+# Docker itself
+Dockerfile*
.dockerignore
-# Temporary files
-tmp
-temp
-*.tmp
-*.swp
-*~
-
-# Runtime database and config files (locked by running app)
-app/core/configs/**
-stirling/**
-stirling-pdf-DB*.mv.db
-stirling-pdf-DB*.trace.db
-
-# Documentation
-*.md
-!README.md
-docs
-
-# CI/CD
-.github
+# CI / CD configs (not needed in build context)
+.github/
+.circleci/
.gitlab-ci.yml
+
+# Test reports
+**/test-results/
+**/jacoco/
+
+# Local env
+.env
+.env.*
+!.env.example
diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml
index 580f4bc5e..06454ece2 100644
--- a/.github/workflows/PR-Demo-Comment-with-react.yml
+++ b/.github/workflows/PR-Demo-Comment-with-react.yml
@@ -150,10 +150,10 @@ jobs:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }}
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 38e610ed2..aa5b2990c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -52,7 +52,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- jdk-version: [17, 21]
+ jdk-version: [21, 25]
spring-security: [true, false]
steps:
- name: Harden Runner
@@ -140,7 +140,7 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
@@ -209,10 +209,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
@@ -221,7 +221,12 @@ jobs:
gradle-version: 8.14
- name: check the licenses for compatibility
- run: ./gradlew checkLicense
+ # NOTE: --no-parallel is intentional here. Running the checkLicense task in parallel with other
+ # Gradle tasks has been observed to cause intermittent failures with the dependency license
+ # checking plugin on this Gradle version. Disabling parallel execution trades some build speed
+ # for more reliable, deterministic license checks. If upgrading Gradle or the plugin, consider
+ # re-evaluating whether this flag is still required before removing it.
+ run: ./gradlew checkLicense --no-parallel
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
@@ -265,10 +270,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
@@ -355,10 +360,10 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml
index 0b7fbd20c..5fdd69f4d 100644
--- a/.github/workflows/frontend-backend-licenses-update.yml
+++ b/.github/workflows/frontend-backend-licenses-update.yml
@@ -330,10 +330,10 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
@@ -344,7 +344,12 @@ jobs:
- name: Check licenses and generate report
id: license-check
run: |
- ./gradlew checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
+ # NOTE: --no-parallel is intentional here. Running the license-checking tasks in parallel has
+ # previously caused intermittent concurrency issues in CI (e.g. flaky failures in the license
+ # plugin/Gradle when multiple projects are evaluated concurrently). Disabling parallelism trades
+ # some build speed for more reliable license reports. If the underlying issues are resolved in
+ # future Gradle or plugin versions, this flag can be reconsidered.
+ ./gradlew checkLicense generateLicenseReport --no-parallel || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml
index ad91b2878..1595a62c2 100644
--- a/.github/workflows/multiOSReleases.yml
+++ b/.github/workflows/multiOSReleases.yml
@@ -44,10 +44,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
@@ -117,10 +117,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
@@ -197,10 +197,10 @@ jobs:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml
index a54365d8b..3146ebb0e 100644
--- a/.github/workflows/push-docker.yml
+++ b/.github/workflows/push-docker.yml
@@ -39,10 +39,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml
index f7b4b8c25..c4b940e76 100644
--- a/.github/workflows/swagger.yml
+++ b/.github/workflows/swagger.yml
@@ -33,10 +33,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Set up JDK 21
+ - name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
- java-version: "21"
+ java-version: "25"
distribution: "temurin"
- name: Setup Gradle
diff --git a/app/common/build.gradle b/app/common/build.gradle
index c2719d29f..be7016aaa 100644
--- a/app/common/build.gradle
+++ b/app/common/build.gradle
@@ -27,8 +27,8 @@ spotless {
}
}
dependencies {
- api 'org.springframework.boot:spring-boot-starter-web'
- api 'org.springframework.boot:spring-boot-starter-aop'
+ api 'org.springframework.boot:spring-boot-starter-webmvc'
+ api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
@@ -42,7 +42,7 @@ dependencies {
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
- api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15"
+ api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
diff --git a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java
index 49ed068e2..b1ecc1020 100644
--- a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java
+++ b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java
@@ -91,9 +91,9 @@ public class RuntimePathConfig {
// Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
- String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
+ String defaultUnoConvertPath = isDocker ? "/usr/local/bin/unoconvert" : "unoconvert";
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
- String defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf";
+ String defaultOcrMyPdfPath = isDocker ? "/opt/venv/bin/ocrmypdf" : "ocrmypdf";
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
Operations operations = customPaths.getOperations();
diff --git a/app/common/src/main/java/stirling/software/common/configuration/SchedulingConfig.java b/app/common/src/main/java/stirling/software/common/configuration/SchedulingConfig.java
new file mode 100644
index 000000000..650f8d947
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/configuration/SchedulingConfig.java
@@ -0,0 +1,23 @@
+package stirling.software.common.configuration;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
+
+/**
+ * Configures the scheduler used by all {@code @Scheduled} methods. Uses virtual threads so that
+ * long-running scheduled tasks (e.g. cleanup, license checks, file monitoring) never block each
+ * other — each runs on its own lightweight virtual thread.
+ */
+@Configuration
+public class SchedulingConfig {
+
+ @Bean
+ public TaskScheduler taskScheduler() {
+ SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
+ scheduler.setVirtualThreads(true);
+ scheduler.setThreadNamePrefix("scheduled-vt-");
+ return scheduler;
+ }
+}
diff --git a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java
index eff801858..7cc01541e 100644
--- a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java
+++ b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java
@@ -1,175 +1,562 @@
package stirling.software.common.service;
-import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
-import java.util.Locale;
-import java.util.concurrent.atomic.AtomicLong;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.function.Consumer;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.MemoryUsageSetting;
+import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
import org.apache.pdfbox.io.ScratchFile;
import org.apache.pdfbox.pdmodel.PDDocument;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
-import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager;
-import stirling.software.common.util.TempFileRegistry;
-/**
- * Adaptive PDF document factory that optimizes memory usage based on file size and available system
- * resources.
- */
@Component
@Slf4j
-@RequiredArgsConstructor
public class CustomPDFDocumentFactory {
private final PdfMetadataService pdfMetadataService;
- // Memory thresholds and limits
+ // TempFileManager is optional at construction time so that test code can instantiate this
+ // class without a full Spring context. When null, falls back to Files.createTempFile().
+ private final TempFileManager tempFileManager;
- public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
- // Files smaller than this threshold are loaded entirely in memory for better performance.
- // These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM.
- // No temp files are created for document data, reducing I/O operations but consuming more
- // memory.
-
- private static final long LARGE_FILE_THRESHOLD = 50 * 1024 * 1024; // 50 MB
- // Files between SMALL and LARGE thresholds use file-based caching with ScratchFile,
- // but are loaded directly from byte arrays if provided that way.
- // When loading from byte arrays, once size exceeds this threshold, bytes are first
- // written to temp files before loading to reduce memory pressure.
-
- private static final long LARGE_FILE_USAGE = 10 * 1024 * 1024;
-
- private static final long EXTREMELY_LARGE_THRESHOLD = 100 * 1024 * 1024; // 100 MB
- // Files exceeding this threshold use specialized loading with RandomAccessReadBufferedFile
- // which provides buffered access to the file without loading the entire content at once.
- // These files are always processed using file-based caching with minimal memory footprint,
- // trading some performance for significantly reduced memory usage.
- // For extremely large PDFs, this prevents OutOfMemoryErrors at the cost of being more I/O
- // bound.
-
- private static final double MIN_FREE_MEMORY_PERCENTAGE = 30.0; // 30%
- private static final long MIN_FREE_MEMORY_BYTES = 4L * 1024 * 1024 * 1024; // 4 GB
-
- // Counter for tracking temporary resources
- private static final AtomicLong tempCounter = new AtomicLong(0);
+ /** Primary constructor used by Spring. Both collaborators are required in production. */
+ @Autowired
+ public CustomPDFDocumentFactory(
+ PdfMetadataService pdfMetadataService, TempFileManager tempFileManager) {
+ this.pdfMetadataService = pdfMetadataService;
+ this.tempFileManager = tempFileManager;
+ }
/**
- * Main entry point for loading a PDF document from a file. Automatically selects the most
- * appropriate loading strategy.
+ * Test-only convenience constructor. {@link TempFileManager} falls back to {@link
+ * Files#createTempFile}.
*/
+ public CustomPDFDocumentFactory(PdfMetadataService pdfMetadataService) {
+ this(pdfMetadataService, null);
+ }
+
+ /** Documents ≤ this size are loaded entirely into heap — no temp files needed. */
+ public static final long SMALL_FILE_THRESHOLD = 10L * 1024 * 1024; // 10 MB
+
+ /** Upper boundary of the "mixed" memory+file zone; above this always file-backed. */
+ private static final long LARGE_FILE_THRESHOLD = 50L * 1024 * 1024; // 50 MB
+
+ /** Heap budget reserved for a document loaded in mixed mode. */
+ private static final long MIXED_MODE_MEMORY_LIMIT = 10L * 1024 * 1024; // 10 MB
+
+ /** Minimum free-heap fraction before falling back to file-backed caching. */
+ private static final double MIN_FREE_MEMORY_PERCENTAGE = 30.0;
+
+ /**
+ * Absolute free-heap floor. Kept well below typical JVM heap sizes (256 MB) so that the
+ * percentage gate above remains the primary trigger. The previous value of 4 GB caused
+ * file-backed caching to fire on every request against any JVM with a heap smaller than 4 GB,
+ * defeating the purpose of the threshold hierarchy entirely.
+ */
+ private static final long MIN_FREE_MEMORY_FLOOR = 256L * 1024 * 1024; // 256 MB
+
+ /** Maximum number of concurrent PDF operations in batch methods. */
+ private static final int MAX_CONCURRENT_OPS =
+ Math.max(4, Runtime.getRuntime().availableProcessors());
+
+ private static final Semaphore CONCURRENT_GATE = new Semaphore(MAX_CONCURRENT_OPS);
+
+ /**
+ * Immutable point-in-time snapshot of JVM heap metrics. Capturing all three {@code
+ * Runtime.getRuntime()} values in one call prevents the race where {@code freeMemory()} and
+ * {@code totalMemory()} are read milliseconds apart under GC pressure, yielding a logically
+ * inconsistent picture.
+ */
+ record MemorySnapshot(long maxBytes, long usedBytes, long freeBytes, double freePct) {
+
+ static MemorySnapshot capture() {
+ Runtime rt = Runtime.getRuntime();
+ long max = rt.maxMemory();
+ long used = rt.totalMemory() - rt.freeMemory();
+ long free = max - used;
+ return new MemorySnapshot(max, used, free, (double) free / max * 100.0);
+ }
+
+ /** {@code true} when available heap is too low for in-memory PDF caching. */
+ boolean isLow() {
+ return freePct < MIN_FREE_MEMORY_PERCENTAGE || freeBytes < MIN_FREE_MEMORY_FLOOR;
+ }
+ }
+
public PDDocument load(File file) throws IOException {
return load(file, false);
}
/**
- * Main entry point for loading a PDF document from a file with read-only option. Automatically
- * selects the most appropriate loading strategy.
+ * Loads a PDF from a caller-owned {@link File}. Small files (<= {@link #SMALL_FILE_THRESHOLD})
+ * are slurped into a byte array. Larger files are loaded directly using a non-destructive
+ * {@link RandomAccessReadBufferedFile} so the caller's original is never modified or deleted.
+ *
+ *
Note: for files larger than {@link #SMALL_FILE_THRESHOLD}, the returned document holds an
+ * open file handle to the original file until {@link PDDocument#close()} is called.
*/
public PDDocument load(File file, boolean readOnly) throws IOException {
- if (file == null) {
- throw ExceptionUtils.createNullArgumentException("File");
+ if (file == null) throw ExceptionUtils.createNullArgumentException("File");
+ long size = file.length();
+ log.debug("Loading PDF from file: {} MB", size >> 20);
+ if (size < SMALL_FILE_THRESHOLD) {
+ return load(Files.readAllBytes(file.toPath()), readOnly);
}
-
- long fileSize = file.length();
- log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024));
-
- PDDocument doc = loadAdaptively(file, fileSize);
- if (!readOnly) {
- postProcessDocument(doc);
+ MemorySnapshot mem = MemorySnapshot.capture();
+ // Use the overridable method so that test spies (SpyPDFDocumentFactory) can intercept.
+ StreamCacheCreateFunction cache = getStreamCacheFunction(size, mem);
+ // Non-destructive — caller's file is never deleted
+ RandomAccessReadBufferedFile raf = new RandomAccessReadBufferedFile(file);
+ PDDocument doc;
+ try {
+ doc = Loader.loadPDF(raf, "", null, null, cache);
+ } catch (IOException e) {
+ try {
+ raf.close();
+ } catch (IOException ce) {
+ e.addSuppressed(ce);
+ }
+ ExceptionUtils.logException("PDF loading from file", e);
+ throw ExceptionUtils.handlePdfException(e);
+ }
+ if (size > LARGE_FILE_THRESHOLD || mem.isLow()) {
+ doc.setResourceCache(null);
+ }
+ try {
+ return maybePostProcess(doc, readOnly);
+ } catch (IOException | RuntimeException ex) {
+ doc.close();
+ throw ex;
}
- return doc;
}
- /**
- * Main entry point for loading a PDF document from a Path. Automatically selects the most
- * appropriate loading strategy.
- */
public PDDocument load(Path path) throws IOException {
return load(path, false);
}
- /**
- * Main entry point for loading a PDF document from a Path with read-only option. Automatically
- * selects the most appropriate loading strategy.
- */
+ /** Loads a PDF from a caller-owned {@link Path}. Delegates to {@link #load(File, boolean)}. */
public PDDocument load(Path path, boolean readOnly) throws IOException {
- if (path == null) {
- throw ExceptionUtils.createNullArgumentException("File");
- }
-
- long fileSize = Files.size(path);
- log.debug("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024));
-
- PDDocument doc = loadAdaptively(path.toFile(), fileSize);
- if (!readOnly) {
- postProcessDocument(doc);
- }
- return doc;
+ if (path == null) throw ExceptionUtils.createNullArgumentException("Path");
+ return load(path.toFile(), readOnly);
}
- /** Load a PDF from byte array with automatic optimization. */
public PDDocument load(byte[] input) throws IOException {
return load(input, false);
}
- /** Load a PDF from byte array with automatic optimization and read-only option. */
public PDDocument load(byte[] input, boolean readOnly) throws IOException {
- if (input == null) {
- throw ExceptionUtils.createNullArgumentException("Input bytes");
+ if (input == null) throw ExceptionUtils.createNullArgumentException("Input bytes");
+ long size = input.length;
+ log.debug("Loading PDF from byte[]: {} MB", size >> 20);
+ PDDocument doc = loadAdaptively(input, size, null);
+ try {
+ return maybePostProcess(doc, readOnly);
+ } catch (IOException | RuntimeException ex) {
+ doc.close();
+ throw ex;
}
-
- long dataSize = input.length;
- log.debug("Loading PDF from byte array, size: {}MB", dataSize / (1024 * 1024));
-
- PDDocument doc = loadAdaptively(input, dataSize);
- if (!readOnly) {
- postProcessDocument(doc);
- }
- return doc;
}
- /** Load a PDF from InputStream with automatic optimization. */
public PDDocument load(InputStream input) throws IOException {
return load(input, false);
}
- /** Load a PDF from InputStream with automatic optimization and read-only option. */
public PDDocument load(InputStream input, boolean readOnly) throws IOException {
- if (input == null) {
- throw ExceptionUtils.createNullArgumentException("InputStream");
+ if (input == null) throw ExceptionUtils.createNullArgumentException("InputStream");
+ return streamToTemp(input, null, readOnly);
+ }
+
+ public PDDocument load(InputStream input, String password) throws IOException {
+ return load(input, password, false);
+ }
+
+ public PDDocument load(InputStream input, String password, boolean readOnly)
+ throws IOException {
+ if (input == null) throw ExceptionUtils.createNullArgumentException("InputStream");
+ return streamToTemp(input, password, readOnly);
+ }
+
+ public PDDocument load(String path) throws IOException {
+ return load(path, false);
+ }
+
+ public PDDocument load(String path, boolean readOnly) throws IOException {
+ return load(new File(path), readOnly);
+ }
+
+ public PDDocument load(PDFFile pdfFile) throws IOException {
+ return load(pdfFile, false);
+ }
+
+ public PDDocument load(PDFFile pdfFile, boolean readOnly) throws IOException {
+ return load(pdfFile.getFileInput(), readOnly);
+ }
+
+ public PDDocument load(MultipartFile pdfFile) throws IOException {
+ return load(pdfFile, false);
+ }
+
+ /**
+ * Loads a {@link MultipartFile}. Small uploads (<= {@link #SMALL_FILE_THRESHOLD}) are read
+ * directly into a byte array, bypassing the InputStream → temp-file round-trip and saving one
+ * disk write + read cycle on the hot path.
+ */
+ public PDDocument load(MultipartFile pdfFile, boolean readOnly) throws IOException {
+ long size = pdfFile.getSize();
+ if (size > 0 && size <= SMALL_FILE_THRESHOLD) {
+ return load(pdfFile.getBytes(), readOnly);
+ }
+ return streamToTemp(pdfFile.getInputStream(), null, readOnly);
+ }
+
+ public PDDocument load(MultipartFile fileInput, String password) throws IOException {
+ return load(fileInput, password, false);
+ }
+
+ public PDDocument load(MultipartFile fileInput, String password, boolean readOnly)
+ throws IOException {
+ return streamToTemp(fileInput.getInputStream(), password, readOnly);
+ }
+
+ /**
+ * Returns the {@link StreamCacheCreateFunction} appropriate for a document of the given byte
+ * size given the current heap state. Captures a fresh {@link MemorySnapshot} on each call.
+ *
+ *
Overridden by {@code SpyPDFDocumentFactory} in tests to record which strategy was chosen.
+ */
+ public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
+ return getStreamCacheFunction(contentSize, MemorySnapshot.capture());
+ }
+
+ /**
+ * Overload accepting a pre-captured {@link MemorySnapshot} so that internal callers can reuse a
+ * single snapshot for both cache selection and resource-cache decisions. Overridable so that
+ * test spies ({@code SpyPDFDocumentFactory}) can intercept.
+ */
+ protected StreamCacheCreateFunction getStreamCacheFunction(
+ long contentSize, MemorySnapshot mem) {
+ return selectCacheFunction(contentSize, mem);
+ }
+
+ public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException {
+ PDDocument doc = new PDDocument(scratchCache(settings));
+ pdfMetadataService.setDefaultMetadata(doc);
+ return doc;
+ }
+
+ /**
+ * Creates a new empty document using a memory-only cache. New documents start at zero bytes, so
+ * file-backed scratch space is wasteful until the caller has actually written content. Callers
+ * that build very large documents can use {@link #createNewDocument(MemoryUsageSetting)} to opt
+ * in to file-backed caching from the start.
+ */
+ public PDDocument createNewDocument() throws IOException {
+ PDDocument doc = new PDDocument(IOUtils.createMemoryOnlyStreamCache());
+ pdfMetadataService.setDefaultMetadata(doc);
+ return doc;
+ }
+
+ /**
+ * Serialises a {@link PDDocument} to a byte array. The document is written to a temp file first
+ * so that the PDDocument's internal object graph can be GC'd before {@link Files#readAllBytes}
+ * allocates the returned byte array, preventing double-peak memory for large documents. The OS
+ * buffer cache absorbs the I/O overhead for small documents.
+ */
+ public byte[] saveToBytes(PDDocument document) throws IOException {
+ Path temp = createTempFilePath("pdf-save-");
+ try {
+ document.save(temp.toFile());
+ return Files.readAllBytes(temp);
+ } finally {
+ try {
+ Files.deleteIfExists(temp);
+ } catch (IOException e) {
+ log.warn("Failed to delete temp file: {}", temp, e);
+ }
+ }
+ }
+
+ public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
+ try (PDDocument document = load(oldDocument)) {
+ return saveToBytes(document);
+ }
+ }
+
+ public PDDocument createNewDocumentBasedOnOldDocument(byte[] oldDocument) throws IOException {
+ try (PDDocument document = load(oldDocument)) {
+ return createNewDocumentBasedOnOldDocument(document);
+ }
+ }
+
+ public PDDocument createNewDocumentBasedOnOldDocument(File oldDocument) throws IOException {
+ try (PDDocument document = load(oldDocument)) {
+ return createNewDocumentBasedOnOldDocument(document);
+ }
+ }
+
+ public PDDocument createNewDocumentBasedOnOldDocument(PDDocument oldDocument)
+ throws IOException {
+ PDDocument document = createNewDocument();
+ try {
+ pdfMetadataService.setMetadataToPdf(
+ document, pdfMetadataService.extractMetadataFromPdf(oldDocument), true);
+ return document;
+ } catch (RuntimeException ex) {
+ document.close();
+ throw ex;
+ }
+ }
+
+ public byte[] loadToBytes(File file) throws IOException {
+ try (PDDocument document = load(file)) {
+ return saveToBytes(document);
+ }
+ }
+
+ public byte[] loadToBytes(byte[] bytes) throws IOException {
+ try (PDDocument document = load(bytes)) {
+ return saveToBytes(document);
+ }
+ }
+
+ /**
+ * Loads all {@code files} concurrently, one virtual thread per file. PDF loading is I/O-bound;
+ * virtual threads yield their carrier threads during blocking reads, so the JVM can serve other
+ * requests while each document is being parsed from disk.
+ *
+ *
Concurrency is bounded by {@link #MAX_CONCURRENT_OPS} to prevent unbounded memory pressure
+ * when many files are submitted simultaneously.
+ *
+ *
If any single load fails, all pending tasks are cancelled, any already-open documents are
+ * closed, and the first {@link IOException} is rethrown. The caller retains ownership of all
+ * returned documents and must close them.
+ *
+ * @param files ordered list of files to load; the returned list preserves insertion order
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ */
+ public List loadAll(List files) throws IOException, InterruptedException {
+ List> tasks =
+ files.stream()
+ .>map(
+ f ->
+ () -> {
+ CONCURRENT_GATE.acquire();
+ try {
+ return load(f);
+ } finally {
+ CONCURRENT_GATE.release();
+ }
+ })
+ .toList();
+ return runConcurrently(tasks, CustomPDFDocumentFactory::closeQuietly);
+ }
+
+ /**
+ * Loads all multipart uploads concurrently, one virtual thread per upload. Small uploads (≤
+ * {@link #SMALL_FILE_THRESHOLD}) are read into heap; larger uploads spill to temp files.
+ * Concurrency is bounded by {@link #MAX_CONCURRENT_OPS}. Failure semantics are identical to
+ * {@link #loadAll(List)}.
+ *
+ * @param files ordered list of uploads; the returned list preserves insertion order
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ */
+ public List loadAllMultipart(List files)
+ throws IOException, InterruptedException {
+ List> tasks =
+ files.stream()
+ .>map(
+ f ->
+ () -> {
+ CONCURRENT_GATE.acquire();
+ try {
+ return load(f);
+ } finally {
+ CONCURRENT_GATE.release();
+ }
+ })
+ .toList();
+ return runConcurrently(tasks, CustomPDFDocumentFactory::closeQuietly);
+ }
+
+ /**
+ * Serialises all documents to byte arrays concurrently, one virtual thread per document.
+ * Concurrency is bounded by {@link #MAX_CONCURRENT_OPS}. Each document is written to a temp
+ * file (preventing double-peak-memory, see {@link #saveToBytes}); the concurrent writes proceed
+ * in parallel. The returned list preserves insertion order.
+ *
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ */
+ public List saveAllToBytes(List documents)
+ throws IOException, InterruptedException {
+ List> tasks =
+ documents.stream()
+ .>map(
+ doc ->
+ () -> {
+ CONCURRENT_GATE.acquire();
+ try {
+ return saveToBytes(doc);
+ } finally {
+ CONCURRENT_GATE.release();
+ }
+ })
+ .toList();
+ return runConcurrently(tasks, null);
+ }
+
+ /**
+ * Runs {@code tasks} concurrently on virtual threads (one per task), collecting results in
+ * insertion order. The executor is scoped to this call - no shared mutable state between
+ * invocations. On any failure: pending tasks are cancelled, {@code onFailureCleanup} is applied
+ * to every result collected before the failure, and the first exception is rethrown.
+ *
+ * @param onFailureCleanup may be {@code null} when no result-level cleanup is needed
+ */
+ private static List runConcurrently(
+ List> tasks, Consumer onFailureCleanup)
+ throws IOException, InterruptedException {
+ try (ExecutorService vte = Executors.newVirtualThreadPerTaskExecutor()) {
+ List> futures = tasks.stream().map(vte::submit).toList();
+ List results = new ArrayList<>(futures.size());
+ try {
+ for (Future future : futures) {
+ results.add(future.get());
+ }
+ return List.copyOf(results);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ futures.forEach(f -> f.cancel(true));
+ cleanupFutureResults(futures, onFailureCleanup);
+ throw ie;
+ } catch (ExecutionException e) {
+ futures.forEach(f -> f.cancel(true));
+ cleanupFutureResults(futures, onFailureCleanup);
+ Throwable cause = e.getCause();
+ if (cause instanceof IOException ioe) throw ioe;
+ if (cause instanceof InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw ie;
+ }
+ throw new IOException("Concurrent PDF operation failed", cause);
+ }
+ }
+ }
+
+ /** Cleans up results from concurrent execution on failure. */
+ private static void cleanupFutureResults(
+ List> futures, Consumer onFailureCleanup) {
+ if (onFailureCleanup == null) return;
+ for (Future f : futures) {
+ if (f.isDone() && !f.isCancelled()) {
+ try {
+ onFailureCleanup.accept(f.get());
+ } catch (Exception ignored) {
+ // best-effort cleanup
+ }
+ }
+ }
+ }
+
+ private static void closeQuietly(PDDocument doc) {
+ try {
+ doc.close();
+ } catch (IOException e) {
+ log.warn("Failed to close PDF during error cleanup", e);
+ }
+ }
+
+ /**
+ * Selects a loading strategy and loads the document from {@code source} (a {@link File} or
+ * {@code byte[]}). A single {@link MemorySnapshot} is captured once and reused for both cache
+ * selection and resource-cache configuration, guaranteeing that both decisions see the same
+ * heap state.
+ *
+ * @param password {@code null} for unencrypted (or to-be-decrypted-later) documents
+ */
+ private PDDocument loadAdaptively(Object source, long contentSize, String password)
+ throws IOException {
+ Object sourceObj = source;
+ // Capture a single snapshot for both cache selection and resource-cache decision.
+ MemorySnapshot mem = MemorySnapshot.capture();
+ // Use the overridable method so that test spies (SpyPDFDocumentFactory) can intercept.
+ StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize, mem);
+
+ // Slurp small on-disk files into heap immediately so the temp file can be removed and its
+ // file descriptor released before PDFBox opens its own internal scratch space.
+ if (contentSize < SMALL_FILE_THRESHOLD && sourceObj instanceof File f) {
+ byte[] bytes = Files.readAllBytes(f.toPath());
+ Files.deleteIfExists(f.toPath());
+ sourceObj = bytes;
}
- // Since we don't know the size upfront, buffer to a temp file
- Path tempFile = createTempFile("pdf-stream-");
+ PDDocument document =
+ switch (sourceObj) {
+ case File f ->
+ password != null
+ ? loadFromFileWithPassword(f, cacheFunction, password)
+ : loadFromFile(f, cacheFunction);
+ case byte[] b ->
+ password != null
+ ? loadFromBytesWithPassword(
+ b, contentSize, cacheFunction, password)
+ : loadFromBytes(b, contentSize, cacheFunction);
+ default ->
+ throw new IllegalArgumentException(
+ "Unsupported source type: "
+ + sourceObj.getClass().getSimpleName());
+ };
+
+ // Use the same snapshot captured above for consistent resource-cache decision.
+ if (contentSize > LARGE_FILE_THRESHOLD || mem.isLow()) {
+ document.setResourceCache(null);
+ }
+ return document;
+ }
+
+ /** Buffers an {@link InputStream} to a managed temp file then delegates to the core loader. */
+ private PDDocument streamToTemp(InputStream input, String password, boolean readOnly)
+ throws IOException {
+ Path tempFile = createTempFilePath("pdf-stream-");
boolean success = false;
try {
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
- PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile));
- if (!readOnly) {
- postProcessDocument(doc);
+ PDDocument doc = loadAdaptively(tempFile.toFile(), Files.size(tempFile), password);
+ try {
+ PDDocument result = maybePostProcess(doc, readOnly);
+ success = true;
+ return result;
+ } catch (IOException | RuntimeException ex) {
+ doc.close();
+ throw ex;
}
- success = true;
- return doc;
} finally {
- // On success: small files are deleted inside loadAdaptively; large files are deleted
- // by DeletingRandomAccessFile when the returned PDDocument is closed.
+ // On success: small files are deleted inside loadAdaptively; large files are owned by
+ // DeletingRandomAccessFile and deleted when the PDDocument closes.
// On failure: clean up the temp file ourselves since no one else will.
if (!success) {
Files.deleteIfExists(tempFile);
@@ -177,261 +564,87 @@ public class CustomPDFDocumentFactory {
}
}
- /** Load with password from InputStream */
- public PDDocument load(InputStream input, String password) throws IOException {
- return load(input, password, false);
- }
-
- /** Load with password from InputStream and read-only option */
- public PDDocument load(InputStream input, String password, boolean readOnly)
- throws IOException {
- if (input == null) {
- throw ExceptionUtils.createNullArgumentException("InputStream");
- }
-
- // Since we don't know the size upfront, buffer to a temp file
- Path tempFile = createTempFile("pdf-stream-");
- boolean success = false;
- try {
- Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
- PDDocument doc =
- loadAdaptivelyWithPassword(tempFile.toFile(), Files.size(tempFile), password);
- if (!readOnly) {
- postProcessDocument(doc);
- }
- success = true;
- return doc;
- } finally {
- if (!success) {
- Files.deleteIfExists(tempFile);
- }
- }
- }
-
- /** Load from a file path string */
- public PDDocument load(String path) throws IOException {
- return load(path, false);
- }
-
- /** Load from a file path string with read-only option */
- public PDDocument load(String path, boolean readOnly) throws IOException {
- return load(new File(path), readOnly);
- }
-
- /** Load from a PDFFile object */
- public PDDocument load(PDFFile pdfFile) throws IOException {
- return load(pdfFile, false);
- }
-
- /** Load from a PDFFile object with read-only option */
- public PDDocument load(PDFFile pdfFile, boolean readOnly) throws IOException {
- return load(pdfFile.getFileInput(), readOnly);
- }
-
- /** Load from a MultipartFile */
- public PDDocument load(MultipartFile pdfFile) throws IOException {
- return load(pdfFile, false);
- }
-
- /** Load from a MultipartFile with read-only option */
- public PDDocument load(MultipartFile pdfFile, boolean readOnly) throws IOException {
- return load(pdfFile.getInputStream(), readOnly);
- }
-
- /** Load with password from MultipartFile */
- public PDDocument load(MultipartFile fileInput, String password) throws IOException {
- return load(fileInput, password, false);
- }
-
- /** Load with password from MultipartFile with read-only option */
- public PDDocument load(MultipartFile fileInput, String password, boolean readOnly)
- throws IOException {
- return load(fileInput.getInputStream(), password, readOnly);
- }
-
/**
- * Determine the appropriate caching strategy based on file size and available memory. This
- * common method is used by both password and non-password loading paths.
+ * Internal helper that reuses an already-captured {@link MemorySnapshot}, avoiding a second
+ * {@code Runtime.getRuntime()} call from within the same load operation.
*/
- public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
- long maxMemory = Runtime.getRuntime().maxMemory();
- long freeMemory = Runtime.getRuntime().freeMemory();
- long totalMemory = Runtime.getRuntime().totalMemory();
- long usedMemory = totalMemory - freeMemory;
-
- // Calculate percentage of free memory
- double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100;
- long actualFreeMemory = maxMemory - usedMemory;
-
- // Log memory status
- log.debug(
- "Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB",
- actualFreeMemory / (1024 * 1024),
- String.format(Locale.ROOT, "%.2f", freeMemoryPercent),
- usedMemory / (1024 * 1024),
- maxMemory / (1024 * 1024));
-
- // If free memory is critically low, always use file-based caching
- if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE
- || actualFreeMemory < MIN_FREE_MEMORY_BYTES) {
+ private static StreamCacheCreateFunction selectCacheFunction(
+ long contentSize, MemorySnapshot mem) {
+ if (mem.isLow()) {
log.debug(
- "Low memory detected ({}%), forcing file-based cache",
- String.format(Locale.ROOT, "%.2f", freeMemoryPercent));
- return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
- } else if (contentSize < SMALL_FILE_THRESHOLD) {
- log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024);
+ "Heap pressure ({}% free, {} MB free), forcing file-backed cache",
+ (int) mem.freePct(), mem.freeBytes() >> 20);
+ return scratchCache(MemoryUsageSetting.setupTempFileOnly());
+ }
+ if (contentSize < SMALL_FILE_THRESHOLD) {
+ log.debug("Memory-only cache for {} KB document", contentSize >> 10);
return IOUtils.createMemoryOnlyStreamCache();
- } else if (contentSize < LARGE_FILE_THRESHOLD) {
- // For medium files (10-50MB), use a mixed approach
- log.debug(
- "Using mixed memory/file cache for medium document ({}MB)",
- contentSize / (1024 * 1024));
- return createScratchFileCacheFunction(MemoryUsageSetting.setupMixed(LARGE_FILE_USAGE));
- } else {
- log.debug("Using file-based cache for large document");
- return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
}
+ if (contentSize < LARGE_FILE_THRESHOLD) {
+ log.debug("Mixed cache for {} MB document", contentSize >> 20);
+ return scratchCache(MemoryUsageSetting.setupMixed(MIXED_MODE_MEMORY_LIMIT));
+ }
+ log.debug("File-backed cache for {} MB document", contentSize >> 20);
+ return scratchCache(MemoryUsageSetting.setupTempFileOnly());
}
- /** Update the existing loadAdaptively method to use the common function */
- private PDDocument loadAdaptively(Object source, long contentSize) throws IOException {
- // Get the appropriate caching strategy
- StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize);
-
- // If small handle as bytes and remove original file
- if (contentSize <= SMALL_FILE_THRESHOLD && source instanceof File file) {
- source = Files.readAllBytes(file.toPath());
- file.delete();
- }
- PDDocument document;
- if (source instanceof File file) {
- document = loadFromFile(file, contentSize, cacheFunction);
- } else if (source instanceof byte[] bytes) {
- document = loadFromBytes(bytes, contentSize, cacheFunction);
- } else {
- throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
- }
-
- configureResourceCacheIfNeeded(document, contentSize);
-
- return document;
- }
-
- /**
- * Configure resource cache based on content size and memory constraints. Disables resource
- * cache for large files or when memory is low to prevent OOM errors.
- */
- private void configureResourceCacheIfNeeded(PDDocument document, long contentSize) {
- if (contentSize > LARGE_FILE_THRESHOLD) {
- document.setResourceCache(null);
- } else {
- // Check current memory status for smaller files
- long maxMemory = Runtime.getRuntime().maxMemory();
- long usedMemory =
- Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
- double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100;
-
- if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE) {
- document.setResourceCache(null);
- }
- }
- }
-
- /** Load a PDF with password protection using adaptive loading strategies */
- private PDDocument loadAdaptivelyWithPassword(Object source, long contentSize, String password)
- throws IOException {
- // Get the appropriate caching strategy
- StreamCacheCreateFunction cacheFunction = getStreamCacheFunction(contentSize);
- // If small handle as bytes and remove original file
- if (contentSize <= SMALL_FILE_THRESHOLD && source instanceof File file) {
- source = Files.readAllBytes(file.toPath());
- file.delete();
- }
- PDDocument document;
- if (source instanceof File file) {
- document = loadFromFileWithPassword(file, contentSize, cacheFunction, password);
- } else if (source instanceof byte[] bytes) {
- document = loadFromBytesWithPassword(bytes, contentSize, cacheFunction, password);
- } else {
- throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
- }
-
- configureResourceCacheIfNeeded(document, contentSize);
-
- return document;
- }
-
- /** Load a file with password */
- private PDDocument loadFromFileWithPassword(
- File file, long size, StreamCacheCreateFunction cache, String password)
- throws IOException {
- return Loader.loadPDF(new DeletingRandomAccessFile(file), password, null, null, cache);
- }
-
- /** Load bytes with password */
- private PDDocument loadFromBytesWithPassword(
- byte[] bytes, long size, StreamCacheCreateFunction cache, String password)
- throws IOException {
- if (size >= SMALL_FILE_THRESHOLD) {
- log.debug("Writing large byte array to temp file for password-protected PDF");
- Path tempFile = createTempFile("pdf-bytes-");
- boolean success = false;
- try {
- Files.write(tempFile, bytes);
- // Use DeletingRandomAccessFile so the temp file is removed when the document closes
- PDDocument doc =
- Loader.loadPDF(
- new DeletingRandomAccessFile(tempFile.toFile()),
- password,
- null,
- null,
- cache);
- success = true;
- return doc;
- } finally {
- if (!success) {
- Files.deleteIfExists(tempFile);
- }
- }
- }
- return Loader.loadPDF(bytes, password, null, null, cache);
- }
-
- private StreamCacheCreateFunction createScratchFileCacheFunction(MemoryUsageSetting settings) {
- return () -> {
- try {
- return new ScratchFile(settings);
- } catch (IOException e) {
- throw new RuntimeException("ScratchFile initialization failed", e);
- }
- };
- }
-
- private void postProcessDocument(PDDocument doc) throws IOException {
- pdfMetadataService.setDefaultMetadata(doc);
- removePassword(doc);
- }
-
- private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache)
+ private static PDDocument loadFromFile(File file, StreamCacheCreateFunction cache)
throws IOException {
+ DeletingRandomAccessFile raf = new DeletingRandomAccessFile(file);
try {
- return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache);
+ // Empty string password: PDFBox convention for unencrypted documents.
+ return Loader.loadPDF(raf, "", null, null, cache);
} catch (IOException e) {
+ try {
+ raf.close();
+ } catch (IOException ce) {
+ e.addSuppressed(ce);
+ }
ExceptionUtils.logException("PDF loading from file", e);
throw ExceptionUtils.handlePdfException(e);
}
}
+ /**
+ * Loads a password-protected PDF from a file. The {@link DeletingRandomAccessFile} is
+ * explicitly closed if {@link Loader#loadPDF} throws to prevent file descriptor leaks.
+ */
+ private static PDDocument loadFromFileWithPassword(
+ File file, StreamCacheCreateFunction cache, String password) throws IOException {
+ DeletingRandomAccessFile raf = new DeletingRandomAccessFile(file);
+ try {
+ return Loader.loadPDF(raf, password, null, null, cache);
+ } catch (IOException e) {
+ try {
+ raf.close();
+ } catch (IOException ce) {
+ e.addSuppressed(ce);
+ }
+ ExceptionUtils.logException("PDF loading from file with password", e);
+ throw ExceptionUtils.handlePdfException(e);
+ }
+ }
+
+ /**
+ * Loads from a byte array. If the array exceeds {@link #SMALL_FILE_THRESHOLD} (which can happen
+ * when the caller passes a large byte[] directly through the public API), the bytes are first
+ * written to a temp file to limit simultaneous heap pressure.
+ */
private PDDocument loadFromBytes(byte[] bytes, long size, StreamCacheCreateFunction cache)
throws IOException {
if (size >= SMALL_FILE_THRESHOLD) {
- log.debug("Writing large byte array to temp file");
- Path tempFile = createTempFile("pdf-bytes-");
-
- Files.write(tempFile, bytes);
- return loadFromFile(tempFile.toFile(), size, cache);
+ log.debug("Spilling {} MB byte[] to temp file before loading", size >> 20);
+ Path tmp = createTempFilePath("pdf-bytes-");
+ boolean ok = false;
+ try {
+ Files.write(tmp, bytes);
+ PDDocument doc = loadFromFile(tmp.toFile(), cache);
+ ok = true;
+ return doc;
+ } finally {
+ if (!ok) Files.deleteIfExists(tmp);
+ }
}
-
try {
return Loader.loadPDF(bytes, "", null, null, cache);
} catch (IOException e) {
@@ -440,134 +653,80 @@ public class CustomPDFDocumentFactory {
}
}
- public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException {
- PDDocument doc = new PDDocument(createScratchFileCacheFunction(settings));
- pdfMetadataService.setDefaultMetadata(doc);
+ /**
+ * Loads a password-protected PDF from a byte array. Large arrays are spilled to a temp file.
+ * The {@link DeletingRandomAccessFile} is explicitly closed if loading throws to prevent file
+ * descriptor leaks on Windows.
+ */
+ private PDDocument loadFromBytesWithPassword(
+ byte[] bytes, long size, StreamCacheCreateFunction cache, String password)
+ throws IOException {
+ if (size >= SMALL_FILE_THRESHOLD) {
+ Path tmp = createTempFilePath("pdf-bytes-");
+ boolean success = false;
+ try {
+ Files.write(tmp, bytes);
+ DeletingRandomAccessFile raf = new DeletingRandomAccessFile(tmp.toFile());
+ try {
+ PDDocument doc = Loader.loadPDF(raf, password, null, null, cache);
+ success = true;
+ return doc;
+ } catch (IOException e) {
+ try {
+ raf.close();
+ } catch (IOException ce) {
+ e.addSuppressed(ce);
+ }
+ throw e;
+ }
+ } finally {
+ if (!success) Files.deleteIfExists(tmp);
+ }
+ }
+ return Loader.loadPDF(bytes, password, null, null, cache);
+ }
+
+ private PDDocument maybePostProcess(PDDocument doc, boolean readOnly) throws IOException {
+ if (!readOnly) {
+ pdfMetadataService.setDefaultMetadata(doc);
+ removePassword(doc);
+ }
return doc;
}
- public PDDocument createNewDocument() throws IOException {
- return createNewDocument(MemoryUsageSetting.setupTempFileOnly());
- }
-
- public byte[] saveToBytes(PDDocument document) throws IOException {
- if (document.getNumberOfPages() < 10) { // Simple heuristic
- try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
- document.save(baos);
- return baos.toByteArray();
- }
- } else {
- Path tempFile = createTempFile("pdf-save-");
- try {
- document.save(tempFile.toFile());
- return Files.readAllBytes(tempFile);
- } finally {
- Files.deleteIfExists(tempFile);
- }
- }
- }
-
- // Improved password handling
- private void removePassword(PDDocument document) throws IOException {
+ private static void removePassword(PDDocument document) throws IOException {
if (document.isEncrypted()) {
try {
document.setAllSecurityToBeRemoved(true);
- } catch (Exception e) {
+ } catch (RuntimeException e) {
ExceptionUtils.logException("PDF decryption", e);
throw new IOException("PDF decryption failed", e);
}
}
}
- // Temp file handling with enhanced logging and registry integration
- private Path createTempFile(String prefix) throws IOException {
- // Check if TempFileManager is available in the application context
- try {
- TempFileManager tempFileManager =
- ApplicationContextProvider.getBean(TempFileManager.class);
- if (tempFileManager != null) {
- // Use TempFileManager to create and register the temp file
- File file = tempFileManager.createTempFile(".tmp");
- log.debug("Created and registered temp file via TempFileManager: {}", file);
- return file.toPath();
+ private static StreamCacheCreateFunction scratchCache(MemoryUsageSetting settings) {
+ return () -> {
+ try {
+ return new ScratchFile(settings);
+ } catch (IOException e) {
+ throw new RuntimeException("ScratchFile initialisation failed", e);
}
- } catch (Exception e) {
- log.debug("TempFileManager not available, falling back to standard temp file creation");
- }
-
- // Fallback to standard temp file creation
- Path file = Files.createTempFile(prefix + tempCounter.incrementAndGet() + "-", ".tmp");
- log.debug("Created temp file: {}", file);
-
- // Try to register the file with a static registry if possible
- try {
- TempFileRegistry registry = ApplicationContextProvider.getBean(TempFileRegistry.class);
- if (registry != null) {
- registry.register(file);
- log.debug("Registered fallback temp file with registry: {}", file);
- }
- } catch (Exception e) {
- log.debug("Could not register fallback temp file with registry: {}", file);
- }
-
- return file;
+ };
}
- /** Create new document bytes based on an existing document */
- public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
- try (PDDocument document = load(oldDocument)) {
- return saveToBytes(document);
- }
- }
-
- /** Create new document bytes based on an existing document file */
- public byte[] createNewBytesBasedOnOldDocument(File oldDocument) throws IOException {
- try (PDDocument document = load(oldDocument)) {
- return saveToBytes(document);
- }
- }
-
- /** Create new document bytes based on an existing PDDocument */
- public byte[] createNewBytesBasedOnOldDocument(PDDocument oldDocument) throws IOException {
- pdfMetadataService.setMetadataToPdf(
- oldDocument, pdfMetadataService.extractMetadataFromPdf(oldDocument), true);
- return saveToBytes(oldDocument);
- }
-
- /** Create a new document based on an existing document bytes */
- public PDDocument createNewDocumentBasedOnOldDocument(byte[] oldDocument) throws IOException {
- try (PDDocument document = load(oldDocument)) {
- return createNewDocumentBasedOnOldDocument(document);
- }
- }
-
- /** Create a new document based on an existing document file */
- public PDDocument createNewDocumentBasedOnOldDocument(File oldDocument) throws IOException {
- try (PDDocument document = load(oldDocument)) {
- return createNewDocumentBasedOnOldDocument(document);
- }
- }
-
- /** Create a new document based on an existing PDDocument */
- public PDDocument createNewDocumentBasedOnOldDocument(PDDocument oldDocument)
- throws IOException {
- PDDocument document = createNewDocument();
- pdfMetadataService.setMetadataToPdf(
- document, pdfMetadataService.extractMetadataFromPdf(oldDocument), true);
- return document;
- }
-
- /** Load document from a file and convert it to bytes */
- public byte[] loadToBytes(File file) throws IOException {
- try (PDDocument document = load(file)) {
- return saveToBytes(document);
- }
- }
-
- /** Load document from bytes and convert it back to bytes */
- public byte[] loadToBytes(byte[] bytes) throws IOException {
- try (PDDocument document = load(bytes)) {
- return saveToBytes(document);
+ /**
+ * Creates a managed temp file. When {@link TempFileManager} is available (production Spring
+ * context) it registers the file for automatic cleanup. Falls back to {@link
+ * Files#createTempFile} for test environments where the full context is not present; the
+ * fallback file is registered for JVM-shutdown deletion.
+ */
+ private Path createTempFilePath(String prefix) throws IOException {
+ if (tempFileManager != null) {
+ return tempFileManager.createTempFile(".tmp").toPath();
}
+ Path p = Files.createTempFile(prefix, ".tmp");
+ p.toFile().deleteOnExit();
+ return p;
}
}
diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java
index 3195bb89a..f8d3cc0a9 100644
--- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java
+++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java
@@ -35,7 +35,7 @@ public class JobExecutorService {
private final HttpServletRequest request;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
- private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
+ private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
private final long effectiveTimeoutMs;
@Autowired(required = false)
diff --git a/app/common/src/main/java/stirling/software/common/service/JobQueue.java b/app/common/src/main/java/stirling/software/common/service/JobQueue.java
index df5394cee..595cc6e39 100644
--- a/app/common/src/main/java/stirling/software/common/service/JobQueue.java
+++ b/app/common/src/main/java/stirling/software/common/service/JobQueue.java
@@ -44,8 +44,10 @@ public class JobQueue implements SmartLifecycle {
private volatile BlockingQueue jobQueue;
private final Map jobMap = new ConcurrentHashMap<>();
- private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
- private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
+ private final ScheduledExecutorService scheduler =
+ Executors.newSingleThreadScheduledExecutor(
+ Thread.ofVirtual().name("job-queue-scheduler-", 0).factory());
+ private final ExecutorService jobExecutor = ExecutorFactory.newVirtualThreadExecutor();
private final Object queueLock = new Object(); // Lock for synchronizing queue operations
private boolean shuttingDown = false;
diff --git a/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java b/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java
index b22923959..031361d64 100644
--- a/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java
+++ b/app/common/src/main/java/stirling/software/common/service/ResourceMonitor.java
@@ -43,7 +43,9 @@ public class ResourceMonitor {
@Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds
- private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ private final ScheduledExecutorService scheduler =
+ Executors.newSingleThreadScheduledExecutor(
+ Thread.ofVirtual().name("resource-monitor-", 0).factory());
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java
index 5d2a9edca..2ee880794 100644
--- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java
+++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java
@@ -42,7 +42,8 @@ public class TaskManager {
private final FileStorage fileStorage;
private final ScheduledExecutorService cleanupExecutor =
- Executors.newSingleThreadScheduledExecutor();
+ Executors.newSingleThreadScheduledExecutor(
+ Thread.ofVirtual().name("task-cleanup-", 0).factory());
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
diff --git a/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java b/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java
index 13cca386e..0d9d340b5 100644
--- a/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java
+++ b/app/common/src/main/java/stirling/software/common/util/ExecutorFactory.java
@@ -2,30 +2,28 @@ package stirling.software.common.util;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
-import lombok.extern.slf4j.Slf4j;
+/**
+ * Factory for creating executors backed by virtual threads (Java 21+). Virtual threads are
+ * lightweight, managed by the JVM, and ideal for I/O-bound tasks. They eliminate the need for
+ * thread pool sizing since thousands can run concurrently with minimal overhead.
+ */
+public final class ExecutorFactory {
-@Slf4j
-public class ExecutorFactory {
+ private ExecutorFactory() {}
+
+ /** Creates an {@link ExecutorService} that starts a new virtual thread for each task. */
+ public static ExecutorService newVirtualThreadExecutor() {
+ return Executors.newVirtualThreadPerTaskExecutor();
+ }
/**
- * Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
- * cached thread pool on older Java versions.
+ * Creates a {@link ScheduledExecutorService} backed by a single virtual thread. Useful for
+ * periodic/delayed tasks that should not pin a platform thread.
*/
- public static ExecutorService newVirtualOrCachedThreadExecutor() {
- try {
- ExecutorService executor =
- (ExecutorService)
- Executors.class
- .getMethod("newVirtualThreadPerTaskExecutor")
- .invoke(null);
- return executor;
- } catch (NoSuchMethodException e) {
- log.debug("Virtual threads not available; falling back to cached thread pool.");
- } catch (Exception e) {
- log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
- }
-
- return Executors.newCachedThreadPool();
+ public static ScheduledExecutorService newSingleVirtualThreadScheduledExecutor() {
+ return Executors.newSingleThreadScheduledExecutor(
+ Thread.ofVirtual().name("scheduled-vt-", 0).factory());
}
}
diff --git a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java
index fac9bf650..09caccdb5 100644
--- a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java
+++ b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java
@@ -68,6 +68,17 @@ public class FileMonitor {
if (this.rootDirs.isEmpty()) {
log.error("No valid directories to monitor - FileMonitor will not function");
+ } else {
+ // Register directories eagerly so the first @Scheduled tick does not warn.
+ for (Path rootDir : this.rootDirs) {
+ if (Files.exists(rootDir)) {
+ try {
+ recursivelyRegisterEntry(rootDir);
+ } catch (IOException e) {
+ log.error("Failed to register monitoring for {}", rootDir, e);
+ }
+ }
+ }
}
}
diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java
index bb2adca4b..144759e55 100644
--- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java
+++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java
@@ -649,7 +649,8 @@ public class GeneralUtils {
}
public List parsePageList(String[] pages, int totalPages, boolean oneBased) {
- List result = new ArrayList<>();
+ // Use LinkedHashSet to prevent duplicates from inflating size and triggering maxSize guard
+ Set result = new LinkedHashSet<>();
int offset = oneBased ? 1 : 0;
int maxSize = Math.max(1000, totalPages * 3);
for (String page : pages) {
@@ -672,7 +673,7 @@ public class GeneralUtils {
"Page list exceeds maximum allowed size of " + maxSize);
}
}
- return result;
+ return new ArrayList<>(result);
}
/*
diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java
index dea028fdf..0e48628d8 100644
--- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java
+++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java
@@ -226,50 +226,54 @@ public class ProcessExecutor {
List outputLines = new ArrayList<>();
Thread errorReaderThread =
- new Thread(
- () -> {
- try (BufferedReader errorReader =
- new BufferedReader(
- new InputStreamReader(
- process.getErrorStream(),
- StandardCharsets.UTF_8))) {
- String line;
- while ((line =
- BoundedLineReader.readLine(
- errorReader, 5_000_000))
- != null) {
- errorLines.add(line);
- if (liveUpdates) log.info(line);
- }
- } catch (InterruptedIOException e) {
- log.warn("Error reader thread was interrupted due to timeout.");
- } catch (IOException e) {
- log.error("exception", e);
- }
- });
+ Thread.ofVirtual()
+ .unstarted(
+ () -> {
+ try (BufferedReader errorReader =
+ new BufferedReader(
+ new InputStreamReader(
+ process.getErrorStream(),
+ StandardCharsets.UTF_8))) {
+ String line;
+ while ((line =
+ BoundedLineReader.readLine(
+ errorReader, 5_000_000))
+ != null) {
+ errorLines.add(line);
+ if (liveUpdates) log.info(line);
+ }
+ } catch (InterruptedIOException e) {
+ log.warn(
+ "Error reader thread was interrupted due to timeout.");
+ } catch (IOException e) {
+ log.error("exception", e);
+ }
+ });
Thread outputReaderThread =
- new Thread(
- () -> {
- try (BufferedReader outputReader =
- new BufferedReader(
- new InputStreamReader(
- process.getInputStream(),
- StandardCharsets.UTF_8))) {
- String line;
- while ((line =
- BoundedLineReader.readLine(
- outputReader, 5_000_000))
- != null) {
- outputLines.add(line);
- if (liveUpdates) log.info(line);
- }
- } catch (InterruptedIOException e) {
- log.warn("Error reader thread was interrupted due to timeout.");
- } catch (IOException e) {
- log.error("exception", e);
- }
- });
+ Thread.ofVirtual()
+ .unstarted(
+ () -> {
+ try (BufferedReader outputReader =
+ new BufferedReader(
+ new InputStreamReader(
+ process.getInputStream(),
+ StandardCharsets.UTF_8))) {
+ String line;
+ while ((line =
+ BoundedLineReader.readLine(
+ outputReader, 5_000_000))
+ != null) {
+ outputLines.add(line);
+ if (liveUpdates) log.info(line);
+ }
+ } catch (InterruptedIOException e) {
+ log.warn(
+ "Error reader thread was interrupted due to timeout.");
+ } catch (IOException e) {
+ log.error("exception", e);
+ }
+ });
errorReaderThread.start();
outputReaderThread.start();
@@ -370,7 +374,7 @@ public class ProcessExecutor {
}
// Match common unoconvert variants (but NOT soffice)
String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT);
- if (lowerBasename.contains("unoconvert") || lowerBasename.equals("unoconv")) {
+ if (lowerBasename.contains("unoconvert") || "unoconv".equals(lowerBasename)) {
return true;
}
}
diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java
index 98cba7e8c..faaa1926f 100644
--- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java
+++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java
@@ -4,18 +4,23 @@ import java.beans.PropertyEditorSupport;
import java.util.ArrayList;
import java.util.List;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.security.RedactionArea;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
@Slf4j
public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
- private final ObjectMapper objectMapper = new ObjectMapper();
+ private final ObjectMapper objectMapper =
+ JsonMapper.builder()
+ .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .build();
@Override
public void setAsText(String text) throws IllegalArgumentException {
@@ -24,7 +29,6 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
return;
}
try {
- objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
TypeReference> typeRef = new TypeReference<>() {};
List list = objectMapper.readValue(text, typeRef);
setValue(list);
diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java
index 4a9afc2f6..63476d556 100644
--- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java
+++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java
@@ -4,12 +4,13 @@ import java.beans.PropertyEditorSupport;
import java.util.HashMap;
import java.util.Map;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
public class StringToMapPropertyEditor extends PropertyEditorSupport {
- private final ObjectMapper objectMapper = new ObjectMapper();
+ private final ObjectMapper objectMapper = JsonMapper.builder().build();
@Override
public void setAsText(String text) throws IllegalArgumentException {
diff --git a/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java b/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java
index 823a7e4d8..044e16f06 100644
--- a/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java
+++ b/app/common/src/test/java/stirling/software/common/service/SpyPDFDocumentFactory.java
@@ -12,11 +12,12 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
public StrategyType lastStrategyUsed;
public SpyPDFDocumentFactory(PdfMetadataService service) {
- super(service);
+ super(service); // TempFileManager falls back to Files.createTempFile in tests
}
@Override
- public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
+ protected StreamCacheCreateFunction getStreamCacheFunction(
+ long contentSize, MemorySnapshot mem) {
StrategyType type;
if (contentSize < 10 * 1024 * 1024) {
type = StrategyType.MEMORY_ONLY;
@@ -26,6 +27,6 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
type = StrategyType.TEMP_FILE;
}
this.lastStrategyUsed = type;
- return super.getStreamCacheFunction(contentSize); // delegate to real behavior
+ return super.getStreamCacheFunction(contentSize, mem);
}
}
diff --git a/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java b/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java
index 99ea224be..424e66a80 100644
--- a/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java
+++ b/app/common/src/test/java/stirling/software/common/util/UnoServerPoolTest.java
@@ -121,17 +121,19 @@ public class UnoServerPoolTest {
// Try to acquire a third endpoint in separate thread (should block)
Thread blockingThread =
- new Thread(
- () -> {
- try {
- acquireLatch.countDown(); // Signal we're about to block
- UnoServerPool.UnoServerLease lease3 = pool.acquireEndpoint();
- acquired.incrementAndGet();
- lease3.close();
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- });
+ Thread.ofVirtual()
+ .unstarted(
+ () -> {
+ try {
+ acquireLatch.countDown(); // Signal we're about to block
+ UnoServerPool.UnoServerLease lease3 =
+ pool.acquireEndpoint();
+ acquired.incrementAndGet();
+ lease3.close();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
blockingThread.start();
acquireLatch.await(); // Wait for thread to start
diff --git a/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java b/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java
index bda522e0f..f68879271 100644
--- a/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java
+++ b/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java
@@ -143,11 +143,21 @@ class StringToArrayListPropertyEditorTest {
}
@Test
- void testSetAsText_InvalidStructure() {
- // Arrange - this JSON doesn't match RedactionArea structure
+ void testSetAsText_UnknownProperties() {
+ // Arrange - this JSON contains properties not in RedactionArea
+ // With FAIL_ON_UNKNOWN_PROPERTIES disabled, this should ignore the unknown properties
String json = "[{\"invalid\":\"structure\"}]";
- // Act & Assert
- assertThrows(IllegalArgumentException.class, () -> editor.setAsText(json));
+ // Act
+ editor.setAsText(json);
+ Object value = editor.getValue();
+
+ // Assert
+ assertNotNull(value, "Value should not be null");
+ assertInstanceOf(List.class, value, "Value should be a List");
+
+ @SuppressWarnings("unchecked")
+ List list = (List) value;
+ assertEquals(1, list.size(), "List should have 1 entry (empty object)");
}
}
diff --git a/app/core/build.gradle b/app/core/build.gradle
index b75eaeea0..e03c487d5 100644
--- a/app/core/build.gradle
+++ b/app/core/build.gradle
@@ -46,8 +46,23 @@ dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
- implementation 'com.posthog.java:posthog:1.2.0'
- implementation 'org.telegram:telegrambots:6.9.7.1'
+ implementation 'org.eclipse.jetty.http2:jetty-http2-server'
+ implementation ('org.telegram:telegrambots:6.9.7.1') {
+ // Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
+ // Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
+ exclude group: 'org.glassfish.jersey.inject'
+ exclude group: 'org.glassfish.jersey.media'
+ exclude group: 'org.glassfish.jersey.containers'
+ exclude group: 'org.glassfish.jersey.core'
+ exclude group: 'org.glassfish.jersey.ext'
+ exclude group: 'org.glassfish.grizzly'
+ exclude group: 'org.glassfish.hk2'
+ exclude group: 'org.glassfish.hk2.external'
+ exclude group: 'org.javassist', module: 'javassist'
+ // Old javax JAX-RS Jackson bindings (not needed, project uses jakarta)
+ exclude group: 'com.fasterxml.jackson.jaxrs'
+ exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
+ }
implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
@@ -78,8 +93,9 @@ dependencies {
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
- // Batik
- implementation 'org.apache.xmlgraphics:batik-all:1.19'
+ // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
+ // Replaces batik-all which included unused codec, svggen, transcoder, script modules
+ implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
diff --git a/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java b/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java
index 2be2a082c..78abea7ff 100644
--- a/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java
+++ b/app/core/src/main/java/stirling/software/SPDF/LibreOfficeListener.java
@@ -49,8 +49,8 @@ public class LibreOfficeListener {
process = SystemCommand.runCommand(Runtime.getRuntime(), "unoconv --listener");
lastActivityTime = System.currentTimeMillis();
- // Start a background thread to monitor the activity timeout
- executorService = Executors.newSingleThreadExecutor();
+ // Start a virtual thread to monitor the activity timeout
+ executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(
() -> {
while (true) {
diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java
index 525b61e4d..c8258668d 100644
--- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java
+++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java
@@ -13,7 +13,7 @@ import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.boot.web.context.WebServerInitializedEvent;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -176,11 +176,14 @@ public class SPDFApplication {
}
@EventListener
- public void onWebServerInitialized(WebServerInitializedEvent event) {
- int actualPort = event.getWebServer().getPort();
- serverPortStatic = String.valueOf(actualPort);
+ public void onApplicationReady(ApplicationReadyEvent event) {
+ String port =
+ event.getApplicationContext().getEnvironment().getProperty("local.server.port");
+ if (port != null) {
+ serverPortStatic = port;
+ }
// Log the actual runtime port for Tauri to parse
- log.info("Stirling-PDF running on port: {}", actualPort);
+ log.info("Stirling-PDF running on port: {}", serverPortStatic);
}
private static void printStartupLogs() {
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java
index 041439232..1a75c60f3 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java
@@ -51,9 +51,7 @@ public class ExternalAppDepConfig {
*/
private final Map> commandToGroupMapping;
- private final ExecutorService pool =
- Executors.newFixedThreadPool(
- Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
+ private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java
index e57b3d5af..9625f9e67 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/MultipartConfiguration.java
@@ -1,7 +1,7 @@
package stirling.software.SPDF.config;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.web.servlet.MultipartConfigFactory;
+import org.springframework.boot.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java b/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java
index a3feded74..9ec1bb37e 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/TauriProcessMonitor.java
@@ -54,8 +54,8 @@ public class TauriProcessMonitor {
scheduler =
Executors.newSingleThreadScheduledExecutor(
r -> {
- Thread t = new Thread(r, "tauri-process-monitor");
- t.setDaemon(true);
+ Thread t =
+ Thread.ofVirtual().name("tauri-process-monitor").unstarted(r);
return t;
});
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java
index 58887e822..4622bdb68 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java
@@ -154,8 +154,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
- logger.info(
- "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
+ logger.debug(
+ "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java
index 3cdf7fa2b..c97ca1125 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java
@@ -15,9 +15,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter;
@@ -32,6 +29,9 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
+
@GeneralApi
@Slf4j
@RequiredArgsConstructor
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java
index c60965f72..b9af74ddb 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java
@@ -16,9 +16,6 @@ import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import io.swagger.v3.oas.annotations.Operation;
import lombok.Data;
@@ -35,6 +32,9 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
+
@Slf4j
@UiDataApi
public class UIDataController {
@@ -44,18 +44,21 @@ public class UIDataController {
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
+ private final ObjectMapper objectMapper;
public UIDataController(
ApplicationProperties applicationProperties,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
- RuntimePathConfig runtimePathConfig) {
+ RuntimePathConfig runtimePathConfig,
+ ObjectMapper objectMapper) {
this.applicationProperties = applicationProperties;
this.signatureService = signatureService;
this.userService = userService;
this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig;
+ this.objectMapper = objectMapper;
}
@GetMapping("/footer-info")
@@ -93,9 +96,8 @@ public class UIDataController {
try (InputStream is = resource.getInputStream()) {
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
- ObjectMapper mapper = new ObjectMapper();
Map> licenseData =
- mapper.readValue(json, new TypeReference<>() {});
+ objectMapper.readValue(json, new TypeReference<>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
@@ -127,8 +129,8 @@ public class UIDataController {
for (String config : pipelineConfigs) {
Map jsonContent =
- new ObjectMapper()
- .readValue(config, new TypeReference