Compare commits

...
Author SHA1 Message Date
Balázs SzücsandGitHub 9ac260ee92 feat(aot): add aot-diagnostics.sh for AOT cache diagnostics and validation (#5848)
# Description of Changes



This pull request makes significant improvements to the Docker build
process for the embedded Stirling-PDF image, focusing on build
efficiency, runtime optimization, and maintainability. Key changes
include upgrading major tool versions, introducing optional stripping of
Calibre's WebEngine to reduce image size, consolidating ImageMagick
layers, and refining the Python environment build process. The runtime
image is now leaner, with clearer separation between build and runtime
dependencies, and improved caching for faster builds and pulls.

**Build and Dependency Management Improvements**
* Upgraded Calibre to version `9.4.0` and added support for the
`TARGETPLATFORM` build argument for multi-platform builds.
* Added an optional `CALIBRE_STRIP_WEBENGINE` build argument to strip
Chromium/WebEngine from Calibre, saving ~80 MB when PDF output via
Calibre is not needed.
* Consolidated ImageMagick outputs into a single staging directory
(`/magick-export`) to reduce Docker layers and improve caching
efficiency.
* Refactored Python virtual environment build: now built in a dedicated
stage with pre-built wheels and copied into the runtime image,
eliminating the need for build tools and pip installs at runtime.

**Runtime Image Optimization**
* Reduced installed system packages to only what is needed at runtime;
Python build tools and dev packages are no longer included.
* Cleaned up unnecessary runtime files, including removal of build-only
Python artifacts and system headers, for a smaller and more secure
image.

**Layer and Copy Optimization**
* Switched to `COPY --link` for all major external tool layers and
application files, enabling independent layer caching and parallel pulls
for faster builds.

**Runtime Configuration and Health**
* Improved runtime directory structure and permissions, added persistent
cache directories for Project Leyden AOT, and wrote the version tag to
`/etc/stirling_version` for easier script access.
* Updated the healthcheck to wait longer for startup and increased
timeout/retries for more robust readiness detection.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-03-03 19:06:46 +00:00
Anthony StirlingandGitHub 1b68a513a9 tauri jdk25 and docs (#5814) 2026-03-03 13:49:33 +00:00
93d7919c4c Fix split tooltips (#5847)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-03-03 13:49:05 +00:00
ConnorYohandGitHub 3e4c984fcc Add check for ghostscript before plowing on with removeDataOutsideCrop (#5845) 2026-03-03 12:52:28 +00:00
ConnorYohandGitHub c4c43593e6 fallback for /api/v1/config/endpoints-availability (#5842) 2026-03-02 22:03:23 +00:00
fd1b7abc83 refactor(merge,split,json): adopt streaming approach and standardize types, address gradle warnings (#5803)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Balázs <balazs@heim-041-30.jkh.uni-linz.ac.at>
2026-03-02 21:55:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Anthony Stirling
0c46f77179 build(deps): bump com.sun.xml.bind:jaxb-core from 2.3.0.1 to 4.0.6 (#5365)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-02 16:16:05 +00:00
ConnorYohandGitHub afda066579 Frontend and Desktop audit fixes (#5840) 2026-03-02 15:44:05 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e9148437f6 build(deps-dev): bump stylelint from 16.26.1 to 17.4.0 in /devTools (#5822)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 15:23:26 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cb835bcce1 build(deps): bump ajv from 8.17.1 to 8.18.0 in /devTools in the npm_and_yarn group across 1 directory (#5774)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 15:17:53 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
161bfef7da build(deps): bump actions/ai-inference from 2.0.5 to 2.0.7 (#5831)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 15:14:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
690eceb548 build(deps): bump github/codeql-action from 4.31.10 to 4.32.4 (#5833)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 15:13:52 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b53c236234 build(deps): bump pypdf from 6.6.2 to 6.7.4 in /testing/cucumber (#5825)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 15:12:30 +00:00
Anthony StirlingandGitHub abb8b1f721 Fix health status checks falling under mettric flag (#5821) 2026-03-02 13:56:51 +00:00
012bd1af92 hardening (#5807)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 13:56:39 +00:00
Anthony StirlingandGitHub 8b25db37ad fix split cuased by defaultParameters breaking dynamic endpoint tools (#5838) 2026-03-02 13:55:58 +00:00
Balázs SzücsandGitHub 48dd4154e9 feat(conversion): switch PDF input engine to pdftohtml for improved performance and reduced dependencies (#5820) 2026-03-02 13:55:42 +00:00
StepSecurity BotandGitHub cfe040485b [StepSecurity] Apply security best practices (#5830) 2026-03-01 17:16:03 +00:00
Balázs SzücsandGitHub c15ff1e832 fix(aot): use Spring Boot exploded layer format for aot cache (#5811) 2026-03-01 15:46:02 +00:00
Balázs SzücsandGitHub c244edf8b7 feat(annotation): add moveAnnotation API for efficient repositioning of annotations, and bump embed to 2.7.0 (#5809) 2026-03-01 15:45:44 +00:00
Anthony StirlingandGitHub 13d7ee7496 skip certs (#5819)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-27 17:31:40 +00:00
Anthony StirlingandGitHub 930d7a0df8 open-saml bumps (#5805) 2026-02-27 15:03:12 +00:00
Anthony StirlingandGitHub 6a1597bb8d ci: provide default desktop env vars in tauri GitHub Actions builds (#5815) 2026-02-27 10:57:50 +00:00
aikido-autofix[bot]GitHubaikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
d98ff194e4 [Aikido] AI Fix for 3rd party Github Actions should be pinned (#5817)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-02-27 10:42:51 +00:00
125 changed files with 4333 additions and 2819 deletions
+5
View File
@@ -68,3 +68,8 @@ updates:
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: cargo
directory: /frontend/src-tauri/provisioner
schedule:
interval: daily
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+7 -7
View File
@@ -69,7 +69,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
@@ -147,14 +147,14 @@ 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: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
@@ -236,7 +236,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
@@ -309,7 +309,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
@@ -328,7 +328,7 @@ jobs:
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
uses: crazy-max/ghaction-github-runtime@v3
uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0
- name: Install Docker Compose
run: |
@@ -416,7 +416,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
+4 -3
View File
@@ -49,7 +49,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
@@ -419,8 +419,9 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
# Only enable Windows signing in Tauri when on release or V2-master
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
+2 -2
View File
@@ -59,10 +59,10 @@ jobs:
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
continue-on-error: true
- 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
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
+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@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
with:
sarif_file: results.sarif
+8 -7
View File
@@ -73,6 +73,7 @@ jobs:
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
@@ -101,10 +102,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
@@ -271,7 +272,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -292,7 +293,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -327,9 +328,9 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
+2 -2
View File
@@ -32,10 +32,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
+1 -1
View File
@@ -308,7 +308,7 @@ The frontend is organized with a clear separation of concerns:
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
- **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 (Java 17+, JDK 21 recommended)
- Spring Boot (Java 21+, JDK 25 recommended)
- PDFBox for core PDF operations
- LibreOffice for document conversions
- qpdf for PDF optimization
@@ -44,7 +44,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Docker
- Git
- Java JDK 17 or later (JDK 21 recommended)
- 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)
- Rust and Cargo (required for Tauri desktop app development)
@@ -59,7 +59,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
cd Stirling-PDF
```
2. Install Docker and JDK17 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 -1
View File
@@ -5,10 +5,10 @@ bootRun {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/java/org/apache/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -27,6 +27,7 @@ spotless {
}
}
dependencies {
api 'com.google.guava:guava:33.4.8-jre'
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:20260102.1'
@@ -103,8 +103,9 @@ public class EndpointConfiguration {
// Rule 2: Functional-group override - check if endpoint belongs to any disabled functional
// group
for (String group : endpointGroups.keySet()) {
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
// Skip tool groups (qpdf, OCRmyPDF, Ghostscript, LibreOffice, etc.)
if (!isToolGroup(group)) {
log.debug(
@@ -131,10 +132,11 @@ public class EndpointConfiguration {
// Rule 4: Single-dependency check - if no alternatives defined, check if endpoint belongs
// to any disabled tool groups
for (String group : endpointGroups.keySet()) {
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
if (isToolGroup(group)
&& disabledGroups.contains(group)
&& endpointGroups.get(group).contains(endpoint)) {
&& entry.getValue().contains(endpoint)) {
log.debug(
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
original,
@@ -645,8 +647,9 @@ public class EndpointConfiguration {
}
// Check if endpoint belongs to any disabled functional group
for (String group : endpointGroups.keySet()) {
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
if (!isToolGroup(group)) {
return false;
}
@@ -365,11 +365,11 @@ public class ApplicationProperties {
}
public boolean isSettingsValid() {
return !ValidationUtils.isStringEmpty(this.getIssuer())
&& !ValidationUtils.isStringEmpty(this.getClientId())
&& !ValidationUtils.isStringEmpty(this.getClientSecret())
&& !ValidationUtils.isCollectionEmpty(this.getScopes())
&& !ValidationUtils.isStringEmpty(this.getUseAsUsername());
return !ValidationUtils.isStringEmpty(this.issuer)
&& !ValidationUtils.isStringEmpty(this.clientId)
&& !ValidationUtils.isStringEmpty(this.clientSecret)
&& !ValidationUtils.isCollectionEmpty(this.scopes)
&& !ValidationUtils.isStringEmpty(this.useAsUsername);
}
@Data
@@ -575,19 +575,17 @@ public class ApplicationProperties {
}
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
return this.enableAnalytics != null && this.enableAnalytics;
}
public boolean isPosthogEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
return this.isAnalyticsEnabled() && (this.enablePosthog == null || this.enablePosthog);
}
public boolean isScarfEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnableScarf() == null || this.getEnableScarf());
return this.isAnalyticsEnabled() && (this.enableScarf == null || this.enableScarf);
}
}
@@ -42,7 +42,7 @@ public enum Role {
// Using the fromString method to get the Role enum based on the roleId
Role role = fromString(roleId);
// Return the roleName of the found Role enum
return role.getRoleName();
return role.roleName;
}
// Method to retrieve all role IDs and role names
@@ -50,14 +50,14 @@ public enum Role {
// Using LinkedHashMap to preserve order
Map<String, String> roleDetails = new LinkedHashMap<>();
for (Role role : Role.values()) {
roleDetails.put(role.getRoleId(), role.getRoleName());
roleDetails.put(role.roleId, role.roleName);
}
return roleDetails;
}
public static Role fromString(String roleId) {
for (Role role : Role.values()) {
if (role.getRoleId().equalsIgnoreCase(roleId)) {
if (role.roleId.equalsIgnoreCase(roleId)) {
return role;
}
}
@@ -117,13 +117,13 @@ public class Provider {
+ ", clientName="
+ getClientName()
+ ", clientId="
+ getClientId()
+ clientId
+ ", clientSecret="
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
+ (clientSecret != null && !clientSecret.isEmpty() ? "*****" : "NULL")
+ ", scopes="
+ getScopes()
+ ", useAsUsername="
+ getUseAsUsername()
+ useAsUsername
+ "]";
}
}
@@ -1,6 +1,8 @@
package stirling.software.common.service;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
@@ -21,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class FileStorage {
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
public record StoredFile(String fileId, long size) {}
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
@@ -104,6 +109,40 @@ public class FileStorage {
return Files.readAllBytes(filePath);
}
/**
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
* the returned stream.
*
* @param fileId The ID of the file to retrieve
* @return A buffered InputStream for the file
* @throws IOException If the file doesn't exist or can't be read
*/
public InputStream retrieveInputStream(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
// between exists-check and open when another thread may delete concurrently.
return new BufferedInputStream(Files.newInputStream(filePath));
}
/**
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
* directly to disk without buffering the entire content in heap.
*
* @param inputStream The input stream to read from
* @param originalName The original name of the file (unused, kept for API symmetry)
* @return A {@link StoredFile} containing the file ID and the number of bytes written
* @throws IOException If there is an error storing the file
*/
public StoredFile storeInputStream(InputStream inputStream, String originalName)
throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.copy(inputStream, filePath);
log.debug("Stored input stream with ID: {}", fileId);
return new StoredFile(fileId, size);
}
/**
* Delete a file by its ID
*
@@ -316,7 +316,7 @@ public class JobExecutorService {
filename =
disposition.substring(
disposition.indexOf("filename=") + 9,
disposition.lastIndexOf("\""));
disposition.lastIndexOf('"'));
}
}
@@ -129,7 +129,7 @@ public class MobileScannerService {
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
? safeFilename.substring(safeFilename.lastIndexOf('.'))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
@@ -1,8 +1,8 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
@@ -19,7 +19,6 @@ import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.ZipSecurity;
@@ -363,39 +362,29 @@ public class TaskManager {
String zipFileId, String originalZipFileName) throws IOException {
List<ResultFile> extractedFiles = new ArrayList<>();
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new ByteArrayInputStream(zipFile.getBytes()))) {
try (InputStream fileStream = fileStorage.retrieveInputStream(zipFileId);
ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new BufferedInputStream(fileStream))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
// Use buffered reading for memory safety
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = zipIn.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
byte[] fileContent = out.toByteArray();
String contentType = determineContentType(entry.getName());
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
// storeInputStream returns the fileId and byte count — no extra stat needed
FileStorage.StoredFile stored =
fileStorage.storeInputStream(zipIn, entry.getName());
ResultFile resultFile =
ResultFile.builder()
.fileId(individualFileId)
.fileId(stored.fileId())
.fileName(entry.getName())
.contentType(contentType)
.fileSize(fileContent.length)
.fileSize(stored.size())
.build();
extractedFiles.add(resultFile);
log.debug(
"Extracted file: {} (size: {} bytes)",
entry.getName(),
fileContent.length);
"Extracted file: {} (size: {} bytes)", entry.getName(), stored.size());
}
zipIn.closeEntry();
}
@@ -467,4 +456,24 @@ public class TaskManager {
}
return null;
}
/**
* Find the job key that owns a given file ID.
*
* @param fileId file identifier to look up
* @return scoped job key if found, otherwise null
*/
public String findJobKeyByFileId(String fileId) {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult jobResult = entry.getValue();
if (jobResult.hasFiles()) {
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
if (fileId.equals(resultFile.getFileId())) {
return entry.getKey();
}
}
}
}
return null;
}
}
@@ -4,6 +4,7 @@ import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
@@ -30,15 +31,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class CbzUtils {
public byte[] convertCbzToPdf(
MultipartFile cbzFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
throws IOException {
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
}
public byte[] convertCbzToPdf(
public TempFile convertCbzToPdf(
MultipartFile cbzFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
@@ -64,70 +57,90 @@ public class CbzUtils {
try (PDDocument document = pdfDocumentFactory.createNewDocument();
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
// Pass 1: collect sorted image names (cheap just strings, no image data)
List<String> sortedImageNames = new ArrayList<>();
Enumeration<? extends ZipEntry> entries = zipFile.entries();
List<ImageEntryData> imageEntries = new ArrayList<>();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.isDirectory() && isImageFile(entry.getName())) {
try (InputStream is = zipFile.getInputStream(entry)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
is.transferTo(baos);
imageEntries.add(
new ImageEntryData(entry.getName(), baos.toByteArray()));
} catch (IOException e) {
log.warn("Error reading image {}: {}", entry.getName(), e.getMessage());
}
sortedImageNames.add(entry.getName());
}
}
sortedImageNames.sort(new NaturalOrderComparator());
imageEntries.sort(
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
if (imageEntries.isEmpty()) {
if (sortedImageNames.isEmpty()) {
throw ExceptionUtils.createCbzNoImagesException();
}
for (ImageEntryData imageEntry : imageEntries) {
try {
PDImageXObject pdImage =
PDImageXObject.createFromByteArray(
document, imageEntry.data(), imageEntry.name());
PDPage page =
new PDPage(
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0);
// Pass 2: load ONE image at a time peak memory = max(single image)
for (String imageName : sortedImageNames) {
ZipEntry entry = zipFile.getEntry(imageName);
try (InputStream is = zipFile.getInputStream(entry)) {
ByteArrayOutputStream imgBaos = new ByteArrayOutputStream();
is.transferTo(imgBaos);
byte[] imageBytes = imgBaos.toByteArray();
try {
PDImageXObject pdImage =
PDImageXObject.createFromByteArray(
document, imageBytes, imageName);
PDPage page =
new PDPage(
new PDRectangle(
pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0);
}
} catch (IOException e) {
log.warn("Error processing image {}: {}", imageName, e.getMessage());
}
// imageBytes eligible for GC after each iteration
} catch (IOException e) {
log.warn(
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
log.warn("Error reading image {}: {}", imageName, e.getMessage());
}
}
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createCbzCorruptedImagesException();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] pdfBytes = baos.toByteArray();
// Apply Ghostscript optimization if requested
if (optimizeForEbook) {
try {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
// Write to TempFile (not BAOS)
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf");
try {
document.save(pdfTempFile.getFile());
if (optimizeForEbook) {
try {
byte[] pdfBytes = Files.readAllBytes(pdfTempFile.getPath());
byte[] optimized = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
pdfTempFile.close();
TempFile optimizedFile = new TempFile(tempFileManager, ".pdf");
try {
Files.write(optimizedFile.getPath(), optimized);
return optimizedFile;
} catch (Exception e) {
optimizedFile.close();
throw e;
}
} catch (IOException e) {
log.warn(
"Ghostscript optimization failed, returning unoptimized PDF",
e);
}
}
}
return pdfBytes;
return pdfTempFile;
} catch (Exception e) {
pdfTempFile.close();
throw e;
}
}
}
}
@@ -175,8 +188,6 @@ public class CbzUtils {
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
}
private record ImageEntryData(String name, byte[] data) {}
private class NaturalOrderComparator implements Comparator<String> {
@Override
public int compare(String s1, String s2) {
@@ -44,9 +44,7 @@ public class FileToPdf {
sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8),
customHtmlSanitizer);
Files.write(
tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
Files.writeString(tempInputFile.getPath(), sanitizedHtml);
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip(
@@ -115,8 +113,7 @@ public class FileToPdf {
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
String sanitizedContent =
sanitizeHtmlContent(content, customHtmlSanitizer);
Files.write(
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
Files.writeString(filePath, sanitizedContent);
} else {
Files.copy(zipIn, filePath);
}
@@ -17,6 +17,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
@@ -74,6 +75,10 @@ public class FormUtils {
*/
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
private static final Pattern HEX_UUID_PATTERN =
Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
/**
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
* so all callers share identical mapping logic.
@@ -1357,7 +1362,7 @@ public class FormUtils {
if (da != null && !da.isBlank()) {
// Standard DA looks like: /Helv 12 Tf 0 g
// We want the number before 'Tf'
String[] tokens = da.split("\\s+");
String[] tokens = WHITESPACE_PATTERN.split(da);
for (int i = 0; i < tokens.length; i++) {
if ("Tf".equals(tokens[i]) && i > 0) {
try {
@@ -1457,9 +1462,8 @@ public class FormUtils {
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
// Standard UUIDs are 32 hex characters; require at least that to avoid
// false positives on short hex-like field names.
String nospaces = simplified.replaceAll("\\s+", "");
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
return true;
String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll("");
if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true;
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
@@ -10,6 +10,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -33,6 +34,8 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
public class PDFToFile {
private static final Pattern PATTERN =
Pattern.compile("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)");
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@@ -163,7 +166,7 @@ public class PDFToFile {
private String updateImageReferences(String markdown) {
// Match markdown image syntax: ![alt text](image.png)
// Only update if the path doesn't already start with images/
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
@@ -1,8 +1,8 @@
package stirling.software.common.util;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -22,8 +22,11 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
public class PdfToCbzUtils {
public static byte[] convertPdfToCbz(
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
public static TempFile convertPdfToCbz(
MultipartFile pdfFile,
int dpi,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
throws IOException {
validatePdfFile(pdfFile);
@@ -33,7 +36,7 @@ public class PdfToCbzUtils {
throw ExceptionUtils.createPdfNoPages();
}
return createCbzFromPdf(document, dpi);
return createCbzFromPdf(document, dpi, tempFileManager);
}
}
@@ -53,46 +56,49 @@ public class PdfToCbzUtils {
}
}
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
private static TempFile createCbzFromPdf(
PDDocument document, int dpi, TempFileManager tempFileManager) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
TempFile cbzTempFile = new TempFile(tempFileManager, ".cbz");
try {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(cbzTempFile.getPath()))) {
int totalPages = document.getNumberOfPages();
int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
ZipEntry zipEntry = new ZipEntry(imageFilename);
zipOut.putNextEntry(zipEntry);
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
zipOut.putNextEntry(new ZipEntry(imageFilename));
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBZ creation for page " + (currentPage + 1), e);
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBZ creation for page " + (currentPage + 1), e);
}
}
}
zipOut.finish();
return cbzOutputStream.toByteArray();
return cbzTempFile;
} catch (Exception e) {
cbzTempFile.close();
throw e;
}
}
@@ -219,58 +219,31 @@ public class PdfUtils {
int maxWidth = 0;
int totalHeight = 0;
BufferedImage pdfSizeImage = null;
int pdfSizeImageIndex = -1;
// Using a map to store the rendered dimensions of each page size
// to avoid rendering the same page sizes multiple times
// Using a map to store the calculated dimensions of each page size
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
new HashMap<>();
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
PDPage page = document.getPage(i);
PDRectangle mediaBox = page.getMediaBox();
PDRectangle cropBox = page.getCropBox();
int rotation = page.getRotation();
PdfRenderSettingsKey settings =
new PdfRenderSettingsKey(
mediaBox.getWidth(), mediaBox.getHeight(), rotation);
cropBox.getWidth(), cropBox.getHeight(), rotation);
PdfImageDimensionValue dimension = pageSizes.get(settings);
if (dimension == null) {
// Render the image to get the dimensions
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
page, pageIndex + 1, DPI);
pdfSizeImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigExceedsArray",
"PDF page {0} is too large to render at {1} DPI. The"
+ " resulting image would exceed Java's maximum"
+ " array size. Please try a lower DPI value"
+ " (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
float scale = DPI / 72f;
int widthPx = (int) Math.max(Math.floor(cropBox.getWidth() * scale), 1);
int heightPx =
(int) Math.max(Math.floor(cropBox.getHeight() * scale), 1);
if (rotation == 90 || rotation == 270) {
int tmp = widthPx;
widthPx = heightPx;
heightPx = tmp;
}
pdfSizeImageIndex = i;
dimension =
new PdfImageDimensionValue(
pdfSizeImage.getWidth(), pdfSizeImage.getHeight());
dimension = new PdfImageDimensionValue(widthPx, heightPx);
pageSizes.put(settings, dimension);
if (pdfSizeImage.getWidth() > maxWidth) {
maxWidth = pdfSizeImage.getWidth();
if (widthPx > maxWidth) {
maxWidth = widthPx;
}
}
totalHeight += dimension.height();
@@ -284,40 +257,32 @@ public class PdfUtils {
int currentHeight = 0;
BufferedImage pageImage;
// Check if the first image is the last rendered image
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
if (firstImageAlreadyRendered && i == 0) {
pageImage = pdfSizeImage;
} else {
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
pageImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please"
+ " try a lower DPI value (recommended: 150 or"
+ " less).",
i + 1,
DPI);
}
throw e;
pageImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please"
+ " try a lower DPI value (recommended: 150 or"
+ " less).",
i + 1,
DPI);
}
throw e;
}
// Calculate the x-coordinate to center the image
@@ -191,7 +191,7 @@ public class YamlHelper {
mappingNode.getValue().clear();
mappingNode.getValue().addAll(updatedTuples);
}
setNewNode(node);
updatedRootNode = node;
return updated;
}
@@ -19,7 +19,7 @@ public abstract class ReplaceAndInvertColorStrategy extends PDFFile {
public ReplaceAndInvertColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
setFileInput(file);
setReplaceAndInvert(replaceAndInvert);
this.replaceAndInvert = replaceAndInvert;
}
public abstract InputStreamResource replace() throws IOException;
+3 -4
View File
@@ -12,11 +12,10 @@ configurations {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/resources/static/**'
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -82,7 +81,7 @@ dependencies {
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
@@ -91,7 +90,7 @@ dependencies {
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
exclude group: 'com.google.code.gson', module: 'gson'
}
// CVE-2022-25647: Explicit gson 2.8.9 to prevent unsafe deserialization (tabula would pull 2.8.7)
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
@@ -88,10 +88,10 @@ public abstract class CreateSignatureBase implements SignatureInterface {
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
alias = aliases.nextElement();
setPrivateKey((PrivateKey) keystore.getKey(alias, pin));
privateKey = (PrivateKey) keystore.getKey(alias, pin);
Certificate[] certChain = keystore.getCertificateChain(alias);
if (certChain != null) {
setCertificateChain(certChain);
certificateChain = certChain;
cert = certChain[0];
if (cert instanceof X509Certificate) {
// avoid expired certificate
@@ -134,7 +134,7 @@ public class SPDFApplication {
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
String url = buildFullUrl(baseUrlStatic, serverPortStatic, contextPathStatic);
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -188,7 +188,7 @@ public class SPDFApplication {
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
String url = buildFullUrl(baseUrlStatic, serverPortStatic, contextPathStatic);
log.info("Navigate to {}", url);
}
@@ -104,7 +104,7 @@ public class EndpointInspector implements ApplicationListener<ContextRefreshedEv
String infoString = mappingInfo.toString();
if (infoString.contains("{")) {
String patternsSection =
infoString.substring(infoString.indexOf("{") + 1, infoString.indexOf("}"));
infoString.substring(infoString.indexOf('{') + 1, infoString.indexOf('}'));
for (String pattern : patternsSection.split(",")) {
pattern = pattern.trim();
@@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
@@ -118,6 +119,12 @@ public class CropController {
return r >= threshold && g >= threshold && b >= threshold;
}
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Crops a PDF document",
@@ -137,7 +144,7 @@ public class CropController {
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
if (request.isRemoveDataOutsideCrop()) {
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
return cropWithGhostscript(request);
} else {
return cropWithPDFBox(request);
@@ -1,7 +1,7 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
@@ -14,10 +14,10 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -31,6 +31,8 @@ import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -39,10 +41,9 @@ import stirling.software.common.util.WebResponseUtils;
public class PosterPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
value = "/split-for-poster-print",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(value = "/split-for-poster-print", consumes = "multipart/form-data")
@MultiFileResponse
@Operation(
summary = "Split large PDF pages into smaller printable chunks",
@@ -51,7 +52,7 @@ public class PosterPdfController {
+ "suitable for printing on standard paper sizes (e.g., A4, Letter). "
+ "Divides each page into a grid of smaller pages using Apache PDFBox. "
+ "Input: PDF Output: ZIP-PDF Type: SISO")
public ResponseEntity<byte[]> posterPdf(@ModelAttribute PosterPdfRequest request)
public ResponseEntity<StreamingResponseBody> posterPdf(@ModelAttribute PosterPdfRequest request)
throws Exception {
log.debug("Starting PDF poster split process with request: {}", request);
@@ -60,184 +61,187 @@ public class PosterPdfController {
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
log.debug("Base filename for output: {}", filename);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream();
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream()) {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf")) {
// Get target page size
PDRectangle targetPageSize = getTargetPageSize(request.getPageSize());
log.debug(
"Target page size: {} ({}x{})",
request.getPageSize(),
targetPageSize.getWidth(),
targetPageSize.getHeight());
// Get target page size
PDRectangle targetPageSize = getTargetPageSize(request.getPageSize());
log.debug(
"Target page size: {} ({}x{})",
request.getPageSize(),
targetPageSize.getWidth(),
targetPageSize.getHeight());
// Create LayerUtility for importing pages as forms
LayerUtility layerUtility = new LayerUtility(outputDocument);
// Create LayerUtility for importing pages as forms
LayerUtility layerUtility = new LayerUtility(outputDocument);
int totalPages = sourceDocument.getNumberOfPages();
int xFactor = request.getXFactor();
int yFactor = request.getYFactor();
boolean rightToLeft = request.isRightToLeft();
log.debug(
"Processing {} pages with grid {}x{}, RTL={}",
totalPages,
xFactor,
yFactor,
rightToLeft);
// Process each page
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
// Get both MediaBox and CropBox
PDRectangle mediaBox = sourcePage.getMediaBox();
PDRectangle cropBox = sourcePage.getCropBox();
// If no CropBox is set, use MediaBox
if (cropBox == null) {
cropBox = mediaBox;
}
// Save original boxes for restoration
PDRectangle originalMediaBox = sourcePage.getMediaBox();
PDRectangle originalCropBox = sourcePage.getCropBox();
// Normalize the page: set MediaBox to CropBox
// This ensures the form's coordinate space starts at (0, 0)
// instead of having an offset from the original MediaBox
sourcePage.setMediaBox(cropBox);
sourcePage.setCropBox(cropBox);
// Handle page rotation
int rotation = sourcePage.getRotation();
float sourceWidth = cropBox.getWidth();
float sourceHeight = cropBox.getHeight();
// Swap dimensions if rotated 90 or 270 degrees
if (rotation == 90 || rotation == 270) {
float temp = sourceWidth;
sourceWidth = sourceHeight;
sourceHeight = temp;
}
int totalPages = sourceDocument.getNumberOfPages();
int xFactor = request.getXFactor();
int yFactor = request.getYFactor();
boolean rightToLeft = request.isRightToLeft();
log.debug(
"Page {}: Normalized to CropBox dimensions {}x{}, rotation {}",
pageIndex,
sourceWidth,
sourceHeight,
rotation);
"Processing {} pages with grid {}x{}, RTL={}",
totalPages,
xFactor,
yFactor,
rightToLeft);
// Import source page as form (now with normalized coordinate space)
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
// Process each page
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
// Restore original boxes
sourcePage.setMediaBox(originalMediaBox);
sourcePage.setCropBox(originalCropBox);
// Get both MediaBox and CropBox
PDRectangle mediaBox = sourcePage.getMediaBox();
PDRectangle cropBox = sourcePage.getCropBox();
// Calculate cell dimensions in source page coordinates
float cellWidth = sourceWidth / xFactor;
float cellHeight = sourceHeight / yFactor;
// If no CropBox is set, use MediaBox
if (cropBox == null) {
cropBox = mediaBox;
}
// Create grid cells (rows × columns)
for (int row = 0; row < yFactor; row++) {
for (int col = 0; col < xFactor; col++) {
// Apply RTL ordering for columns if enabled
int actualCol = rightToLeft ? (xFactor - 1 - col) : col;
// Save original boxes for restoration
PDRectangle originalMediaBox = sourcePage.getMediaBox();
PDRectangle originalCropBox = sourcePage.getCropBox();
// Calculate crop rectangle in source coordinates
// PDF coordinates start at bottom-left
float cropX = actualCol * cellWidth;
// For Y: invert so row 0 shows TOP (following SplitPdfBySectionsController
// pattern)
float cropY = (yFactor - 1 - row) * cellHeight;
// Normalize the page: set MediaBox to CropBox
// This ensures the form's coordinate space starts at (0, 0)
// instead of having an offset from the original MediaBox
sourcePage.setMediaBox(cropBox);
sourcePage.setCropBox(cropBox);
// Create new output page with target size
PDPage outputPage = new PDPage(targetPageSize);
outputDocument.addPage(outputPage);
// Handle page rotation
int rotation = sourcePage.getRotation();
float sourceWidth = cropBox.getWidth();
float sourceHeight = cropBox.getHeight();
try (PDPageContentStream contentStream =
new PDPageContentStream(
outputDocument,
outputPage,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Swap dimensions if rotated 90 or 270 degrees
if (rotation == 90 || rotation == 270) {
float temp = sourceWidth;
sourceWidth = sourceHeight;
sourceHeight = temp;
}
// Calculate uniform scale to fit cell into target page
// Scale UP if cell is smaller than target, scale DOWN if larger
float scaleX = targetPageSize.getWidth() / cellWidth;
float scaleY = targetPageSize.getHeight() / cellHeight;
float scale = Math.min(scaleX, scaleY);
log.debug(
"Page {}: Normalized to CropBox dimensions {}x{}, rotation {}",
pageIndex,
sourceWidth,
sourceHeight,
rotation);
// Center the scaled content on the target page
float scaledCellWidth = cellWidth * scale;
float scaledCellHeight = cellHeight * scale;
float offsetX = (targetPageSize.getWidth() - scaledCellWidth) / 2;
float offsetY = (targetPageSize.getHeight() - scaledCellHeight) / 2;
// Import source page as form (now with normalized coordinate space)
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
// Apply transformations
contentStream.saveGraphicsState();
// Restore original boxes
sourcePage.setMediaBox(originalMediaBox);
sourcePage.setCropBox(originalCropBox);
// Translate to center position
contentStream.transform(Matrix.getTranslateInstance(offsetX, offsetY));
// Calculate cell dimensions in source page coordinates
float cellWidth = sourceWidth / xFactor;
float cellHeight = sourceHeight / yFactor;
// Scale uniformly
contentStream.transform(Matrix.getScaleInstance(scale, scale));
// Create grid cells (rows × columns)
for (int row = 0; row < yFactor; row++) {
for (int col = 0; col < xFactor; col++) {
// Apply RTL ordering for columns if enabled
int actualCol = rightToLeft ? (xFactor - 1 - col) : col;
// Translate to show only the desired grid cell
// IMPORTANT: The PDFormXObject's BBox already matches the CropBox
// (including its offset), so we only need to translate by cropX/cropY
// relative to the CropBox origin, NOT the MediaBox origin
contentStream.transform(Matrix.getTranslateInstance(-cropX, -cropY));
// Calculate crop rectangle in source coordinates
// PDF coordinates start at bottom-left
float cropX = actualCol * cellWidth;
// For Y: invert so row 0 shows TOP (following
// SplitPdfBySectionsController
// pattern)
float cropY = (yFactor - 1 - row) * cellHeight;
// Draw the form
contentStream.drawForm(form);
// Create new output page with target size
PDPage outputPage = new PDPage(targetPageSize);
outputDocument.addPage(outputPage);
contentStream.restoreGraphicsState();
try (PDPageContentStream contentStream =
new PDPageContentStream(
outputDocument,
outputPage,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Calculate uniform scale to fit cell into target page
// Scale UP if cell is smaller than target, scale DOWN if larger
float scaleX = targetPageSize.getWidth() / cellWidth;
float scaleY = targetPageSize.getHeight() / cellHeight;
float scale = Math.min(scaleX, scaleY);
// Center the scaled content on the target page
float scaledCellWidth = cellWidth * scale;
float scaledCellHeight = cellHeight * scale;
float offsetX = (targetPageSize.getWidth() - scaledCellWidth) / 2;
float offsetY = (targetPageSize.getHeight() - scaledCellHeight) / 2;
// Apply transformations
contentStream.saveGraphicsState();
// Translate to center position
contentStream.transform(
Matrix.getTranslateInstance(offsetX, offsetY));
// Scale uniformly
contentStream.transform(Matrix.getScaleInstance(scale, scale));
// Translate to show only the desired grid cell
// IMPORTANT: The PDFormXObject's BBox already matches the CropBox
// (including its offset), so we only need to translate by
// cropX/cropY
// relative to the CropBox origin, NOT the MediaBox origin
contentStream.transform(
Matrix.getTranslateInstance(-cropX, -cropY));
// Draw the form
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
log.trace(
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
row,
actualCol,
pageIndex,
cropX,
cropY,
-cropX,
-cropY);
}
log.trace(
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
row,
actualCol,
pageIndex,
cropX,
cropY,
-cropX,
-cropY);
}
}
// Save output PDF to intermediate TempFile
outputDocument.save(pdfTempFile.getFile());
log.debug("Generated output PDF with {} pages", outputDocument.getNumberOfPages());
// Create ZIP from the PDF TempFile, streaming directly to zip TempFile
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
zipOut.putNextEntry(new ZipEntry(filename + "_poster.pdf"));
Files.copy(pdfTempFile.getPath(), zipOut);
zipOut.closeEntry();
}
// pdfTempFile auto-closed and deleted here (end of inner try-with-resources)
}
// Save output PDF
outputDocument.save(pdfOutputStream);
byte[] pdfData = pdfOutputStream.toByteArray();
log.debug(
"Generated output PDF with {} pages ({} bytes)",
outputDocument.getNumberOfPages(),
pdfData.length);
// Create ZIP file with the result
try (ZipOutputStream zipOut = new ZipOutputStream(zipOutputStream)) {
ZipEntry zipEntry = new ZipEntry(filename + "_poster.pdf");
zipOut.putNextEntry(zipEntry);
zipOut.write(pdfData);
zipOut.closeEntry();
}
byte[] zipData = zipOutputStream.toByteArray();
log.debug("Successfully created ZIP with {} bytes", zipData.length);
return WebResponseUtils.bytesToWebResponse(
zipData, filename + "_poster.zip", MediaType.APPLICATION_OCTET_STREAM);
log.debug("Successfully created ZIP");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_poster.zip");
} catch (IOException e) {
ExceptionUtils.logException("PDF poster split process", e);
zipTempFile.close();
throw e;
} catch (Exception e) {
zipTempFile.close();
throw e;
}
}
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
@@ -10,11 +9,11 @@ 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.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -49,83 +48,60 @@ public class SplitPDFController {
+ " specified page numbers or ranges. Users can specify pages using"
+ " individual numbers, ranges, or 'all' for every page. Input:PDF"
+ " Output:PDF Type:SIMO")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
public ResponseEntity<StreamingResponseBody> splitPdf(@ModelAttribute PDFWithPageNums request)
throws IOException {
MultipartFile file = request.getFileInput();
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
PDDocument document = pdfDocumentFactory.load(file)) {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
// Create a mutable ArrayList so we can add to it
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
log.debug(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
splitDocumentsBoas = new ArrayList<>(pageNumbers.size());
int previousPageNumber = 0;
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", i);
}
previousPageNumber = splitPoint + 1;
// Transfer metadata to split pdf
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
try {
try (PDDocument document = pdfDocumentFactory.load(file)) {
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
}
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
log.debug(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
int splitDocumentsSize = splitDocumentsBoas.size();
for (int i = 0; i < splitDocumentsSize; i++) {
StringBuilder sb = new StringBuilder(baseFilename.length() + 10);
sb.append(baseFilename).append('_').append(i + 1).append(".pdf");
String fileName = sb.toString();
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
splitDocument.addPage(document.getPage(i));
log.debug("Adding page {} to split document", i);
}
previousPageNumber = splitPoint + 1;
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
String fileName = baseFilename + "_" + (splitIndex + 1) + ".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.debug(
"Successfully created zip file with split documents: {}",
outputTempFile.getPath().toString());
byte[] data = Files.readAllBytes(outputTempFile.getPath());
String zipFilename =
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip");
return WebResponseUtils.bytesToWebResponse(
data, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
return WebResponseUtils.zipFileToWebResponse(outputTempFile, zipFilename);
} catch (Exception e) {
outputTempFile.close();
throw e;
}
}
}
@@ -1,8 +1,6 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -17,6 +15,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -36,6 +35,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -47,6 +48,8 @@ public class SplitPdfByChaptersController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
PDOutlineItem current,
@@ -122,22 +125,19 @@ public class SplitPdfByChaptersController {
@Operation(
summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfByChaptersRequest request)
throws Exception {
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfByChaptersRequest request) throws Exception {
MultipartFile file = request.getFileInput();
PDDocument sourceDocument = null;
Path zipFile = null;
try {
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
}
sourceDocument = pdfDocumentFactory.load(file);
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
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 (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
@@ -157,12 +157,10 @@ public class SplitPdfByChaptersController {
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
} catch (Exception e) {
ExceptionUtils.logException("outline extraction", e);
return ResponseEntity.internalServerError()
.body("Unable to extract outline items".getBytes());
throw e;
}
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
@@ -181,29 +179,10 @@ public class SplitPdfByChaptersController {
bookmark.getStartPage(),
bookmark.getEndPage());
}
List<ByteArrayOutputStream> splitDocumentsBoas =
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
sourceDocument.close();
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
if (sourceDocument != null) {
sourceDocument.close();
}
if (zipFile != null) {
Files.deleteIfExists(zipFile);
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
}
}
@@ -232,72 +211,55 @@ public class SplitPdfByChaptersController {
return bookmarks;
}
private Path createZipFile(
List<Bookmark> bookmarks, List<ByteArrayOutputStream> splitDocumentsBoas)
throws Exception {
Path zipFile = Files.createTempFile("split_documents", ".zip");
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, i)
+ bookmarks.get(i).getTitle()
+ ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
}
} catch (Exception e) {
log.error("Failed writing to zip", e);
throw e;
}
log.info("Successfully created zip file with split documents: {}", zipFile);
return zipFile;
}
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
private TempFile createZipFile(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
PdfMetadata metadata = null;
if (includeMetadata) {
metadata = pdfMetadataService.extractMetadataFromPdf(sourceDocument);
}
for (Bookmark bookmark : bookmarks) {
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
PdfMetadata metadata =
includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null;
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
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 i = bookmark.getStartPage();
i < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
i++) {
PDPage page = sourceDocument.getPage(i);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", i);
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;
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (includeMetadata) {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
}
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
log.info(
"Successfully created zip file with split documents: {}",
zipTempFile.getPath());
return zipTempFile;
} catch (Exception e) {
zipTempFile.close();
throw e;
}
return splitDocumentsBoas;
}
}
@@ -3,7 +3,6 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -13,9 +12,11 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -50,8 +51,8 @@ public class SplitPdfBySizeController {
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
+ " (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF"
+ " Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request)
throws Exception {
public ResponseEntity<StreamingResponseBody> autoSplitPdf(
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
log.debug("Starting PDF split process with request: {}", request);
MultipartFile file = request.getFileInput();
@@ -59,63 +60,51 @@ public class SplitPdfBySizeController {
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
log.debug("Base filename for output: {}", filename);
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
Path managedZipPath = zipTempFile.getPath();
log.debug("Created temporary managed zip file: {}", managedZipPath);
try {
log.debug("Reading input file bytes");
byte[] pdfBytes = file.getBytes();
log.debug("Successfully read {} bytes from input file", pdfBytes.length);
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
log.debug("Created temporary managed zip file: {}", zipTempFile.getPath());
log.debug("Creating ZIP output stream");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
log.debug(
"Successfully loaded PDF with {} pages", sourceDocument.getNumberOfPages());
log.debug("Creating ZIP output stream");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(managedZipPath))) {
log.debug("Loading PDF document");
try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) {
log.debug(
"Successfully loaded PDF with {} pages",
sourceDocument.getNumberOfPages());
int type = request.getSplitType();
String value = request.getSplitValue();
log.debug("Split type: {}, Split value: {}", type, value);
int type = request.getSplitType();
String value = request.getSplitValue();
log.debug("Split type: {}, Split value: {}", type, value);
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) {
log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value);
log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} else if (type == 2) {
log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value);
log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else {
log.error("Invalid split type: {}", type);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"split type: " + type);
}
log.debug("PDF splitting completed successfully");
}
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) {
log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value);
log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} else if (type == 2) {
log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value);
log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else {
log.error("Invalid split type: {}", type);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"split type: " + type);
}
byte[] data = Files.readAllBytes(managedZipPath);
log.debug("Successfully read {} bytes from ZIP file", data.length);
log.debug("Returning response with {} bytes of data", data.length);
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
ExceptionUtils.logException("PDF splitting process", e);
throw e; // Re-throw to ensure proper error response
log.debug("PDF splitting completed successfully");
}
log.debug("Returning streaming response for zip file");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
} catch (Exception e) {
ExceptionUtils.logException("PDF splitting process", e);
zipTempFile.close();
throw e;
}
}
@@ -124,6 +113,7 @@ public class SplitPdfBySizeController {
throws IOException {
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
@Getter
class DocHolder implements AutoCloseable {
private PDDocument doc;
@@ -131,10 +121,6 @@ public class SplitPdfBySizeController {
this.doc = doc;
}
public PDDocument getDoc() {
return doc;
}
public void setDoc(PDDocument doc) {
if (this.doc != null) {
try {
@@ -22,6 +22,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -51,6 +52,7 @@ import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -78,7 +80,7 @@ public class ConvertImgPDFController {
"This endpoint converts a PDF file to image(s) with the specified image format,"
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images. Input:PDF Output:Image Type:SI-Conditional")
public ResponseEntity<byte[]> convertToImage(@ModelAttribute ConvertToImageRequest request)
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
String imageFormat = request.getImageFormat();
@@ -180,28 +182,33 @@ public class ConvertImgPDFController {
"No WebP files were created. " + resultProcess.getMessages());
}
byte[] bodyBytes = new byte[0];
if (webpFiles.size() == 1) {
// Return the single WebP file directly
Path webpFilePath = webpFiles.get(0);
bodyBytes = Files.readAllBytes(webpFilePath);
byte[] webpBytes = Files.readAllBytes(webpFilePath);
Files.deleteIfExists(tempFile);
tempFile = null;
FileUtils.deleteDirectory(tempOutputDir.toFile());
tempOutputDir = null;
String docName = filename + "." + imageFormat;
MediaType mediaType = MediaType.parseMediaType(getMediaType(imageFormat));
return WebResponseUtils.bytesToWebResponse(webpBytes, docName, mediaType);
} else {
// Create a ZIP file containing all WebP images
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
ByteArrayOutputStream zipBAOS = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(zipBAOS)) {
for (Path webpFile : webpFiles) {
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
Files.copy(webpFile, zos);
zos.closeEntry();
}
bodyBytes = zipOutputStream.toByteArray();
}
Files.deleteIfExists(tempFile);
tempFile = null;
FileUtils.deleteDirectory(tempOutputDir.toFile());
tempOutputDir = null;
String zipFilename = filename + "_convertedToImages.zip";
return WebResponseUtils.bytesToWebResponse(
zipBAOS.toByteArray(), zipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
// Clean up the temporary files
Files.deleteIfExists(tempFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
result = bodyBytes;
}
if (singleImage) {
@@ -267,8 +274,8 @@ public class ConvertImgPDFController {
description =
"This endpoint converts a CBZ (ZIP) comic book archive to a PDF file. "
+ "Input:CBZ Output:PDF Type:SISO")
public ResponseEntity<?> convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request)
throws IOException {
public ResponseEntity<StreamingResponseBody> convertCbzToPdf(
@ModelAttribute ConvertCbzToPdfRequest request) throws IOException {
MultipartFile file = request.getFileInput();
boolean optimizeForEbook = request.isOptimizeForEbook();
@@ -278,13 +285,13 @@ public class ConvertImgPDFController {
optimizeForEbook = false;
}
byte[] pdfBytes =
TempFile pdfFile =
CbzUtils.convertCbzToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
@@ -293,8 +300,8 @@ public class ConvertImgPDFController {
description =
"This endpoint converts a PDF file to a CBZ (ZIP) comic book archive. "
+ "Input:PDF Output:CBZ Type:SISO")
public ResponseEntity<?> convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request)
throws IOException {
public ResponseEntity<StreamingResponseBody> convertPdfToCbz(
@ModelAttribute ConvertPdfToCbzRequest request) throws IOException {
MultipartFile file = request.getFileInput();
int dpi = request.getDpi();
@@ -302,12 +309,12 @@ public class ConvertImgPDFController {
dpi = 300;
}
byte[] cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
TempFile cbzFile =
PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory, tempFileManager);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
return WebResponseUtils.bytesToWebResponse(
cbzBytes, filename, MediaType.APPLICATION_OCTET_STREAM);
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
@@ -54,6 +54,12 @@ public class ConvertPDFToEpubController {
command.add(inputPath.toString());
command.add(outputPath.toString());
// Use pdftohtml engine (poppler) for PDF input instead of calibre's Qt-based engine.
// This avoids the Qt WebEngine dependency for PDF parsing and uses the lighter
// poppler-utils pdftohtml binary which is already available in the container.
command.add("--pdf-engine");
command.add("pdftohtml");
// Golden defaults
command.add("--enable-heuristics");
command.add("--insert-blank-line");
@@ -39,6 +39,8 @@ import stirling.software.common.util.WebResponseUtils;
public class ConvertPdfJsonController {
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("[\\r\\n\\t]+");
private static final Pattern NON_PRINTABLE_PATTERN = Pattern.compile("[^\\x20-\\x7E]");
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
@@ -259,7 +261,10 @@ public class ConvertPdfJsonController {
if (length > 0) {
int start = Math.max(0, length - 64);
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
tail =
NON_PRINTABLE_PATTERN
.matcher(WHITESPACE_PATTERN.matcher(tail).replaceAll(" "))
.replaceAll("?");
}
log.debug(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
@@ -421,9 +426,9 @@ public class ConvertPdfJsonController {
private String truncateForLog(String value) {
int max = 64;
if (value.length() <= max) {
return value.replaceAll("[\\r\\n\\t]+", " ");
return WHITESPACE_PATTERN.matcher(value).replaceAll(" ");
}
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
return WHITESPACE_PATTERN.matcher(value.substring(0, max)).replaceAll(" ") + "...";
}
/**
@@ -67,7 +67,7 @@ public class ExtractImageScansController {
MultipartFile inputFile = request.getFileInput();
String fileName = inputFile.getOriginalFilename();
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
String extension = fileName.substring(fileName.lastIndexOf('.') + 1);
List<String> images = new ArrayList<>();
@@ -5,6 +5,7 @@ import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
@@ -28,6 +29,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -42,6 +44,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ImageProcessingUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@MiscApi
@@ -50,6 +54,7 @@ import stirling.software.common.util.WebResponseUtils;
public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/extract-images")
@MultiFileResponse
@@ -59,111 +64,115 @@ public class ExtractImagesController {
"This endpoint extracts images from a given PDF file and returns them in a zip"
+ " file. Users can specify the output image format. Input:PDF"
+ " Output:IMAGE/ZIP Type:SIMO")
public ResponseEntity<byte[]> extractImages(@ModelAttribute PDFExtractImagesRequest request)
public ResponseEntity<StreamingResponseBody> extractImages(
@ModelAttribute PDFExtractImagesRequest request)
throws IOException, InterruptedException, ExecutionException {
MultipartFile file = request.getFileInput();
String format = request.getFormat();
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
PDDocument document = pdfDocumentFactory.load(file);
// Determine if multithreading should be used based on PDF size or number of pages
boolean useMultithreading = shouldUseMultithreading(file, document);
// Create ByteArrayOutputStream to write zip file to byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Create ZipOutputStream to create zip file
ZipOutputStream zos = new ZipOutputStream(baos);
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
String filename = GeneralUtils.removeExtension(file.getOriginalFilename());
Set<byte[]> processedImages = new HashSet<>();
if (useMultithreading) {
// Virtual thread executor lightweight threads ideal for I/O-bound image extraction
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Future<Void>> futures = new HashSet<>();
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zos =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
PDDocument document = pdfDocumentFactory.load(file)) {
// Safely iterate over each page, handling corrupt PDFs where page count might be wrong
try {
int pageCount = document.getPages().getCount();
log.debug("Document reports {} pages", pageCount);
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
int consecutiveFailures = 0;
// Determine if multithreading should be used based on PDF size or number of pages
boolean useMultithreading = shouldUseMultithreading(file, document);
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
try {
PDPage page = document.getPage(pgNum);
consecutiveFailures = 0; // Reset on success
final int currentPageNum = pgNum + 1; // Convert to 1-based page numbering
Future<Void> future =
executor.submit(
() -> {
try {
// Call the image extraction method for each page
extractImagesFromPage(
page,
format,
filename,
currentPageNum,
processedImages,
zos,
allowDuplicates);
} catch (Exception e) {
// Log the error and continue processing other pages
ExceptionUtils.logException(
"image extraction from page "
+ currentPageNum,
e);
}
if (useMultithreading) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Future<Void>> futures = new HashSet<>();
return null; // Callable requires a return type
});
try {
int pageCount = document.getPages().getCount();
log.debug("Document reports {} pages", pageCount);
// Add the Future object to the list to track completion
futures.add(future);
} catch (Exception e) {
consecutiveFailures++;
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
int consecutiveFailures = 0;
if (consecutiveFailures >= 3) {
log.warn("Stopping page iteration after 3 consecutive failures");
break;
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
try {
PDPage page = document.getPage(pgNum);
consecutiveFailures = 0; // Reset on success
final int currentPageNum =
pgNum + 1; // Convert to 1-based page numbering
Future<Void> future =
executor.submit(
() -> {
try {
// Call the image extraction method for each
// page
extractImagesFromPage(
page,
format,
filename,
currentPageNum,
processedImages,
zos,
allowDuplicates);
} catch (Exception e) {
// Log the error and continue processing other
// pages
ExceptionUtils.logException(
"image extraction from page "
+ currentPageNum,
e);
}
return null; // Callable requires a return type
});
// Add the Future object to the list to track completion
futures.add(future);
} catch (Exception e) {
consecutiveFailures++;
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
if (consecutiveFailures >= 3) {
log.warn("Stopping page iteration after 3 consecutive failures");
break;
}
}
}
} catch (Exception e) {
ExceptionUtils.logException("page count determination", e);
throw e;
}
} catch (Exception e) {
ExceptionUtils.logException("page count determination", e);
throw e;
}
// Wait for all tasks to complete
for (Future<Void> future : futures) {
future.get();
}
// Wait for all tasks to complete
for (Future<Void> future : futures) {
future.get();
}
// Close executor service
executor.shutdown();
} else {
// Single-threaded extraction
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
extractImagesFromPage(
page, format, filename, pgNum + 1, processedImages, zos, allowDuplicates);
// Close executor service
executor.shutdown();
} else {
// Single-threaded extraction
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
extractImagesFromPage(
page,
format,
filename,
pgNum + 1,
processedImages,
zos,
allowDuplicates);
}
}
// document and zos closed by try-with-resources
} catch (Exception e) {
zipTempFile.close();
throw e;
}
// Close PDDocument and ZipOutputStream
document.close();
zos.close();
// Create ByteArrayResource from byte array
byte[] zipContents = baos.toByteArray();
return WebResponseUtils.baosToWebResponse(
baos, filename + "_extracted-images.zip", MediaType.APPLICATION_OCTET_STREAM);
return WebResponseUtils.zipFileToWebResponse(
zipTempFile, filename + "_extracted-images.zip");
}
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
@@ -214,13 +223,17 @@ public class ExtractImagesController {
// Convert to standard RGB colorspace if needed
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
// Write image to zip file
// Encode image outside the lock to allow parallel encoding across threads
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
byte[] imageData = imageBaos.toByteArray();
// Write encoded bytes to zip under lock (ZipOutputStream requires
// serialization)
synchronized (zos) {
zos.putNextEntry(new ZipEntry(imageName));
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
zos.write(imageBaos.toByteArray());
zos.write(imageData);
zos.closeEntry();
}
}
@@ -94,7 +94,7 @@ public class OCRController {
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
List<String> selectedLanguages = request.getLanguages();
Boolean sidecar = request.isSidecar();
boolean sidecar = request.isSidecar();
Boolean deskew = request.isDeskew();
Boolean clean = request.isClean();
Boolean cleanFinal = request.isCleanFinal();
@@ -233,7 +233,7 @@ public class StampController {
};
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf('.'));
// Use TempFile with try-with-resources for automatic cleanup
try (TempFile tempFileWrapper = new TempFile(tempFileManager, fileExtension)) {
@@ -1,7 +1,7 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -13,6 +13,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -28,6 +29,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.PipelineApi;
import stirling.software.common.service.PostHogService;
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 tools.jackson.core.JacksonException;
@@ -45,6 +48,8 @@ public class PipelineController {
private final PostHogService postHogService;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@MultiFileResponse
@Operation(
@@ -53,8 +58,8 @@ public class PipelineController {
"This endpoint processes multiple PDF files through a configurable pipeline of operations. "
+ "Users provide files and a JSON configuration defining the sequence of operations to perform. "
+ "Input:PDF Output:PDF/ZIP Type:MIMO")
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws DatabindException, JacksonException {
public ResponseEntity<StreamingResponseBody> handleData(
@ModelAttribute HandleDataRequest request) throws DatabindException, JacksonException {
MultipartFile[] files = request.getFileInput();
String jsonString = request.getJson();
if (files == null) {
@@ -80,51 +85,56 @@ public class PipelineController {
PipelineResult result = processor.runPipelineAgainstFiles(inputFiles, config);
List<Resource> outputFiles = result.getOutputFiles();
if (outputFiles != null && outputFiles.size() == 1) {
// If there is only one file, return it directly
// If there is only one file, return it directly stream without int-overflow
Resource singleFile = outputFiles.get(0);
InputStream is = singleFile.getInputStream();
byte[] bytes = new byte[(int) singleFile.contentLength()];
is.read(bytes);
is.close();
log.info("Returning single file response...");
return WebResponseUtils.bytesToWebResponse(
bytes, singleFile.getFilename(), MediaType.APPLICATION_OCTET_STREAM);
TempFile singleTempFile = new TempFile(tempFileManager, ".out");
try {
try (InputStream is = singleFile.getInputStream()) {
is.transferTo(Files.newOutputStream(singleTempFile.getPath()));
}
log.info("Returning single file response...");
return WebResponseUtils.fileToWebResponse(
singleTempFile,
singleFile.getFilename(),
MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
singleTempFile.close();
throw e;
}
} else if (outputFiles == null) {
return null;
}
// Create a ByteArrayOutputStream to hold the zip
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(baos);
// A map to keep track of filenames and their counts
Map<String, Integer> filenameCount = new HashMap<>();
// Loop through each file and add it to the zip
for (Resource file : outputFiles) {
String originalFilename = file.getFilename();
String filename = originalFilename;
// Check if the filename already exists, and modify it if necessary
if (filenameCount.containsKey(originalFilename)) {
int count = filenameCount.get(originalFilename);
assert originalFilename != null;
filename = GeneralUtils.generateFilename(originalFilename, "(" + count + ")");
filenameCount.put(originalFilename, count + 1);
} else {
filenameCount.put(originalFilename, 1);
// Multiple files: stream into a zip TempFile
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
Map<String, Integer> filenameCount = new HashMap<>();
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (Resource file : outputFiles) {
String originalFilename = file.getFilename();
String filename = originalFilename;
if (filenameCount.containsKey(originalFilename)) {
int count = filenameCount.get(originalFilename);
filename =
GeneralUtils.generateFilename(
originalFilename, "(" + count + ")");
filenameCount.put(originalFilename, count + 1);
} else {
filenameCount.put(originalFilename, 1);
}
zipOut.putNextEntry(new ZipEntry(filename));
try (InputStream is = file.getInputStream()) {
is.transferTo(zipOut);
}
zipOut.closeEntry();
}
}
ZipEntry zipEntry = new ZipEntry(filename);
zipOut.putNextEntry(zipEntry);
// Read the file into a byte array
InputStream is = file.getInputStream();
byte[] bytes = new byte[(int) file.contentLength()];
is.read(bytes);
// Write the bytes of the file to the zip
zipOut.write(bytes, 0, bytes.length);
zipOut.closeEntry();
is.close();
log.info("Returning zipped file response...");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, "output.zip");
} catch (Exception e) {
zipTempFile.close();
throw e;
}
zipOut.close();
log.info("Returning zipped file response...");
return WebResponseUtils.baosToWebResponse(
baos, "output.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
log.error("Error handling data: ", e);
return null;
@@ -254,7 +254,7 @@ public class PipelineDirectoryProcessor {
String extension =
filename.contains(".")
? filename.substring(
filename.lastIndexOf(".")
filename.lastIndexOf('.')
+ 1)
.toLowerCase(Locale.ROOT)
: "";
@@ -67,7 +67,7 @@ public class PipelineProcessor {
public static String removeTrailingNaming(String filename) {
// Splitting filename into name and extension
int dotIndex = filename.lastIndexOf(".");
int dotIndex = filename.lastIndexOf('.');
if (dotIndex == -1) {
// No extension found
return filename;
@@ -75,7 +75,7 @@ public class PipelineProcessor {
String name = filename.substring(0, dotIndex);
String extension = filename.substring(dotIndex);
// Finding the last underscore
int underscoreIndex = name.lastIndexOf("_");
int underscoreIndex = name.lastIndexOf('_');
if (underscoreIndex == -1) {
// No underscore found
return filename;
@@ -173,7 +173,7 @@ public class PipelineProcessor {
String providedExtension = "no extension";
if (filename != null && filename.contains(".")) {
providedExtension =
filename.substring(filename.lastIndexOf("."))
filename.substring(filename.lastIndexOf('.'))
.toLowerCase(Locale.ROOT);
}
@@ -248,7 +248,7 @@ public class PipelineProcessor {
String filename = file.getFilename();
if (filename != null && filename.contains(".")) {
return filename.substring(
filename.lastIndexOf("."))
filename.lastIndexOf('.'))
.toLowerCase(Locale.ROOT);
}
return "no extension";
@@ -450,7 +450,21 @@ public class PipelineProcessor {
return isZip(data, null);
}
private static final int MAX_UNZIP_DEPTH = 10;
private List<Resource> unzip(Resource data, PipelineResult result) throws IOException {
return unzip(data, result, 0);
}
private List<Resource> unzip(Resource data, PipelineResult result, int depth)
throws IOException {
if (depth > MAX_UNZIP_DEPTH) {
log.warn(
"ZIP nesting depth {} exceeds limit {}, treating as file",
depth,
MAX_UNZIP_DEPTH);
return List.of(data);
}
log.info("Unzipping data of length: {}", data.contentLength());
List<Resource> unzippedFiles = new ArrayList<>();
try (InputStream bais = data.getInputStream();
@@ -481,7 +495,7 @@ public class PipelineProcessor {
// If the unzipped file is a zip file, unzip it
if (isZip(fileResource, filename)) {
log.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(fileResource, result));
unzippedFiles.addAll(unzip(fileResource, result, depth + 1));
} else {
unzippedFiles.add(fileResource);
}
@@ -188,7 +188,7 @@ public class WatermarkController {
};
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf('.'));
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
try (InputStream is = classPathResource.getInputStream();
FileOutputStream os = new FileOutputStream(tempFile)) {
@@ -51,9 +51,19 @@ public class MetricsController {
description =
"This endpoint returns the status of the application and its version number.")
public ResponseEntity<?> getStatus() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
return getApplicationStatus();
}
@GetMapping("/health")
@Operation(
summary = "Application health check",
description =
"This endpoint returns the health status of the application and its version number. Mirrors /api/v1/info/status.")
public ResponseEntity<?> getHealth() {
return getApplicationStatus();
}
private ResponseEntity<?> getApplicationStatus() {
Map<String, String> status = new HashMap<>();
status.put("status", "UP");
String version = getClass().getPackage().getImplementationVersion();
@@ -3,6 +3,7 @@ package stirling.software.SPDF.exception;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import org.springframework.context.MessageSource;
@@ -23,8 +24,6 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -35,6 +34,8 @@ import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ExceptionUtils.*;
import stirling.software.common.util.RegexPatternUtils;
import tools.jackson.databind.ObjectMapper;
/**
* Returns RFC 7807 Problem Details for HTTP APIs, ensuring consistent error responses across the
* application.
@@ -870,7 +871,7 @@ public class GlobalExceptionHandler {
// Use ObjectMapper to properly escape JSON values and prevent XSS
ObjectMapper mapper = new ObjectMapper();
java.util.Map<String, Object> errorMap = new java.util.LinkedHashMap<>();
java.util.Map<String, Object> errorMap = new LinkedHashMap<>();
errorMap.put("type", "about:blank");
errorMap.put("title", "Not Acceptable");
errorMap.put("status", 406);
@@ -1,7 +1,5 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
@@ -27,13 +25,13 @@ public class PdfJsonAnnotation {
private String contents;
/** Annotation rectangle [x1, y1, x2, y2] */
private List<Float> rect;
private float[] rect;
/** Annotation appearance characteristics */
private String appearanceState;
/** Color components (e.g., [r, g, b] for RGB) */
private List<Float> color;
private float[] color;
/** Annotation flags (print, hidden, etc.) */
private Integer flags;
@@ -14,7 +14,7 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public class PdfJsonDocument {
private PdfJsonMetadata metadata;
@@ -23,7 +23,7 @@ public class PdfJsonDocument {
private String xmpMetadata;
/** Indicates that images should be loaded lazily via API rather than embedded in the JSON. */
private Boolean lazyImages;
private boolean lazyImages;
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
@@ -1,7 +1,5 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
@@ -65,5 +63,5 @@ public class PdfJsonFontConversionCandidate {
private String diagnostics;
/** Known unicode/codepoint coverage derived from the conversion strategy. */
private List<Integer> glyphCoverage;
private int[] glyphCoverage;
}
@@ -45,13 +45,13 @@ public class PdfJsonFormField {
private Integer pageNumber;
/** Field rectangle [x1, y1, x2, y2] on the page */
private List<Float> rect;
private float[] rect;
/** For choice fields: list of options */
private List<String> options;
/** For choice fields: selected indices */
private List<Integer> selectedIndices;
private int[] selectedIndices;
/** For button fields: whether it's checked */
private Boolean checked;
@@ -1,8 +1,5 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
@@ -30,7 +27,7 @@ public class PdfJsonImageElement {
private Float right;
private Float top;
private Float bottom;
@Builder.Default private List<Float> transform = new ArrayList<>();
private float[] transform;
private Integer zOrder;
private String imageData;
private String imageFormat;
@@ -11,10 +11,10 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public class PdfJsonPageDimension {
private Integer pageNumber;
private Float width;
private Float height;
private Integer rotation;
private int pageNumber;
private float width;
private float height;
private int rotation;
}
@@ -1,7 +1,5 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
@@ -17,5 +15,5 @@ import lombok.NoArgsConstructor;
public class PdfJsonTextColor {
private String colorSpace;
private List<Float> components;
private float[] components;
}
@@ -1,7 +1,5 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
@@ -32,10 +30,10 @@ public class PdfJsonTextElement {
private Float y;
private Float width;
private Float height;
private List<Float> textMatrix;
private float[] textMatrix;
private PdfJsonTextColor fillColor;
private PdfJsonTextColor strokeColor;
private Integer renderingMode;
private Boolean fallbackUsed;
private List<Integer> charCodes;
private int[] charCodes;
}
@@ -273,11 +273,11 @@ public class PdfJsonConversionService {
// Get job ID from request context if running in async mode
String contextJobId = getJobIdFromRequest();
boolean isRealJobId = (contextJobId != null && !contextJobId.isEmpty());
boolean useLazyImages = (contextJobId != null && !contextJobId.isEmpty());
// Generate synthetic jobId for synchronous conversions to prevent cache collisions
final String jobId;
if (!isRealJobId) {
if (!useLazyImages) {
jobId = "pdf2json:" + java.util.UUID.randomUUID().toString();
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
} else {
@@ -302,7 +302,7 @@ public class PdfJsonConversionService {
: "");
progressCallback.accept(p);
}
: isRealJobId
: useLazyImages
? (p) -> {
log.debug(
"Progress: [{}%] {} - {}{}",
@@ -369,7 +369,6 @@ public class PdfJsonConversionService {
int totalPages = document.getNumberOfPages();
// Always enable lazy mode for real async jobs so cache is available regardless of
// page count. Synchronous calls with synthetic jobId still do full extraction.
boolean useLazyImages = isRealJobId;
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
log.debug(
@@ -377,7 +376,7 @@ public class PdfJsonConversionService {
totalPages,
useLazyImages ? "lazy image" : "standard",
jobId,
isRealJobId);
useLazyImages);
Map<String, PdfJsonFont> fonts = new LinkedHashMap<>();
Map<Integer, List<PdfJsonTextElement>> textByPage = new LinkedHashMap<>();
Map<Integer, Map<PDFont, String>> pageFontResources = new HashMap<>();
@@ -431,7 +430,7 @@ public class PdfJsonConversionService {
progress.accept(
PdfJsonConversionProgress.of(
80, "annotations", "Collecting annotations and form fields"));
boolean includeAnnotationRawData = !(lightweight && isRealJobId);
boolean includeAnnotationRawData = !(lightweight && useLazyImages);
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage =
collectAnnotations(
document, totalPages, progress, includeAnnotationRawData);
@@ -463,8 +462,8 @@ public class PdfJsonConversionService {
textByPage,
imagesByPage,
annotationsByPage,
lightweight && isRealJobId));
if (lightweight && isRealJobId) {
lightweight && useLazyImages));
if (lightweight && useLazyImages) {
// Lightweight async editor flow does not use form fields and this payload can
// be
// very large due nested raw dictionaries.
@@ -474,12 +473,11 @@ public class PdfJsonConversionService {
}
// Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) {
if (useLazyImages) {
log.debug(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
"Creating cache for jobId: {} (useLazyImages={})",
jobId,
useLazyImages,
isRealJobId);
useLazyImages);
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
@@ -529,16 +527,15 @@ public class PdfJsonConversionService {
scheduleDocumentCleanup(jobId);
} else {
log.warn(
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
"Skipping cache creation: useLazyImages={}, jobId={}",
useLazyImages,
isRealJobId,
jobId);
}
if (lightweight) {
applyLightweightTransformations(pdfJson);
}
if (lightweight && isRealJobId) {
if (lightweight && useLazyImages) {
stripFontProgramPayloads(responseFonts);
stripFontCosStreamData(responseFonts);
}
@@ -2503,11 +2500,12 @@ public class PdfJsonConversionService {
PDRectangle rect = annotation.getRectangle();
if (rect != null) {
ann.setRect(
List.of(
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY()));
new float[] {
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY()
});
}
COSName appearanceState = annotation.getAppearanceState();
@@ -2516,12 +2514,7 @@ public class PdfJsonConversionService {
}
if (annotation.getColor() != null) {
float[] colorComponents = annotation.getColor().getComponents();
List<Float> colorList = new ArrayList<>(colorComponents.length);
for (float c : colorComponents) {
colorList.add(c);
}
ann.setColor(colorList);
ann.setColor(annotation.getColor().getComponents());
}
COSDictionary annotDict = annotation.getCOSObject();
@@ -2636,11 +2629,12 @@ public class PdfJsonConversionService {
PDRectangle rect = widget.getRectangle();
if (rect != null) {
formField.setRect(
List.of(
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY()));
new float[] {
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY()
});
}
}
}
@@ -3351,7 +3345,7 @@ public class PdfJsonConversionService {
PDFont currentFont = baseFont;
String currentFontId = baseFontId;
List<Integer> elementCodes = element.getCharCodes();
int[] elementCodes = element.getCharCodes();
int codeIndex = 0;
boolean rawType3CodesUsed = false;
int rawType3GlyphCount = 0;
@@ -3363,8 +3357,8 @@ public class PdfJsonConversionService {
PDFont targetFont = baseFont;
String targetFontId = baseFontId;
Integer rawCode = null;
if (elementCodes != null && codeIndex < elementCodes.size()) {
rawCode = elementCodes.get(codeIndex);
if (elementCodes != null && codeIndex < elementCodes.length) {
rawCode = elementCodes[codeIndex];
}
codeIndex++;
@@ -3667,10 +3661,7 @@ public class PdfJsonConversionService {
if (color == null || color.getComponents() == null) {
return;
}
float[] components = new float[color.getComponents().size()];
for (int i = 0; i < components.length; i++) {
components[i] = color.getComponents().get(i);
}
float[] components = color.getComponents();
String space = color.getColorSpace();
if (space == null) {
// Infer color space from component count
@@ -4287,8 +4278,11 @@ public class PdfJsonConversionService {
List<Integer> combinedCodes = new ArrayList<>();
for (PdfJsonTextElement element : elements) {
builder.append(Objects.toString(element.getText(), ""));
if (element.getCharCodes() != null && !element.getCharCodes().isEmpty()) {
combinedCodes.addAll(element.getCharCodes());
int[] codes = element.getCharCodes();
if (codes != null && codes.length > 0) {
for (int code : codes) {
combinedCodes.add(code);
}
}
}
return new MergedText(builder.toString(), combinedCodes.isEmpty() ? null : combinedCodes);
@@ -4354,9 +4348,9 @@ public class PdfJsonConversionService {
}
private int countGlyphs(PdfJsonTextElement element) {
List<Integer> codes = element.getCharCodes();
if (codes != null && !codes.isEmpty()) {
return codes.size();
int[] codes = element.getCharCodes();
if (codes != null && codes.length > 0) {
return codes.length;
}
String text = element.getText();
if (text != null && !text.isEmpty()) {
@@ -4920,15 +4914,15 @@ public class PdfJsonConversionService {
private void applyTextMatrix(PDPageContentStream contentStream, PdfJsonTextElement element)
throws IOException {
List<Float> matrix = element.getTextMatrix();
if (matrix != null && matrix.size() == 6) {
float[] matrix = element.getTextMatrix();
if (matrix != null && matrix.length == 6) {
float fontScale = resolveFontMatrixSize(element);
float a = matrix.get(0);
float b = matrix.get(1);
float c = matrix.get(2);
float d = matrix.get(3);
float e = matrix.get(4);
float f = matrix.get(5);
float a = matrix[0];
float b = matrix[1];
float c = matrix[2];
float d = matrix[3];
float e = matrix[4];
float f = matrix[5];
if (fontScale != 0f) {
a /= fontScale;
@@ -4950,12 +4944,12 @@ public class PdfJsonConversionService {
if (fromElement != null && fromElement > 0f) {
return fromElement;
}
List<Float> matrix = element.getTextMatrix();
if (matrix != null && matrix.size() >= 4) {
float a = matrix.get(0);
float b = matrix.get(1);
float c = matrix.get(2);
float d = matrix.get(3);
float[] matrix = element.getTextMatrix();
if (matrix != null && matrix.length >= 4) {
float a = matrix[0];
float b = matrix[1];
float c = matrix[2];
float d = matrix[3];
float verticalScale = (float) Math.hypot(b, d);
if (verticalScale > 0f) {
return verticalScale;
@@ -5048,7 +5042,7 @@ public class PdfJsonConversionService {
}
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
Bounds bounds = computeBounds(ctm);
List<Float> matrixValues = toMatrixValues(ctm);
float[] matrixValues = toMatrixValues(ctm);
PdfJsonImageElement element =
PdfJsonImageElement.builder()
@@ -5222,15 +5216,15 @@ public class PdfJsonConversionService {
private record EncodedImage(String base64, String format) {}
private List<Float> toMatrixValues(Matrix matrix) {
List<Float> values = new ArrayList<>(6);
values.add(matrix.getValue(0, 0));
values.add(matrix.getValue(0, 1));
values.add(matrix.getValue(1, 0));
values.add(matrix.getValue(1, 1));
values.add(matrix.getValue(2, 0));
values.add(matrix.getValue(2, 1));
return values;
private float[] toMatrixValues(Matrix matrix) {
return new float[] {
matrix.getValue(0, 0),
matrix.getValue(0, 1),
matrix.getValue(1, 0),
matrix.getValue(1, 1),
matrix.getValue(2, 0),
matrix.getValue(2, 1)
};
}
private EncodedImage encodeImage(PDImage image) {
@@ -5341,16 +5335,16 @@ public class PdfJsonConversionService {
cache.put(cacheKey, image);
}
List<Float> transform = element.getTransform();
if (transform != null && transform.size() == 6) {
float[] transform = element.getTransform();
if (transform != null && transform.length == 6) {
Matrix matrix =
new Matrix(
safeFloat(transform.get(0), 1f),
safeFloat(transform.get(1), 0f),
safeFloat(transform.get(2), 0f),
safeFloat(transform.get(3), 1f),
safeFloat(transform.get(4), 0f),
safeFloat(transform.get(5), 0f));
safeFloat(transform[0], 1f),
safeFloat(transform[1], 0f),
safeFloat(transform[2], 0f),
safeFloat(transform[3], 1f),
safeFloat(transform[4], 0f),
safeFloat(transform[5], 0f));
contentStream.drawImage(image, matrix);
return;
}
@@ -5516,14 +5510,17 @@ public class PdfJsonConversionService {
if (pdfont instanceof PDType3Font) {
int[] codes = position.getCharacterCodes();
if (codes != null && codes.length > 0) {
List<Integer> codeList = new ArrayList<>(codes.length);
int count = 0;
for (int code : codes) {
if (code >= 0) {
codeList.add(code);
}
if (code >= 0) count++;
}
if (!codeList.isEmpty()) {
element.setCharCodes(codeList);
if (count > 0) {
int[] filtered = new int[count];
int idx = 0;
for (int code : codes) {
if (code >= 0) filtered[idx++] = code;
}
element.setCharCodes(filtered);
}
}
}
@@ -5552,11 +5549,11 @@ public class PdfJsonConversionService {
return;
}
List<Float> matrix = element.getTextMatrix();
float[] matrix = element.getTextMatrix();
if (matrix != null) {
if (matrix.isEmpty()) {
if (matrix.length == 0) {
element.setTextMatrix(null);
} else if (matrix.size() == 6) {
} else if (matrix.length == 6) {
element.setX(null);
element.setY(null);
}
@@ -5597,29 +5594,29 @@ public class PdfJsonConversionService {
if (color == null || color.getComponents() == null) {
return true;
}
List<Float> components = color.getComponents();
if (components.isEmpty()) {
float[] components = color.getComponents();
if (components.length == 0) {
return true;
}
String space = color.getColorSpace();
if (space == null || "DeviceRGB".equals(space)) {
if (components.size() < 3) {
if (components.length < 3) {
return false;
}
return Math.abs(components.get(0)) < FLOAT_EPSILON
&& Math.abs(components.get(1)) < FLOAT_EPSILON
&& Math.abs(components.get(2)) < FLOAT_EPSILON;
return Math.abs(components[0]) < FLOAT_EPSILON
&& Math.abs(components[1]) < FLOAT_EPSILON
&& Math.abs(components[2]) < FLOAT_EPSILON;
}
if ("DeviceGray".equals(space)) {
return Math.abs(components.get(0)) < FLOAT_EPSILON;
return Math.abs(components[0]) < FLOAT_EPSILON;
}
return false;
}
private Float baselineFrom(PdfJsonTextElement element) {
List<Float> matrix = element.getTextMatrix();
if (matrix != null && matrix.size() >= 6) {
return matrix.get(5);
float[] matrix = element.getTextMatrix();
if (matrix != null && matrix.length >= 6) {
return matrix[5];
}
return element.getY();
}
@@ -5648,11 +5645,12 @@ public class PdfJsonConversionService {
private final float orientationC;
private final float orientationD;
private final Float baseline;
private final List<Float> baseMatrix;
private final float[] baseMatrix;
private final float startXCoord;
private final float startYCoord;
private final StringBuilder textBuilder = new StringBuilder();
private final List<Integer> charCodeBuffer = new ArrayList<>();
private int[] charCodeBuf = new int[16];
private int charCodeLen = 0;
private float totalWidth;
private float maxHeight;
private float endXCoord;
@@ -5660,17 +5658,15 @@ public class PdfJsonConversionService {
TextRunAccumulator(PdfJsonTextElement element, TextPosition position) {
this.baseElement = element;
this.styleKey = buildStyleKey(element);
this.baseMatrix =
element.getTextMatrix() != null
? new ArrayList<>(element.getTextMatrix())
: null;
if (baseMatrix != null && baseMatrix.size() >= 6) {
orientationA = baseMatrix.get(0);
orientationB = baseMatrix.get(1);
orientationC = baseMatrix.get(2);
orientationD = baseMatrix.get(3);
startXCoord = baseMatrix.get(4);
startYCoord = baseMatrix.get(5);
float[] tm = element.getTextMatrix();
this.baseMatrix = tm != null ? tm.clone() : null;
if (baseMatrix != null && baseMatrix.length >= 6) {
orientationA = baseMatrix[0];
orientationB = baseMatrix[1];
orientationC = baseMatrix[2];
orientationD = baseMatrix[3];
startXCoord = baseMatrix[4];
startYCoord = baseMatrix[5];
} else {
orientationA = 1f;
orientationB = 0f;
@@ -5684,25 +5680,23 @@ public class PdfJsonConversionService {
this.maxHeight = element.getHeight() != null ? element.getHeight() : 0f;
this.endXCoord = position.getXDirAdj() + position.getWidthDirAdj();
this.textBuilder.append(element.getText());
if (element.getCharCodes() != null) {
charCodeBuffer.addAll(element.getCharCodes());
}
appendCharCodes(element.getCharCodes());
}
boolean canAppend(PdfJsonTextElement element, TextPosition position) {
if (!styleKey.equals(buildStyleKey(element))) {
return false;
}
List<Float> matrix = element.getTextMatrix();
float[] matrix = element.getTextMatrix();
float a = 1f;
float b = 0f;
float c = 0f;
float d = 1f;
if (matrix != null && matrix.size() >= 4) {
a = matrix.get(0);
b = matrix.get(1);
c = matrix.get(2);
d = matrix.get(3);
if (matrix != null && matrix.length >= 4) {
a = matrix[0];
b = matrix[1];
c = matrix[2];
d = matrix[3];
}
if (Math.abs(a - orientationA) > ORIENTATION_TOLERANCE
|| Math.abs(b - orientationB) > ORIENTATION_TOLERANCE
@@ -5734,9 +5728,19 @@ public class PdfJsonConversionService {
maxHeight = height;
}
endXCoord = position.getXDirAdj() + position.getWidthDirAdj();
if (element.getCharCodes() != null) {
charCodeBuffer.addAll(element.getCharCodes());
appendCharCodes(element.getCharCodes());
}
private void appendCharCodes(int[] codes) {
if (codes == null) return;
int needed = charCodeLen + codes.length;
if (needed > charCodeBuf.length) {
charCodeBuf =
java.util.Arrays.copyOf(
charCodeBuf, Math.max(needed, charCodeBuf.length * 2));
}
System.arraycopy(codes, 0, charCodeBuf, charCodeLen, codes.length);
charCodeLen += codes.length;
}
PdfJsonTextElement build() {
@@ -5748,22 +5752,21 @@ public class PdfJsonConversionService {
}
result.setWidth(totalWidth);
result.setHeight(maxHeight);
if (baseMatrix != null && baseMatrix.size() == 6) {
List<Float> matrix = new ArrayList<>(baseMatrix);
matrix.set(0, orientationA);
matrix.set(1, orientationB);
matrix.set(2, orientationC);
matrix.set(3, orientationD);
matrix.set(4, startXCoord);
matrix.set(5, startYCoord);
if (baseMatrix != null && baseMatrix.length == 6) {
float[] matrix =
new float[] {
orientationA, orientationB,
orientationC, orientationD,
startXCoord, startYCoord
};
result.setTextMatrix(matrix);
result.setX(null);
result.setY(null);
}
if (charCodeBuffer.isEmpty()) {
if (charCodeLen == 0) {
result.setCharCodes(null);
} else {
result.setCharCodes(new ArrayList<>(charCodeBuffer));
result.setCharCodes(java.util.Arrays.copyOf(charCodeBuf, charCodeLen));
}
compactTextElement(result);
return result;
@@ -5784,29 +5787,25 @@ public class PdfJsonConversionService {
Integer renderingMode,
Float spaceWidth) {}
private List<Float> extractMatrix(TextPosition position) {
float[] values = new float[6];
values[0] = position.getTextMatrix().getValue(0, 0);
values[1] = position.getTextMatrix().getValue(0, 1);
values[2] = position.getTextMatrix().getValue(1, 0);
values[3] = position.getTextMatrix().getValue(1, 1);
values[4] = position.getTextMatrix().getValue(2, 0);
values[5] = position.getTextMatrix().getValue(2, 1);
List<Float> matrix = new ArrayList<>(6);
for (float value : values) {
matrix.add(value);
}
return matrix;
private float[] extractMatrix(TextPosition position) {
return new float[] {
position.getTextMatrix().getValue(0, 0),
position.getTextMatrix().getValue(0, 1),
position.getTextMatrix().getValue(1, 0),
position.getTextMatrix().getValue(1, 1),
position.getTextMatrix().getValue(2, 0),
position.getTextMatrix().getValue(2, 1)
};
}
private Float computeFontMatrixSize(List<Float> matrix) {
if (matrix == null || matrix.size() < 4) {
private Float computeFontMatrixSize(float[] matrix) {
if (matrix == null || matrix.length < 4) {
return null;
}
float a = matrix.get(0);
float b = matrix.get(1);
float c = matrix.get(2);
float d = matrix.get(3);
float a = matrix[0];
float b = matrix[1];
float c = matrix[2];
float d = matrix[3];
float scaleX = (float) Math.hypot(a, c);
float scaleY = (float) Math.hypot(b, d);
float scale = Math.max(scaleX, scaleY);
@@ -5850,11 +5849,10 @@ public class PdfJsonConversionService {
colorSpaceName,
ex.getMessage());
}
List<Float> values = new ArrayList<>(effective.length);
for (float component : effective) {
values.add(component);
}
return PdfJsonTextColor.builder().colorSpace(colorSpaceName).components(values).build();
return PdfJsonTextColor.builder()
.colorSpace(colorSpaceName)
.components(effective)
.build();
}
private String sanitizeForLog(String value) {
@@ -6241,11 +6239,12 @@ public class PdfJsonConversionService {
PDRectangle rect = annotation.getRectangle();
if (rect != null) {
ann.setRect(
List.of(
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY()));
new float[] {
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY()
});
}
COSName appearanceState = annotation.getAppearanceState();
@@ -6254,12 +6253,7 @@ public class PdfJsonConversionService {
}
if (annotation.getColor() != null) {
float[] colorComponents = annotation.getColor().getComponents();
List<Float> colorList = new ArrayList<>(colorComponents.length);
for (float c : colorComponents) {
colorList.add(c);
}
ann.setColor(colorList);
ann.setColor(annotation.getColor().getComponents());
}
COSDictionary annotDict = annotation.getCOSObject();
@@ -30,7 +30,7 @@ public class SharedSignatureService {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private static final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService(ObjectMapper objectMapper) {
@@ -159,12 +159,12 @@ public class SharedSignatureService {
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
String base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
String rawExtension = mimeType.substring(mimeType.indexOf('/') + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file only
@@ -134,16 +134,16 @@ public class PdfJsonImageService {
cache.put(cacheKey, image);
}
List<Float> transform = element.getTransform();
if (transform != null && transform.size() == 6) {
float[] transform = element.getTransform();
if (transform != null && transform.length == 6) {
Matrix matrix =
new Matrix(
safeFloat(transform.get(0), 1f),
safeFloat(transform.get(1), 0f),
safeFloat(transform.get(2), 0f),
safeFloat(transform.get(3), 1f),
safeFloat(transform.get(4), 0f),
safeFloat(transform.get(5), 0f));
safeFloat(transform[0], 1f),
safeFloat(transform[1], 0f),
safeFloat(transform[2], 0f),
safeFloat(transform[3], 1f),
safeFloat(transform[4], 0f),
safeFloat(transform[5], 0f));
contentStream.drawImage(image, matrix);
return;
}
@@ -269,18 +269,18 @@ public class PdfJsonImageService {
return 0f;
}
private List<Float> toMatrixValues(Matrix matrix) {
List<Float> values = new ArrayList<>(6);
values.add(matrix.getValue(0, 0));
values.add(matrix.getValue(0, 1));
values.add(matrix.getValue(1, 0));
values.add(matrix.getValue(1, 1));
values.add(matrix.getValue(2, 0));
values.add(matrix.getValue(2, 1));
return values;
private float[] toMatrixValues(Matrix matrix) {
return new float[] {
matrix.getValue(0, 0),
matrix.getValue(0, 1),
matrix.getValue(1, 0),
matrix.getValue(1, 1),
matrix.getValue(2, 0),
matrix.getValue(2, 1)
};
}
private float safeFloat(Float value, float defaultValue) {
private static float safeFloat(Float value, float defaultValue) {
if (value == null || Float.isNaN(value) || Float.isInfinite(value)) {
return defaultValue;
}
@@ -324,7 +324,7 @@ public class PdfJsonImageService {
}
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
Bounds bounds = computeBounds(ctm);
List<Float> matrixValues = toMatrixValues(ctm);
float[] matrixValues = toMatrixValues(ctm);
PdfJsonImageElement element =
PdfJsonImageElement.builder()
@@ -110,7 +110,12 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
.webProgramFormat(toFormat(entry.getWebProgram()))
.pdfProgram(toBase64(entry.getPdfProgram()))
.pdfProgramFormat(toFormat(entry.getPdfProgram()))
.glyphCoverage(entry.getGlyphCoverage())
.glyphCoverage(
entry.getGlyphCoverage() != null
? entry.getGlyphCoverage().stream()
.mapToInt(Integer::intValue)
.toArray()
: null)
.message(message)
.build();
}
@@ -262,17 +262,30 @@ public class JobController {
@Operation(summary = "Get file metadata")
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
try {
// Verify file exists
if (!fileStorage.fileExists(fileId)) {
String jobKey = taskManager.findJobKeyByFileId(fileId);
if (jobKey == null) {
return ResponseEntity.notFound().build();
}
if (!validateJobAccess(jobKey)) {
log.warn("Unauthorized attempt to access file metadata: {}", fileId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this file"));
}
// Find the file metadata from any job that contains this file
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
if (resultFile != null) {
return ResponseEntity.ok(resultFile);
} else {
}
if (!isSecurityEnabled()) {
// Backwards compatibility when ownership service is unavailable
if (!fileStorage.fileExists(fileId)) {
return ResponseEntity.notFound().build();
}
// File exists but no metadata found, get basic info efficiently
long fileSize = fileStorage.getFileSize(fileId);
return ResponseEntity.ok(
@@ -286,6 +299,8 @@ public class JobController {
"fileSize",
fileSize));
}
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
return ResponseEntity.internalServerError()
@@ -303,11 +318,17 @@ public class JobController {
@Operation(summary = "Download a file")
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
try {
// Verify file exists
if (!fileStorage.fileExists(fileId)) {
String jobKey = taskManager.findJobKeyByFileId(fileId);
if (jobKey == null) {
return ResponseEntity.notFound().build();
}
if (!validateJobAccess(jobKey)) {
log.warn("Unauthorized attempt to download file: {}", fileId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this file"));
}
// Retrieve file content
byte[] fileContent = fileStorage.retrieveBytes(fileId);
@@ -327,11 +348,14 @@ public class JobController {
.body(fileContent);
} catch (Exception e) {
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
return ResponseEntity.internalServerError()
.body("Error retrieving file: " + e.getMessage());
return ResponseEntity.internalServerError().body("Error retrieving file");
}
}
private boolean isSecurityEnabled() {
return jobOwnershipService != null;
}
/**
* Create Content-Disposition header with UTF-8 filename support
*
@@ -1,4 +1,9 @@
multipart.enabled=true
# Jackson 3 defaults FAIL_ON_NULL_FOR_PRIMITIVES to true (was false in Jackson 2).
# Restore Jackson 2 behaviour so absent/null JSON fields map to Java primitive defaults.
spring.jackson.deserialization.fail-on-null-for-primitives=false
logging.level.org.springframework=WARN
logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
@@ -25,14 +30,16 @@ server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
server.error.path=/error
server.error.whitelabel.enabled=false
server.error.include-stacktrace=always
server.error.include-exception=true
server.error.include-message=always
spring.web.error.path=/error
spring.web.error.whitelabel.enabled=false
spring.web.error.include-stacktrace=always
spring.web.error.include-exception=true
spring.web.error.include-message=always
# Enable RFC 7807 Problem Details for HTTP APIs
spring.mvc.problemdetails.enabled=true
# Disable Spring's built-in ProblemDetailsExceptionHandler (@Order(0)) so that
# GlobalExceptionHandler runs first and provides detailed, logged error responses.
# GlobalExceptionHandler already produces RFC 7807 ProblemDetail objects.
spring.mvc.problemdetails.enabled=false
#logging.level.org.springframework.web=DEBUG
#logging.level.org.springframework=DEBUG
@@ -173,7 +173,7 @@ system:
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
mobileScannerSettings:
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
@@ -25,6 +25,7 @@ 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.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -69,16 +70,15 @@ class SplitPdfBySizeControllerTest {
request.setSplitType(1); // Page count
request.setSplitValue("2");
when(pdfDocumentFactory.load(any(byte[].class)))
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
ResponseEntity<?> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
}
@@ -104,15 +104,19 @@ class SplitPdfBySizeControllerTest {
request.setSplitType(2); // Document count
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
when(pdfDocumentFactory.load(any(byte[].class)))
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
.thenAnswer(
inv ->
Loader.loadPDF(
((org.springframework.web.multipart.MultipartFile)
inv.getArgument(0))
.getBytes()));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
ResponseEntity<?> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
}
}
@@ -113,10 +113,12 @@ class ConvertPDFToEpubControllerTest {
ResponseEntity<byte[]> response = controller.convertPdfToEpub(request);
List<String> command = commandCaptor.getValue();
assertEquals(11, command.size());
assertEquals(13, command.size());
assertEquals("ebook-convert", command.get(0));
assertEquals(expectedInput.toString(), command.get(1));
assertEquals(expectedOutput.toString(), command.get(2));
assertTrue(command.contains("--pdf-engine"));
assertTrue(command.contains("pdftohtml"));
assertTrue(command.contains("--enable-heuristics"));
assertTrue(command.contains("--insert-blank-line"));
assertTrue(command.contains("--filter-css"));
@@ -206,11 +208,13 @@ class ConvertPDFToEpubControllerTest {
assertTrue(command.stream().noneMatch(arg -> "--chapter".equals(arg)));
assertTrue(command.contains("--output-profile"));
assertTrue(command.contains(TargetDevice.KINDLE_EINK_TEXT.getCalibreProfile()));
assertTrue(command.contains("--pdf-engine"));
assertTrue(command.contains("pdftohtml"));
assertTrue(command.contains("--filter-css"));
assertTrue(
command.contains(
"font-family,color,background-color,margin-left,margin-right"));
assertTrue(command.size() >= 9);
assertTrue(command.size() >= 11);
assertEquals(EPUB_MEDIA_TYPE, response.getHeaders().getContentType());
assertEquals(
@@ -289,6 +293,8 @@ class ConvertPDFToEpubControllerTest {
assertEquals("ebook-convert", command.get(0));
assertEquals(expectedInput.toString(), command.get(1));
assertEquals(expectedOutput.toString(), command.get(2));
assertTrue(command.contains("--pdf-engine"));
assertTrue(command.contains("pdftohtml"));
assertTrue(command.contains("--enable-heuristics"));
assertTrue(command.contains("--insert-blank-line"));
assertTrue(command.contains("--filter-css"));
@@ -13,10 +13,12 @@ import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PdfToCbzUtils;
import stirling.software.common.util.TempFileManager;
public class PdfToCbzUtilsTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@BeforeEach
public void setUp() {
@@ -42,7 +44,9 @@ public class PdfToCbzUtilsTest {
IllegalArgumentException exception =
Assertions.assertThrows(
IllegalArgumentException.class,
() -> PdfToCbzUtils.convertPdfToCbz(null, 300, pdfDocumentFactory));
() ->
PdfToCbzUtils.convertPdfToCbz(
null, 300, pdfDocumentFactory, tempFileManager));
Assertions.assertEquals("File cannot be null or empty", exception.getMessage());
}
@@ -54,7 +58,9 @@ public class PdfToCbzUtilsTest {
IllegalArgumentException exception =
Assertions.assertThrows(
IllegalArgumentException.class,
() -> PdfToCbzUtils.convertPdfToCbz(emptyFile, 300, pdfDocumentFactory));
() ->
PdfToCbzUtils.convertPdfToCbz(
emptyFile, 300, pdfDocumentFactory, tempFileManager));
Assertions.assertEquals("File cannot be null or empty", exception.getMessage());
}
@@ -66,7 +72,9 @@ public class PdfToCbzUtilsTest {
IllegalArgumentException exception =
Assertions.assertThrows(
IllegalArgumentException.class,
() -> PdfToCbzUtils.convertPdfToCbz(nonPdfFile, 300, pdfDocumentFactory));
() ->
PdfToCbzUtils.convertPdfToCbz(
nonPdfFile, 300, pdfDocumentFactory, tempFileManager));
Assertions.assertEquals("File must be in PDF format", exception.getMessage());
}
@@ -84,7 +92,9 @@ public class PdfToCbzUtilsTest {
// structure
Assertions.assertThrows(
Exception.class,
() -> PdfToCbzUtils.convertPdfToCbz(pdfFile, 300, pdfDocumentFactory));
() ->
PdfToCbzUtils.convertPdfToCbz(
pdfFile, 300, pdfDocumentFactory, tempFileManager));
// Verify that load was called
Mockito.verify(pdfDocumentFactory).load(pdfFile);
@@ -27,7 +27,7 @@ import stirling.software.common.service.UserServiceInterface;
class PdfMetadataServiceBasicTest {
private PdfMetadataService pdfMetadataService;
private final String STIRLING_PDF_LABEL = "Stirling PDF";
private static final String STIRLING_PDF_LABEL = "Stirling PDF";
@BeforeEach
void setUp() {
@@ -33,7 +33,7 @@ class PdfMetadataServiceTest {
@Mock private ApplicationProperties applicationProperties;
@Mock private UserServiceInterface userService;
private PdfMetadataService pdfMetadataService;
private final String STIRLING_PDF_LABEL = "Stirling PDF";
private static final String STIRLING_PDF_LABEL = "Stirling PDF";
@BeforeEach
void setUp() {
@@ -28,8 +28,8 @@ class SignatureServiceTest {
private SharedSignatureService signatureService;
private Path personalSignatureFolder;
private Path sharedSignatureFolder;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final String TEST_USER = "testUser";
private static final String ALL_USERS_FOLDER = "ALL_USERS";
private static final String TEST_USER = "testUser";
@BeforeEach
void setUp() throws IOException {
@@ -15,11 +15,13 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
@@ -33,6 +35,8 @@ class JobControllerTest {
@Mock private HttpServletRequest request;
@Mock private JobOwnershipService jobOwnershipService;
private MockHttpSession session;
@InjectMocks private JobController controller;
@@ -404,4 +408,32 @@ class JobControllerTest {
verify(taskManager).setError(jobId, "Job was cancelled by user");
}
@Test
void testDownloadFile_ForbiddenWhenFileOwnedByAnotherUser() throws Exception {
String fileId = "file-id";
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
when(taskManager.findJobKeyByFileId(fileId)).thenReturn("other-user:job-id");
when(jobOwnershipService.validateJobAccess("other-user:job-id")).thenReturn(false);
ResponseEntity<?> response = controller.downloadFile(fileId);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
verify(fileStorage, never()).retrieveBytes(eq(fileId));
}
@Test
void testGetFileMetadata_ForbiddenWhenFileOwnedByAnotherUser() throws Exception {
String fileId = "file-id";
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
when(taskManager.findJobKeyByFileId(fileId)).thenReturn("other-user:job-id");
when(jobOwnershipService.validateJobAccess("other-user:job-id")).thenReturn(false);
ResponseEntity<?> response = controller.getFileMetadata(fileId);
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
verify(fileStorage, never()).getFileSize(eq(fileId));
}
}
+5 -6
View File
@@ -1,5 +1,6 @@
repositories {
maven { url = "https://build.shibboleth.net/maven/releases" }
maven { url = "https://repository.jboss.org/" }
}
ext {
@@ -13,10 +14,10 @@ bootRun {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/java/org/apache/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -36,6 +37,7 @@ spotless {
}
dependencies {
implementation project(':common')
api 'com.google.guava:guava:33.4.8-jre'
api 'org.springframework:spring-jdbc'
api 'org.springframework:spring-webmvc'
@@ -63,12 +65,9 @@ dependencies {
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
runtimeOnly 'org.postgresql:postgresql:42.7.10'
constraints {
implementation "org.opensaml:opensaml-core:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-impl:$openSamlVersion"
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
}
implementation 'com.coveo:saml-client:5.0.0'
}
tasks.register('prepareKotlinBuildScriptModel') {}
@@ -49,7 +49,7 @@ public enum AuditEventType {
// If the exact enum name doesn't match, try finding a similar one
for (AuditEventType eventType : values()) {
if (eventType.name().equalsIgnoreCase(type)
|| eventType.getDescription().equalsIgnoreCase(type)) {
|| eventType.description.equalsIgnoreCase(type)) {
return eventType;
}
}
@@ -43,6 +43,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
@@ -357,8 +358,12 @@ public class ProprietaryUIDataController {
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
List<AdminUserSummary> userSummaries =
sortedUsers.stream().map(this::convertUserToSummary).toList();
AdminSettingsData data = new AdminSettingsData();
data.setUsers(sortedUsers);
data.setUsers(userSummaries);
data.setCurrentUsername(authentication.getName());
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
@@ -518,6 +523,34 @@ public class ProprietaryUIDataController {
return ResponseEntity.ok(data);
}
/**
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
* apiKey.
*/
private AdminUserSummary convertUserToSummary(User user) {
AdminUserSummary summary = new AdminUserSummary();
summary.setId(user.getId());
summary.setUsername(user.getUsername());
summary.setEmail(user.getUsername()); // Use username as email for consistency
summary.setRoleName(user.getRoleName());
summary.setRolesAsString(user.getRolesAsString());
summary.setEnabled(user.isEnabled());
summary.setIsFirstLogin(user.isFirstLogin());
summary.setAuthenticationType(user.getAuthenticationType());
summary.setCreatedAt(user.getCreatedAt());
summary.setUpdatedAt(user.getUpdatedAt());
// Map team if present
if (user.getTeam() != null) {
AdminUserSummary.TeamSummary teamSummary = new AdminUserSummary.TeamSummary();
teamSummary.setId(user.getTeam().getId());
teamSummary.setName(user.getTeam().getName());
summary.setTeam(teamSummary);
}
return summary;
}
// Data classes
@Data
public static class AuditDashboardData {
@@ -544,7 +577,7 @@ public class ProprietaryUIDataController {
@Data
public static class AdminSettingsData {
private List<User> users;
private List<AdminUserSummary> users;
private String currentUsername;
private Map<String, String> roleDetails;
private Map<String, Boolean> userSessions;
@@ -196,8 +196,9 @@ public class AdminSettingsController {
log.info("Admin updating setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change
pendingChanges.put(key, value);
// Track this as a pending change (convert null to empty string for
// ConcurrentHashMap)
pendingChanges.put(key, value != null ? value : "");
updatedCount++;
}
@@ -268,6 +269,9 @@ public class AdminSettingsController {
}
}
// Mask sensitive fields before returning to frontend
sectionMap = maskSensitiveFields(sectionMap);
log.debug(
"Admin requested settings section: {} (includePending={})",
sectionName,
@@ -404,6 +408,13 @@ public class AdminSettingsController {
return ResponseEntity.badRequest()
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
}
// Mask sensitive values before returning
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
if (isSensitiveFieldWithPath(keyName, key)) {
value = createMaskedValue(value);
}
log.debug("Admin requested setting: {}", key);
return ResponseEntity.ok(new SettingValueResponse(key, value));
} catch (IllegalArgumentException e) {
@@ -441,6 +452,20 @@ public class AdminSettingsController {
}
Object value = request.getValue();
// Prevent saving masked values for sensitive fields to avoid data loss
if ("********".equals(value)) {
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
if (isSensitiveFieldWithPath(keyName, key)) {
log.warn(
"Admin attempted to save masked value for sensitive field: {}. This operation is blocked to prevent data loss.",
key);
return ResponseEntity.badRequest()
.body(
"Cannot save masked values for sensitive settings. Please provide the actual value.");
}
}
log.info("Admin updating single setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
@@ -123,7 +123,7 @@ public class AuthController {
log.warn("Invalid password for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
.body(Map.of("error", "Invalid username or password"));
}
if (!user.isEnabled()) {
@@ -191,6 +191,20 @@ public class DatabaseController {
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("File must not be null or empty");
}
// Validate that file is a legitimate backup file
// Only allow files matching the backup naming pattern
if (!fileName.startsWith("backup_") || !fileName.endsWith(".sql")) {
log.warn("Attempted download of non-backup file: {}", fileName);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"invalidFileName",
"message",
"Only backup files are allowed"));
}
try {
Path filePath = databaseService.getBackupFilePath(fileName);
InputStreamResource resource = new InputStreamResource(Files.newInputStream(filePath));
@@ -358,27 +358,23 @@ public class InviteLinkController {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
return invalidInviteResponse();
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has already been used"));
return invalidInviteResponse();
}
if (invite.isExpired()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has expired"));
return invalidInviteResponse();
}
// Check if user already exists (only if email is pre-set)
if (invite.getEmail() != null
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
return invalidInviteResponse();
}
Map<String, Object> response = new HashMap<>();
@@ -391,8 +387,7 @@ public class InviteLinkController {
} catch (Exception e) {
log.error("Failed to validate invite token: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to validate invite link"));
return invalidInviteResponse();
}
}
@@ -419,20 +414,17 @@ public class InviteLinkController {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
return invalidInviteResponse();
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has already been used"));
return invalidInviteResponse();
}
if (invite.isExpired()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has expired"));
return invalidInviteResponse();
}
// Determine the email to use
@@ -455,8 +447,7 @@ public class InviteLinkController {
// Check if user already exists
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
return invalidInviteResponse();
}
// Create the user account
@@ -484,7 +475,12 @@ public class InviteLinkController {
} catch (Exception e) {
log.error("Failed to accept invite: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
.body(Map.of("error", "Failed to create account"));
}
}
private ResponseEntity<Map<String, String>> invalidInviteResponse() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
}
}
@@ -942,7 +942,7 @@ public class UserController {
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
if (username == null || "anonymousUser".equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
@@ -46,9 +46,11 @@ public class User implements UserDetails, Serializable {
private String username;
@Column(name = "password")
@JsonIgnore
private String password;
@Column(name = "apiKey")
@JsonIgnore
private String apiKey;
@Column(name = "enabled")
@@ -0,0 +1,72 @@
package stirling.software.proprietary.security.model.dto;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Data Transfer Object for admin user listings. Contains only the fields needed by the admin UI for
* displaying user information. Excludes sensitive fields like password and apiKey.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Admin user summary for listing in admin UI - excludes sensitive fields")
public class AdminUserSummary {
@Schema(description = "User ID")
private Long id;
@Schema(description = "Username/login identifier")
private String username;
@Schema(description = "User email address")
private String email;
@Schema(description = "Role name (translation key like 'adminUserSettings.admin')")
private String roleName;
@Schema(description = "Role identifier (e.g., 'ROLE_ADMIN')")
private String rolesAsString;
@Schema(description = "Whether user account is enabled")
private boolean enabled;
@Schema(description = "Whether this is the user's first login")
private Boolean isFirstLogin;
@Schema(description = "Authentication type (WEB, OAUTH2, SAML2)")
private String authenticationType;
@Schema(description = "Team membership (if any)")
private TeamSummary team;
@Schema(description = "User account creation timestamp")
private LocalDateTime createdAt;
@Schema(description = "User account last update timestamp")
private LocalDateTime updatedAt;
/** Minimal Team DTO for admin user listings */
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Team summary (id and name only)")
public static class TeamSummary {
@Schema(description = "Team ID")
private Long id;
@Schema(description = "Team name")
private String name;
}
}
@@ -22,6 +22,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.sql.DataSource;
@@ -47,6 +48,48 @@ public class DatabaseService implements DatabaseServiceInterface {
public static final String SQL_SUFFIX = ".sql";
private final Path BACKUP_DIR;
// Whitelist of allowed SQL patterns for H2 database backups (generated by SCRIPT command)
// Only standard DDL/DML operations are permitted - anything else is rejected by default
private static final java.util.List<Pattern> ALLOWED_PATTERNS =
java.util.List.of(
Pattern.compile("(?i)\\bCREATE\\b"),
Pattern.compile("(?i)\\bSET\\s+SCHEMA\\b"),
Pattern.compile("(?i)\\bALTER\\s+SEQUENCE\\b"),
Pattern.compile("(?i)\\bDROP\\s+TABLE\\b"),
Pattern.compile("(?i)\\bALTER\\s+TABLE\\b"),
Pattern.compile("(?i)\\bADD\\s+COLUMN\\b"),
Pattern.compile("(?i)\\bINSERT\\s+INTO\\b"),
Pattern.compile("(?i)\\bVALUES\\b"),
Pattern.compile("(?i)\\bADD\\s+CONSTRAINT\\b"),
Pattern.compile("(?i)\\bPRIMARY\\s+KEY\\b"),
Pattern.compile("(?i)\\bFOREIGN\\s+KEY\\b"),
Pattern.compile("(?i)\\bCONSTRAINT\\b"),
Pattern.compile("(?i)\\bCHECK\\b"),
Pattern.compile(
"(?i)\\bGENERATED\\s+(BY\\s+DEFAULT|ALWAYS)\\s+AS\\s+IDENTITY\\b"),
Pattern.compile("(?i)\\bDEFAULT\\b"),
Pattern.compile(
"(?i)\\bSET\\s+(REFERENTIAL_INTEGRITY|MODE|DATABASE|IGNORECASE|AUTOINCREMENT|DB_CLOSE_DELAY)\\b"),
Pattern.compile("(?i)\\bIF\\s+EXISTS\\b"),
Pattern.compile("(?i)\\bIF\\s+NOT\\s+EXISTS\\b"),
Pattern.compile("(?i)\\bNULLS\\s+(FIRST|LAST)\\b"),
Pattern.compile("(?i)\\bREFERENCES\\b"),
Pattern.compile("(?i)\\bNOCHECK\\b"),
Pattern.compile("(?i)\\bRESTART\\s+WITH\\b"),
Pattern.compile("(?i)\\bCASCADE\\b"),
Pattern.compile("(?i)\\bON\\b"),
Pattern.compile("(?i)\\bSTART\\s+WITH\\b"),
Pattern.compile("(?i)\\bAS\\b"),
Pattern.compile("(?i)\\bUNIQUE\\b"),
Pattern.compile("(?i)\\bDISTINCT\\b"),
Pattern.compile("(?i)\\bWITH\\s+TIME\\s+ZONE\\b"),
Pattern.compile("(?i)\\bTIMESTAMP\\b"),
Pattern.compile("(?i)\\bVARYING\\b"),
Pattern.compile("(?i)\\bNOT\\s+NULL\\b"),
Pattern.compile("(?i)\\bTRUE\\b"),
Pattern.compile("(?i)\\bFALSE\\b"),
Pattern.compile("(?i)\\bNULL\\b"));
private final ApplicationProperties.Datasource datasourceProps;
private final DataSource dataSource;
private final DatabaseNotificationServiceInterface backupNotificationService;
@@ -450,6 +493,9 @@ public class DatabaseService implements DatabaseServiceInterface {
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
}
// Validate SQL content before execution to prevent injection attacks
validateSqlContent(scriptPath);
String query = "RUNSCRIPT from ?;";
try (Connection conn = dataSource.getConnection();
@@ -466,6 +512,68 @@ public class DatabaseService implements DatabaseServiceInterface {
log.info("Database import completed: {}", scriptPath);
}
/**
* Validates SQL content to prevent execution of dangerous functions. Uses an allowlist approach
* - only allows standard DML/DDL operations generated by H2's SCRIPT command.
*
* @param scriptPath the path to the SQL file
* @throws IllegalArgumentException if dangerous SQL or disallowed patterns are detected
*/
private void validateSqlContent(Path scriptPath) {
try {
String content = Files.readString(scriptPath);
String normalizedContent = normalizeSqlContent(content);
// Validate that content only contains allowed operations (whitelist approach)
// Split by semicolons to check individual statements
String[] statements = normalizedContent.split(";");
for (String statement : statements) {
statement = statement.trim();
if (statement.isEmpty()) {
continue;
}
// Check if statement matches any allowed pattern
boolean isAllowed = false;
for (Pattern allowedPattern : ALLOWED_PATTERNS) {
if (allowedPattern.matcher(statement).find()) {
isAllowed = true;
break;
}
}
if (!isAllowed) {
log.error(
"Unrecognized SQL statement in backup file: {}",
statement.substring(0, Math.min(50, statement.length())));
throw new IllegalArgumentException(
"SQL script contains unrecognized or disallowed SQL statements. File may be"
+ " corrupted or tampered with.");
}
}
} catch (IOException e) {
log.error("Error reading SQL script for validation: {}", e.getMessage());
throw new IllegalArgumentException("Failed to validate SQL script: " + e.getMessage());
}
}
/**
* Normalizes SQL content by removing comments to prevent bypass attacks.
*
* @param sql the SQL content to normalize
* @return normalized SQL without comments
*/
private String normalizeSqlContent(String sql) {
// Remove block comments (/* ... */)
sql = sql.replaceAll("/\\*[\\s\\S]*?\\*/", " ");
// Remove line comments (--....)
sql = sql.replaceAll("--[^\r\n]*", " ");
// Collapse multiple whitespaces
sql = sql.replaceAll("\\s+", " ");
return sql.trim();
}
/**
* Checks for invalid characters or sequences
*
@@ -35,7 +35,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private static final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
// Storage limits per user
@@ -115,7 +115,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
}
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
String base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Validate decoded size
@@ -127,8 +127,8 @@ public class SignatureService implements PersonalSignatureServiceInterface {
}
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
String rawExtension = mimeType.substring(mimeType.indexOf('/') + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file
@@ -149,7 +149,7 @@ class AuthControllerLoginTest {
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Invalid credentials"));
.andExpect(jsonPath("$.error").value("Invalid username or password"));
verify(loginAttemptService).loginFailed("user@example.com");
}
@@ -125,7 +125,7 @@ class InviteLinkControllerTest {
}
@Test
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
void validateInviteTokenReturnsNotFoundWhenExpired() throws Exception {
InviteToken expired = new InviteToken();
expired.setToken("abc");
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
@@ -133,8 +133,8 @@ class InviteLinkControllerTest {
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
mockMvc.perform(get("/api/v1/invite/validate/abc"))
.andExpect(status().isGone())
.andExpect(jsonPath("$.error").value("This invite link has expired"));
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("Invalid invite link"));
}
@Test
@@ -128,4 +128,139 @@ class DatabaseServiceTest {
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString());
}
@Test
void validateSqlContentAcceptsRealH2BackupFile() throws IOException {
String sqlContent =
"-- H2 2.4.240; \n"
+ "SET DB_CLOSE_DELAY -1; \n"
+ "; \n"
+ "CREATE USER IF NOT EXISTS \"SA\" SALT 'd4a7c2f9e1b5a8c3' HASH 'b8f3e6c1a9d2f7e4c5a8d1f6b3e7a2c9d0f5b8e1a4c7f2d9e6b3a1c8f5d2' ADMIN; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"AUDIT_EVENTS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"AUTHORITIES\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"INVITE_TOKENS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"PERSISTENT_LOGINS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"SESSIONS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"TEAMS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"USER_LICENSE_SETTINGS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"USER_SETTINGS\" CASCADE; \n"
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"USERS\" CASCADE; \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"AUDIT_EVENTS\"(\n"
+ " \"ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1) NOT NULL,\n"
+ " \"DATA\" CHARACTER VARYING,\n"
+ " \"PRINCIPAL\" CHARACTER VARYING(255),\n"
+ " \"TIMESTAMP\" TIMESTAMP(6) WITH TIME ZONE,\n"
+ " \"TYPE\" CHARACTER VARYING(255)\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"AUDIT_EVENTS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_C\" PRIMARY KEY(\"ID\"); \n"
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.AUDIT_EVENTS; \n"
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_TIMESTAMP\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"TIMESTAMP\" NULLS FIRST); \n"
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_PRINCIPAL\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"PRINCIPAL\" NULLS FIRST); \n"
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_TYPE\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"TYPE\" NULLS FIRST); \n"
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_PRINCIPAL_TYPE\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"PRINCIPAL\" NULLS FIRST, \"TYPE\" NULLS FIRST); \n"
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_TYPE_TIMESTAMP\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"TYPE\" NULLS FIRST, \"TIMESTAMP\" NULLS FIRST); \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"AUTHORITIES\"(\n"
+ " \"ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1 RESTART WITH 3) NOT NULL,\n"
+ " \"AUTHORITY\" CHARACTER VARYING(255),\n"
+ " \"USER_ID\" BIGINT\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"AUTHORITIES\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_A\" PRIMARY KEY(\"ID\"); \n"
+ "-- 2 +/- SELECT COUNT(*) FROM PUBLIC.AUTHORITIES; \n"
+ "INSERT INTO \"PUBLIC\".\"AUTHORITIES\"(\"ID\", \"AUTHORITY\", \"USER_ID\") VALUES(1, 'ROLE_ADMIN', 1); \n"
+ "INSERT INTO \"PUBLIC\".\"AUTHORITIES\"(\"ID\", \"AUTHORITY\", \"USER_ID\") VALUES(2, 'STIRLING-PDF-BACKEND-API-USER', 2);\n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"INVITE_TOKENS\"(\n"
+ " \"ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1) NOT NULL,\n"
+ " \"CREATED_AT\" TIMESTAMP(6),\n"
+ " \"CREATED_BY\" CHARACTER VARYING(255) NOT NULL,\n"
+ " \"EMAIL\" CHARACTER VARYING(255),\n"
+ " \"EXPIRES_AT\" TIMESTAMP(6) NOT NULL,\n"
+ " \"ROLE\" CHARACTER VARYING(50) NOT NULL,\n"
+ " \"TEAM_ID\" BIGINT,\n"
+ " \"TOKEN\" CHARACTER VARYING(100) NOT NULL,\n"
+ " \"USED\" BOOLEAN NOT NULL,\n"
+ " \"USED_AT\" TIMESTAMP(6)\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"INVITE_TOKENS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_E\" PRIMARY KEY(\"ID\"); \n"
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.INVITE_TOKENS; \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"PERSISTENT_LOGINS\"(\n"
+ " \"SERIES\" CHARACTER VARYING(255) NOT NULL,\n"
+ " \"LAST_USED\" TIMESTAMP(6) WITH TIME ZONE NOT NULL,\n"
+ " \"TOKEN\" CHARACTER VARYING(64) NOT NULL,\n"
+ " \"USERNAME\" CHARACTER VARYING(64) NOT NULL\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"PERSISTENT_LOGINS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_A3\" PRIMARY KEY(\"SERIES\"); \n"
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.PERSISTENT_LOGINS; \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"SESSIONS\"(\n"
+ " \"SESSION_ID\" CHARACTER VARYING(255) NOT NULL,\n"
+ " \"EXPIRED\" BOOLEAN NOT NULL,\n"
+ " \"LAST_REQUEST\" TIMESTAMP(6) WITH TIME ZONE,\n"
+ " \"PRINCIPAL_NAME\" CHARACTER VARYING(255)\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"SESSIONS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_8\" PRIMARY KEY(\"SESSION_ID\"); \n"
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.SESSIONS; \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"TEAMS\"(\n"
+ " \"TEAM_ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1 RESTART WITH 3) NOT NULL,\n"
+ " \"NAME\" CHARACTER VARYING(255) NOT NULL\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"TEAMS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_4\" PRIMARY KEY(\"TEAM_ID\"); \n"
+ "-- 2 +/- SELECT COUNT(*) FROM PUBLIC.TEAMS; \n"
+ "INSERT INTO \"PUBLIC\".\"TEAMS\"(\"TEAM_ID\", \"NAME\") VALUES(1, 'Default'); \n"
+ "INSERT INTO \"PUBLIC\".\"TEAMS\"(\"TEAM_ID\", \"NAME\") VALUES(2, 'Internal'); \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"USER_LICENSE_SETTINGS\"(\n"
+ " \"ID\" BIGINT NOT NULL,\n"
+ " \"GRANDFATHERED_USER_COUNT\" INTEGER NOT NULL,\n"
+ " \"GRANDFATHERED_USER_SIGNATURE\" CHARACTER VARYING(256) NOT NULL,\n"
+ " \"GRANDFATHERING_LOCKED\" BOOLEAN NOT NULL,\n"
+ " \"INTEGRITY_SALT\" CHARACTER VARYING(64) NOT NULL,\n"
+ " \"LICENSE_MAX_USERS\" INTEGER NOT NULL\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"USER_LICENSE_SETTINGS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_9\" PRIMARY KEY(\"ID\"); \n"
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.USER_LICENSE_SETTINGS; \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"USER_SETTINGS\"(\n"
+ " \"USER_ID\" BIGINT NOT NULL,\n"
+ " \"SETTING_VALUE\" CHARACTER VARYING,\n"
+ " \"SETTING_KEY\" CHARACTER VARYING(255) NOT NULL\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"USER_SETTINGS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_9A\" PRIMARY KEY(\"USER_ID\", \"SETTING_KEY\"); \n"
+ "-- 4 +/- SELECT COUNT(*) FROM PUBLIC.USER_SETTINGS; \n"
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(1, 'false', 'mfaEnabled'); \n"
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(1, 'false', 'mfaRequired'); \n"
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(2, 'false', 'mfaEnabled'); \n"
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(2, 'false', 'mfaRequired'); \n"
+ "CREATE CACHED TABLE \"PUBLIC\".\"USERS\"(\n"
+ " \"USER_ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1 RESTART WITH 3) NOT NULL,\n"
+ " \"API_KEY\" CHARACTER VARYING(255),\n"
+ " \"AUTHENTICATIONTYPE\" CHARACTER VARYING(255),\n"
+ " \"CREATED_AT\" TIMESTAMP(6),\n"
+ " \"ENABLED\" BOOLEAN,\n"
+ " \"FORCE_PASSWORD_CHANGE\" BOOLEAN,\n"
+ " \"HAS_COMPLETED_INITIAL_SETUP\" BOOLEAN,\n"
+ " \"IS_FIRST_LOGIN\" BOOLEAN,\n"
+ " \"OAUTH_GRANDFATHERED\" BOOLEAN,\n"
+ " \"PASSWORD\" CHARACTER VARYING(255),\n"
+ " \"ROLE_NAME\" CHARACTER VARYING(255),\n"
+ " \"SSO_PROVIDER\" CHARACTER VARYING(255),\n"
+ " \"SSO_PROVIDER_ID\" CHARACTER VARYING(255),\n"
+ " \"UPDATED_AT\" TIMESTAMP(6),\n"
+ " \"USERNAME\" CHARACTER VARYING(255),\n"
+ " \"TEAM_ID\" BIGINT\n"
+ "); \n"
+ "ALTER TABLE \"PUBLIC\".\"USERS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_4D\" PRIMARY KEY(\"USER_ID\"); \n"
+ "-- 2 +/- SELECT COUNT(*) FROM PUBLIC.USERS; \n"
+ "INSERT INTO \"PUBLIC\".\"USERS\"(\"USER_ID\", \"API_KEY\", \"AUTHENTICATIONTYPE\", \"CREATED_AT\", \"ENABLED\", \"FORCE_PASSWORD_CHANGE\", \"HAS_COMPLETED_INITIAL_SETUP\", \"IS_FIRST_LOGIN\", \"OAUTH_GRANDFATHERED\", \"PASSWORD\", \"ROLE_NAME\", \"SSO_PROVIDER\", \"SSO_PROVIDER_ID\", \"UPDATED_AT\", \"USERNAME\", \"TEAM_ID\") VALUES(1, NULL, 'web', TIMESTAMP '2026-02-26 00:25:03.688329', TRUE, FALSE, FALSE, FALSE, FALSE, '$2a$10$k9mF3xL6pD8nB2v5rT1wSuZqJ4kP7mH8gN5aY3bX9cW2eR6fD9sY1', NULL, NULL, NULL, TIMESTAMP '2026-02-26 00:25:03.688365', 'ludy', 1); \n"
+ "INSERT INTO \"PUBLIC\".\"USERS\"(\"USER_ID\", \"API_KEY\", \"AUTHENTICATIONTYPE\", \"CREATED_AT\", \"ENABLED\", \"FORCE_PASSWORD_CHANGE\", \"HAS_COMPLETED_INITIAL_SETUP\", \"IS_FIRST_LOGIN\", \"OAUTH_GRANDFATHERED\", \"PASSWORD\", \"ROLE_NAME\", \"SSO_PROVIDER\", \"SSO_PROVIDER_ID\", \"UPDATED_AT\", \"USERNAME\", \"TEAM_ID\") VALUES(2, 'e3c7f1a9-6b2d-4f5e-8c3a-9d1b7e4f2c6a', 'web', TIMESTAMP '2026-02-26 00:25:03.845308', TRUE, FALSE, FALSE, FALSE, FALSE, '$2a$10$m7pB4qL9tF2jW5hX8vR3nUYsK1dE6cG9oP0jM3iC8lA7nZ4fT9xR2', NULL, NULL, NULL, TIMESTAMP '2026-02-26 00:25:03.914916', 'STIRLING-PDF-BACKEND-API-USER', 2); \n"
+ "ALTER TABLE \"PUBLIC\".\"USERS\" ADD CONSTRAINT \"PUBLIC\".\"UKR43AF9AP4EDM43MMTQ01ODDJ6\" UNIQUE NULLS DISTINCT (\"USERNAME\"); \n"
+ "ALTER TABLE \"PUBLIC\".\"TEAMS\" ADD CONSTRAINT \"PUBLIC\".\"UKA510NO6SJWQCX153YD5SM4JRR\" UNIQUE NULLS DISTINCT (\"NAME\"); \n"
+ "ALTER TABLE \"PUBLIC\".\"INVITE_TOKENS\" ADD CONSTRAINT \"PUBLIC\".\"UKEWCI50C0NCFIIHR2TC41NFBNF\" UNIQUE NULLS DISTINCT (\"TOKEN\"); \n"
+ "ALTER TABLE \"PUBLIC\".\"AUTHORITIES\" ADD CONSTRAINT \"PUBLIC\".\"FKK91UPMBUEYIM93V469WJ7B2QH\" FOREIGN KEY(\"USER_ID\") REFERENCES \"PUBLIC\".\"USERS\"(\"USER_ID\") NOCHECK;\n"
+ "ALTER TABLE \"PUBLIC\".\"USER_SETTINGS\" ADD CONSTRAINT \"PUBLIC\".\"FK8V82NJ88RMAI0NYCK19F873DW\" FOREIGN KEY(\"USER_ID\") REFERENCES \"PUBLIC\".\"USERS\"(\"USER_ID\") NOCHECK; \n"
+ "ALTER TABLE \"PUBLIC\".\"USERS\" ADD CONSTRAINT \"PUBLIC\".\"FKFJWS1RDRUAB2BQG7QIPOQF65R\" FOREIGN KEY(\"TEAM_ID\") REFERENCES \"PUBLIC\".\"TEAMS\"(\"TEAM_ID\") NOCHECK; \n"
+ "";
Path script = Files.createTempFile("backup", ".sql");
Files.writeString(script, sqlContent);
boolean result = databaseService.importDatabaseFromUI(script);
assertThat(result).isTrue();
}
}
+31 -39
View File
@@ -26,7 +26,7 @@ ext {
lombokVersion = "1.18.42"
bouncycastleVersion = "1.83"
springSecuritySamlVersion = "7.0.2"
openSamlVersion = "4.3.2"
openSamlVersion = "5.2.1"
commonmarkVersion = "0.27.1"
googleJavaFormatVersion = "1.34.1"
logback = "1.5.32"
@@ -85,49 +85,40 @@ allprojects {
}
}
def writeIfChanged(File targetFile, String newContent) {
if (targetFile.getText('UTF-8') != newContent) {
targetFile.write(newContent, 'UTF-8')
}
}
def updateTauriConfigVersion(String version) {
File tauriConfig = file('frontend/src-tauri/tauri.conf.json')
def parsed = new JsonSlurper().parse(tauriConfig)
parsed.version = version
def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator()
writeIfChanged(tauriConfig, formatted)
}
def updateSimulationVersion(File fileToUpdate, String version) {
def content = fileToUpdate.getText('UTF-8')
def matcher = content =~ /(appVersion:\s*')([^']*)(')/
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}")
writeIfChanged(fileToUpdate, updatedContent)
}
def rootProjectRef = project
def appVersionStr = project.version.toString()
def tauriConfigPath = layout.projectDirectory.file('frontend/src-tauri/tauri.conf.json').asFile.path
def sim1Path = layout.projectDirectory.file('frontend/src/core/testing/serverExperienceSimulations.ts').asFile.path
def sim2Path = layout.projectDirectory.file('frontend/src/proprietary/testing/serverExperienceSimulations.ts').asFile.path
tasks.register('syncAppVersion') {
group = 'versioning'
description = 'Synchronizes app version across desktop and simulation configs.'
doLast {
def appVersion = rootProjectRef.version.toString()
println "Synchronizing application version to ${appVersion}"
updateTauriConfigVersion(appVersion)
println "Synchronizing application version to ${appVersionStr}"
[
'frontend/src/core/testing/serverExperienceSimulations.ts',
'frontend/src/proprietary/testing/serverExperienceSimulations.ts'
].each { path ->
updateSimulationVersion(file(path), appVersion)
def tauriConfigFile = new File(tauriConfigPath)
if (tauriConfigFile.exists()) {
def parsed = new groovy.json.JsonSlurper().parse(tauriConfigFile)
parsed.version = appVersionStr
def formatted = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(parsed)) + System.lineSeparator()
if (tauriConfigFile.getText('UTF-8') != formatted) {
tauriConfigFile.write(formatted, 'UTF-8')
}
}
[new File(sim1Path), new File(sim2Path)].each { f ->
if (f.exists()) {
def content = f.getText('UTF-8')
def matcher = (content =~ /(appVersion:\s*')([^']*)(')/)
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}")
if (content != updatedContent) {
f.write(updatedContent, 'UTF-8')
}
}
}
}
}
@@ -176,6 +167,7 @@ subprojects {
}
}
maven { url = "https://build.shibboleth.net/maven/releases" }
maven { url = "https://repository.jboss.org/" }
mavenCentral()
}
@@ -278,7 +270,7 @@ subprojects {
}
def thresholds = [
LINE : 0.16,
LINE : 0.13,
INSTRUCTION: 0.14,
BRANCH : 0.09
]
@@ -371,7 +363,7 @@ subprojects {
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.16
minimum = 0.13
}
// Verzweigungen (if/else, switch) abgedeckt; misst Logik-Abdeckung
limit {
+2 -2
View File
@@ -25,7 +25,7 @@ Stirling-PDF is built using:
- Docker
- Git
- Java JDK 17 or later
- Java JDK 21 or later (JDK 25 recommended)
- Gradle 7.0 or later (Included within the repo)
### Setup Steps
@@ -37,7 +37,7 @@ Stirling-PDF is built using:
cd Stirling-PDF
```
2. Install Docker and JDK17 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
+369 -231
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0",
"stylelint": "^16.26.1",
"stylelint": "^17.4.0",
"stylelint-config-standard": "^40.0.0"
}
},
@@ -39,27 +39,27 @@
}
},
"node_modules/@cacheable/memory": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.5.tgz",
"integrity": "sha512-fkiAxCvssEyJZ5fxX4tcdZFRmW9JehSTGvvqmXn6rTzG5cH6V/3C4ad8yb01vOjp2xBydHkHrgpW0qeGtzt6VQ==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
"integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.3.0",
"@keyv/bigmap": "^1.1.0",
"hookified": "^1.12.2",
"keyv": "^5.5.4"
"@cacheable/utils": "^2.4.0",
"@keyv/bigmap": "^1.3.1",
"hookified": "^1.15.1",
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/utils": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.1.tgz",
"integrity": "sha512-38NJXjIr4W1Sghun8ju+uYWD8h2c61B4dKwfnQHVDFpAJ9oS28RpfqZQJ6Dgd3RceGkILDY9YT+72HJR3LoeSQ==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
"integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"hashery": "^1.2.0",
"keyv": "^5.5.4"
"hashery": "^1.5.0",
"keyv": "^5.6.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
@@ -86,9 +86,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
"integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
"version": "1.0.29",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz",
"integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==",
"dev": true,
"funding": [
{
@@ -100,10 +100,7 @@
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
"license": "MIT-0"
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
@@ -149,10 +146,10 @@
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/selector-specificity": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
"integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==",
"node_modules/@csstools/selector-resolve-nested": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz",
"integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==",
"dev": true,
"funding": [
{
@@ -166,38 +163,50 @@
],
"license": "MIT-0",
"engines": {
"node": ">=18"
"node": ">=20.19.0"
},
"peerDependencies": {
"postcss-selector-parser": "^7.0.0"
"postcss-selector-parser": "^7.1.1"
}
},
"node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
"node_modules/@csstools/selector-specificity": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
"integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/JounQin"
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"postcss-selector-parser": "^7.1.1"
}
},
"node_modules/@keyv/bigmap": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz",
"integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"hashery": "^1.2.0",
"hookified": "^1.13.0"
"hashery": "^1.4.0",
"hookified": "^1.15.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"keyv": "^5.5.4"
"keyv": "^5.6.0"
}
},
"node_modules/@keyv/serialize": {
@@ -245,6 +254,19 @@
"node": ">= 8"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@stylistic/stylelint-plugin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-4.0.0.tgz",
@@ -268,9 +290,9 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -317,16 +339,6 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -337,13 +349,6 @@
"node": ">=8"
}
},
"node_modules/balanced-match": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
"integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
"dev": true,
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -358,17 +363,17 @@
}
},
"node_modules/cacheable": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.2.0.tgz",
"integrity": "sha512-LEJxRqfeomiiRd2t0uON6hxAtgOoWDfY3fugebbz+J3vDLO+SkdfFChQcOHTZhj9SYa9iwE9MGYNX72dKiOE4w==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
"integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cacheable/memory": "^2.0.5",
"@cacheable/utils": "^2.3.0",
"hookified": "^1.13.0",
"keyv": "^5.5.4",
"qified": "^0.5.2"
"@cacheable/memory": "^2.0.8",
"@cacheable/utils": "^2.4.0",
"hookified": "^1.15.0",
"keyv": "^5.6.0",
"qified": "^0.6.0"
}
},
"node_modules/callsites": {
@@ -436,13 +441,13 @@
}
},
"node_modules/css-functions-list": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz",
"integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz",
"integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12 || >=16"
"node": ">=12"
}
},
"node_modules/css-tree": {
@@ -490,19 +495,6 @@
}
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -582,9 +574,9 @@
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -592,13 +584,13 @@
}
},
"node_modules/file-entry-cache": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.1.tgz",
"integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
"integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^6.1.19"
"flat-cache": "^6.1.20"
}
},
"node_modules/fill-range": {
@@ -615,24 +607,37 @@
}
},
"node_modules/flat-cache": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.19.tgz",
"integrity": "sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==",
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz",
"integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"cacheable": "^2.2.0",
"cacheable": "^2.3.2",
"flatted": "^3.3.3",
"hookified": "^1.13.0"
"hookified": "^1.15.0"
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"dev": true,
"license": "ISC"
},
"node_modules/get-east-asian-width": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -675,36 +680,26 @@
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/globby/-/globby-16.1.1.tgz",
"integrity": "sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
"@sindresorhus/merge-streams": "^4.0.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"is-path-inside": "^4.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.4.0"
},
"engines": {
"node": ">=10"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/globjoin": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
@@ -713,43 +708,46 @@
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz",
"integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/hashery": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.2.0.tgz",
"integrity": "sha512-43XJKpwle72Ik5Zpam7MuzRWyNdwwdf6XHlh8wCj2PggvWf+v/Dm5B0dxGZOmddidgeO6Ofu9As/o231Ti/9PA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
"integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"hookified": "^1.13.0"
"hookified": "^1.14.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hookified": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.13.0.tgz",
"integrity": "sha512-6sPYUY8olshgM/1LDNW4QZQN0IqgKhtl/1C8koNZBJrKLBk3AZl6chQtNwpNztvfiApHMEwMHek5rv993PRbWw==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-tags": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
"integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz",
"integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
"node": ">=20.10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -792,6 +790,17 @@
"node": ">=4"
}
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -859,6 +868,19 @@
"node": ">=0.12.0"
}
},
"node_modules/is-path-inside": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
"integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
@@ -911,9 +933,9 @@
"license": "MIT"
},
"node_modules/keyv": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz",
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -930,13 +952,6 @@
"node": ">=0.10.0"
}
},
"node_modules/known-css-properties": {
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -952,9 +967,9 @@
"license": "MIT"
},
"node_modules/mathml-tag-names": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz",
"integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz",
"integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -970,13 +985,13 @@
"license": "CC0-1.0"
},
"node_modules/meow": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
"integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz",
"integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -1074,16 +1089,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1133,13 +1138,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-resolve-nested-selector": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
"integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
"dev": true,
"license": "MIT"
},
"node_modules/postcss-safe-parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
@@ -1168,9 +1166,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1189,13 +1187,13 @@
"license": "MIT"
},
"node_modules/qified": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.5.2.tgz",
"integrity": "sha512-7gJ6mxcQb9vUBOtbKm5mDevbe2uRcOEVp1g4gb/Q+oLntB3HY8eBhOYRxFI2mlDFlY1e4DOSCptzxarXRvzxCA==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hookified": "^1.13.0"
"hookified": "^1.14.0"
},
"engines": {
"node": ">=20"
@@ -1232,16 +1230,6 @@
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -1291,13 +1279,16 @@
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/slice-ansi": {
@@ -1364,9 +1355,9 @@
"license": "ISC"
},
"node_modules/stylelint": {
"version": "16.26.1",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz",
"integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
"version": "17.4.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.4.0.tgz",
"integrity": "sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==",
"dev": true,
"funding": [
{
@@ -1380,51 +1371,49 @@
],
"license": "MIT",
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
"@csstools/css-tokenizer": "^3.0.4",
"@csstools/media-query-list-parser": "^4.0.3",
"@csstools/selector-specificity": "^5.0.0",
"@dual-bundle/import-meta-resolve": "^4.2.1",
"balanced-match": "^2.0.0",
"@csstools/css-calc": "^3.1.1",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-syntax-patches-for-csstree": "^1.0.27",
"@csstools/css-tokenizer": "^4.0.0",
"@csstools/media-query-list-parser": "^5.0.0",
"@csstools/selector-resolve-nested": "^4.0.0",
"@csstools/selector-specificity": "^6.0.0",
"colord": "^2.9.3",
"cosmiconfig": "^9.0.0",
"css-functions-list": "^3.2.3",
"css-functions-list": "^3.3.3",
"css-tree": "^3.1.0",
"debug": "^4.4.3",
"fast-glob": "^3.3.3",
"fastest-levenshtein": "^1.0.16",
"file-entry-cache": "^11.1.1",
"file-entry-cache": "^11.1.2",
"global-modules": "^2.0.0",
"globby": "^11.1.0",
"globby": "^16.1.0",
"globjoin": "^0.1.4",
"html-tags": "^3.3.1",
"html-tags": "^5.1.0",
"ignore": "^7.0.5",
"import-meta-resolve": "^4.2.0",
"imurmurhash": "^0.1.4",
"is-plain-object": "^5.0.0",
"known-css-properties": "^0.37.0",
"mathml-tag-names": "^2.1.3",
"meow": "^13.2.0",
"mathml-tag-names": "^4.0.0",
"meow": "^14.0.0",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.5.6",
"postcss-resolve-nested-selector": "^0.1.6",
"postcss-safe-parser": "^7.0.1",
"postcss-selector-parser": "^7.1.0",
"postcss-selector-parser": "^7.1.1",
"postcss-value-parser": "^4.2.0",
"resolve-from": "^5.0.0",
"string-width": "^4.2.3",
"supports-hyperlinks": "^3.2.0",
"string-width": "^8.1.1",
"supports-hyperlinks": "^4.4.0",
"svg-tags": "^1.0.0",
"table": "^6.9.0",
"write-file-atomic": "^5.0.1"
"write-file-atomic": "^7.0.0"
},
"bin": {
"stylelint": "bin/stylelint.mjs"
},
"engines": {
"node": ">=18.12.0"
"node": ">=20.19.0"
}
},
"node_modules/stylelint-config-standard": {
@@ -1476,31 +1465,168 @@
"stylelint": "^17.0.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"node_modules/stylelint/node_modules/@csstools/css-calc": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/stylelint/node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/stylelint/node_modules/@csstools/media-query-list-parser": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz",
"integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/stylelint/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/stylelint/node_modules/string-width": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
"integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=8"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stylelint/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-hyperlinks": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz",
"integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0",
"supports-color": "^7.0.0"
"has-flag": "^5.0.1",
"supports-color": "^10.2.2"
},
"engines": {
"node": ">=14.18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
@@ -1542,6 +1668,19 @@
"node": ">=8.0"
}
},
"node_modules/unicorn-magic": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz",
"integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -1563,17 +1702,16 @@
}
},
"node_modules/write-file-atomic": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz",
"integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==",
"dev": true,
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
"node": "^20.17.0 || >=22.9.0"
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0",
"stylelint": "^16.26.1",
"stylelint": "^17.4.0",
"stylelint-config-standard": "^40.0.0"
}
}
+255 -81
View File
@@ -1,8 +1,9 @@
# Stirling-PDF - Full version (embedded frontend)
FROM ubuntu:noble AS calibre-build
ARG CALIBRE_VERSION=9.3.1
ARG TARGETPLATFORM
ARG CALIBRE_VERSION=9.4.0
ARG CALIBRE_STRIP_WEBENGINE=false
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
set -eux; \
@@ -10,7 +11,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils libnss3 libfontconfig1 \
libgl1 libegl1 libdbus-1-3 libasound2t64 libxcomposite1 \
libxrandr2 libxkbcommon0 libxi6 libxtst6 libopengl0; \
libxrandr2 libxkbcommon0 libxi6 libxtst6 libopengl0 \
poppler-utils; \
rm -rf /var/lib/apt/lists/*; \
\
case "$(uname -m)" in \
@@ -26,8 +28,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
tar xJf /tmp/calibre.txz -C /opt/calibre; \
rm /tmp/calibre.txz; \
\
# Remove GUI-only shared libraries.
# Libs required by WebEngine PDF output are preserved.
# We only need Qt6 WebEngine (Chromium) for ebook->PDF output.
# PDF INPUT now uses the pdftohtml engine (poppler), not Qt.
rm -f /opt/calibre/lib/libQt6Designer* \
/opt/calibre/lib/libQt6Multimedia* \
/opt/calibre/lib/libQt6SpatialAudio.so.* \
@@ -35,6 +37,27 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/opt/calibre/lib/libQt6Concurrent.so.* \
/opt/calibre/lib/libQt6OpenGLWidgets.so.* \
/opt/calibre/lib/libQt6QuickWidgets.so.* \
/opt/calibre/lib/libQt6Svg.so.* \
/opt/calibre/lib/libQt6SvgWidgets.so.* \
/opt/calibre/lib/libQt6Pdf*.so.* \
/opt/calibre/lib/libQt6ShaderTools.so.* \
/opt/calibre/lib/libQt6SerialPort.so.* \
/opt/calibre/lib/libQt6Sensors.so.* \
/opt/calibre/lib/libQt6Test.so.* \
/opt/calibre/lib/libQt6Sql.so.* \
/opt/calibre/lib/libQt6RemoteObjects.so.* \
/opt/calibre/lib/libQt6Help.so.* \
/opt/calibre/lib/libQt6VirtualKeyboard.so.* \
/opt/calibre/lib/libQt6WaylandClient.so.* \
/opt/calibre/lib/libQt6WaylandCompositor.so.* \
/opt/calibre/lib/libQt6Bluetooth.so.* \
/opt/calibre/lib/libQt6Nfc.so.* \
/opt/calibre/lib/libQt6Charts.so.* \
/opt/calibre/lib/libQt6DataVisualization.so.* \
/opt/calibre/lib/libQt6Scxml.so.* \
/opt/calibre/lib/libQt6StateMachine.so.* \
/opt/calibre/lib/libQt6TextToSpeech.so.* \
/opt/calibre/lib/libQt63D*.so.* \
/opt/calibre/lib/libavcodec.so.* \
/opt/calibre/lib/libavfilter.so.* \
/opt/calibre/lib/libavformat.so.* \
@@ -65,11 +88,39 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/opt/calibre/lib/libgpg-error.so.* \
/opt/calibre/lib/libicuio.so.* \
/opt/calibre/lib/libreadline.so.* \
/opt/calibre/lib/libusb-1.0.so.*; \
/opt/calibre/lib/libusb-1.0.so.* \
/opt/calibre/lib/libpulse*.so.* \
/opt/calibre/lib/libsndfile.so.* \
/opt/calibre/lib/libmpv.so.* \
/opt/calibre/lib/libass.so.* \
/opt/calibre/lib/librubberband.so.* \
/opt/calibre/lib/libsamplerate.so.*; \
rm -rf /opt/calibre/lib/qt6/plugins/platformthemes \
/opt/calibre/lib/qt6/plugins/multimedia \
/opt/calibre/lib/qt6/plugins/designer \
/opt/calibre/lib/qt6/plugins/qmltooling; \
/opt/calibre/lib/qt6/plugins/virtualkeyboard \
/opt/calibre/lib/qt6/plugins/wayland* \
/opt/calibre/lib/qt6/plugins/texttospeech \
/opt/calibre/lib/qt6/plugins/position \
/opt/calibre/lib/qt6/plugins/sensors \
/opt/calibre/lib/qt6/plugins/sqldrivers \
/opt/calibre/lib/qt6/plugins/canbus \
/opt/calibre/lib/qt6/plugins/sceneparsers \
/opt/calibre/lib/qt6/plugins/renderers \
/opt/calibre/lib/qt6/plugins/geometryloaders \
/opt/calibre/lib/qt6/plugins/generic \
/opt/calibre/lib/qt6/plugins/qmltooling \
/opt/calibre/lib/qt6/libexec/QtWebEngineProcess.bak; \
rm -rf /opt/calibre/plugins/sqldrivers \
/opt/calibre/plugins/multimedia \
/opt/calibre/plugins/wayland-shell-integration \
/opt/calibre/plugins/wayland-graphics-integration-client \
/opt/calibre/plugins/wayland-decoration-client \
/opt/calibre/plugins/texttospeech \
/opt/calibre/plugins/platformthemes \
/opt/calibre/plugins/platforminputcontexts \
/opt/calibre/plugins/egldeviceintegrations \
/opt/calibre/plugins/iconengines; \
\
# Remove GUI executables but keep ebook-convert, ebook-meta, and calibre-parallel.
rm -f /opt/calibre/calibre \
@@ -95,7 +146,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/opt/calibre/lib/calibre/db \
/opt/calibre/lib/calibre/srv \
/opt/calibre/lib/calibre/spell \
/opt/calibre/lib/calibre/live; \
/opt/calibre/lib/calibre/live \
/opt/calibre/lib/calibre/utils/piper \
/opt/calibre/lib/calibre/utils/certgen.so \
/opt/calibre/lib/calibre/utils/https \
/opt/calibre/lib/calibre/utils/mdns; \
\
# Remove resources not needed for CLI conversion.
rm -rf /opt/calibre/resources/images \
@@ -143,15 +198,18 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/opt/calibre/resources/user-manual-translation-stats.json \
/opt/calibre/resources/pin-template.svg \
/opt/calibre/resources/scripts.calibre_msgpack \
/opt/calibre/resources/fonts \
/opt/calibre/resources/qtwebengine_devtools_resources.pak \
/opt/calibre/lib/calibre/ebooks/docx/images \
/opt/calibre/share \
/opt/calibre/man; \
\
# Remove translations and localization while keeping required libraries.
# Keep iso639.calibre_msgpack (required)
# Keep qtwebengine_locales (required for WebEngine)
# Keep only en-US.pak from qtwebengine_locales (required for WebEngine)
rm -rf /opt/calibre/lib/qt6/translations; \
find /opt/calibre/translations -mindepth 1 -maxdepth 1 ! -name 'qtwebengine_locales' -exec rm -rf {} +; \
find /opt/calibre/translations/qtwebengine_locales -type f ! -name 'en-US.pak' -delete 2>/dev/null || true; \
if [ -d /opt/calibre/resources/localization ]; then \
rm -rf /opt/calibre/resources/localization/locales.zip \
/opt/calibre/resources/localization/stats.calibre_msgpack \
@@ -172,7 +230,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
find /opt/calibre -name '*.pyc' -delete 2>/dev/null || true; \
\
# ── Verify conversion still works ──
# NOTE: txtepub used intentionally NOT txtpdf.
# NOTE: txt->epub used intentionally NOT txt->pdf.
# Calibre 7+ uses WebEngine (Chromium) for PDF output, which requires kernel
# capabilities unavailable in Docker RUN steps and segfaults under QEMU.
# epub output exercises the same Python/plugin stack without touching WebEngine.
@@ -180,8 +238,26 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
echo "Hello" > /tmp/test.txt; \
/opt/calibre/ebook-convert /tmp/test.txt /tmp/test.epub; \
rm -f /tmp/test.txt /tmp/test.epub; \
\
# Verify pdftohtml (poppler) is available for the pdftohtml PDF engine.
pdftohtml -v >/dev/null 2>&1 && echo "pdftohtml OK" || { echo "ERROR: pdftohtml not found"; exit 1; }; \
echo "=== Calibre stripped successfully ==="
# Optional: strip Chromium/WebEngine (~80 MB savings) when PDF output via Calibre is not needed.
# Build with --build-arg CALIBRE_STRIP_WEBENGINE=true to enable.
RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \
echo "Stripping Calibre WebEngine (Chromium), PDF output via Calibre will be disabled"; \
rm -rf /opt/calibre/lib/qt6/libexec/QtWebEngineProcess \
/opt/calibre/lib/qt6/resources \
/opt/calibre/lib/libQt6WebEngine*.so.* \
/opt/calibre/lib/libQt6Quick*.so.* \
/opt/calibre/lib/libQt6Qml*.so.* \
/opt/calibre/translations/qtwebengine_locales 2>/dev/null || true; \
echo "WebEngine stripped, Calibre PDF output disabled"; \
else \
echo "CALIBRE_STRIP_WEBENGINE=false, keeping WebEngine for PDF output"; \
fi
# Build the Java application and frontend.
FROM gradle:9.3.1-jdk25 AS app-build
@@ -218,6 +294,7 @@ COPY . .
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
--mount=type=cache,target=/root/.npm,sharing=locked \
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
@@ -233,6 +310,7 @@ RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Build Ghostscript 10.06.0 from source in an isolated stage (avoids library conflicts).
FROM ubuntu:noble AS gs-build
ARG TARGETPLATFORM
ARG GS_VERSION=10.06.0
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/tmp/gs-build,id=gs-build-${TARGETPLATFORM:-local} \
@@ -255,6 +333,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# Build PDF Tools (QPDF and ImageMagick 7).
FROM ubuntu:noble AS pdf-tools-build
ARG TARGETPLATFORM
ARG QPDF_VERSION=12.3.2
ARG IM_VERSION=7.1.2-13
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -270,14 +349,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
cd "qpdf-${QPDF_VERSION}" && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DALLOW_CRYPTO_OPENSSL=ON -DDEFAULT_CRYPTO=openssl && \
cmake --build build --parallel "$(nproc)" && \
cmake --install build && \
cmake --install build --strip && \
cd .. && \
# Build ImageMagick 7
(test -d "ImageMagick-${IM_VERSION}" || curl -fsSL "https://github.com/ImageMagick/ImageMagick/archive/refs/tags/${IM_VERSION}.tar.gz" | tar xz) && \
cd "ImageMagick-${IM_VERSION}" && \
./configure --prefix=/usr/local --with-modules --with-perl=no --with-magick-plus-plus=no --with-quantum-depth=16 --disable-static --enable-shared && \
make -j"$(nproc)" && \
make install && \
make install-strip && \
# Enable PDF/PS/EPS in policy
sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /usr/local/etc/ImageMagick-7/policy.xml && \
sed -i 's/rights="none" pattern="PS"/rights="read|write" pattern="PS"/' /usr/local/etc/ImageMagick-7/policy.xml && \
@@ -285,6 +364,44 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
cd .. && \
ldconfig /usr/local/lib
# Stage ImageMagick outputs into a single directory so runtime can import them with one COPY
# (reduces 4 separate COPY layers to 1 independent --link layer).
RUN mkdir -p /magick-export/usr/bin \
/magick-export/usr/local/lib \
/magick-export/usr/local/etc && \
cp /usr/local/bin/magick /magick-export/usr/bin/ && \
cp -a /usr/local/lib/libMagick*.so* /magick-export/usr/local/lib/ && \
cp -a /usr/local/lib/ImageMagick-7* /magick-export/usr/local/lib/ && \
cp -a /usr/local/etc/ImageMagick-7 /magick-export/usr/local/etc/
# Build Python venv in an isolated stage so runtime image never needs build tools.
# Packages with native extensions (opencv, cryptography) use pre-built wheels (--prefer-binary).
# python3-uno is intentionally NOT installed here, it is a system package in the runtime stage
# and accessed via --system-site-packages at runtime.
FROM ubuntu:noble AS python-venv-build
ARG TARGETPLATFORM
ARG UNOSERVER_VERSION=3.6
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
python3 python3-venv ca-certificates binutils && \
rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
python3 -m venv /opt/venv --system-site-packages && \
/opt/venv/bin/pip install --no-cache-dir --prefer-binary \
weasyprint pdf2image opencv-python-headless ocrmypdf \
cryptography \
"unoserver==${UNOSERVER_VERSION}" && \
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \
find /opt/venv \( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true && \
rm -rf /opt/venv/lib/python*/site-packages/pip \
/opt/venv/lib/python*/site-packages/pip-*.dist-info \
/opt/venv/lib/python*/site-packages/setuptools \
/opt/venv/lib/python*/site-packages/setuptools-*.dist-info && \
find /opt/venv -name '*.so' -exec strip --strip-unneeded {} + 2>/dev/null || true
# Final runtime image.
FROM eclipse-temurin:25-jre-noble AS runtime
@@ -316,10 +433,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
fonts-crosextra-caladea fonts-crosextra-carlito \
fonts-noto-core fonts-noto-mono fonts-noto-extra \
fonts-noto-cjk poppler-data \
# We install these via apt to avoid downloading "fat wheels" from pip
# python3-full replaced with minimal set
python3 python3-dev python3-venv python3-uno \
# Python dependencies via pip to avoid conflicts, so we don't install them here
# python3-uno required for UNO bridge (accessed by venv via --system-site-packages)
# python3-venv is NOT needed: the copied /opt/venv works without it at runtime
# python3-dev is NOT needed, venv is pre-built in python-venv-build stage
python3 python3-uno \
# Python packages are in /opt/venv (copied from python-venv-build stage below)
# OCR
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
@@ -340,27 +458,19 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
; \
\
\
# Note: We do NOT install numpy/pillow/cv2 here; it uses the system versions
python3 -m venv /opt/venv --system-site-packages; \
/opt/venv/bin/pip install --no-cache-dir \
weasyprint pdf2image opencv-python-headless ocrmypdf \
"unoserver==${UNOSERVER_VERSION}"; \
\
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
\
# Verify and fix LibreOffice
libreoffice --version; \
soffice --version 2>/dev/null || true; \
# Rebuild UNO bridge type database
/usr/lib/libreoffice/program/soffice.bin --headless --convert-to pdf /dev/null 2>/dev/null || true; \
# Force font cache rebuild and verify filters are available
# Force font cache rebuild
fc-cache -f -v 2>&1 | awk 'NR <= 20'; \
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)"; \
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')"; \
\
# Cleanup stage.
\
# Remove PPA helper, no longer needed after apt-get update
apt-get remove --purge -y software-properties-common || true; \
apt-get autoremove --purge -y || true; \
rm -rf /var/lib/apt/lists/*; \
\
# Docs / man / info / icons / themes / GUI assets (headless server)
@@ -370,15 +480,31 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/usr/share/javascript/* \
/usr/share/gtk-3.0/* \
/usr/share/fontforge/pixmaps \
/usr/share/fontforge/osx \
/usr/share/fontforge/cidmap \
/usr/share/fontforge/prefs \
/usr/share/liblangtag/* \
/usr/share/tcltk/* \
/usr/share/python-wheels/*; \
/usr/share/python-wheels/* \
/usr/share/glib-2.0/schemas/* \
/usr/share/mime/* \
/usr/share/xml/iso-codes \
/usr/share/GConf \
/usr/share/bash-completion \
/usr/share/zsh \
/usr/share/libmysofa \
/usr/share/alsa \
/usr/share/iso-codes \
/usr/share/perl5 \
/usr/share/libthai \
/usr/share/libexttextcat \
/usr/share/openal \
/usr/share/gcc; \
\
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
! -name 'en*' -exec rm -rf {} + 2>/dev/null || true; \
rm -rf /usr/share/i18n/locales /usr/share/i18n/charmaps; \
\
# LibreOffice extras: Only remove specific directories as requested
rm -rf /usr/lib/libreoffice/share/gallery \
/usr/lib/libreoffice/share/template \
/usr/lib/libreoffice/share/wizards \
@@ -401,43 +527,36 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
/usr/lib/libreoffice/share/dtd \
/usr/lib/libreoffice/share/tipoftheday \
/usr/lib/libreoffice/share/toolbarmode \
/usr/lib/libreoffice/share/psprint; \
/usr/lib/libreoffice/share/psprint \
/usr/lib/libreoffice/CREDITS.fodt \
/usr/lib/libreoffice/LICENSE.html; \
\
# Preserving soffice.cfg because LibreOffice needs it to load documents.
# Remove unused LO extensions (GUI-only; not needed for document conversion)
rm -rf /usr/lib/libreoffice/share/extensions/wiki-publisher \
/usr/lib/libreoffice/share/extensions/nlpsolver \
/usr/lib/libreoffice/share/extensions/dict-* 2>/dev/null || true; \
# Remove LO database components (LO Base; not needed for Writer/Calc/Impress conversion)
rm -rf /usr/lib/libreoffice/program/libdba* \
/usr/lib/libreoffice/program/libdbahsql* \
/usr/lib/libreoffice/program/libdbu* \
/usr/lib/libreoffice/program/libreport* 2>/dev/null || true; \
\
\
# Python caches + pip/setuptools cleanup
find /opt/venv -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /opt/venv \
\( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true; \
rm -rf /opt/venv/lib/python*/site-packages/pip \
/opt/venv/lib/python*/site-packages/pip-*.dist-info \
/opt/venv/lib/python*/site-packages/setuptools \
/opt/venv/lib/python*/site-packages/setuptools-*.dist-info; \
\
# Python stdlib: remove unused modules (~71 MB)
rm -rf /usr/lib/python3.12/test \
/usr/lib/python3.12/idlelib \
/usr/lib/python3.12/tkinter \
/usr/lib/python3.12/lib2to3 \
/usr/lib/python3.12/pydoc_data; \
\
# System Python packages not needed at runtime (~153 MB)
rm -rf /usr/lib/python3/dist-packages/scipy \
/usr/lib/python3/dist-packages/sympy \
/usr/lib/python3/dist-packages/mpmath; \
\
# Remove system cffi superseded by venv cffi 2.0
rm -rf \
/usr/lib/python3/dist-packages/cffi \
/usr/lib/python3/dist-packages/cffi-*.dist-info \
/usr/lib/python3/dist-packages/_cffi_backend*.so \
/usr/lib/python3/dist-packages/_cffi_backend*.cpython*.so \
2>/dev/null || true; \
# Verify cffi is still importable from the venv after system package removal
/opt/venv/bin/python -c "import cffi; print('cffi OK:', cffi.__version__)" \
|| { echo 'ERROR: cffi broken after system package cleanup'; exit 1; }; \
\
# Strip debug symbols from ALL shared libraries
find /usr/lib -name '*.so*' -type f \
@@ -455,16 +574,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
"${MULTIARCH_LIBDIR}"/libgallium*.so* \
2>/dev/null || true; \
\
# System-wide Python cache cleanup
find /usr/lib/python3* -type d -name __pycache__ \
-exec rm -rf {} + 2>/dev/null || true; \
find /usr/lib/python3* \( -name '*.pyc' -o -name '*.pyi' \) \
-delete 2>/dev/null || true; \
\
# Additional metadata cleanup
# FIX: Only remove ImageMagick doc/www, NOT the whole dir (preserves policy.xml/delegates.xml)
rm -rf /usr/share/bug /usr/share/lintian /usr/share/linda \
/var/lib/dpkg/info/*.md5sums \
/var/log/dpkg.log /var/log/apt/* \
/usr/local/share/ghostscript/*/doc \
/usr/local/share/ghostscript/*/examples \
@@ -475,24 +590,77 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# NEW: Tesseract training configs (not needed for OCR, but keep configs/ for hocr/txt output)
rm -rf /usr/share/tesseract-ocr/*/tessdata/tessconfigs; \
\
# Trim CJK fonts to Regular weight only (FIX: Broadened path)
find /usr/share/fonts -name '*CJK*' \
! -name '*Regular*' -type f -delete 2>/dev/null || true; \
# Noto fonts ship 1800+ files in many weights (Bold, Italic, SemiBold, etc.)
# For PDF processing, Regular weight covers all scripts. Saves ~370MB.
find /usr/share/fonts/truetype/noto -type f \
! -name '*Regular*' -delete 2>/dev/null || true; \
find /usr/share/fonts/opentype -type f \
! -name '*Regular*' -delete 2>/dev/null || true; \
# DejaVu: keep Regular and Bold only (commonly referenced in PDFs)
find /usr/share/fonts/truetype -name '*DejaVu*' \
! -name '*-Regular*' ! -name '*-Bold*' ! -name '*Bold.ttf' \
! -name 'DejaVuSans.ttf' ! -name 'DejaVuSerif.ttf' ! -name 'DejaVuSansMono.ttf' \
-type f -delete 2>/dev/null || true; \
# Remove empty font directories after cleanup
find /usr/share/fonts -type d -empty -delete 2>/dev/null || true; \
\
# ── gconv: keep only essential charset conversion modules (~6MB savings) ──
# PDF processing needs UTF-8, ISO-8859-*, and a few CJK encodings.
GCONV_DIR=$(find /usr/lib -type d -name gconv 2>/dev/null | head -1); \
if [ -n "$GCONV_DIR" ] && [ -d "$GCONV_DIR" ]; then \
mkdir -p /tmp/gconv-keep; \
for mod in UTF-8.so UTF-16.so UTF-32.so UTF-7.so \
ISO8859-1.so ISO8859-15.so ISO8859-2.so ISO8859-9.so \
UNICODE.so CP1252.so CP1251.so CP1250.so \
EUC-JP.so EUC-KR.so EUC-CN.so \
SHIFT_JIS.so GB18030.so BIG5.so \
gconv-modules gconv-modules.d gconv-modules.cache; do \
[ -e "$GCONV_DIR/$mod" ] && \
cp -a "$GCONV_DIR/$mod" /tmp/gconv-keep/ 2>/dev/null || true; \
done; \
rm -rf "$GCONV_DIR"/*; \
cp -a /tmp/gconv-keep/* "$GCONV_DIR/" 2>/dev/null || true; \
rm -rf /tmp/gconv-keep; \
fi; \
\
# NOTE: flite TTS voice libs (~26MB) are kept because ffmpeg directly links them.
# Removing them breaks ffmpeg startup. To save these 26MB, ffmpeg would need
# to be rebuilt without --enable-libflite (not worth the complexity).
\
# ── dpkg metadata cleanup (~14MB) ──
# Not needed at runtime, container won't run apt-get.
rm -rf /var/lib/dpkg/info/*.list \
/var/lib/dpkg/info/*.md5sums \
/var/lib/dpkg/info/*.conffiles \
/var/lib/dpkg/info/*.postinst \
/var/lib/dpkg/info/*.preinst \
/var/lib/dpkg/info/*.prerm \
/var/lib/dpkg/info/*.postrm \
/var/lib/dpkg/info/*.triggers \
/var/lib/dpkg/info/*.shlibs \
/var/lib/dpkg/info/*.symbols \
/var/lib/dpkg/info/*.templates; \
\
# Misc caches
rm -rf /var/cache/fontconfig/* /tmp/*
# Calibre and QPDF tools.
COPY --from=calibre-build /opt/calibre /opt/calibre
COPY --from=pdf-tools-build /usr/local/bin/qpdf /usr/bin/qpdf
COPY --from=pdf-tools-build /usr/local/bin/magick /usr/bin/magick
COPY --from=pdf-tools-build /usr/local/lib/libMagick* /usr/local/lib/
# Copy loadable coder/filter modules (required when built with --with-modules)
COPY --from=pdf-tools-build /usr/local/lib/ImageMagick-7* /usr/local/lib/
COPY --from=pdf-tools-build /usr/local/etc/ImageMagick-7 /usr/local/etc/ImageMagick-7
COPY --from=gs-build /usr/local/bin/gs /usr/local/bin/gs
COPY --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript
RUN ldconfig /usr/local/lib
# External tool layers, all use --link for independent layer caching and parallel pulls.
COPY --link --from=calibre-build /opt/calibre /opt/calibre
COPY --link --from=pdf-tools-build /usr/local/bin/qpdf /usr/bin/qpdf
# ImageMagick: 4 layers collapsed to 1 via the magick-export staging dir in pdf-tools-build
COPY --link --from=pdf-tools-build /magick-export/ /
COPY --link --from=gs-build /usr/local/bin/gs /usr/local/bin/gs
COPY --link --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript
# Python venv pre-built in python-venv-build (no pip install at runtime, no build tools needed)
COPY --link --from=python-venv-build /opt/venv /opt/venv
RUN ldconfig /usr/local/lib && \
PYTHONDONTWRITEBYTECODE=1 \
/opt/venv/bin/python -c "import cffi; print('cffi OK:', cffi.__version__)" && \
PYTHONDONTWRITEBYTECODE=1 \
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)" && \
PYTHONDONTWRITEBYTECODE=1 \
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')" && \
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# ---
# Non-root user
@@ -515,16 +683,16 @@ RUN set -eux; \
# Application files.
WORKDIR /app
COPY --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
COPY --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
COPY --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
COPY --from=jar-extract --chown=1000:1000 /layers/application/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
COPY --from=app-build --chown=1000:1000 \
COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
COPY --chown=1000:1000 scripts/ /scripts/
COPY --link --chown=1000:1000 scripts/ /scripts/
# Fonts go to system dir root ownership is correct (world-readable)
# Fonts go to system dir, root ownership is correct (world-readable)
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
# Permissions and configuration.
@@ -536,7 +704,7 @@ RUN set -eux; \
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
chmod +x /scripts/*; \
mkdir -p /configs /logs /customFiles \
mkdir -p /configs /configs/cache /configs/heap_dumps /logs /customFiles \
/pipeline/watchedFolders /pipeline/finishedFolders \
/tmp/stirling-pdf/heap_dumps; \
# Create symlinks to allow app to find these in /app/
@@ -553,15 +721,21 @@ RUN set -eux; \
chmod 750 /tmp/stirling-pdf/heap_dumps; \
fc-cache -f
# NOTE: Project Leyden AOT cache is generated in the background on first boot
# by init-without-ocr.sh. The cache is picked up on subsequent boots for
# 15-25% faster startup. See: JEP 483 + 514 + 515 (JDK 25).
# by init-without-ocr.sh and stored in /configs/cache/stirling.aot (persistent volume).
# The cache is picked up on subsequent boots for 15-25% faster startup.
# See: JEP 483 + 514 + 515 (JDK 25).
# Environment variables.
ARG VERSION_TAG
# Write version to a file so it is readable by scripts without env-var inheritance.
# init-without-ocr.sh reads /etc/stirling_version for the AOT cache fingerprint.
RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
ENV VERSION_TAG=$VERSION_TAG \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=${PUID} \
@@ -593,8 +767,8 @@ LABEL org.opencontainers.image.title="Stirling-PDF" \
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -fs --show-error http://localhost:8080/api/v1/info/status || exit 1
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
CMD curl -fs --max-time 10 http://localhost:8080/api/v1/info/status || exit 1
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD []
+5 -4
View File
@@ -3,7 +3,7 @@
FROM ubuntu:noble AS calibre-build
ARG CALIBRE_VERSION=9.3.1
ARG CALIBRE_VERSION=9.4.0
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
set -eux; \
@@ -245,7 +245,7 @@ ENV PATH="/opt/venv/bin:$PATH"
# Build all heavy python packages here
RUN --mount=type=cache,target=/root/.cache/pip \
pip install \
weasyprint pdf2image opencv-python-headless ocrmypdf \
weasyprint pdf2image opencv-python-headless ocrmypdf \
"unoserver==${UNOSERVER_VERSION}"
@@ -562,9 +562,10 @@ RUN set -eux; \
# Environment variables.
ARG VERSION_TAG
ENV VERSION_TAG=$VERSION_TAG \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=${PUID} \
+3 -2
View File
@@ -69,9 +69,10 @@ LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite" \
# NOTE: Memory flags (InitialRAMPercentage, MaxRAMPercentage, MaxMetaspaceSize)
# are computed dynamically by init-without-ocr.sh based on container memory limits.
ENV VERSION_TAG=$VERSION_TAG \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
+530 -532
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -8,29 +8,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.6.2",
"@embedpdf/engines": "^2.6.2",
"@embedpdf/models": "^2.6.2",
"@embedpdf/plugin-annotation": "^2.6.2",
"@embedpdf/plugin-attachment": "^2.6.2",
"@embedpdf/plugin-bookmark": "^2.6.2",
"@embedpdf/plugin-document-manager": "^2.6.2",
"@embedpdf/plugin-export": "^2.6.2",
"@embedpdf/plugin-history": "^2.6.2",
"@embedpdf/plugin-interaction-manager": "^2.6.2",
"@embedpdf/plugin-pan": "^2.6.2",
"@embedpdf/plugin-print": "^2.6.2",
"@embedpdf/plugin-redaction": "^2.6.2",
"@embedpdf/plugin-render": "^2.6.2",
"@embedpdf/plugin-rotate": "^2.6.2",
"@embedpdf/plugin-scroll": "^2.6.2",
"@embedpdf/plugin-search": "^2.6.2",
"@embedpdf/plugin-selection": "^2.6.2",
"@embedpdf/plugin-spread": "^2.6.2",
"@embedpdf/plugin-thumbnail": "^2.6.2",
"@embedpdf/plugin-tiling": "^2.6.2",
"@embedpdf/plugin-viewport": "^2.6.2",
"@embedpdf/plugin-zoom": "^2.6.2",
"@embedpdf/core": "^2.7.0",
"@embedpdf/engines": "^2.7.0",
"@embedpdf/models": "^2.7.0",
"@embedpdf/plugin-annotation": "^2.7.0",
"@embedpdf/plugin-attachment": "^2.7.0",
"@embedpdf/plugin-bookmark": "^2.7.0",
"@embedpdf/plugin-document-manager": "^2.7.0",
"@embedpdf/plugin-export": "^2.7.0",
"@embedpdf/plugin-history": "^2.7.0",
"@embedpdf/plugin-interaction-manager": "^2.7.0",
"@embedpdf/plugin-pan": "^2.7.0",
"@embedpdf/plugin-print": "^2.7.0",
"@embedpdf/plugin-redaction": "^2.7.0",
"@embedpdf/plugin-render": "^2.7.0",
"@embedpdf/plugin-rotate": "^2.7.0",
"@embedpdf/plugin-scroll": "^2.7.0",
"@embedpdf/plugin-search": "^2.7.0",
"@embedpdf/plugin-selection": "^2.7.0",
"@embedpdf/plugin-spread": "^2.7.0",
"@embedpdf/plugin-thumbnail": "^2.7.0",
"@embedpdf/plugin-tiling": "^2.7.0",
"@embedpdf/plugin-viewport": "^2.7.0",
"@embedpdf/plugin-zoom": "^2.7.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -105,6 +105,7 @@ save = "Save"
saveToBrowser = "Save to Browser"
saveUnavailable = "Save unavailable for this item"
seeDockerHub = "See Docker Hub"
editSecret = "Edit this value"
selectFillter = "-- Select --"
size = "Size"
sponsor = "Sponsor"
+467 -578
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -36,13 +36,13 @@ tauri-plugin-opener = "2.5.3"
tauri-plugin-deep-link = "2.4.6"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] }
tiny_http = "0.12"
url = "2.5"
urlencoding = "2.1"
sha2 = "0.10"
base64 = "0.22"
rand = "0.8"
rand = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
+4 -4
View File
@@ -6,8 +6,8 @@ use tauri_plugin_store::StoreExt;
use tiny_http::{Response, Server};
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::{thread_rng, Rng};
use rand::distributions::Alphanumeric;
use rand::Rng;
use rand::distr::Alphanumeric;
const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
@@ -524,8 +524,8 @@ pub async fn login(
/// Generate PKCE code_verifier (random 43-128 character string)
fn generate_code_verifier() -> String {
thread_rng()
.sample_iter(&Alphanumeric)
rand::rng()
.sample_iter(Alphanumeric)
.take(128)
.map(char::from)
.collect()

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