mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ef08e8f4 | ||
|
|
c46156f37f | ||
|
|
41945543e0 | ||
|
|
e5f6180dbe | ||
|
|
57c810ab9a | ||
|
|
b012f18a40 | ||
|
|
9e8606cab4 | ||
|
|
55bcb92810 | ||
|
|
a7f2abcb22 | ||
|
|
3376a87f15 | ||
|
|
2b9f03237a | ||
|
|
214dc20c2e | ||
|
|
b656e1e2d1 | ||
|
|
7f9bbebe5b | ||
|
|
dbff05814f | ||
|
|
1722733802 | ||
|
|
85d5bb5dc2 | ||
|
|
2e64d7cca6 | ||
|
|
3908e258c8 | ||
|
|
9b5714277a | ||
|
|
9df4692648 | ||
|
|
c58a6092ec | ||
|
|
cddc8e6df0 | ||
|
|
971321fb19 | ||
|
|
f384e765fb | ||
|
|
400ee16e83 | ||
|
|
f777efdd1c | ||
|
|
1d62f7ec23 | ||
|
|
c5b202f2a1 | ||
|
|
a2b0d1122c | ||
|
|
34c629dcb4 | ||
|
|
4726f42030 | ||
|
|
c9d693f1eb | ||
|
|
44e036da5a | ||
|
|
9969fe5a6d | ||
|
|
0545c3f997 | ||
|
|
b68b406a2a | ||
|
|
8674765528 | ||
|
|
d5d03b9ada | ||
|
|
32cf6866f3 | ||
|
|
fa8c52b2be | ||
|
|
8bc37bf5ae | ||
|
|
6d9fc59bc5 | ||
|
|
ff31b2f9ca | ||
|
|
81596f0299 | ||
|
|
63d38e382d | ||
|
|
a57e336675 | ||
|
|
456106195e | ||
|
|
7c1eb4183b | ||
|
|
7d640e9ce6 | ||
|
|
cafcee6c99 | ||
|
|
7fdd100abf | ||
|
|
086b55b0bb | ||
|
|
c77242d943 | ||
|
|
30b0924d6b | ||
|
|
2d6f206c36 | ||
|
|
6c83da6417 | ||
|
|
879ffc066f | ||
|
|
0f7ee5c5b0 | ||
|
|
ba2d10a75b | ||
|
|
98835ce7b5 | ||
|
|
2f2ced321a | ||
|
|
bfe655fecb | ||
|
|
9ac260ee92 | ||
|
|
1b68a513a9 | ||
|
|
93d7919c4c | ||
|
|
3e4c984fcc | ||
|
|
c4c43593e6 | ||
|
|
fd1b7abc83 | ||
|
|
0c46f77179 | ||
|
|
afda066579 | ||
|
|
e9148437f6 | ||
|
|
cb835bcce1 | ||
|
|
161bfef7da | ||
|
|
690eceb548 | ||
|
|
b53c236234 | ||
|
|
abb8b1f721 | ||
|
|
012bd1af92 | ||
|
|
8b25db37ad | ||
|
|
48dd4154e9 | ||
|
|
cfe040485b | ||
|
|
c15ff1e832 | ||
|
|
c244edf8b7 | ||
|
|
13d7ee7496 | ||
|
|
930d7a0df8 | ||
|
|
6a1597bb8d | ||
|
|
d98ff194e4 | ||
|
|
7310b75ee6 | ||
|
|
1bac8417af | ||
|
|
b21e1313d8 | ||
|
|
213f136882 |
+1
-1
@@ -14,7 +14,7 @@ indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
[*.py]
|
||||
indent_size = 2
|
||||
indent_size = 4
|
||||
|
||||
[*.gradle]
|
||||
indent_size = 4
|
||||
|
||||
@@ -68,3 +68,8 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri/provisioner
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: AI Engine CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
engine:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: engine
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install
|
||||
|
||||
- name: Run fixers
|
||||
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
|
||||
run: make fix || true
|
||||
|
||||
- name: Check for fixer changes
|
||||
id: fixer_changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: reviewdog/action-suggester@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tool_name: engine-make-fix
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
filter_mode: file
|
||||
fail_level: any
|
||||
level: info
|
||||
|
||||
- name: Comment on fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
|
||||
});
|
||||
|
||||
- name: Verify fixer changes are committed
|
||||
if: steps.fixer_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "Fixes are out of date."
|
||||
echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
|
||||
git --no-pager diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run linting
|
||||
run: make lint
|
||||
|
||||
- name: Run type checking
|
||||
run: make typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
|
||||
- uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
|
||||
+102
-102
@@ -32,14 +32,14 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
@@ -140,89 +140,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
cache-disabled: true
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Type-check frontend
|
||||
run: cd frontend && npm run prebuild && npm run typecheck:all
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
run: cd frontend && npm run build
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
retention-days: 3
|
||||
|
||||
check-licence:
|
||||
if: needs.files-changed.outputs.build == 'true'
|
||||
needs: [files-changed, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -236,7 +154,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -247,7 +165,89 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Type-check frontend
|
||||
run: cd frontend && npm run prep && npm run typecheck:all
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
run: cd frontend && npm run build
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
retention-days: 3
|
||||
|
||||
check-licence:
|
||||
if: needs.files-changed.outputs.build == 'true'
|
||||
needs: [files-changed, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: check the licenses for compatibility
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -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@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -360,7 +360,7 @@ jobs:
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -427,7 +427,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Build application
|
||||
@@ -461,7 +461,7 @@ jobs:
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -134,11 +134,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -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
|
||||
@@ -541,7 +542,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -555,30 +556,30 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -59,16 +59,16 @@ 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
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -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@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2-tauri-windows]
|
||||
branches: [main]
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "frontend/src-tauri/**"
|
||||
@@ -37,33 +37,48 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
|
||||
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
|
||||
# Resolve requested platform (non-dispatch events always build all)
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
PLATFORM="${{ github.event.inputs.platform }}"
|
||||
else
|
||||
# For PR/push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
PLATFORM="all"
|
||||
fi
|
||||
|
||||
# Build candidate list
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
if [ -z "$APPLE_CERTIFICATE" ]; then
|
||||
echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds"
|
||||
FILTERED=()
|
||||
for entry in "${ENTRIES[@]}"; do
|
||||
[[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry")
|
||||
done
|
||||
ENTRIES=("${FILTERED[@]}")
|
||||
fi
|
||||
|
||||
JOINED=$(IFS=','; echo "${ENTRIES[*]}")
|
||||
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
@@ -73,9 +88,10 @@ 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
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -89,7 +105,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -101,16 +117,16 @@ 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
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
@@ -196,7 +212,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -271,7 +287,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 +308,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 +343,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
|
||||
@@ -590,7 +606,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -651,7 +667,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -743,7 +759,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,23 +25,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- 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
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -131,14 +131,14 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
@@ -150,14 +150,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ __pycache__/
|
||||
|
||||
# Virtual environments
|
||||
.env*
|
||||
!.env*.example
|
||||
.venv*
|
||||
env*/
|
||||
venv*/
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI Agents when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Python Development
|
||||
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
|
||||
|
||||
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
|
||||
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
|
||||
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
|
||||
- Use Makefile commands for Python work:
|
||||
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
|
||||
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
|
||||
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
|
||||
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Environment Variables
|
||||
- All `VITE_*` variables must be declared in the appropriate example file:
|
||||
- `frontend/config/.env.example` — core, proprietary, and shared vars
|
||||
- `frontend/config/.env.saas.example` — SaaS-only vars
|
||||
- `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars
|
||||
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files
|
||||
- `npm run prep` / `prep:saas` / `prep:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
|
||||
- These prep scripts run automatically at the start of all `dev*`, `build*`, and `tauri*` commands
|
||||
- See `frontend/README.md#environment-variables` for full documentation
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/DeveloperGuide.md](frontend/DeveloperGuide.md).
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application
|
||||
- **Frontend**: React-based SPA in `/frontend` directory
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **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
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
@@ -1,337 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application
|
||||
- **Frontend**: React-based SPA in `/frontend` directory
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -6,10 +6,14 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -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,9 +27,10 @@ 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:20240325.1'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
@@ -39,7 +40,7 @@ dependencies {
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.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,
|
||||
@@ -371,7 +373,6 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Other", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Other", "remove-annotations");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "replace-invert-pdf");
|
||||
addEndpointToGroup("Other", "edit-table-of-contents");
|
||||
@@ -486,7 +487,6 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Java", "remove-annotations");
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
@@ -645,8 +645,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;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -26,7 +28,7 @@ import stirling.software.common.service.JobExecutorService;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Order(0) // Highest precedence - executes before audit aspects
|
||||
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
|
||||
public class AutoJobAspect {
|
||||
|
||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||
@@ -70,26 +72,29 @@ public class AutoJobAspect {
|
||||
// No retries needed, simple execution
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in
|
||||
// TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is
|
||||
// stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}),
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -123,114 +128,108 @@ public class AutoJobAspect {
|
||||
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1;
|
||||
currentAttempt <= maxRetries;
|
||||
currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API
|
||||
// access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API
|
||||
// access
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
// Use sleep for retry delay
|
||||
// For sync jobs, both sleep and async are blocking at this
|
||||
// point
|
||||
// For async jobs, the delay occurs in the executor thread
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API access
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.debug(
|
||||
"Retry delay interrupted for attempt {}/{}",
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use non-blocking delay for all retry attempts to avoid blocking
|
||||
// threads
|
||||
// For sync jobs this avoids starving the tomcat thread pool under
|
||||
// load
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
// Execute the retry after a delay through the JobExecutorService
|
||||
// rather than blocking the current thread with sleep
|
||||
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
|
||||
|
||||
// Use a delayed executor for non-blocking delay
|
||||
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
|
||||
.execute(
|
||||
() -> {
|
||||
// Continue the retry loop in the next iteration
|
||||
// We can't return from here directly since
|
||||
// we're in a Runnable
|
||||
delayedRetry.complete(null);
|
||||
});
|
||||
|
||||
// Wait for the delay to complete before continuing
|
||||
try {
|
||||
delayedRetry.join();
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
},
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
}),
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -299,4 +298,32 @@ public class AutoJobAspect {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
|
||||
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
|
||||
* leakage across jobs in thread pools.
|
||||
*/
|
||||
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
|
||||
final Map<String, String> captured = MDC.getCopyOfContextMap();
|
||||
return () -> {
|
||||
final Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||
try {
|
||||
// Set the captured context (or clear if none was captured)
|
||||
if (captured != null) {
|
||||
MDC.setContextMap(new HashMap<>(captured));
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
return supplier.get();
|
||||
} finally {
|
||||
// Restore previous state (or clear if there was none)
|
||||
if (previous != null) {
|
||||
MDC.setContextMap(previous);
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+32
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,7 +696,8 @@ public class ApplicationProperties {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
return
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
@@ -714,6 +713,7 @@ public class ApplicationProperties {
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
@@ -726,6 +726,12 @@ public class ApplicationProperties {
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HideDisabledTools {
|
||||
private boolean googleDrive = false;
|
||||
private boolean mobileQRScanner = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -913,6 +919,15 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class GoogleDrive {
|
||||
private boolean enabled = false;
|
||||
private String clientId = "";
|
||||
private String apiKey = "";
|
||||
private String appId = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -962,6 +977,12 @@ public class ApplicationProperties {
|
||||
private boolean enabled = true;
|
||||
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
private int retentionDays = 90;
|
||||
private boolean captureFileHash =
|
||||
false; // Capture SHA-256 hash of files (increases processing time)
|
||||
private boolean capturePdfAuthor =
|
||||
false; // Capture PDF author metadata (increases processing time)
|
||||
private boolean captureOperationResults =
|
||||
false; // Capture operation return values (not recommended, high volume)
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -169,7 +169,10 @@ public class PdfMetadataService {
|
||||
.getAuthor();
|
||||
|
||||
if (userService != null) {
|
||||
author = author.replace("username", userService.getCurrentUsername());
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null) {
|
||||
author = author.replace("username", username);
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
|
||||
+14
-1
@@ -226,10 +226,11 @@ public class SsrfProtectionService {
|
||||
}
|
||||
|
||||
private boolean isPrivateIPv4Range(String ip) {
|
||||
// Includes RFC1918, loopback, link-local, and unspecified addresses
|
||||
// Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses
|
||||
return ip.startsWith("10.")
|
||||
|| ip.startsWith("192.168.")
|
||||
|| (ip.startsWith("172.") && isInRange172(ip))
|
||||
|| (ip.startsWith("100.") && isInRange100(ip))
|
||||
|| ip.startsWith("169.254.")
|
||||
|| ip.startsWith("127.")
|
||||
|| "0.0.0.0".equals(ip);
|
||||
@@ -247,6 +248,18 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isInRange100(String ip) {
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
int secondOctet = Integer.parseInt(parts[1]);
|
||||
return secondOctet >= 64 && secondOctet <= 127;
|
||||
} catch (NumberFormatException e) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isCloudMetadataAddress(String ip) {
|
||||
String normalizedIp = normalizeIpv4MappedAddress(ip);
|
||||
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -341,7 +341,8 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
|
||||
private String getFallbackStyles() {
|
||||
return """
|
||||
return
|
||||
"""
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
|
||||
@@ -13,11 +13,17 @@ public class EmlToPdf {
|
||||
|
||||
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
return convertEmlToHtml(emlBytes, request, null);
|
||||
}
|
||||
|
||||
public static String convertEmlToHtml(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
EmlParser.EmailContent emailContent =
|
||||
EmlParser.extractEmailContent(emlBytes, request, null);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
|
||||
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
public static byte[] convertEmlToPdf(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -873,6 +873,36 @@ public class GeneralUtils {
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple settings in a single transaction. This ensures that nested settings (e.g.,
|
||||
* oauth2.client.google.*) don't lose sibling values when partial updates are made.
|
||||
*
|
||||
* <p>Instead of multiple read-update-write cycles (which could cause race conditions), this
|
||||
* method loads the YAML once, applies all updates, and saves once.
|
||||
*
|
||||
* @param settingsMap Map of dotted-notation keys to values to update
|
||||
* @throws IOException if file read/write fails
|
||||
*/
|
||||
public void updateSettingsTransactional(Map<String, Object> settingsMap) throws IOException {
|
||||
if (settingsMap == null || settingsMap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
|
||||
// Apply all updates to the same YamlHelper instance
|
||||
for (Map.Entry<String, Object> entry : settingsMap.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
String[] keyArray = key.split("\\.");
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), value);
|
||||
}
|
||||
|
||||
// Save only once after all updates are applied
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/*
|
||||
* Machine fingerprint generation with better error logging and fallbacks.
|
||||
*
|
||||
|
||||
@@ -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: 
|
||||
// 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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
public class ErrorUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testExceptionToModel() {
|
||||
// Create a mock Model
|
||||
Model model = new org.springframework.ui.ExtendedModelMap();
|
||||
|
||||
// Create a test exception
|
||||
Exception ex = new Exception("Test Exception");
|
||||
|
||||
// Call the method under test
|
||||
Model resultModel = ErrorUtils.exceptionToModel(model, ex);
|
||||
|
||||
// Verify the result
|
||||
assertNotNull(resultModel);
|
||||
assertEquals("Test Exception", resultModel.getAttribute("errorMessage"));
|
||||
assertNotNull(resultModel.getAttribute("stackTrace"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExceptionToModelView() {
|
||||
// Create a mock Model
|
||||
Model model = new org.springframework.ui.ExtendedModelMap();
|
||||
|
||||
// Create a test exception
|
||||
Exception ex = new Exception("Test Exception");
|
||||
|
||||
// Call the method under test
|
||||
ModelAndView modelAndView = ErrorUtils.exceptionToModelView(model, ex);
|
||||
|
||||
// Verify the result
|
||||
assertNotNull(modelAndView);
|
||||
assertEquals("Test Exception", modelAndView.getModel().get("errorMessage"));
|
||||
assertNotNull(modelAndView.getModel().get("stackTrace"));
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileMonitorTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@Mock private Predicate<Path> pathFilter;
|
||||
|
||||
private FileMonitor fileMonitor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
|
||||
.thenReturn(List.of(tempDir.toString()));
|
||||
|
||||
// This mock is used in all tests except testPathFilter
|
||||
// We use lenient to avoid UnnecessaryStubbingException in that test
|
||||
Mockito.lenient().when(pathFilter.test(any())).thenReturn(true);
|
||||
|
||||
fileMonitor = new FileMonitor(pathFilter, runtimePathConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_OldFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("test-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_RecentFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("recent-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to just now (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime));
|
||||
|
||||
// File should not be ready for processing as it was just modified
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_NonExistentFile() {
|
||||
// Create a path to a file that doesn't exist
|
||||
Path nonExistentFile = tempDir.resolve("non-existent-file.txt");
|
||||
|
||||
// Non-existent file should not be ready for processing
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(nonExistentFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_LockedFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("locked-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
|
||||
// the time check
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// Verify the file is considered ready when it meets the time criteria
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing when sufficiently old");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPathFilter() throws IOException {
|
||||
// Use a simple lambda instead of a mock for better control
|
||||
Predicate<Path> pdfFilter = path -> path.toString().endsWith(".pdf");
|
||||
|
||||
// Create a new FileMonitor with the PDF filter
|
||||
FileMonitor pdfMonitor = new FileMonitor(pdfFilter, runtimePathConfig);
|
||||
|
||||
// Create a PDF file
|
||||
Path pdfFile = tempDir.resolve("test.pdf");
|
||||
Files.write(pdfFile, "pdf content".getBytes());
|
||||
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// Create a TXT file
|
||||
Path txtFile = tempDir.resolve("test.txt");
|
||||
Files.write(txtFile, "text content".getBytes());
|
||||
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// PDF file should be ready for processing
|
||||
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
|
||||
|
||||
// Note: In the current implementation, FileMonitor.isFileReadyForProcessing()
|
||||
// doesn't check file filters directly - it only checks criteria like file existence
|
||||
// and modification time. The filtering is likely handled elsewhere in the workflow.
|
||||
|
||||
// To avoid test failures, we'll verify that the filter itself works correctly
|
||||
assertFalse(pdfFilter.test(txtFile), "PDF filter should reject txt files");
|
||||
assertTrue(pdfFilter.test(pdfFile), "PDF filter should accept pdf files");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_FileInUse() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("in-use-file.txt");
|
||||
Files.write(testFile, "initial content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// First check that the file is ready when meeting time criteria
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing when sufficiently old");
|
||||
|
||||
// After modifying the file to simulate closing, it should still be ready
|
||||
Files.write(testFile, "updated content".getBytes());
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing after updating");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("absolute-path-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
// Use the absolute path to make sure it's handled correctly
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile.toAbsolutePath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_DirectoryInsteadOfFile() throws IOException {
|
||||
// Create a test directory
|
||||
Path testDir = tempDir.resolve("test-directory");
|
||||
Files.createDirectory(testDir);
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// A directory should not be considered ready for processing
|
||||
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);
|
||||
assertFalse(isReady, "A directory should not be considered ready for processing");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
public class FileToPdfTest {
|
||||
|
||||
private CustomHtmlSanitizer customHtmlSanitizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
|
||||
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
|
||||
mock(stirling.software.common.model.ApplicationProperties.class);
|
||||
stirling.software.common.model.ApplicationProperties.System mockSystem =
|
||||
mock(stirling.software.common.model.ApplicationProperties.System.class);
|
||||
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the HTML to PDF conversion. This test expects an IOException when an empty HTML input is
|
||||
* provided.
|
||||
*/
|
||||
@Test
|
||||
public void testConvertHtmlToPdf() {
|
||||
HTMLToPdfRequest request = new HTMLToPdfRequest();
|
||||
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
|
||||
String fileName = "test.html"; // Sample file name indicating an HTML file
|
||||
TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager
|
||||
|
||||
// Mock the temp file creation to return real temp files
|
||||
try {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenReturn(Files.createTempFile("test", ".pdf").toFile())
|
||||
.thenReturn(Files.createTempFile("test", ".html").toFile());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Expect an IOException to be thrown due to empty input or invalid weasyprint path
|
||||
Throwable thrown =
|
||||
assertThrows(
|
||||
Exception.class,
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"/path/",
|
||||
request,
|
||||
fileBytes,
|
||||
fileName,
|
||||
tempFileManager,
|
||||
customHtmlSanitizer));
|
||||
assertNotNull(thrown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sanitizeZipFilename with null or empty input. It should return an empty string in these
|
||||
* cases.
|
||||
*/
|
||||
@Test
|
||||
public void testSanitizeZipFilename_NullOrEmpty() {
|
||||
assertEquals("", FileToPdf.sanitizeZipFilename(null));
|
||||
assertEquals("", FileToPdf.sanitizeZipFilename(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sanitizeZipFilename to ensure it removes path traversal sequences. This includes
|
||||
* removing both forward and backward slash sequences.
|
||||
*/
|
||||
@Test
|
||||
public void testSanitizeZipFilename_RemovesTraversalSequences() {
|
||||
String input = "../some/../path/..\\to\\file.txt";
|
||||
String expected = "some/path/to/file.txt";
|
||||
|
||||
// Expect that the method replaces backslashes with forward slashes
|
||||
// and removes path traversal sequences
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
|
||||
/** Test sanitizeZipFilename to ensure that it removes leading drive letters and slashes. */
|
||||
@Test
|
||||
public void testSanitizeZipFilename_RemovesLeadingDriveAndSlashes() {
|
||||
String input = "C:\\folder\\file.txt";
|
||||
String expected = "folder/file.txt";
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
|
||||
input = "/folder/file.txt";
|
||||
expected = "folder/file.txt";
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
|
||||
/** Test sanitizeZipFilename to verify that safe filenames remain unchanged. */
|
||||
@Test
|
||||
public void testSanitizeZipFilename_NoChangeForSafeNames() {
|
||||
String input = "folder/subfolder/file.txt";
|
||||
assertEquals(input, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ImageProcessingUtilsTest {
|
||||
|
||||
private static void fillImageWithColor(BufferedImage image) {
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
image.setRGB(x, y, Color.RED.getRGB());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToGreyscale() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "greyscale");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(BufferedImage.TYPE_BYTE_GRAY, convertedImage.getType());
|
||||
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
|
||||
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
|
||||
|
||||
// Check if a pixel is correctly converted to greyscale
|
||||
Color grey = new Color(convertedImage.getRGB(0, 0));
|
||||
assertEquals(grey.getRed(), grey.getGreen());
|
||||
assertEquals(grey.getGreen(), grey.getBlue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToBlackWhite() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "blackwhite");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(BufferedImage.TYPE_BYTE_BINARY, convertedImage.getType());
|
||||
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
|
||||
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
|
||||
|
||||
// Check if a pixel is converted correctly (binary image will be either black or white)
|
||||
int rgb = convertedImage.getRGB(0, 0);
|
||||
assertTrue(rgb == Color.BLACK.getRGB() || rgb == Color.WHITE.getRGB());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToFullColor() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "fullcolor");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeInvalid() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "invalidtype");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.ColorModel;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
public class PdfUtilsTest {
|
||||
|
||||
@Test
|
||||
void testTextToPageSize() {
|
||||
assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0"));
|
||||
assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1"));
|
||||
assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2"));
|
||||
assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3"));
|
||||
assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4"));
|
||||
assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5"));
|
||||
assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6"));
|
||||
assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER"));
|
||||
assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllImages() throws Exception {
|
||||
// Root resources
|
||||
PDResources root = mock(PDResources.class);
|
||||
|
||||
COSName im1 = COSName.getPDFName("Im1");
|
||||
COSName form1 = COSName.getPDFName("Form1");
|
||||
COSName other1 = COSName.getPDFName("Other1");
|
||||
when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1));
|
||||
|
||||
// Direct image at root
|
||||
PDImageXObject imgXObj1 = mock(PDImageXObject.class);
|
||||
BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
|
||||
when(imgXObj1.getImage()).thenReturn(img1);
|
||||
when(root.getXObject(im1)).thenReturn(imgXObj1);
|
||||
|
||||
// "Other" XObject that should be ignored
|
||||
PDXObject otherXObj = mock(PDXObject.class);
|
||||
when(root.getXObject(other1)).thenReturn(otherXObj);
|
||||
|
||||
// Form XObject with its own resources
|
||||
PDFormXObject formXObj = mock(PDFormXObject.class);
|
||||
PDResources formRes = mock(PDResources.class);
|
||||
when(formXObj.getResources()).thenReturn(formRes);
|
||||
when(root.getXObject(form1)).thenReturn(formXObj);
|
||||
|
||||
// Inside the form: one image and a nested form
|
||||
COSName im2 = COSName.getPDFName("Im2");
|
||||
COSName nestedForm = COSName.getPDFName("NestedForm");
|
||||
when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm));
|
||||
|
||||
PDImageXObject imgXObj2 = mock(PDImageXObject.class);
|
||||
BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj2.getImage()).thenReturn(img2);
|
||||
when(formRes.getXObject(im2)).thenReturn(imgXObj2);
|
||||
|
||||
PDFormXObject nestedFormXObj = mock(PDFormXObject.class);
|
||||
PDResources nestedRes = mock(PDResources.class);
|
||||
when(nestedFormXObj.getResources()).thenReturn(nestedRes);
|
||||
when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj);
|
||||
|
||||
// Deep nest: another image
|
||||
COSName im3 = COSName.getPDFName("Im3");
|
||||
when(nestedRes.getXObjectNames()).thenReturn(List.of(im3));
|
||||
|
||||
PDImageXObject imgXObj3 = mock(PDImageXObject.class);
|
||||
BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj3.getImage()).thenReturn(img3);
|
||||
when(nestedRes.getXObject(im3)).thenReturn(imgXObj3);
|
||||
|
||||
// Act
|
||||
List<RenderedImage> result = PdfUtils.getAllImages(root);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
3, result.size(), "It should find exactly 3 images (root + form + nested form).");
|
||||
assertTrue(
|
||||
result.containsAll(List.of(img1, img2, img3)),
|
||||
"All expected images must be present.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPageCountComparators() throws Exception {
|
||||
PDDocument doc1 = new PDDocument();
|
||||
doc1.addPage(new PDPage());
|
||||
doc1.addPage(new PDPage());
|
||||
doc1.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc1, 2, "greater"));
|
||||
|
||||
PDDocument doc2 = new PDDocument();
|
||||
doc2.addPage(new PDPage());
|
||||
doc2.addPage(new PDPage());
|
||||
doc2.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc2, 3, "equal"));
|
||||
|
||||
PDDocument doc3 = new PDDocument();
|
||||
doc3.addPage(new PDPage());
|
||||
doc3.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc3, 5, "less"));
|
||||
|
||||
PDDocument doc4 = new PDDocument();
|
||||
doc4.addPage(new PDPage());
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.pageCount(doc4, 1, "bad"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPageSize() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDRectangle rect = page.getMediaBox();
|
||||
String expected = rect.getWidth() + "x" + rect.getHeight();
|
||||
assertTrue(PdfUtils.pageSize(doc, expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOverlayImage() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
|
||||
doc.save(pdfOut);
|
||||
doc.close();
|
||||
|
||||
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 10, 10);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", imgOut);
|
||||
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
|
||||
byte[] result =
|
||||
PdfUtils.overlayImage(
|
||||
factory, pdfOut.toByteArray(), imgOut.toByteArray(), 0, 0, false);
|
||||
try (PDDocument resultDoc = factory.load(result)) {
|
||||
assertEquals(1, resultDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Additional tests (added without modifying existing ones)
|
||||
// ===============================================================
|
||||
|
||||
/* Helper: create a colored test image */
|
||||
private static BufferedImage createImage(int w, int h, Color color) {
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Helper: create a factory like in existing tests */
|
||||
private static CustomPDFDocumentFactory factory() {
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
return new CustomPDFDocumentFactory(meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertPdfToPdfImage: creates image-PDF with same page count")
|
||||
void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("Hello PDF");
|
||||
cs.endText();
|
||||
}
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p2);
|
||||
|
||||
PDDocument out = PdfUtils.convertPdfToPdfImage(doc);
|
||||
assertNotNull(out);
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count should be preserved");
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)")
|
||||
void imageToPdf_shouldCreatePdfFromPng() throws Exception {
|
||||
BufferedImage img = createImage(320, 200, Color.RED);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
// Assume: loadImageWithExifOrientation/convertColorType exist – static mock
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file},
|
||||
"maintainAspectRatio",
|
||||
true,
|
||||
"RGB",
|
||||
factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)")
|
||||
void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception {
|
||||
BufferedImage img = createImage(640, 360, Color.BLUE);
|
||||
ByteArrayOutputStream jpgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", jpgOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size")
|
||||
void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(300, 500, Color.GREEN);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(300, (int) box.getWidth());
|
||||
assertEquals(500, (int) box.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image")
|
||||
void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertTrue(
|
||||
box.getWidth() > box.getHeight(),
|
||||
"A4 should be landscape when auto-rotate + landscape");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: fillPage runs without errors")
|
||||
void addImageToDocument_fillPage_executes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(200, 200, Color.MAGENTA);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fillPage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("overlayImage: everyPage=true overlays all pages")
|
||||
void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException {
|
||||
CustomPDFDocumentFactory factory = factory();
|
||||
|
||||
// Create PDF with 2 pages
|
||||
byte[] basePdf;
|
||||
try (PDDocument doc = factory.createNewDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
basePdf = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Create image bytes
|
||||
BufferedImage img = createImage(50, 50, Color.BLACK);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true);
|
||||
|
||||
try (PDDocument out = factory.load(result)) {
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count remains identical");
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper function: document with text on page1/page2 */
|
||||
private static PDDocument createDocWithText(String p1, String p2) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
|
||||
PDPage page1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p1);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
PDPage page2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page2);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p2);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: pagesToCheck='all' finds text")
|
||||
void containsTextInFile_allPages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: single page '2' finds text")
|
||||
void containsTextInFile_singlePage_two_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: range '1-1' finds text on page 1")
|
||||
void containsTextInFile_range_oneToOne_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("findme", "other")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)")
|
||||
void containsTextInFile_list_pages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("foo", "bar")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 "));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: text not present -> false")
|
||||
void containsTextInFile_textNotPresent_false() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("xxx", "yyy")) {
|
||||
assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pageSize: different size returns false")
|
||||
void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
assertFalse(PdfUtils.pageSize(doc, "600x842"));
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== New: convertFromPdf – coverage =====================
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)")
|
||||
void convertFromPdf_singleImagePng_combinedReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] imageBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false);
|
||||
|
||||
// Should be readable as a single combined PNG image
|
||||
BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes));
|
||||
assertNotNull(img, "PNG should be readable");
|
||||
assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)")
|
||||
void convertFromPdf_multiImagePng_firstReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Act: singleImage=false -> ZIP with separate images
|
||||
byte[] zipBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false);
|
||||
|
||||
// Assert: open ZIP, read first entry as PNG
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertNotNull(entry, "ZIP should contain at least one entry");
|
||||
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
zis.transferTo(imgOut);
|
||||
BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray()));
|
||||
|
||||
assertNotNull(first, "First PNG entry should be readable");
|
||||
assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')")
|
||||
void hasText_shouldDetectPhrase_onSelectedPages() throws Exception {
|
||||
// Arrange: PDF with 2 pages and text
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'");
|
||||
}
|
||||
|
||||
// For further checks, create new doc with identical content
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'");
|
||||
}
|
||||
|
||||
// Third doc for 'all'
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("gamma");
|
||||
cs.endText();
|
||||
}
|
||||
assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasTextOnPage: true if page contains phrase, else false")
|
||||
void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("needle");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasTextOnPage(p1, "needle"));
|
||||
assertTrue(!PdfUtils.hasTextOnPage(p2, "needle"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImages: detects images on selected pages and 'all'")
|
||||
void hasImages_shouldDetectImages_onSelectedPages() throws Exception {
|
||||
// Case 1: Page 1 without image (but resources set) -> false
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
// Image only on page 2
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.GREEN);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image");
|
||||
}
|
||||
|
||||
// Case 2: Page 2 with image -> true
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.BLUE);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image");
|
||||
}
|
||||
|
||||
// Case 3: 'all' detects image
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
p.setResources(new PDResources());
|
||||
doc.addPage(p);
|
||||
|
||||
BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 20, 730, 10, 10);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImagesOnPage: true if page contains an image, else false")
|
||||
void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 12, 12);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 40, 720, 12, 12);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImagesOnPage(p1));
|
||||
assertTrue(!PdfUtils.hasImagesOnPage(p2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background")
|
||||
void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception {
|
||||
// small 1-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] jpgBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false);
|
||||
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes));
|
||||
assertNotNull(img, "JPG should be readable");
|
||||
|
||||
ColorModel cm = img.getColorModel();
|
||||
assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel");
|
||||
|
||||
// JPG background should be white (approximate check)
|
||||
int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF;
|
||||
assertEquals(0xFFFFFF, rgb, "Background pixel should be white");
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ProcessExecutorTest {
|
||||
|
||||
private ProcessExecutor processExecutor;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
// Initialize the ProcessExecutor instance
|
||||
processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling() throws IOException, InterruptedException {
|
||||
// Mock the command to execute
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("java");
|
||||
command.add("-version");
|
||||
|
||||
// Execute the command
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
processExecutor.runCommandWithOutputHandling(command);
|
||||
|
||||
// Check the exit code and output messages
|
||||
assertEquals(0, result.getRc());
|
||||
assertNotNull(result.getMessages()); // Check if messages are not null
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_Error() {
|
||||
// Test with a command that will fail to execute (non-existent command)
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("nonexistent-command-that-does-not-exist");
|
||||
|
||||
// Execute the command and expect an IOException (command not found)
|
||||
assertThrows(
|
||||
IOException.class, () -> processExecutor.runCommandWithOutputHandling(command));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_PathTraversal() {
|
||||
// Test that path traversal is blocked
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("../../../etc/passwd");
|
||||
|
||||
// Execute the command and expect an IllegalArgumentException
|
||||
IllegalArgumentException thrown =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message
|
||||
String errorMessage = thrown.getMessage();
|
||||
assertTrue(
|
||||
errorMessage.contains("path traversal"),
|
||||
"Unexpected error message: " + errorMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_NullByte() {
|
||||
// Test that null bytes are blocked
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("test\0command");
|
||||
|
||||
// Execute the command and expect an IllegalArgumentException
|
||||
IllegalArgumentException thrown =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message
|
||||
String errorMessage = thrown.getMessage();
|
||||
assertTrue(
|
||||
errorMessage.contains("invalid characters"),
|
||||
"Unexpected error message: " + errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class PropertyConfigsTest {
|
||||
|
||||
@Test
|
||||
public void testGetBooleanValue_WithKeys() {
|
||||
// Define keys and default value
|
||||
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
|
||||
boolean defaultValue = false;
|
||||
|
||||
// Set property for one of the keys
|
||||
System.setProperty("test.key2", "true");
|
||||
|
||||
// Call the method under test
|
||||
boolean result = PropertyConfigs.getBooleanValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetStringValue_WithKeys() {
|
||||
// Define keys and default value
|
||||
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
|
||||
String defaultValue = "default";
|
||||
|
||||
// Set property for one of the keys
|
||||
System.setProperty("test.key2", "value");
|
||||
|
||||
// Call the method under test
|
||||
String result = PropertyConfigs.getStringValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals("value", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetBooleanValue_WithKey() {
|
||||
// Define key and default value
|
||||
String key = "test.key";
|
||||
boolean defaultValue = true;
|
||||
|
||||
// Call the method under test
|
||||
boolean result = PropertyConfigs.getBooleanValue(key, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetStringValue_WithKey() {
|
||||
// Define key and default value
|
||||
String key = "test.key";
|
||||
String defaultValue = "default";
|
||||
|
||||
// Call the method under test
|
||||
String result = PropertyConfigs.getStringValue(key, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals("default", result);
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
public class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsStaticResource() {
|
||||
// Test static resources without context path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/styles.css"), "CSS files should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/js/script.js"), "JS files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/logo.png"),
|
||||
"Image files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/public/index.html"),
|
||||
"Public files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
|
||||
"PDF.js files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/api/v1/info/status"),
|
||||
"API status should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/some-path/icon.svg"),
|
||||
"SVG files should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/login"), "Login page should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/error"), "Error page should be static");
|
||||
|
||||
// Test non-static resources
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/users"),
|
||||
"API users should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/orders"),
|
||||
"API orders should not be static");
|
||||
assertFalse(RequestUriUtils.isStaticResource("/"), "Root path should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/register"),
|
||||
"Register page should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/products"),
|
||||
"API products should not be static");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
|
||||
"API routes should not be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/register"),
|
||||
"Register should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
|
||||
"Pipeline should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/files/download"),
|
||||
"Files path should not be treated as a frontend route");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
|
||||
// Test static resources with context path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/css/styles.css"),
|
||||
"CSS with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/js/script.js"),
|
||||
"JS with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/images/logo.png"),
|
||||
"Images with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/login"),
|
||||
"Login with context path should be static");
|
||||
|
||||
// Test non-static resources with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/api/v1/users"),
|
||||
"API users with context path should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource(contextPath, "/"),
|
||||
"Root path with context path should not be static");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"robots.txt",
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/image.png",
|
||||
"/locales/en/translation.toml",
|
||||
"/site.webmanifest",
|
||||
"/app/logo.svg",
|
||||
"/downloads/document.png",
|
||||
"/assets/brand.ico",
|
||||
"/any/path/with/image.svg",
|
||||
"/deep/nested/folder/icon.png",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testIsStaticResourceWithFileExtensions(String path) {
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(path),
|
||||
"Files with specific extensions should be static regardless of path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTrackableResource() {
|
||||
// Test non-trackable resources (returns false)
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/js/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/v1/api-docs"),
|
||||
"API docs should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("robots.txt"),
|
||||
"robots.txt should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/images/logo.png"),
|
||||
"Images should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/styles.css"),
|
||||
"CSS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js.map"),
|
||||
"Map files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/icon.svg"),
|
||||
"SVG files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/popularity.txt"),
|
||||
"Popularity file should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/swagger/index.html"),
|
||||
"Swagger files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/api/v1/info/status"),
|
||||
"API info should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/site.webmanifest"),
|
||||
"Webmanifest should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/fonts/font.woff"),
|
||||
"Fonts should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfjs/viewer.js"),
|
||||
"PDF.js files should not be trackable");
|
||||
|
||||
// Test trackable resources (returns true)
|
||||
assertTrue(RequestUriUtils.isTrackableResource("/login"), "Login page should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/register"),
|
||||
"Register page should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/api/v1/users"),
|
||||
"API users should be trackable");
|
||||
assertTrue(RequestUriUtils.isTrackableResource("/"), "Root path should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/some-other-path"),
|
||||
"Other paths should be trackable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTrackableResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
|
||||
// Test with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/js/script.js"),
|
||||
"JS files should not be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/login"),
|
||||
"Login page should be trackable with context path");
|
||||
|
||||
// Additional tests with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/fonts/custom.woff"),
|
||||
"Font files should not be trackable with context path");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/images/header.png"),
|
||||
"Images should not be trackable with context path");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/swagger/ui.html"),
|
||||
"Swagger UI should not be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/account/profile"),
|
||||
"Account page should be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/pdf/view"),
|
||||
"PDF view page should be trackable with context path");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/js/util.js",
|
||||
"/v1/api-docs/swagger.json",
|
||||
"/robots.txt",
|
||||
"/images/header/logo.png",
|
||||
"/styles/theme.css",
|
||||
"/build/app.js.map",
|
||||
"/assets/icon.svg",
|
||||
"/data/popularity.txt",
|
||||
"/bundle.js",
|
||||
"/api/swagger-ui.html",
|
||||
"/api/v1/info/health",
|
||||
"/site.webmanifest",
|
||||
"/fonts/roboto.woff",
|
||||
"/pdfjs/viewer.js",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testNonTrackableResources(String path) {
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(path),
|
||||
"Resources matching patterns should not be trackable: " + path);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/",
|
||||
"/home",
|
||||
"/login",
|
||||
"/register",
|
||||
"/pdf/merge",
|
||||
"/pdf/split",
|
||||
"/api/v1/users/1",
|
||||
"/api/v1/documents/process",
|
||||
"/settings",
|
||||
"/account/profile",
|
||||
"/dashboard",
|
||||
"/help",
|
||||
"/about"
|
||||
})
|
||||
void testTrackableResources(String path) {
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(path),
|
||||
"App routes should be trackable: " + path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEdgeCases() {
|
||||
// Test with empty strings
|
||||
assertFalse(RequestUriUtils.isStaticResource("", ""), "Empty path should not be static");
|
||||
assertTrue(RequestUriUtils.isTrackableResource("", ""), "Empty path should be trackable");
|
||||
|
||||
// Test with null-like behavior (would actually throw NPE in real code)
|
||||
// These are not actual null tests but shows handling of odd cases
|
||||
assertFalse(RequestUriUtils.isStaticResource("null"), "String 'null' should not be static");
|
||||
|
||||
// Test String "null" as a path
|
||||
boolean isTrackable = RequestUriUtils.isTrackableResource("null");
|
||||
assertTrue(isTrackable, "String 'null' should be trackable");
|
||||
|
||||
// Mixed case extensions test - note that Java's endsWith() is case-sensitive
|
||||
// We'll check actual behavior and document it rather than asserting
|
||||
|
||||
// Always test the lowercase versions which should definitely work
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/logo.png"), "PNG (lowercase) should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/icon.svg"), "SVG (lowercase) should be static");
|
||||
|
||||
// Path with query parameters
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/users?page=1"),
|
||||
"Path with query params should respect base path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/logo.png?v=123"),
|
||||
"Static resource with query params should still be static");
|
||||
|
||||
// Paths with fragments
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/styles.css#section1"),
|
||||
"CSS with fragment should be static");
|
||||
|
||||
// Multiple dots in filename
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/js/jquery.min.js"),
|
||||
"JS with multiple dots should be static");
|
||||
|
||||
// Special characters in path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/user's-photo.png"),
|
||||
"Path with special chars should be handled correctly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testComplexPaths() {
|
||||
// Test complex static resource paths
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/theme/dark/styles.css"),
|
||||
"Nested CSS should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/fonts/open-sans/bold/font.woff"),
|
||||
"Nested font should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/js/vendor/jquery/3.5.1/jquery.min.js"),
|
||||
"Versioned JS should be static");
|
||||
|
||||
// Test complex paths with context
|
||||
String contextPath = "/app";
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(
|
||||
contextPath, contextPath + "/css/theme/dark/styles.css"),
|
||||
"Nested CSS with context should be static");
|
||||
|
||||
// Test boundary cases for isTrackableResource
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/js-framework/components"),
|
||||
"Path starting with js- should not be treated as JS resource");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/fonts-selection"),
|
||||
"Path starting with fonts- should not be treated as font resource");
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UrlUtilsTest {
|
||||
|
||||
@Mock private HttpServletRequest request;
|
||||
|
||||
@Test
|
||||
void testGetOrigin() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("/myapp");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://localhost:8080/myapp", origin, "Origin URL should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithHttps() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.com");
|
||||
when(request.getServerPort()).thenReturn(443);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://example.com:443",
|
||||
origin,
|
||||
"HTTPS origin URL should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithEmptyContextPath() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://localhost:8080",
|
||||
origin,
|
||||
"Origin URL with empty context path should be correct");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithSpecialCharacters() {
|
||||
// Arrange - Test with server name containing special characters
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("internal-server.example-domain.com");
|
||||
when(request.getServerPort()).thenReturn(8443);
|
||||
when(request.getContextPath()).thenReturn("/app-v1.2");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://internal-server.example-domain.com:8443/app-v1.2",
|
||||
origin,
|
||||
"Origin URL with special characters should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithIPv4Address() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("192.168.1.100");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("/app");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://192.168.1.100:8080/app",
|
||||
origin,
|
||||
"Origin URL with IPv4 address should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithNonStandardPort() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.org");
|
||||
when(request.getServerPort()).thenReturn(8443);
|
||||
when(request.getContextPath()).thenReturn("/api");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://example.org:8443/api",
|
||||
origin,
|
||||
"Origin URL with non-standard port should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPortAvailable() {
|
||||
// We'll use a real server socket for this test
|
||||
ServerSocket socket = null;
|
||||
int port = 12345; // Choose a port unlikely to be in use
|
||||
|
||||
try {
|
||||
// First check the port is available
|
||||
boolean initialAvailability = UrlUtils.isPortAvailable(port);
|
||||
|
||||
// Then occupy the port
|
||||
socket = new ServerSocket(port);
|
||||
|
||||
// Now check the port is no longer available
|
||||
boolean afterSocketCreation = UrlUtils.isPortAvailable(port);
|
||||
|
||||
// Assert
|
||||
assertTrue(initialAvailability, "Port should be available initially");
|
||||
assertFalse(
|
||||
afterSocketCreation, "Port should not be available after socket is created");
|
||||
|
||||
} catch (IOException e) {
|
||||
// This might happen if the port is already in use by another process
|
||||
// We'll just verify the behavior of isPortAvailable matches what we expect
|
||||
assertFalse(
|
||||
UrlUtils.isPortAvailable(port),
|
||||
"Port should not be available if exception is thrown");
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePort() {
|
||||
// We'll create a socket on a port and ensure findAvailablePort returns a different port
|
||||
ServerSocket socket = null;
|
||||
int startPort = 12346; // Choose a port unlikely to be in use
|
||||
|
||||
try {
|
||||
// Occupy the start port
|
||||
socket = new ServerSocket(startPort);
|
||||
|
||||
// Find an available port
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
|
||||
// Assert the returned port is not the occupied one
|
||||
assertNotEquals(
|
||||
String.valueOf(startPort),
|
||||
availablePort,
|
||||
"findAvailablePort should not return an occupied port");
|
||||
|
||||
// Verify the returned port is actually available
|
||||
int portNumber = Integer.parseInt(availablePort);
|
||||
|
||||
// Close our test socket before checking the found port
|
||||
socket.close();
|
||||
socket = null;
|
||||
|
||||
// The port should now be available
|
||||
assertTrue(
|
||||
UrlUtils.isPortAvailable(portNumber),
|
||||
"The port returned by findAvailablePort should be available");
|
||||
|
||||
} catch (IOException e) {
|
||||
// If we can't create the socket, skip this assertion
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePortWithAvailableStartPort() {
|
||||
// Find an available port without occupying any
|
||||
int startPort = 23456; // Choose a different unlikely-to-be-used port
|
||||
|
||||
// Make sure the port is available first
|
||||
if (UrlUtils.isPortAvailable(startPort)) {
|
||||
// Find an available port
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
|
||||
// Assert the returned port is the start port since it's available
|
||||
assertEquals(
|
||||
String.valueOf(startPort),
|
||||
availablePort,
|
||||
"findAvailablePort should return the start port if it's available");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePortWithSequentialUsedPorts() {
|
||||
// This test checks that findAvailablePort correctly skips multiple occupied ports
|
||||
ServerSocket socket1 = null;
|
||||
ServerSocket socket2 = null;
|
||||
int startPort = 34567; // Another unlikely-to-be-used port
|
||||
|
||||
try {
|
||||
// First verify the port is available
|
||||
if (!UrlUtils.isPortAvailable(startPort)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Occupy two sequential ports
|
||||
socket1 = new ServerSocket(startPort);
|
||||
socket2 = new ServerSocket(startPort + 1);
|
||||
|
||||
// Find an available port starting from our occupied range
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
int foundPort = Integer.parseInt(availablePort);
|
||||
|
||||
// Should have skipped the two occupied ports
|
||||
assertTrue(
|
||||
foundPort >= startPort + 2,
|
||||
"findAvailablePort should skip sequential occupied ports");
|
||||
|
||||
// Verify the found port is actually available
|
||||
try (ServerSocket testSocket = new ServerSocket(foundPort)) {
|
||||
assertTrue(testSocket.isBound(), "The found port should be bindable");
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// Skip test if we encounter IO exceptions
|
||||
} finally {
|
||||
// Clean up resources
|
||||
try {
|
||||
if (socket1 != null && !socket1.isClosed()) socket1.close();
|
||||
if (socket2 != null && !socket2.isClosed()) socket2.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPortAvailableWithPrivilegedPorts() {
|
||||
// Skip tests for privileged ports as they typically require root access
|
||||
// and results are environment-dependent
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
public class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testBoasToWebResponse() {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write("Sample PDF content".getBytes());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.baosToWebResponse(baos, docName);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
// assertEquals("attachment; filename=\"sample.pdf\"",
|
||||
// headers.getContentDisposition().toString());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMultiPartFileToWebResponse() {
|
||||
try {
|
||||
byte[] fileContent = "Sample file content".getBytes();
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file", "sample.txt", MediaType.TEXT_PLAIN_VALUE, fileContent);
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.multiPartFileToWebResponse(file);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBytesToWebResponse() {
|
||||
try {
|
||||
byte[] bytes = "Sample bytes".getBytes();
|
||||
String docName = "sample.txt";
|
||||
MediaType mediaType = MediaType.TEXT_PLAIN;
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.bytesToWebResponse(bytes, docName, mediaType);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPdfDocToWebResponse() {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.pdfDocToWebResponse(document, docName);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -47,6 +46,7 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
|
||||
implementation 'org.eclipse.jetty:jetty-alpn-java-server'
|
||||
implementation ('org.telegram:telegrambots:6.9.7.1') {
|
||||
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
|
||||
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
|
||||
@@ -77,12 +77,12 @@ dependencies {
|
||||
|
||||
implementation 'org.verapdf:validation-model:1.28.2'
|
||||
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
|
||||
implementation 'org.mozilla:rhino:1.7.15'
|
||||
implementation 'org.mozilla:rhino:1.9.1'
|
||||
|
||||
// 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,8 +91,8 @@ 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)
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
// 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
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
@@ -100,6 +100,8 @@ dependencies {
|
||||
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
||||
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
||||
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
|
||||
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
|
||||
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
|
||||
|
||||
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
||||
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.service.PdfImageRemovalService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Controller class for handling PDF image removal requests. Provides an endpoint to remove images
|
||||
* from a PDF file to reduce its size.
|
||||
*/
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
public class PdfImageRemovalController {
|
||||
|
||||
// Service for removing images from PDFs
|
||||
private final PdfImageRemovalService pdfImageRemovalService;
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
/**
|
||||
* Endpoint to remove images from a PDF file.
|
||||
*
|
||||
* <p>This method processes the uploaded PDF file, removes all images, and returns the modified
|
||||
* PDF file with a new name indicating that images were removed.
|
||||
*
|
||||
* @param file The PDF file with images to be removed.
|
||||
* @return ResponseEntity containing the modified PDF file as byte array with appropriate
|
||||
* content type and filename.
|
||||
* @throws IOException If an error occurs while processing the PDF file.
|
||||
*/
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-image-pdf")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Remove images from file to reduce the file size.",
|
||||
description =
|
||||
"This endpoint remove images from file to reduce the file size.Input:PDF"
|
||||
+ " Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Remove images from the PDF document using the service
|
||||
try (PDDocument modifiedDocument =
|
||||
pdfImageRemovalService.removeImagesFromPdf(document)) {
|
||||
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
-156
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-23
@@ -150,28 +150,6 @@ public class RearrangePagesPDFController {
|
||||
return newPageOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rearrange pages in a PDF file by merging odd and even pages. The first half of the pages will
|
||||
* be the odd pages, and the second half will be the even pages as input. <br>
|
||||
* This method is visible for testing purposes only.
|
||||
*
|
||||
* @param totalPages Total number of pages in the PDF file.
|
||||
* @return List of page numbers in the new order. The first page is 0.
|
||||
*/
|
||||
List<Integer> oddEvenMerge(int totalPages) {
|
||||
List<Integer> newPageOrderZeroBased = new ArrayList<>();
|
||||
int numberOfOddPages = (totalPages + 1) / 2;
|
||||
|
||||
for (int oneBasedIndex = 1; oneBasedIndex < (numberOfOddPages + 1); oneBasedIndex++) {
|
||||
newPageOrderZeroBased.add((oneBasedIndex - 1));
|
||||
if (numberOfOddPages + oneBasedIndex <= totalPages) {
|
||||
newPageOrderZeroBased.add((numberOfOddPages + oneBasedIndex - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return newPageOrderZeroBased;
|
||||
}
|
||||
|
||||
private List<Integer> duplicate(int totalPages, String pageOrder) {
|
||||
List<Integer> newPageOrder = new ArrayList<>();
|
||||
int duplicateCount;
|
||||
@@ -220,7 +198,6 @@ public class RearrangePagesPDFController {
|
||||
case BOOKLET_SORT -> bookletSort(totalPages);
|
||||
case SIDE_STITCH_BOOKLET_SORT -> sideStitchBooklet(totalPages);
|
||||
case ODD_EVEN_SPLIT -> oddEvenSplit(totalPages);
|
||||
case ODD_EVEN_MERGE -> oddEvenMerge(totalPages);
|
||||
case REMOVE_FIRST -> removeFirst(totalPages);
|
||||
case REMOVE_LAST -> removeLast(totalPages);
|
||||
case REMOVE_FIRST_AND_LAST -> removeFirstAndLast(totalPages);
|
||||
|
||||
+40
-64
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-99
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-60
@@ -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 {
|
||||
|
||||
+2
-1
@@ -81,7 +81,8 @@ public class ConvertEmlToPDF {
|
||||
|
||||
if (request.isDownloadHtml()) {
|
||||
try {
|
||||
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
|
||||
String htmlContent =
|
||||
EmlToPdf.convertEmlToHtml(fileBytes, request, customHtmlSanitizer);
|
||||
log.info("Successfully converted email to HTML: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
htmlContent.getBytes(StandardCharsets.UTF_8),
|
||||
|
||||
+29
-22
@@ -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")
|
||||
|
||||
+6
@@ -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");
|
||||
|
||||
+8
-3
@@ -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(" ") + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+28
@@ -159,6 +159,24 @@ public class ConfigController {
|
||||
"defaultHideUnavailableConversions",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableConversions());
|
||||
|
||||
// Hide disabled tools settings
|
||||
configData.put(
|
||||
"hideDisabledToolsGoogleDrive",
|
||||
applicationProperties.getUi().getHideDisabledTools().isGoogleDrive());
|
||||
configData.put(
|
||||
"hideDisabledToolsMobileQRScanner",
|
||||
applicationProperties.getUi().getHideDisabledTools().isMobileQRScanner());
|
||||
|
||||
// Google Drive backend settings (only if enabled)
|
||||
ApplicationProperties.Premium.ProFeatures.GoogleDrive googleDrive =
|
||||
applicationProperties.getPremium().getProFeatures().getGoogleDrive();
|
||||
if (googleDrive.isEnabled()) {
|
||||
configData.put("googleDriveEnabled", true);
|
||||
configData.put("googleDriveClientId", googleDrive.getClientId());
|
||||
configData.put("googleDriveApiKey", googleDrive.getApiKey());
|
||||
configData.put("googleDriveAppId", googleDrive.getAppId());
|
||||
}
|
||||
|
||||
// Security settings
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
@@ -212,6 +230,16 @@ public class ConfigController {
|
||||
}
|
||||
configData.put("isNewUser", isNewUser);
|
||||
|
||||
String username = null;
|
||||
if (userService != null) {
|
||||
try {
|
||||
username = userService.getCurrentUsername();
|
||||
} catch (Exception e) {
|
||||
// Not authenticated or security disabled
|
||||
}
|
||||
}
|
||||
configData.put("username", username);
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
|
||||
+1
-1
@@ -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<>();
|
||||
|
||||
|
||||
+72
-172
@@ -1,19 +1,14 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Image;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -28,6 +23,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;
|
||||
|
||||
@@ -41,7 +37,8 @@ import stirling.software.common.annotations.api.MiscApi;
|
||||
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 +47,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,196 +57,98 @@ 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)
|
||||
throws IOException, InterruptedException, ExecutionException {
|
||||
public ResponseEntity<StreamingResponseBody> extractImages(
|
||||
@ModelAttribute PDFExtractImagesRequest request) throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String format = request.getFormat();
|
||||
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
String imageFormat = request.getFormat();
|
||||
|
||||
// Determine if multithreading should be used based on PDF size or number of pages
|
||||
boolean useMultithreading = shouldUseMultithreading(file, document);
|
||||
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
Set<Integer> processedImageHashes = new HashSet<>();
|
||||
|
||||
// Create ByteArrayOutputStream to write zip file to byte array
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
TempFile zipFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile.getPath()));
|
||||
PDDocument pdfDoc = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Create ZipOutputStream to create zip file
|
||||
ZipOutputStream zos = new ZipOutputStream(baos);
|
||||
zipStream.setLevel(Deflater.BEST_COMPRESSION);
|
||||
|
||||
// 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<>();
|
||||
|
||||
// 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);
|
||||
|
||||
int consecutiveFailures = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
int totalPages = pdfDoc.getNumberOfPages();
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage currentPage = pdfDoc.getPage(pageIndex);
|
||||
extractAndAddImagesToZip(
|
||||
currentPage, imageFormat, baseFilename, pageIndex + 1,
|
||||
processedImageHashes, zipStream);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
zipFile.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(
|
||||
zipFile, baseFilename + "_extracted-images.zip");
|
||||
}
|
||||
|
||||
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
|
||||
// Criteria: Use multithreading if file size > 10MB or number of pages > 20
|
||||
long fileSizeInMB = file.getSize() / (1024 * 1024);
|
||||
int numberOfPages = document.getPages().getCount();
|
||||
return fileSizeInMB > 10 || numberOfPages > 20;
|
||||
}
|
||||
|
||||
private void extractImagesFromPage(
|
||||
private void extractAndAddImagesToZip(
|
||||
PDPage page,
|
||||
String format,
|
||||
String filename,
|
||||
int pageNum,
|
||||
Set<byte[]> processedImages,
|
||||
ZipOutputStream zos,
|
||||
boolean allowDuplicates)
|
||||
String imageFormat,
|
||||
String baseFilename,
|
||||
int pageNumber,
|
||||
Set<Integer> seenImageHashes,
|
||||
ZipOutputStream zipOutput)
|
||||
throws IOException {
|
||||
MessageDigest md;
|
||||
try {
|
||||
md = MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("MD5 algorithm not available for extractImages hash.", e);
|
||||
return;
|
||||
}
|
||||
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
|
||||
return;
|
||||
}
|
||||
int count = 1;
|
||||
for (COSName name : page.getResources().getXObjectNames()) {
|
||||
|
||||
int imageCount = 1;
|
||||
for (COSName resourceName : page.getResources().getXObjectNames()) {
|
||||
if (!page.getResources().isImageXObject(resourceName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (page.getResources().isImageXObject(name)) {
|
||||
PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name);
|
||||
if (!allowDuplicates) {
|
||||
byte[] data = ImageProcessingUtils.getImageData(image.getImage());
|
||||
byte[] imageHash = md.digest(data);
|
||||
synchronized (processedImages) {
|
||||
if (processedImages.stream()
|
||||
.anyMatch(hash -> Arrays.equals(hash, imageHash))) {
|
||||
continue; // Skip already processed images
|
||||
}
|
||||
processedImages.add(imageHash);
|
||||
}
|
||||
}
|
||||
PDImageXObject imageObject =
|
||||
(PDImageXObject) page.getResources().getXObject(resourceName);
|
||||
int imageHashCode = imageObject.hashCode();
|
||||
|
||||
RenderedImage renderedImage = image.getImage();
|
||||
|
||||
// Convert to standard RGB colorspace if needed
|
||||
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
|
||||
|
||||
// Write image to zip file
|
||||
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
|
||||
synchronized (zos) {
|
||||
zos.putNextEntry(new ZipEntry(imageName));
|
||||
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
|
||||
ImageIO.write(bufferedImage, format, imageBaos);
|
||||
zos.write(imageBaos.toByteArray());
|
||||
zos.closeEntry();
|
||||
}
|
||||
if (seenImageHashes.contains(imageHashCode)) {
|
||||
continue;
|
||||
}
|
||||
seenImageHashes.add(imageHashCode);
|
||||
|
||||
RenderedImage sourceImage = imageObject.getImage();
|
||||
BufferedImage convertedImage = convertImageToFormat(sourceImage, imageFormat);
|
||||
|
||||
String imagePath =
|
||||
baseFilename + "_page_" + pageNumber + "_" + imageCount++ + "."
|
||||
+ imageFormat;
|
||||
ByteArrayOutputStream imageBuffer = new ByteArrayOutputStream();
|
||||
ImageIO.write(convertedImage, imageFormat, imageBuffer);
|
||||
|
||||
zipOutput.putNextEntry(new ZipEntry(imagePath));
|
||||
zipOutput.write(imageBuffer.toByteArray());
|
||||
zipOutput.closeEntry();
|
||||
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("image extraction", e);
|
||||
ExceptionUtils.logException("image extraction failed", e);
|
||||
throw ExceptionUtils.handlePdfException(e, "during image extraction");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage convertToRGB(RenderedImage renderedImage, String format) {
|
||||
int width = renderedImage.getWidth();
|
||||
int height = renderedImage.getHeight();
|
||||
BufferedImage rgbImage;
|
||||
private BufferedImage convertImageToFormat(RenderedImage source, String format) {
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
|
||||
int imageType = BufferedImage.TYPE_INT_RGB;
|
||||
if ("png".equalsIgnoreCase(format)) {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
} else if ("jpeg".equalsIgnoreCase(format) || "jpg".equalsIgnoreCase(format)) {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
} else if ("gif".equalsIgnoreCase(format)) {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED);
|
||||
} else {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
imageType = BufferedImage.TYPE_INT_ARGB;
|
||||
}
|
||||
|
||||
Graphics2D g = rgbImage.createGraphics();
|
||||
g.drawImage((Image) renderedImage, 0, 0, null);
|
||||
g.dispose();
|
||||
return rgbImage;
|
||||
BufferedImage result = new BufferedImage(width, height, imageType);
|
||||
Graphics2D graphics = result.createGraphics();
|
||||
graphics.drawImage((Image) source, 0, 0, null);
|
||||
graphics.dispose();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+1
-1
@@ -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)) {
|
||||
|
||||
+52
-42
@@ -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;
|
||||
|
||||
+2
-17
@@ -36,7 +36,6 @@ import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
import stirling.software.common.util.FileMonitor;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -50,7 +49,6 @@ public class PipelineDirectoryProcessor {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
private final PipelineProcessor processor;
|
||||
private final FileMonitor fileMonitor;
|
||||
private final PostHogService postHogService;
|
||||
private final List<String> watchedFoldersDirs;
|
||||
private final String finishedFoldersDir;
|
||||
@@ -63,13 +61,11 @@ public class PipelineDirectoryProcessor {
|
||||
ObjectMapper objectMapper,
|
||||
ApiDocService apiDocService,
|
||||
PipelineProcessor processor,
|
||||
FileMonitor fileMonitor,
|
||||
PostHogService postHogService,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiDocService = apiDocService;
|
||||
this.processor = processor;
|
||||
this.fileMonitor = fileMonitor;
|
||||
this.postHogService = postHogService;
|
||||
this.watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
|
||||
this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath();
|
||||
@@ -254,7 +250,7 @@ public class PipelineDirectoryProcessor {
|
||||
String extension =
|
||||
filename.contains(".")
|
||||
? filename.substring(
|
||||
filename.lastIndexOf(".")
|
||||
filename.lastIndexOf('.')
|
||||
+ 1)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
: "";
|
||||
@@ -274,18 +270,7 @@ public class PipelineDirectoryProcessor {
|
||||
return isAllowed;
|
||||
})
|
||||
.map(Path::toAbsolutePath)
|
||||
.filter(
|
||||
path -> {
|
||||
boolean isReady =
|
||||
fileMonitor.isFileReadyForProcessing(path);
|
||||
if (!isReady) {
|
||||
log.info(
|
||||
"File not ready for processing (locked/created"
|
||||
+ " last 5s): {}",
|
||||
path);
|
||||
}
|
||||
return isReady;
|
||||
})
|
||||
.filter(path -> true)
|
||||
.map(Path::toFile)
|
||||
.toArray(File[]::new);
|
||||
log.info(
|
||||
|
||||
+19
-5
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)) {
|
||||
|
||||
+13
-3
@@ -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();
|
||||
|
||||
+4
-2
@@ -178,7 +178,8 @@ public class ReactRoutingController {
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
return
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -237,7 +238,8 @@ public class ReactRoutingController {
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
return
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,8 +25,9 @@ public class ApiEndpoint {
|
||||
}
|
||||
|
||||
public boolean areParametersValid(Map<String, Object> providedParams) {
|
||||
for (String requiredParam : parameters.keySet()) {
|
||||
if (!providedParams.containsKey(requiredParam)) {
|
||||
for (Map.Entry<String, JsonNode> entry : parameters.entrySet()) {
|
||||
boolean isRequired = entry.getValue().path("required").asBoolean(false);
|
||||
if (isRequired && !providedParams.containsKey(entry.getKey())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ public enum SortTypes {
|
||||
BOOKLET_SORT,
|
||||
SIDE_STITCH_BOOKLET_SORT,
|
||||
ODD_EVEN_SPLIT,
|
||||
ODD_EVEN_MERGE,
|
||||
REMOVE_FIRST,
|
||||
REMOVE_LAST,
|
||||
REMOVE_FIRST_AND_LAST,
|
||||
|
||||
+6
-6
@@ -9,10 +9,10 @@ import lombok.EqualsAndHashCode;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PDFExtractImagesRequest extends PDFWithImageFormatRequest {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Boolean to enable/disable the saving of duplicate images, true to enable"
|
||||
+ " duplicates",
|
||||
defaultValue = "false")
|
||||
private Boolean allowDuplicates;
|
||||
// @Schema(
|
||||
// description =
|
||||
// "Boolean to enable/disable the saving of duplicate images, true to enable"
|
||||
// + " duplicates",
|
||||
// defaultValue = "false")
|
||||
// private Boolean allowDuplicates;
|
||||
}
|
||||
|
||||
-1
@@ -22,7 +22,6 @@ public class RearrangePagesRequest extends PDFWithPageNums {
|
||||
+ "DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in reverse (1, n, 2, n-1, ...). "
|
||||
+ "BOOKLET_SORT: Arranges pages for booklet printing (last, first, second, second last, ...).\n"
|
||||
+ "ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered pages.\n"
|
||||
+ "ODD_EVEN_MERGE: Merges pages and organises them alternately into odd and even pages.\n"
|
||||
+ "REMOVE_FIRST: Removes the first page.\n"
|
||||
+ "REMOVE_LAST: Removes the last page.\n"
|
||||
+ "REMOVE_FIRST_AND_LAST: Removes both the first and the last pages.\n")
|
||||
|
||||
@@ -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
-3
@@ -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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user