mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
52
Commits
mainToV2Clone
...
V1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e034e8cc | ||
|
|
92fa1ddb3b | ||
|
|
d8b43029db | ||
|
|
f600c6447c | ||
|
|
c53000786a | ||
|
|
af2e418129 | ||
|
|
97f3b88222 | ||
|
|
e68871bad3 | ||
|
|
dd96584bf8 | ||
|
|
a266187d68 | ||
|
|
bb4d313b55 | ||
|
|
c760d1a93a | ||
|
|
27ccb3e4f1 | ||
|
|
40bd3ca8e2 | ||
|
|
d786674224 | ||
|
|
6563292567 | ||
|
|
22efcaa478 | ||
|
|
fb2663fde4 | ||
|
|
28abdf084c | ||
|
|
2d34e52459 | ||
|
|
886f9b379e | ||
|
|
43345021bf | ||
|
|
d39cc7f8ff | ||
|
|
1360bc22a5 | ||
|
|
460de57900 | ||
|
|
32f88560e9 | ||
|
|
68a4249ada | ||
|
|
9a834610a3 | ||
|
|
5f560e42ac | ||
|
|
184bb82aaa | ||
|
|
bdd8d2e6d4 | ||
|
|
5b16e4e701 | ||
|
|
7cfe49632f | ||
|
|
430be21786 | ||
|
|
e56ef8019d | ||
|
|
7b58b50c00 | ||
|
|
80206eaf07 | ||
|
|
c895b09142 | ||
|
|
7acc716b80 | ||
|
|
3fdfb9632b | ||
|
|
be824b126f | ||
|
|
789c42af7b | ||
|
|
19aef5e034 | ||
|
|
e932ca01f3 | ||
|
|
57eb6dbed9 | ||
|
|
5535e5003d | ||
|
|
6dce5d675c | ||
|
|
6281db58c9 | ||
|
|
de27ea02c5 | ||
|
|
abd1ae1bf2 | ||
|
|
2acb3aa6e5 | ||
|
|
d673670ebc |
+17
-12
@@ -6,22 +6,27 @@ app: &app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
openapi: &openapi
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- *build
|
||||
- *app
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- 'testing/**/!(requirements*.txt|requirements*.in)*'
|
||||
- build.gradle
|
||||
docker: &docker
|
||||
- Dockerfile
|
||||
- Dockerfile.fat
|
||||
- Dockerfile.ultra-lite
|
||||
- ".github/workflows/build.yml"
|
||||
- scripts/init.sh
|
||||
- scripts/init-without-ocr.sh
|
||||
- exampleYmlFiles/**
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- "testing/**/!(requirements*.txt|requirements*.in)*"
|
||||
- *docker
|
||||
- gradle.properties
|
||||
- gradlew
|
||||
- gradlew.bat
|
||||
|
||||
@@ -39,12 +39,12 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -127,12 +127,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -361,12 +361,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
+233
-50
@@ -33,8 +33,9 @@ jobs:
|
||||
app: ${{ steps.changes.outputs.app }}
|
||||
project: ${{ steps.changes.outputs.project }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
docker: ${{ steps.changes.outputs.docker }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -56,26 +57,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
cache: gradle
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew clean build
|
||||
run: ./gradlew clean build -x spotlessApply -x spotlessCheck -x sonarqube
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
@@ -100,12 +97,14 @@ jobs:
|
||||
if [ ${#missing_reports[@]} -gt 0 ]; then
|
||||
echo "ERROR: The following required test report directories are missing:"
|
||||
printf '%s\n' "${missing_reports[@]}"
|
||||
exit 1
|
||||
echo "reports-present=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "All required test report directories are present"
|
||||
echo "reports-present=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "All required test report directories are present"
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
if: always() && steps.check-reports.outputs.reports-present == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
@@ -127,6 +126,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
|
||||
if: steps.check-reports.outputs.reports-present == 'true'
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
with:
|
||||
@@ -143,27 +143,25 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
cache: gradle
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
@@ -176,18 +174,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
cache: gradle
|
||||
|
||||
- name: Check licenses for compatibility
|
||||
run: ./gradlew clean checkLicense
|
||||
@@ -225,32 +224,33 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
@@ -265,67 +265,250 @@ jobs:
|
||||
./testing/test.sh
|
||||
|
||||
test-build-docker-images:
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
|
||||
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.docker == 'true'
|
||||
needs: [files-changed, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
docker:
|
||||
- name: "Dockerfile.ultra-lite"
|
||||
tag: "ultra-lite"
|
||||
- name: "Dockerfile.fat"
|
||||
tag: "fat"
|
||||
- name: "Dockerfile"
|
||||
tag: "latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
cache: gradle
|
||||
|
||||
- name: Build application
|
||||
run: ./gradlew clean build
|
||||
run: ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
# - name: Free disk space on runner
|
||||
# run: |
|
||||
# echo "Disk space before cleanup:" && df -h
|
||||
# sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
|
||||
# docker system prune -af || true
|
||||
# echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
- name: Prepare branch tag
|
||||
id: branch_tag
|
||||
shell: bash
|
||||
run: |
|
||||
BRANCH_SOURCE="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
|
||||
BRANCH_LOWER=$(echo "$BRANCH_SOURCE" | tr '[:upper:]' '[:lower:]')
|
||||
SAFE_BRANCH=$(echo "$BRANCH_LOWER" | sed 's/[^a-z0-9_.-]/-/g' | sed 's/^-\+//' | sed 's/-\+$//' | sed 's/--\+/-/g')
|
||||
if [ -z "$SAFE_BRANCH" ]; then
|
||||
SAFE_BRANCH="branch"
|
||||
fi
|
||||
SHORT_SHA=$(echo "${GITHUB_SHA:-${{ github.sha }}}" | cut -c1-8)
|
||||
echo "safe_branch=$SAFE_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: |
|
||||
# ${{ secrets.DOCKER_HUB_USERNAME }}/stirling-pdf-test
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.docker.tag }},enable=true
|
||||
# type=raw,value=${{ matrix.docker.tag }}-${{ steps.branch_tag.outputs.safe_branch }},enable=true
|
||||
# type=raw,value=${{ matrix.docker.tag }}-${{ steps.branch_tag.outputs.safe_branch }}-${{ steps.branch_tag.outputs.short_sha }},enable=true
|
||||
labels: |
|
||||
org.opencontainers.image.title=Stirling-PDF Test
|
||||
org.opencontainers.image.description=CI test image for Stirling-PDF
|
||||
org.opencontainers.image.url=https://www.stirlingpdf.com
|
||||
org.opencontainers.image.documentation=https://docs.stirlingpdf.com
|
||||
org.opencontainers.image.authors=Stirling-Tools
|
||||
org.opencontainers.image.licenses=MIT
|
||||
org.opencontainers.image.version=${{ matrix.docker.tag }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.repository }}
|
||||
maintainer=Stirling-Tools
|
||||
|
||||
- name: Choose primary tag for tests
|
||||
id: testtag
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE="ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test"
|
||||
VARIANT="${{ matrix.docker.tag }}"
|
||||
BRANCH="${{ steps.branch_tag.outputs.safe_branch }}"
|
||||
SHA_SHORT="${{ steps.branch_tag.outputs.short_sha }}"
|
||||
CANDIDATE="$IMAGE:$VARIANT-$BRANCH-$SHA_SHORT"
|
||||
SECONDARY="$IMAGE:$VARIANT-$BRANCH"
|
||||
ALL_TAGS="$(echo '${{ steps.meta.outputs.tags }}' | tr ' ' '\n')"
|
||||
if echo "$ALL_TAGS" | grep -qx "$CANDIDATE"; then
|
||||
SELECTED="$CANDIDATE"
|
||||
elif echo "$ALL_TAGS" | grep -qx "$SECONDARY"; then
|
||||
SELECTED="$SECONDARY"
|
||||
else
|
||||
SELECTED="$(echo "$ALL_TAGS" | head -n1)"
|
||||
fi
|
||||
echo "tag=$SELECTED" >> $GITHUB_OUTPUT
|
||||
echo "Using test tag: $SELECTED"
|
||||
|
||||
# - name: Log in to Docker Hub
|
||||
# uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
# - name: Log in to GitHub Container Registry
|
||||
# uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.actor }}
|
||||
# password: ${{ github.token }}
|
||||
|
||||
- name: Build and push amd64 image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
file: ./${{ matrix.docker.name }}
|
||||
push: false
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }} # ALLE Tags publishen
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Upload Reports
|
||||
- name: Show amd64 image size
|
||||
run: |
|
||||
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
|
||||
echo "Inspecting image: ${IMAGE_TAG}"
|
||||
SIZE=$(docker image inspect "${IMAGE_TAG}" --format='{{.Size}}')
|
||||
FORMATTED=$(numfmt --to=iec --suffix=B "${SIZE}")
|
||||
echo "Image size (amd64): ${FORMATTED}"
|
||||
|
||||
- name: Start amd64 image for 2 minutes
|
||||
run: |
|
||||
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
|
||||
CONTAINER_NAME="stirling-pdf-test-${{ matrix.docker.tag }}-amd64"
|
||||
echo "Starting container ${CONTAINER_NAME} from ${IMAGE_TAG}"
|
||||
docker run -d --name "${CONTAINER_NAME}" "${IMAGE_TAG}"
|
||||
echo "Waiting up to 2 minutes..."
|
||||
sleep 120 || true
|
||||
echo "===== Logs for ${CONTAINER_NAME} ====="
|
||||
docker logs "${CONTAINER_NAME}" || true
|
||||
echo "Stopping container ${CONTAINER_NAME} after 2 minutes"
|
||||
docker stop "${CONTAINER_NAME}" || true
|
||||
docker rm "${CONTAINER_NAME}" || true
|
||||
|
||||
- name: Prune amd64 image and cache
|
||||
if: always()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
run: |
|
||||
docker image rm -f ${{ steps.testtag.outputs.tag }} || true
|
||||
docker builder prune --force || true
|
||||
|
||||
- name: Build and push arm64 image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
name: reports-docker-${{ matrix.docker-rev }}
|
||||
path: |
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker.name }}
|
||||
push: false
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta.outputs.tags }} # ALLE Tags publishen
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/arm64/v8
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Show arm64 image size
|
||||
run: |
|
||||
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
|
||||
echo "Inspecting image: ${IMAGE_TAG}"
|
||||
SIZE=$(docker image inspect "${IMAGE_TAG}" --format='{{.Size}}')
|
||||
FORMATTED=$(numfmt --to=iec --suffix=B "${SIZE}")
|
||||
echo "Image size (arm64): ${FORMATTED}"
|
||||
|
||||
- name: Start arm64 image for 2 minutes
|
||||
run: |
|
||||
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
|
||||
CONTAINER_NAME="stirling-pdf-test-${{ matrix.docker.tag }}-arm64"
|
||||
echo "Starting container ${CONTAINER_NAME} from ${IMAGE_TAG}"
|
||||
docker run -d --name "${CONTAINER_NAME}" "${IMAGE_TAG}"
|
||||
echo "Waiting up to 2 minutes..."
|
||||
sleep 120 || true
|
||||
echo "===== Logs for ${CONTAINER_NAME} ====="
|
||||
docker logs "${CONTAINER_NAME}" || true
|
||||
echo "Stopping container ${CONTAINER_NAME} after 2 minutes"
|
||||
docker stop "${CONTAINER_NAME}" || true
|
||||
docker rm "${CONTAINER_NAME}" || true
|
||||
|
||||
- name: Cleanup arm64 image and cache
|
||||
if: always()
|
||||
run: |
|
||||
docker image rm -f ${{ steps.testtag.outputs.tag }} || true
|
||||
docker builder prune --force || true
|
||||
|
||||
# - name: Build and push multi-arch image
|
||||
# uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
# with:
|
||||
# builder: ${{ steps.buildx.outputs.name }}
|
||||
# context: .
|
||||
# file: ./${{ matrix.docker.name }}
|
||||
# push: true
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
# tags: ${{ steps.meta.outputs.tags }}
|
||||
# labels: ${{ steps.meta.outputs.labels }}
|
||||
# platforms: linux/amd64,linux/arm64/v8
|
||||
# provenance: false
|
||||
# sbom: false
|
||||
|
||||
# - name: Upload Docker build reports
|
||||
# if: always()
|
||||
# uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
# with:
|
||||
# name: reports-docker-${{ matrix.docker.name }}
|
||||
# path: |
|
||||
# build/reports/
|
||||
# build/test-results/
|
||||
# build/reports/problems/
|
||||
# retention-days: 3
|
||||
# if-no-files-found: warn
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main branch first
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update 3rd Party Licenses"
|
||||
|
||||
@@ -15,12 +15,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Run Labeler
|
||||
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
|
||||
|
||||
@@ -21,11 +21,11 @@ jobs:
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -60,11 +60,11 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -148,11 +148,11 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -224,84 +224,16 @@ jobs:
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify:
|
||||
needs: [read_versions, build-installers]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: ${{ matrix.platform }}binaries
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Install Cosign
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Generate key pair
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}signed
|
||||
path: |
|
||||
./Stirling-PDF-${{ matrix.platform }}installer.*
|
||||
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
|
||||
!cosign.*
|
||||
|
||||
create-release:
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
|
||||
needs: [read_versions, sign_verify, sign_verify-portable]
|
||||
needs: [read_versions, sign_verify-portable]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -310,7 +242,7 @@ jobs:
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
|
||||
@@ -30,11 +30,11 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -74,6 +74,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -139,11 +139,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+132
-70
@@ -1,11 +1,88 @@
|
||||
# Main stage
|
||||
FROM alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
|
||||
# ==============================================================================
|
||||
# Multi-stage Dockerfile for Stirling-PDF – image with everything included
|
||||
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
|
||||
# ==============================================================================
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
# ========================================
|
||||
# STAGE 1: Runtime image based on Debian stable-slim
|
||||
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
|
||||
# ========================================
|
||||
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
|
||||
|
||||
# Install core runtime dependencies + tools required by Stirling-PDF features
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata tini bash fontconfig \
|
||||
openjdk-21-jre-headless \
|
||||
ffmpeg poppler-utils ocrmypdf \
|
||||
libreoffice-nogui libreoffice-java-common \
|
||||
python3 python3-venv python3-uno \
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
|
||||
tesseract-ocr-por tesseract-ocr-chi-sim \
|
||||
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
|
||||
gosu unpaper \
|
||||
# AWT headless support (required for some Java graphics operations)
|
||||
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
|
||||
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
|
||||
# Qt WebEngine dependencies for Calibre
|
||||
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
|
||||
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
|
||||
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
|
||||
# Virtual framebuffer (required for headless LibreOffice)
|
||||
xvfb x11-utils coreutils \
|
||||
# Temporary packages only needed for Calibre installer
|
||||
xz-utils gpgv curl xdg-utils \
|
||||
\
|
||||
# Install Calibre from official installer script
|
||||
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
|
||||
\
|
||||
# Clean up installer-only packages
|
||||
&& apt-get purge -y xz-utils gpgv xdg-utils \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Make ebook-convert available in PATH
|
||||
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
|
||||
&& /opt/calibre/ebook-convert --version
|
||||
|
||||
# ==============================================================================
|
||||
# Create non-root user (stirlingpdfuser) with configurable UID/GID
|
||||
# ==============================================================================
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
RUN set -eux; \
|
||||
# Create group if it doesn't exist
|
||||
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
|
||||
if getent group "${PGID}" >/dev/null 2>&1; then \
|
||||
groupadd -o -g "${PGID}" stirlingpdfgroup; \
|
||||
else \
|
||||
groupadd -g "${PGID}" stirlingpdfgroup; \
|
||||
fi; \
|
||||
fi; \
|
||||
# Create user if it doesn't exist, avoid UID conflicts
|
||||
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
|
||||
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \
|
||||
echo "UID ${PUID} already in use – creating stirlingpdfuser with automatic UID"; \
|
||||
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
|
||||
else \
|
||||
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Compatibility alias for older entrypoint scripts expecting su-exec
|
||||
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
|
||||
|
||||
# Copy application files from build stage
|
||||
COPY scripts/ /scripts/
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
|
||||
COPY app/core/build/libs/*.jar app.jar
|
||||
|
||||
# Optional version tag (can be passed at build time)
|
||||
ARG VERSION_TAG
|
||||
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF"
|
||||
@@ -20,83 +97,68 @@ LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
|
||||
|
||||
# Set Environment Variables
|
||||
# ==============================================================================
|
||||
# Runtime environment variables
|
||||
# ==============================================================================
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 \
|
||||
-XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 \
|
||||
-XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 \
|
||||
-Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
PUID=${PUID} \
|
||||
PGID=${PGID} \
|
||||
UMASK=022 \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# ==============================================================================
|
||||
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
|
||||
# ==============================================================================
|
||||
RUN python3 -m venv /opt/venv --system-site-packages \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
|
||||
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
|
||||
|
||||
# JDK for app
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
ffmpeg \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF (unpaper for descew and other advanced features)
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
unpaper \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
ocrmypdf \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing \
|
||||
# URW Base 35 fonts for better PDF rendering
|
||||
font-urw-base35 && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
# Configure URW Base 35 fonts
|
||||
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
# Separate venv for unoserver (keeps it isolated)
|
||||
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
|
||||
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
|
||||
|
||||
# Make unoserver tools available in main venv PATH
|
||||
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
|
||||
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
|
||||
|
||||
# Extend PATH to include both virtual environments
|
||||
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
|
||||
|
||||
# ==============================================================================
|
||||
# Final permissions, directories and font cache
|
||||
# ==============================================================================
|
||||
RUN set -eux; \
|
||||
chmod +x /scripts/*; \
|
||||
mkdir -p /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup \
|
||||
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
|
||||
/app.jar /usr/share/fonts/truetype /scripts; \
|
||||
chmod -R 755 /tmp/stirling-pdf
|
||||
|
||||
# Rebuild font cache
|
||||
RUN fc-cache -f -v
|
||||
|
||||
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
|
||||
ENV QT_QPA_PLATFORM=offscreen \
|
||||
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
|
||||
|
||||
# Expose web UI port
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
# Use tini as init (handles signals and zombies correctly)
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
|
||||
# CMD is empty – actual start command is defined in init.sh
|
||||
CMD []
|
||||
|
||||
+7
-1
@@ -25,6 +25,12 @@ RUN apt-get update && apt-get install -y \
|
||||
python3-venv \
|
||||
# ss -tln
|
||||
iproute2 \
|
||||
# calibre requires these dependencies
|
||||
wget \
|
||||
xz-utils \
|
||||
libopengl0 \
|
||||
libxcb-cursor0 \
|
||||
&& wget -nv -O- https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setze die Environment Variable für setuptools
|
||||
@@ -38,7 +44,7 @@ ENV SETUPTOOLS_USE_DISTUTILS=local \
|
||||
COPY .github/scripts/requirements_dev.txt /tmp/requirements_dev.txt
|
||||
RUN python3 -m venv --system-site-packages /opt/venv \
|
||||
&& . /opt/venv/bin/activate \
|
||||
&& pip install --no-cache-dir --require-hashes -r /tmp/requirements_dev.txt
|
||||
&& pip install --no-cache-dir --only-binary=:all: --require-hashes -r /tmp/requirements_dev.txt
|
||||
|
||||
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
+180
-85
@@ -1,114 +1,209 @@
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
# ==============================================================================
|
||||
# Multi-stage Dockerfile for Stirling-PDF – "fat" image with everything included
|
||||
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
|
||||
# ==============================================================================
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
# ========================================
|
||||
# STAGE 1: Build Stirling-PDF with Gradle (Alpine)
|
||||
# ========================================
|
||||
FROM eclipse-temurin:21-jdk-alpine@sha256:c4799f335a65b1ecca8a31239b05522f2b0a184d6818f6349e83484ee6956198 AS build
|
||||
|
||||
# Install build tools
|
||||
RUN apk add --no-cache bash unzip curl git
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Copy Gradle wrapper and configuration files
|
||||
COPY build.gradle settings.gradle gradlew ./
|
||||
COPY gradle ./gradle/
|
||||
|
||||
# Make gradlew executable
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# Create module directories and copy module build files (for Gradle layer caching)
|
||||
RUN mkdir -p core common proprietary
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
# Warm-up Gradle dependency cache (optional but improves subsequent builds)
|
||||
RUN ./gradlew --no-daemon printVersion --quiet | tail -1 > /tmp/version_tag || true
|
||||
RUN ./gradlew --no-daemon build -x spotlessApply -x spotlessCheck -x test -x sonarqube || true
|
||||
|
||||
# Switch to final source directory and copy full source code
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
# Environment variables (can be overridden at build time)
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false
|
||||
|
||||
# Main stage
|
||||
FROM alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
|
||||
# Final build – produce the fat JAR
|
||||
RUN ./gradlew --no-daemon clean build \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
&& apk del bash unzip curl git
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
# first /app directory is for the build stage, second is for the final image
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
|
||||
# ========================================
|
||||
# STAGE 2: Runtime image based on Debian stable-slim
|
||||
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
|
||||
# ========================================
|
||||
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install core runtime dependencies + tools required by Stirling-PDF features
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata tini bash fontconfig \
|
||||
openjdk-21-jre-headless \
|
||||
ffmpeg poppler-utils qpdf ghostscript ocrmypdf \
|
||||
libreoffice-nogui libreoffice-java-common \
|
||||
python3 python3-venv python3-uno \
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
|
||||
tesseract-ocr-por tesseract-ocr-chi-sim \
|
||||
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
|
||||
gosu unpaper \
|
||||
# AWT headless support (required for some Java graphics operations)
|
||||
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
|
||||
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
|
||||
# Qt WebEngine dependencies for Calibre
|
||||
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
|
||||
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
|
||||
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
|
||||
# Virtual framebuffer (required for headless LibreOffice)
|
||||
xvfb x11-utils coreutils \
|
||||
# Temporary packages only needed for Calibre installer
|
||||
xz-utils gpgv curl xdg-utils \
|
||||
\
|
||||
# Install Calibre from official installer script
|
||||
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
|
||||
\
|
||||
# Clean up installer-only packages
|
||||
&& apt-get purge -y xz-utils gpgv xdg-utils \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Make ebook-convert available in PATH
|
||||
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
|
||||
&& /opt/calibre/ebook-convert --version
|
||||
|
||||
# ==============================================================================
|
||||
# Create non-root user (stirlingpdfuser) with configurable UID/GID
|
||||
# ==============================================================================
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
RUN set -eux; \
|
||||
# Create group if it doesn't exist
|
||||
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
|
||||
if getent group "${PGID}" >/dev/null 2>&1; then \
|
||||
groupadd -o -g "${PGID}" stirlingpdfgroup; \
|
||||
else \
|
||||
groupadd -g "${PGID}" stirlingpdfgroup; \
|
||||
fi; \
|
||||
fi; \
|
||||
# Create user if it doesn't exist, avoid UID conflicts
|
||||
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
|
||||
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \
|
||||
echo "UID ${PUID} already in use – creating stirlingpdfuser with automatic UID"; \
|
||||
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
|
||||
else \
|
||||
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Compatibility alias for older entrypoint scripts expecting su-exec
|
||||
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
|
||||
|
||||
# Copy application files from build stage
|
||||
COPY scripts/ /scripts/
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
|
||||
# Copy version tag generated during build
|
||||
COPY --from=build /tmp/version_tag /etc/stirling_version
|
||||
|
||||
# Optional version tag (can be passed at build time)
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
# Metadata labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF"
|
||||
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
|
||||
|
||||
# ==============================================================================
|
||||
# Runtime environment variables
|
||||
# ==============================================================================
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 \
|
||||
-XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 \
|
||||
-XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 \
|
||||
-Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
PUID=${PUID} \
|
||||
PGID=${PGID} \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# ==============================================================================
|
||||
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
|
||||
# ==============================================================================
|
||||
RUN python3 -m venv /opt/venv --system-site-packages \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
|
||||
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
|
||||
|
||||
# JDK for app
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
ffmpeg \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF (unpaper for descew and other advanced featues)
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
unpaper \
|
||||
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
ocrmypdf \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
# Configure URW Base 35 fonts
|
||||
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
# Separate venv for unoserver (keeps it isolated)
|
||||
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
|
||||
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
|
||||
|
||||
# Make unoserver tools available in main venv PATH
|
||||
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
|
||||
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
|
||||
|
||||
# Extend PATH to include both virtual environments
|
||||
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
|
||||
|
||||
# ==============================================================================
|
||||
# Final permissions, directories and font cache
|
||||
# ==============================================================================
|
||||
RUN set -eux; \
|
||||
chmod +x /scripts/*; \
|
||||
mkdir -p /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup \
|
||||
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
|
||||
/app.jar /usr/share/fonts/truetype /scripts; \
|
||||
chmod -R 755 /tmp/stirling-pdf
|
||||
|
||||
# Rebuild font cache
|
||||
RUN fc-cache -f -v
|
||||
|
||||
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
|
||||
ENV QT_QPA_PLATFORM=offscreen \
|
||||
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
|
||||
|
||||
# Expose web UI port
|
||||
EXPOSE 8080/tcp
|
||||
# Set user and run command
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
# Use tini as init (handles signals and zombies correctly)
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
|
||||
# CMD is empty – actual start command is defined in init.sh
|
||||
CMD []
|
||||
|
||||
+10
-5
@@ -24,9 +24,13 @@ COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
COPY app/core/build/libs/*.jar app.jar
|
||||
|
||||
# Set up necessary directories and permissions
|
||||
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
RUN apk add --no-cache bash \
|
||||
&& ln -sf /bin/bash /bin/sh \
|
||||
&& printf '%s\n' \
|
||||
'https://dl-cdn.alpinelinux.org/alpine/edge/main' \
|
||||
'https://dl-cdn.alpinelinux.org/alpine/edge/community' \
|
||||
'https://dl-cdn.alpinelinux.org/alpine/edge/testing' \
|
||||
> /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
@@ -42,7 +46,8 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
|
||||
chmod +x /scripts/*.sh && \
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
|
||||
ln -sf /bin/busybox /bin/sh
|
||||
|
||||
# Set environment variables
|
||||
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI
|
||||
@@ -51,4 +56,4 @@ EXPOSE 8080/tcp
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
|
||||
CMD []
|
||||
|
||||
@@ -33,6 +33,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
### 50+ PDF Operations
|
||||
|
||||
#### Organise
|
||||
- **PDF Multi Tool**: Access merge, rotate, rearrange, split and delete actions from a single dashboard
|
||||
- **Merge**: Combine multiple PDFs into one
|
||||
- **Split**: Divide PDFs into multiple files
|
||||
- **Extract page(s)**: Extract specific pages from PDF
|
||||
@@ -47,11 +48,13 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
#### Convert to PDF
|
||||
- **Image to PDF**: Convert images to PDF format
|
||||
- **Convert file to PDF**: Convert various common file types to PDF
|
||||
- **URL/Website to PDF**: Capture live webpages as PDFs
|
||||
- **HTML to PDF**: Transform HTML documents to PDF
|
||||
- **Markdown to PDF**: Convert Markdown files to PDF
|
||||
- **CBZ to PDF**: Convert comic book archives
|
||||
- **CBR to PDF**: Convert comic book rar archives
|
||||
- **Email to PDF**: Convert email files to PDF
|
||||
- **eBook to PDF**: Convert eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF (using Calibre)
|
||||
- **Vector Image to PDF**: Convert vector images (PS, EPS, EPSF) to PDF format
|
||||
|
||||
#### Convert from PDF
|
||||
@@ -67,6 +70,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
- **PDF to CBZ**: Convert to comic book archive
|
||||
- **PDF to CBR**: Convert to comic book rar archive
|
||||
- **PDF to Vector Image**: Convert PDF to vector image (EPS, PS, PCL, XPS) format
|
||||
- **PDF to Video Slideshow**: Export pages as an automated video presentation
|
||||
|
||||
#### Sign & Security
|
||||
- **Sign**: Add digital signatures
|
||||
@@ -86,6 +90,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
- **OCR / Cleanup scans**: Optical Character Recognition
|
||||
- **Add Image**: Insert images into PDF
|
||||
- **Extract Images**: Extract embedded images
|
||||
- **Extract Attachments**: Retrieve embedded attachments
|
||||
- **Change Metadata**: Edit PDF metadata
|
||||
- **Get ALL Info on PDF**: Comprehensive PDF analysis
|
||||
- **Advanced Colour options**: Colour manipulation (various options for colour inversion, CMYK conversion)
|
||||
@@ -134,46 +139,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
| Language | Progress |
|
||||
|----------------------------------------------|----------------------------------------|
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ dependencies {
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.19.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.10'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AutoJobAspect {
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
public Object wrapWithJobExecution(
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception {
|
||||
// This aspect will run before any audit aspects due to @Order(0)
|
||||
// Extract parameters from the request and annotation
|
||||
boolean async = Boolean.parseBoolean(request.getParameter("async"));
|
||||
@@ -109,7 +109,8 @@ public class AutoJobAspect {
|
||||
int maxRetries,
|
||||
boolean trackProgress,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
int resourceWeight)
|
||||
throws Exception {
|
||||
|
||||
// Keep jobId reference for progress tracking in TaskManager
|
||||
AtomicReference<String> jobIdRef = new AtomicReference<>();
|
||||
|
||||
@@ -70,7 +70,7 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "loginEnabled")
|
||||
public boolean loginEnabled() {
|
||||
return applicationProperties.getSecurity().getEnableLogin();
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
@Bean(name = "appName")
|
||||
@@ -120,9 +120,7 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "enableAlphaFunctionality")
|
||||
public boolean enableAlphaFunctionality() {
|
||||
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null
|
||||
? applicationProperties.getSystem().getEnableAlphaFunctionality()
|
||||
: false;
|
||||
return applicationProperties.getSystem().isEnableAlphaFunctionality();
|
||||
}
|
||||
|
||||
@Bean(name = "rateLimit")
|
||||
|
||||
+40
-2
@@ -10,8 +10,10 @@ import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
|
||||
import stirling.software.common.model.ApplicationProperties.System;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@@ -19,8 +21,16 @@ import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline
|
||||
public class RuntimePathConfig {
|
||||
private final ApplicationProperties properties;
|
||||
private final String basePath;
|
||||
|
||||
// Operation paths
|
||||
private final String weasyPrintPath;
|
||||
private final String unoConvertPath;
|
||||
private final String calibrePath;
|
||||
private final String ocrMyPdfPath;
|
||||
private final String sOfficePath;
|
||||
|
||||
// Tesseract data path
|
||||
private final String tessDataPath;
|
||||
|
||||
// Pipeline paths
|
||||
private final String pipelineWatchedFoldersPath;
|
||||
@@ -37,7 +47,10 @@ public class RuntimePathConfig {
|
||||
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
|
||||
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
|
||||
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
System system = properties.getSystem();
|
||||
CustomPaths customPaths = system.getCustomPaths();
|
||||
|
||||
Pipeline pipeline = customPaths.getPipeline();
|
||||
|
||||
this.pipelineWatchedFoldersPath =
|
||||
resolvePath(
|
||||
@@ -57,8 +70,11 @@ public class RuntimePathConfig {
|
||||
// Initialize Operation paths
|
||||
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
|
||||
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
|
||||
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
|
||||
String defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf";
|
||||
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
|
||||
|
||||
Operations operations = properties.getSystem().getCustomPaths().getOperations();
|
||||
Operations operations = customPaths.getOperations();
|
||||
this.weasyPrintPath =
|
||||
resolvePath(
|
||||
defaultWeasyPrintPath,
|
||||
@@ -67,6 +83,28 @@ public class RuntimePathConfig {
|
||||
resolvePath(
|
||||
defaultUnoConvertPath,
|
||||
operations != null ? operations.getUnoconvert() : null);
|
||||
this.calibrePath =
|
||||
resolvePath(
|
||||
defaultCalibrePath, operations != null ? operations.getCalibre() : null);
|
||||
this.ocrMyPdfPath =
|
||||
resolvePath(
|
||||
defaultOcrMyPdfPath, operations != null ? operations.getOcrmypdf() : null);
|
||||
this.sOfficePath =
|
||||
resolvePath(
|
||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||
|
||||
// Initialize Tesseract data path
|
||||
String defaultTessDataPath =
|
||||
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
|
||||
|
||||
String tessPath = system.getTessdataDir();
|
||||
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
|
||||
this.tessDataPath =
|
||||
resolvePath(
|
||||
defaultTessDataPath,
|
||||
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
}
|
||||
|
||||
private String resolvePath(String defaultPath, String customPath) {
|
||||
|
||||
+17
-14
@@ -112,8 +112,8 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Security {
|
||||
private Boolean enableLogin;
|
||||
private Boolean csrfDisabled;
|
||||
private boolean enableLogin;
|
||||
private boolean csrfDisabled;
|
||||
private InitialLogin initialLogin = new InitialLogin();
|
||||
private OAUTH2 oauth2 = new OAUTH2();
|
||||
private SAML2 saml2 = new SAML2();
|
||||
@@ -295,8 +295,8 @@ public class ApplicationProperties {
|
||||
throw new UnsupportedProviderException(
|
||||
"Logout from the provider "
|
||||
+ registrationId
|
||||
+ " is not supported. "
|
||||
+ "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues");
|
||||
+ " is not supported. Report it at"
|
||||
+ " https://github.com/Stirling-Tools/Stirling-PDF/issues");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -314,19 +314,19 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class System {
|
||||
private String defaultLocale;
|
||||
private Boolean googlevisibility;
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private Boolean showUpdateOnlyAdmin;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
private Boolean enableAlphaFunctionality;
|
||||
private boolean enableAlphaFunctionality;
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Datasource datasource;
|
||||
private Boolean disableSanitize;
|
||||
private boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
private Boolean enableUrlToPDF;
|
||||
private boolean enableUrlToPDF;
|
||||
private Html html = new Html();
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
private String fileUploadLimit;
|
||||
@@ -371,6 +371,9 @@ public class ApplicationProperties {
|
||||
public static class Operations {
|
||||
private String weasyprint;
|
||||
private String unoconvert;
|
||||
private String calibre;
|
||||
private String ocrmypdf;
|
||||
private String soffice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,10 +456,10 @@ public class ApplicationProperties {
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
.formatted(driverName);
|
||||
}
|
||||
}
|
||||
@@ -491,7 +494,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private Boolean enabled;
|
||||
private boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
-5
@@ -478,11 +478,6 @@ public class CustomPDFDocumentFactory {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Create a uniquely named temporary directory */
|
||||
private Path createTempDirectory(String prefix) throws IOException {
|
||||
return Files.createTempDirectory(prefix + tempCounter.incrementAndGet() + "-");
|
||||
}
|
||||
|
||||
/** Create new document bytes based on an existing document */
|
||||
public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
|
||||
try (PDDocument document = load(oldDocument)) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -65,8 +64,9 @@ public class JobExecutorService {
|
||||
* @param async Whether to run the job asynchronously
|
||||
* @param work The work to be done
|
||||
* @return The response
|
||||
* @throws Exception if the job execution fails
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
|
||||
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) throws Exception {
|
||||
return runJobGeneric(async, work, -1);
|
||||
}
|
||||
|
||||
@@ -77,9 +77,10 @@ public class JobExecutorService {
|
||||
* @param work The work to be done
|
||||
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
|
||||
* @return The response
|
||||
* @throws Exception if the job execution fails
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
boolean async, Supplier<Object> work, long customTimeoutMs) {
|
||||
boolean async, Supplier<Object> work, long customTimeoutMs) throws Exception {
|
||||
return runJobGeneric(async, work, customTimeoutMs, false, 50);
|
||||
}
|
||||
|
||||
@@ -92,13 +93,15 @@ public class JobExecutorService {
|
||||
* @param queueable Whether this job can be queued when system resources are limited
|
||||
* @param resourceWeight The resource weight of this job (1-100)
|
||||
* @return The response
|
||||
* @throws Exception if the job execution fails
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
boolean async,
|
||||
Supplier<Object> work,
|
||||
long customTimeoutMs,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
int resourceWeight)
|
||||
throws Exception {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
|
||||
// Store the job ID in the request for potential use by other components
|
||||
@@ -192,29 +195,18 @@ public class JobExecutorService {
|
||||
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else {
|
||||
try {
|
||||
log.debug("Running sync job with timeout {} ms", timeoutToUse);
|
||||
log.debug("Running sync job with timeout {} ms", timeoutToUse);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
|
||||
// If the result is already a ResponseEntity, return it directly
|
||||
if (result instanceof ResponseEntity) {
|
||||
return (ResponseEntity<?>) result;
|
||||
}
|
||||
|
||||
// Process different result types
|
||||
return handleResultForSyncJob(result);
|
||||
} catch (TimeoutException te) {
|
||||
log.error("Synchronous job timed out after {} ms", timeoutToUse);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
// Construct a JSON error response
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
// If the result is already a ResponseEntity, return it directly
|
||||
if (result instanceof ResponseEntity) {
|
||||
return (ResponseEntity<?>) result;
|
||||
}
|
||||
|
||||
// Process different result types
|
||||
return handleResultForSyncJob(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import com.posthog.java.PostHog;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Service
|
||||
@@ -33,6 +34,7 @@ public class PostHogService {
|
||||
private final String uniqueId;
|
||||
private final String appVersion;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final UserServiceInterface userService;
|
||||
private final Environment env;
|
||||
private boolean configDirMounted;
|
||||
@@ -43,12 +45,14 @@ public class PostHogService {
|
||||
@Qualifier("configDirMounted") boolean configDirMounted,
|
||||
@Qualifier("appVersion") String appVersion,
|
||||
ApplicationProperties applicationProperties,
|
||||
RuntimePathConfig runtimePathConfig,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
Environment env) {
|
||||
this.postHog = postHog;
|
||||
this.uniqueId = uuid;
|
||||
this.appVersion = appVersion;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
this.userService = userService;
|
||||
this.env = env;
|
||||
this.configDirMounted = configDirMounted;
|
||||
@@ -253,11 +257,11 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_enableLogin",
|
||||
applicationProperties.getSecurity().getEnableLogin());
|
||||
applicationProperties.getSecurity().isEnableLogin());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_csrfDisabled",
|
||||
applicationProperties.getSecurity().getCsrfDisabled());
|
||||
applicationProperties.getSecurity().isCsrfDisabled());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_loginAttemptCount",
|
||||
@@ -302,25 +306,22 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_googlevisibility",
|
||||
applicationProperties.getSystem().getGooglevisibility());
|
||||
applicationProperties.getSystem().isGooglevisibility());
|
||||
addIfNotEmpty(
|
||||
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().getShowUpdateOnlyAdmin());
|
||||
applicationProperties.getSystem().isShowUpdateOnlyAdmin());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_customHTMLFiles",
|
||||
applicationProperties.getSystem().isCustomHTMLFiles());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_tessdataDir",
|
||||
applicationProperties.getSystem().getTessdataDir());
|
||||
addIfNotEmpty(properties, "system_tessdataDir", runtimePathConfig.getTessDataPath());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
applicationProperties.getSystem().isEnableAlphaFunctionality());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableAnalytics",
|
||||
@@ -337,7 +338,7 @@ public class PostHogService {
|
||||
|
||||
// Capture Metrics properties
|
||||
addIfNotEmpty(
|
||||
properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled());
|
||||
properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled());
|
||||
|
||||
// Capture EnterpriseEdition properties
|
||||
addIfNotEmpty(
|
||||
|
||||
@@ -59,31 +59,24 @@ public class CbrUtils {
|
||||
log.warn(
|
||||
"Failed to open CBR/RAR archive due to corrupt header: {}",
|
||||
e.getMessage());
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use"
|
||||
+ " an unsupported RAR format (RAR5+), or may not be a valid RAR"
|
||||
+ " archive. Please ensure the file is a valid RAR archive.");
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(null);
|
||||
} catch (RarException e) {
|
||||
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
|
||||
String errorMessage;
|
||||
String exMessage = e.getMessage() != null ? e.getMessage() : "";
|
||||
|
||||
if (exMessage.contains("encrypted")) {
|
||||
errorMessage = "Encrypted CBR/RAR archives are not supported.";
|
||||
throw ExceptionUtils.createCbrEncryptedException();
|
||||
} else if (exMessage.isEmpty()) {
|
||||
errorMessage =
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(
|
||||
"Invalid CBR/RAR archive. The file may be encrypted, corrupted, or"
|
||||
+ " use an unsupported format.";
|
||||
+ " use an unsupported format.");
|
||||
} else {
|
||||
errorMessage =
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(
|
||||
"Invalid CBR/RAR archive: "
|
||||
+ exMessage
|
||||
+ ". The file may be encrypted, corrupted, or use an"
|
||||
+ " unsupported format.";
|
||||
+ " unsupported format.");
|
||||
}
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", errorMessage);
|
||||
} catch (IOException e) {
|
||||
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
|
||||
throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
|
||||
@@ -172,17 +165,17 @@ public class CbrUtils {
|
||||
|
||||
private void validateCbrFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"cbr".equals(extension) && !"rar".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBR or RAR archive");
|
||||
throw ExceptionUtils.createNotCbrFileException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ public class CbzUtils {
|
||||
new java.io.FileInputStream(tempFile.getFile()));
|
||||
ZipInputStream zis = new ZipInputStream(bis)) {
|
||||
if (zis.getNextEntry() == null) {
|
||||
throw new IllegalArgumentException("Archive is empty or invalid ZIP");
|
||||
throw ExceptionUtils.createCbzEmptyException();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e);
|
||||
throw ExceptionUtils.createCbzInvalidFormatException(e);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument();
|
||||
@@ -84,7 +84,7 @@ public class CbzUtils {
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid images found in the CBZ file");
|
||||
throw ExceptionUtils.createCbzNoImagesException();
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
@@ -107,8 +107,7 @@ public class CbzUtils {
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"No images could be processed from the CBZ file");
|
||||
throw ExceptionUtils.createCbzCorruptedImagesException();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
@@ -130,17 +129,17 @@ public class CbzUtils {
|
||||
|
||||
private void validateCbzFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"cbz".equals(extension) && !"zip".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
|
||||
throw ExceptionUtils.createNotCbzFileException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,7 @@ public class CustomHtmlSanitizer {
|
||||
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
|
||||
|
||||
public String sanitize(String html) {
|
||||
boolean disableSanitize =
|
||||
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
|
||||
boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize();
|
||||
return disableSanitize ? html : POLICY.sanitize(html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ public class EmlProcessingUtils {
|
||||
|
||||
public static void validateEmlInput(byte[] emlBytes) {
|
||||
if (emlBytes == null || emlBytes.length == 0) {
|
||||
throw new IllegalArgumentException("EML file is empty or null");
|
||||
throw ExceptionUtils.createEmlEmptyException();
|
||||
}
|
||||
|
||||
if (isInvalidEmlFormat(emlBytes)) {
|
||||
throw new IllegalArgumentException("Invalid EML file format");
|
||||
throw ExceptionUtils.createEmlInvalidFormatException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,8 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -148,64 +145,6 @@ public class FileToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteDirectory(Path dir) throws IOException {
|
||||
Files.walkFileTree(
|
||||
dir,
|
||||
new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
Files.delete(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
|
||||
throws IOException {
|
||||
Files.delete(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Path unzipAndGetMainHtml(byte[] fileBytes) throws IOException {
|
||||
Path tempDirectory = Files.createTempDirectory("unzipped_");
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(fileBytes))) {
|
||||
ZipEntry entry = zipIn.getNextEntry();
|
||||
while (entry != null) {
|
||||
Path filePath = tempDirectory.resolve(sanitizeZipFilename(entry.getName()));
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(filePath); // Explicitly create the directory structure
|
||||
} else {
|
||||
Files.createDirectories(
|
||||
filePath.getParent()); // Create parent directories if they don't exist
|
||||
Files.copy(zipIn, filePath);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
entry = zipIn.getNextEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the main HTML file.
|
||||
try (Stream<Path> walk = Files.walk(tempDirectory)) {
|
||||
List<Path> htmlFiles = walk.filter(file -> file.toString().endsWith(".html")).toList();
|
||||
|
||||
if (htmlFiles.isEmpty()) {
|
||||
throw new IOException("No HTML files found in the unzipped directory.");
|
||||
}
|
||||
|
||||
// Prioritize 'index.html' if it exists, otherwise use the first .html file
|
||||
for (Path htmlFile : htmlFiles) {
|
||||
if ("index.html".equals(htmlFile.getFileName().toString())) {
|
||||
return htmlFile;
|
||||
}
|
||||
}
|
||||
|
||||
return htmlFiles.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
static String sanitizeZipFilename(String entryName) {
|
||||
if (entryName == null || entryName.trim().isEmpty()) {
|
||||
return "";
|
||||
|
||||
@@ -94,32 +94,6 @@ public class GeneralUtils {
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the configured temporary directory, creating it if necessary.
|
||||
*
|
||||
* @return Path to the temporary directory
|
||||
* @throws IOException if directory creation fails
|
||||
*/
|
||||
private Path getTempDirectory() throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
}
|
||||
|
||||
Path tempDir;
|
||||
if (customTempDir != null && !customTempDir.isEmpty()) {
|
||||
tempDir = Path.of(customTempDir);
|
||||
} else {
|
||||
tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
|
||||
}
|
||||
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove file extension
|
||||
*
|
||||
@@ -1100,17 +1074,29 @@ public class GeneralUtils {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
ExceptionUtils.GhostscriptException detectedError =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (detectedError != null) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization reported a critical error: {}",
|
||||
detectedError.getMessage());
|
||||
throw detectedError;
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization failed with return code: {}",
|
||||
result.getRc());
|
||||
throw ExceptionUtils.createGhostscriptCompressionException();
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(result.getMessages());
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempOutput);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Ghostscript ebook optimization failed", e);
|
||||
if (e instanceof ExceptionUtils.GhostscriptException ghostscriptException) {
|
||||
throw ghostscriptException;
|
||||
}
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(e);
|
||||
} finally {
|
||||
if (tempInput != null) {
|
||||
|
||||
@@ -144,7 +144,7 @@ public class ImageProcessingUtils {
|
||||
}
|
||||
|
||||
if (image == null) {
|
||||
throw new IOException("Unable to read image from file: " + filename);
|
||||
throw ExceptionUtils.createImageReadException(filename);
|
||||
}
|
||||
|
||||
double orientation = extractImageOrientation(file.getInputStream());
|
||||
|
||||
@@ -27,15 +27,22 @@ import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
public class PDFToFile {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager) {
|
||||
this(tempFileManager, null);
|
||||
}
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager, RuntimePathConfig runtimePathConfig) {
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
|
||||
@@ -241,31 +248,65 @@ public class PDFToFile {
|
||||
byte[] fileBytes;
|
||||
String fileName;
|
||||
|
||||
Path libreOfficeProfile = null;
|
||||
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
|
||||
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
|
||||
|
||||
Path tempInputFile = inputFileTemp.getPath();
|
||||
Path tempOutputDir = outputDirTemp.getPath();
|
||||
Path unoOutputFile =
|
||||
tempOutputDir.resolve(
|
||||
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
|
||||
|
||||
// Save the uploaded file to a temporary location
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Run the LibreOffice command
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--infilter=" + libreOfficeFilter,
|
||||
"--convert-to",
|
||||
outputFormat,
|
||||
"--outdir",
|
||||
tempOutputDir.toString(),
|
||||
tempInputFile.toString()));
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
ProcessExecutorResult returnCode = null;
|
||||
IOException unoconvertException = null;
|
||||
|
||||
if (isUnoConvertEnabled()) {
|
||||
try {
|
||||
List<String> unoCommand =
|
||||
buildUnoConvertCommand(
|
||||
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter);
|
||||
returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(unoCommand);
|
||||
} catch (IOException e) {
|
||||
unoconvertException = e;
|
||||
log.warn(
|
||||
"Unoconvert command failed ({}). Falling back to soffice command.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (returnCode == null) {
|
||||
// Run the LibreOffice command as a fallback
|
||||
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--infilter=" + libreOfficeFilter);
|
||||
command.add("--convert-to");
|
||||
command.add(outputFormat);
|
||||
command.add("--outdir");
|
||||
command.add(tempOutputDir.toString());
|
||||
command.add(tempInputFile.toString());
|
||||
|
||||
try {
|
||||
returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
} catch (IOException e) {
|
||||
if (unoconvertException != null) {
|
||||
e.addSuppressed(unoconvertException);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Get output files
|
||||
List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles());
|
||||
@@ -300,8 +341,42 @@ public class PDFToFile {
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
} finally {
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
private boolean isUnoConvertEnabled() {
|
||||
return runtimePathConfig != null
|
||||
&& runtimePathConfig.getUnoConvertPath() != null
|
||||
&& !runtimePathConfig.getUnoConvertPath().isBlank();
|
||||
}
|
||||
|
||||
private List<String> buildUnoConvertCommand(
|
||||
Path inputFile, Path outputFile, String outputFormat, String libreOfficeFilter) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getUnoConvertPath());
|
||||
command.add("--port");
|
||||
command.add("2003");
|
||||
command.add("--convert-to");
|
||||
command.add(outputFormat);
|
||||
if (libreOfficeFilter != null && !libreOfficeFilter.isBlank()) {
|
||||
command.add("--input-filter=" + libreOfficeFilter);
|
||||
}
|
||||
command.add(inputFile.toString());
|
||||
command.add(outputFile.toString());
|
||||
return command;
|
||||
}
|
||||
|
||||
private String resolvePrimaryExtension(String outputFormat) {
|
||||
if (outputFormat == null) {
|
||||
return "";
|
||||
}
|
||||
int colonIndex = outputFormat.indexOf(':');
|
||||
return colonIndex > 0 ? outputFormat.substring(0, colonIndex) : outputFormat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public class PdfToCbrUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
return createCbrFromPdf(document, dpi);
|
||||
@@ -44,17 +44,17 @@ public class PdfToCbrUtils {
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
throw ExceptionUtils.createPdfFileRequiredException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,28 +67,36 @@ public class PdfToCbrUtils {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", pageIndex + 1);
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
Path imagePath = tempDir.resolve(imageFilename);
|
||||
|
||||
ImageIO.write(image, "PNG", imagePath.toFile());
|
||||
generatedImages.add(imagePath);
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBR creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedImages.isEmpty()) {
|
||||
throw new IOException("Failed to render any pages to images for CBR conversion");
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBR conversion", new IOException("No pages were successfully rendered"));
|
||||
}
|
||||
|
||||
return createRarArchive(tempDir, generatedImages);
|
||||
@@ -117,15 +125,18 @@ public class PdfToCbrUtils {
|
||||
ProcessExecutorResult result =
|
||||
executor.runCommandWithOutputHandling(command, tempDir.toFile());
|
||||
if (result.getRc() != 0) {
|
||||
throw new IOException("RAR command failed: " + result.getMessages());
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"RAR archive creation",
|
||||
new IOException("RAR command failed with code " + result.getRc()));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("RAR command interrupted", e);
|
||||
throw ExceptionUtils.createProcessingInterruptedException("RAR creation", e);
|
||||
}
|
||||
|
||||
if (!Files.exists(rarFile)) {
|
||||
throw new IOException("RAR file was not created");
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"RAR archive creation", new IOException("RAR file was not created"));
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(rarFile.toFile());
|
||||
|
||||
@@ -30,7 +30,7 @@ public class PdfToCbzUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
return createCbzFromPdf(document, dpi);
|
||||
@@ -39,17 +39,17 @@ public class PdfToCbzUtils {
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
throw ExceptionUtils.createPdfFileRequiredException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,25 +62,31 @@ public class PdfToCbzUtils {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", pageIndex + 1);
|
||||
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
ZipEntry zipEntry = new ZipEntry(imageFilename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBZ creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,20 @@ public class PdfUtils {
|
||||
writer.prepareWriteSequence(null);
|
||||
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image;
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
@@ -195,10 +206,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
writer.writeToSequence(new IIOImage(image, null, null), param);
|
||||
}
|
||||
@@ -222,6 +229,7 @@ public class PdfUtils {
|
||||
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();
|
||||
int rotation = page.getRotation();
|
||||
@@ -232,7 +240,17 @@ public class PdfUtils {
|
||||
if (dimension == null) {
|
||||
// Render the image to get the dimensions
|
||||
try {
|
||||
pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// 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()
|
||||
@@ -247,10 +265,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
pdfSizeImageIndex = i;
|
||||
dimension =
|
||||
@@ -276,11 +290,22 @@ public class PdfUtils {
|
||||
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
|
||||
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
if (firstImageAlreadyRendered && i == 0) {
|
||||
pageImage = pdfSizeImage;
|
||||
} else {
|
||||
try {
|
||||
pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// 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()
|
||||
@@ -294,10 +319,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,9 +339,20 @@ public class PdfUtils {
|
||||
// Zip the images and return as byte array
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image;
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
|
||||
image =
|
||||
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")) {
|
||||
@@ -332,10 +364,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, imageType, baosImage);
|
||||
@@ -375,6 +403,7 @@ public class PdfUtils {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
@@ -384,9 +413,16 @@ public class PdfUtils {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim = pdfRenderer.renderImageWithDPI(page, renderDpi, ImageType.RGB);
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
@@ -395,13 +431,9 @@ public class PdfUtils {
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
page + 1);
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
|
||||
@@ -25,6 +26,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
public class ProcessExecutor {
|
||||
|
||||
private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>();
|
||||
private static final String ERROR_KEYWORD = "ERROR";
|
||||
private static ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
private final Semaphore semaphore;
|
||||
private final boolean liveUpdates;
|
||||
@@ -175,6 +177,7 @@ public class ProcessExecutor {
|
||||
String messages = "";
|
||||
int exitCode = 1;
|
||||
semaphore.acquire();
|
||||
AtomicBoolean errorDetected = new AtomicBoolean(false);
|
||||
try {
|
||||
|
||||
log.info("Running command: {}", String.join(" ", command));
|
||||
@@ -204,7 +207,16 @@ public class ProcessExecutor {
|
||||
errorReader, 5_000_000))
|
||||
!= null) {
|
||||
errorLines.add(line);
|
||||
if (liveUpdates) log.info(line);
|
||||
if (liveUpdates) {
|
||||
if (line.toUpperCase().contains(ERROR_KEYWORD)) {
|
||||
errorDetected.set(true);
|
||||
}
|
||||
if (errorDetected.get()) {
|
||||
log.info(line);
|
||||
} else {
|
||||
log.debug(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InterruptedIOException e) {
|
||||
log.warn("Error reader thread was interrupted due to timeout.");
|
||||
@@ -227,7 +239,16 @@ public class ProcessExecutor {
|
||||
outputReader, 5_000_000))
|
||||
!= null) {
|
||||
outputLines.add(line);
|
||||
if (liveUpdates) log.info(line);
|
||||
if (liveUpdates) {
|
||||
if (line.toUpperCase().contains(ERROR_KEYWORD)) {
|
||||
errorDetected.set(true);
|
||||
}
|
||||
if (errorDetected.get()) {
|
||||
log.info(line);
|
||||
} else {
|
||||
log.debug(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InterruptedIOException e) {
|
||||
log.warn("Error reader thread was interrupted due to timeout.");
|
||||
|
||||
@@ -301,6 +301,11 @@ public final class RegexPatternUtils {
|
||||
return getPattern("\\+");
|
||||
}
|
||||
|
||||
/** Pattern for splitting on pipe delimiter (used for hint lists in i18n messages) */
|
||||
public Pattern getPipeDelimiterPattern() {
|
||||
return getPattern("\\|");
|
||||
}
|
||||
|
||||
/** Pattern for username validation */
|
||||
public Pattern getUsernameValidationPattern() {
|
||||
return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$");
|
||||
@@ -518,6 +523,16 @@ public final class RegexPatternUtils {
|
||||
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for splitting on line breaks (Unicode line separator) */
|
||||
public Pattern getLineSeparatorPattern() {
|
||||
return getPattern("\\R");
|
||||
}
|
||||
|
||||
/** Pattern for removing leading asterisks and whitespace */
|
||||
public Pattern getLeadingAsterisksWhitespacePattern() {
|
||||
return getPattern("^[*\\s]+");
|
||||
}
|
||||
|
||||
private record PatternKey(String regex, int flags) {
|
||||
// Record automatically provides equals, hashCode, and toString
|
||||
}
|
||||
|
||||
+7
-9
@@ -56,16 +56,14 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
|
||||
try {
|
||||
image =
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
page, renderDpi); // Render page with global DPI setting
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
}
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
public class FileInfoTest {
|
||||
|
||||
private static final LocalDateTime FIXED_NOW = LocalDateTime.of(2025, 11, 1, 12, 0, 0);
|
||||
|
||||
@ParameterizedTest(name = "{index}: fileSize={0}")
|
||||
@CsvSource({
|
||||
"0, '0 Bytes'",
|
||||
@@ -28,9 +30,9 @@ public class FileInfoTest {
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
LocalDateTime.now(),
|
||||
FIXED_NOW,
|
||||
fileSize,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize());
|
||||
}
|
||||
@@ -45,9 +47,9 @@ public class FileInfoTest {
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
LocalDateTime.now(),
|
||||
FIXED_NOW,
|
||||
123,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
Path path = fi.getFilePathAsPath();
|
||||
|
||||
@@ -103,7 +105,7 @@ public class FileInfoTest {
|
||||
"/path/to/example.txt",
|
||||
null, // modificationDate null
|
||||
1,
|
||||
LocalDateTime.now());
|
||||
FIXED_NOW);
|
||||
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
@@ -120,7 +122,7 @@ public class FileInfoTest {
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
LocalDateTime.now(),
|
||||
FIXED_NOW,
|
||||
1,
|
||||
null); // creationDate null
|
||||
|
||||
@@ -142,9 +144,9 @@ public class FileInfoTest {
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
LocalDateTime.now(),
|
||||
FIXED_NOW,
|
||||
1536, // 1.5 KB
|
||||
LocalDateTime.now().minusDays(1));
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
assertEquals("1.50 KB", fi.getFormattedFileSize());
|
||||
}
|
||||
@@ -158,9 +160,9 @@ public class FileInfoTest {
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
LocalDateTime.now(),
|
||||
FIXED_NOW,
|
||||
twoTB,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
// 2 TB equals 2048.00 GB with current implementation
|
||||
assertEquals(
|
||||
|
||||
@@ -6,7 +6,6 @@ import static org.mockito.Mockito.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -83,7 +82,7 @@ class FileStorageTest {
|
||||
void testRetrieveFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-1";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -103,7 +102,7 @@ class FileStorageTest {
|
||||
void testRetrieveBytes() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-2";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -136,7 +135,7 @@ class FileStorageTest {
|
||||
void testDeleteFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-3";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -164,7 +163,7 @@ class FileStorageTest {
|
||||
void testFileExists() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-4";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
|
||||
+20
-21
@@ -1,6 +1,10 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -8,7 +12,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
@@ -61,7 +64,7 @@ class JobExecutorServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunSyncJobSuccessfully() {
|
||||
void shouldRunSyncJobSuccessfully() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
|
||||
@@ -77,7 +80,7 @@ class JobExecutorServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunAsyncJobSuccessfully() {
|
||||
void shouldRunAsyncJobSuccessfully() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
|
||||
@@ -103,20 +106,16 @@ class JobExecutorServiceTest {
|
||||
throw new RuntimeException("Test error");
|
||||
};
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> errorMap = (Map<String, String>) response.getBody();
|
||||
assertNotNull(errorMap);
|
||||
assertEquals("Job failed: Test error", errorMap.get("error"));
|
||||
// When/Then - Exception should propagate to GlobalExceptionHandler
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> jobExecutorService.runJobGeneric(false, work));
|
||||
assertEquals("Test error", thrown.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueueJobWhenResourcesLimited() {
|
||||
void shouldQueueJobWhenResourcesLimited() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
|
||||
@@ -166,13 +165,13 @@ class JobExecutorServiceTest {
|
||||
// Given
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(100); // Simulate long-running job
|
||||
return "test-result";
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException(e);
|
||||
// Simulate long-running job without actual sleep
|
||||
// Use a loop to consume time instead of Thread.sleep
|
||||
long startTime = System.nanoTime();
|
||||
while (System.nanoTime() - startTime < 100_000_000) { // 100ms in nanoseconds
|
||||
// Busy wait to simulate work without Thread.sleep
|
||||
}
|
||||
return "test-result";
|
||||
};
|
||||
|
||||
// Use reflection to access the private executeWithTimeout method
|
||||
|
||||
@@ -126,12 +126,15 @@ class ResourceMonitorTest {
|
||||
|
||||
@Test
|
||||
void resourceMetricsShouldDetectStaleState() {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Given
|
||||
Instant now = Instant.now();
|
||||
Instant pastInstant = now.minusMillis(6000);
|
||||
Instant pastInstant =
|
||||
testTime.minusMillis(6000); // 6 seconds ago (relative to test start time)
|
||||
|
||||
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
|
||||
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
|
||||
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, testTime);
|
||||
|
||||
// When/Then
|
||||
assertTrue(
|
||||
|
||||
@@ -5,7 +5,6 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -42,7 +41,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testCreateTask() {
|
||||
// Act
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-1";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Assert
|
||||
@@ -56,7 +55,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-2";
|
||||
taskManager.createTask(jobId);
|
||||
Object resultObject = "Test result";
|
||||
|
||||
@@ -74,7 +73,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetFileResult() throws Exception {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-3";
|
||||
taskManager.createTask(jobId);
|
||||
String fileId = "file-id";
|
||||
String originalFileName = "test.pdf";
|
||||
@@ -108,7 +107,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetError() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-4";
|
||||
taskManager.createTask(jobId);
|
||||
String errorMessage = "Test error";
|
||||
|
||||
@@ -126,7 +125,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetComplete_WithExistingResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-5";
|
||||
taskManager.createTask(jobId);
|
||||
Object resultObject = "Test result";
|
||||
taskManager.setResult(jobId, resultObject);
|
||||
@@ -144,7 +143,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetComplete_WithoutExistingResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-6";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Act
|
||||
@@ -160,7 +159,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testIsComplete() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-7";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Assert - not complete initially
|
||||
@@ -216,6 +215,8 @@ class TaskManagerTest {
|
||||
|
||||
@Test
|
||||
void testCleanupOldJobs() {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final LocalDateTime testTime = LocalDateTime.now();
|
||||
// Arrange
|
||||
// 1. Create a recent completed job
|
||||
String recentJobId = "recent-job";
|
||||
@@ -227,8 +228,9 @@ class TaskManagerTest {
|
||||
taskManager.createTask(oldJobId);
|
||||
JobResult oldJob = taskManager.getJobResult(oldJobId);
|
||||
|
||||
// Manually set the completion time to be older than the expiry
|
||||
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
|
||||
// Manually set the completion time to be older than the expiry (relative to test start
|
||||
// time)
|
||||
LocalDateTime oldTime = testTime.minusHours(1);
|
||||
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
|
||||
ReflectionTestUtils.setField(oldJob, "complete", true);
|
||||
|
||||
@@ -280,7 +282,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testAddNote() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-8";
|
||||
taskManager.createTask(jobId);
|
||||
String note = "Test note";
|
||||
|
||||
|
||||
+41
-30
@@ -131,6 +131,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for each directory we'll process
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
@@ -175,18 +178,17 @@ public class TempFileCleanupServiceTest {
|
||||
// maxAgeMillis
|
||||
if (fileName.contains("old")) {
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 5000000);
|
||||
testTime - 5000000); // ~1.4 hours ago
|
||||
}
|
||||
// For empty.tmp file, return a timestamp older than 5 minutes (for
|
||||
// empty file test)
|
||||
else if (fileName.equals("empty.tmp")) {
|
||||
else if ("empty.tmp".equals(fileName)) {
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 6 * 60 * 1000);
|
||||
testTime - 6 * 60 * 1000); // 6 minutes ago
|
||||
}
|
||||
// For all other files, return a recent timestamp
|
||||
else {
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 60000); // 1 minute ago
|
||||
return FileTime.fromMillis(testTime - 60000); // 1 minute ago
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,7 +201,7 @@ public class TempFileCleanupServiceTest {
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
// Return 0 bytes for the empty file
|
||||
if (fileName.equals("empty.tmp")) {
|
||||
if ("empty.tmp".equals(fileName)) {
|
||||
return 0L;
|
||||
}
|
||||
// Return normal size for all other files
|
||||
@@ -274,6 +276,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for systemTempDir
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
@@ -288,9 +293,7 @@ public class TempFileCleanupServiceTest {
|
||||
// Configure Files.getLastModifiedTime to return recent timestamps
|
||||
mockedFiles
|
||||
.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
.thenReturn(
|
||||
FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 60000)); // 1 minute ago
|
||||
.thenReturn(FileTime.fromMillis(testTime - 60000)); // 1 minute ago
|
||||
|
||||
// Configure Files.size to return normal size
|
||||
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB
|
||||
@@ -335,6 +338,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for systemTempDir
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
@@ -354,14 +360,14 @@ public class TempFileCleanupServiceTest {
|
||||
Path path = invocation.getArgument(0);
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (fileName.equals("empty.tmp")) {
|
||||
if ("empty.tmp".equals(fileName)) {
|
||||
// More than 5 minutes old
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 6 * 60 * 1000);
|
||||
testTime - 6 * 60 * 1000); // 6 minutes ago
|
||||
} else {
|
||||
// Less than 5 minutes old
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 2 * 60 * 1000);
|
||||
testTime - 2 * 60 * 1000); // 2 minutes ago
|
||||
}
|
||||
});
|
||||
|
||||
@@ -410,14 +416,25 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for each directory
|
||||
mockedFiles.when(() -> Files.list(eq(systemTempDir))).thenReturn(Stream.of(dir1));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
.thenAnswer(invocation -> Stream.of(dir1));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir1))).thenReturn(Stream.of(tempFile1, dir2));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(dir1)))
|
||||
.thenAnswer(invocation -> Stream.of(tempFile1, dir2));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir2))).thenReturn(Stream.of(tempFile2, dir3));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(dir2)))
|
||||
.thenAnswer(invocation -> Stream.of(tempFile2, dir3));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir3))).thenReturn(Stream.of(tempFile3));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(dir3)))
|
||||
.thenAnswer(invocation -> Stream.of(tempFile3));
|
||||
|
||||
// Configure Files.isDirectory for each path
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
|
||||
@@ -430,6 +447,9 @@ public class TempFileCleanupServiceTest {
|
||||
// Configure Files.exists to return true for all paths
|
||||
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
|
||||
// Configure Files.size to return 0 for all files (ensure they're not empty)
|
||||
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
|
||||
|
||||
// Configure Files.getLastModifiedTime to return different times based on file names
|
||||
mockedFiles
|
||||
.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
@@ -439,19 +459,14 @@ public class TempFileCleanupServiceTest {
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (fileName.contains("old")) {
|
||||
// Old file
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 5000000);
|
||||
// Old file - very old timestamp (older than 1 hour)
|
||||
return FileTime.fromMillis(testTime - 7200000); // 2 hours ago
|
||||
} else {
|
||||
// Recent file
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
|
||||
// Recent file - very recent timestamp (less than 1 hour)
|
||||
return FileTime.fromMillis(testTime - 60000); // 1 minute ago
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Files.size to return normal size
|
||||
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
|
||||
|
||||
// For deleteIfExists, track which files would be deleted
|
||||
mockedFiles
|
||||
.when(() -> Files.deleteIfExists(any(Path.class)))
|
||||
.thenAnswer(
|
||||
@@ -461,13 +476,9 @@ public class TempFileCleanupServiceTest {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act
|
||||
// Act - pass maxAgeMillis = 3600000 (1 hour)
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
|
||||
|
||||
// Debug - print what was deleted
|
||||
System.out.println("Deleted files: " + deletedFiles);
|
||||
System.out.println("Looking for: " + tempFile3);
|
||||
|
||||
// Assert
|
||||
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
|
||||
assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved");
|
||||
|
||||
@@ -36,7 +36,7 @@ class CustomHtmlSanitizerTest {
|
||||
// strict-stubbing failures when individual tests bypass certain branches.
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties);
|
||||
lenient().when(systemProperties.getDisableSanitize()).thenReturn(false);
|
||||
lenient().when(systemProperties.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties);
|
||||
}
|
||||
@@ -374,7 +374,7 @@ class CustomHtmlSanitizerTest {
|
||||
"<p>ok</p><script>alert('XSS')</script><img src=\"http://blocked.local/a.png\">";
|
||||
|
||||
// For this test, disable sanitize
|
||||
when(systemProperties.getDisableSanitize()).thenReturn(true);
|
||||
when(systemProperties.isDisableSanitize()).thenReturn(true);
|
||||
|
||||
// Also ensure SSRF would block it if sanitization were enabled (to prove bypass)
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
|
||||
|
||||
@@ -48,7 +48,7 @@ class EmlToPdfTest {
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.getDisableSanitize()).thenReturn(false);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
|
||||
@@ -25,12 +25,9 @@ class ExceptionUtilsTest {
|
||||
@DisplayName("should create PdfCorruptedException without context")
|
||||
void testCreatePdfCorruptedExceptionWithoutContext() {
|
||||
Exception cause = new Exception("root");
|
||||
IOException ex = ExceptionUtils.createPdfCorruptedException(cause);
|
||||
IOException ex = ExceptionUtils.createPdfCorruptedException(null, cause);
|
||||
|
||||
assertEquals(
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
|
||||
+ " feature first to fix the file before proceeding with this operation.",
|
||||
ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@@ -176,36 +173,18 @@ class ExceptionUtilsTest {
|
||||
@DisplayName("File operation and compression exceptions")
|
||||
class FileAndCompressionTests {
|
||||
|
||||
@Test
|
||||
void testCreateFileNotFoundException() {
|
||||
IOException ex = ExceptionUtils.createFileNotFoundException("123");
|
||||
assertTrue(ex.getMessage().contains("123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePdfaConversionFailedException() {
|
||||
RuntimeException ex = ExceptionUtils.createPdfaConversionFailedException();
|
||||
assertTrue(ex.getMessage().contains("PDF/A conversion failed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateInvalidComparatorException() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createInvalidComparatorException();
|
||||
assertTrue(ex.getMessage().contains("comparator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMd5AlgorithmException() {
|
||||
RuntimeException ex = ExceptionUtils.createMd5AlgorithmException(new Exception("x"));
|
||||
assertTrue(ex.getMessage().contains("MD5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateCompressionOptionsException() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createCompressionOptionsException();
|
||||
assertTrue(ex.getMessage().contains("compression"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateGhostscriptCompressionExceptionNoCause() {
|
||||
IOException ex = ExceptionUtils.createGhostscriptCompressionException();
|
||||
@@ -218,12 +197,6 @@ class ExceptionUtilsTest {
|
||||
ExceptionUtils.createGhostscriptCompressionException(new Exception("cause"));
|
||||
assertTrue(ex.getMessage().contains("Ghostscript"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateQpdfCompressionException() {
|
||||
IOException ex = ExceptionUtils.createQpdfCompressionException(new Exception("cause"));
|
||||
assertTrue(ex.getMessage().contains("QPDF"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -359,8 +332,10 @@ class ExceptionUtilsTest {
|
||||
|
||||
@Test
|
||||
void testCreateInvalidArgumentExceptionSingle() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createInvalidArgumentException("arg");
|
||||
IllegalArgumentException ex =
|
||||
ExceptionUtils.createInvalidArgumentException("arg", "invalidValue");
|
||||
assertTrue(ex.getMessage().contains("arg"));
|
||||
assertTrue(ex.getMessage().contains("invalidValue"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -45,12 +45,15 @@ class FileMonitorTest {
|
||||
|
||||
@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
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// 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));
|
||||
@@ -58,12 +61,15 @@ class FileMonitorTest {
|
||||
|
||||
@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
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now()));
|
||||
// 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));
|
||||
@@ -80,12 +86,16 @@ class FileMonitorTest {
|
||||
|
||||
@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 to make sure it passes the time check
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// 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(
|
||||
@@ -104,12 +114,12 @@ class FileMonitorTest {
|
||||
// Create a PDF file
|
||||
Path pdfFile = tempDir.resolve("test.pdf");
|
||||
Files.write(pdfFile, "pdf content".getBytes());
|
||||
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
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.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// PDF file should be ready for processing
|
||||
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
|
||||
@@ -125,12 +135,15 @@ class FileMonitorTest {
|
||||
|
||||
@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
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// 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(
|
||||
@@ -139,7 +152,7 @@ class FileMonitorTest {
|
||||
|
||||
// After modifying the file to simulate closing, it should still be ready
|
||||
Files.write(testFile, "updated content".getBytes());
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
@@ -148,12 +161,15 @@ class FileMonitorTest {
|
||||
|
||||
@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
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// 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
|
||||
@@ -167,7 +183,7 @@ class FileMonitorTest {
|
||||
Files.createDirectory(testDir);
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// A directory should not be considered ready for processing
|
||||
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);
|
||||
|
||||
@@ -29,7 +29,7 @@ public class FileToPdfTest {
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.getDisableSanitize()).thenReturn(false);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,7 @@ class PDFToFileTest {
|
||||
@Mock private ProcessExecutor mockProcessExecutor;
|
||||
@Mock private ProcessExecutorResult mockExecutorResult;
|
||||
@Mock private TempFileManager mockTempFileManager;
|
||||
@Mock private RuntimePathConfig mockRuntimePathConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
@@ -61,7 +63,9 @@ class PDFToFileTest {
|
||||
.when(mockTempFileManager.createTempDirectory())
|
||||
.thenAnswer(invocation -> Files.createTempDirectory("test"));
|
||||
|
||||
pdfToFile = new PDFToFile(mockTempFileManager);
|
||||
lenient().when(mockRuntimePathConfig.getSOfficePath()).thenReturn("/usr/bin/soffice");
|
||||
|
||||
pdfToFile = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -363,7 +367,8 @@ class PDFToFileTest {
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(
|
||||
args ->
|
||||
args.contains("--convert-to")
|
||||
args != null
|
||||
&& args.contains("--convert-to")
|
||||
&& args.contains("docx"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
@@ -424,7 +429,11 @@ class PDFToFileTest {
|
||||
.thenReturn(mockProcessExecutor);
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(args -> args.contains("--convert-to") && args.contains("odp"))))
|
||||
argThat(
|
||||
args ->
|
||||
args != null
|
||||
&& args.contains("--convert-to")
|
||||
&& args.contains("odp"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
// When command is executed, find the output directory argument
|
||||
@@ -513,7 +522,8 @@ class PDFToFileTest {
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(
|
||||
args ->
|
||||
args.contains("--convert-to")
|
||||
args != null
|
||||
&& args.contains("--convert-to")
|
||||
&& args.contains("txt:Text"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
@@ -611,4 +621,110 @@ class PDFToFileTest {
|
||||
.contains("output.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProcessPdfToOfficeFormat_UsesUnoconvertWhenConfigured()
|
||||
throws IOException, InterruptedException {
|
||||
when(mockRuntimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
|
||||
PDFToFile pdfToFileWithUno = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedStaticProcessExecutor =
|
||||
mockStatic(ProcessExecutor.class)) {
|
||||
MultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"document.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
mockedStaticProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
|
||||
.thenReturn(mockProcessExecutor);
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(args -> args != null && args.contains("/custom/unoconvert"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> args = invocation.getArgument(0);
|
||||
String outputPath = args.get(args.size() - 1);
|
||||
Files.write(Path.of(outputPath), "Fake DOCX content".getBytes());
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
pdfToFileWithUno.processPdfToOfficeFormat(pdfFile, "docx", "writer_pdf_import");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().length > 0);
|
||||
assertTrue(
|
||||
response.getHeaders()
|
||||
.getContentDisposition()
|
||||
.toString()
|
||||
.contains("document.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProcessPdfToOfficeFormat_FallsBackWhenUnoconvertFails()
|
||||
throws IOException, InterruptedException {
|
||||
when(mockRuntimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
|
||||
PDFToFile pdfToFileWithUno = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedStaticProcessExecutor =
|
||||
mockStatic(ProcessExecutor.class)) {
|
||||
MultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"document.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
mockedStaticProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
|
||||
.thenReturn(mockProcessExecutor);
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(args -> args != null && args.contains("/custom/unoconvert"))))
|
||||
.thenThrow(new IOException("Conversion failed"));
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(
|
||||
args ->
|
||||
args != null
|
||||
&& args.stream()
|
||||
.anyMatch(
|
||||
arg ->
|
||||
arg.contains(
|
||||
"soffice")))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> args = invocation.getArgument(0);
|
||||
String outDir = null;
|
||||
for (int i = 0; i < args.size(); i++) {
|
||||
if ("--outdir".equals(args.get(i)) && i + 1 < args.size()) {
|
||||
outDir = args.get(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertNotNull(outDir);
|
||||
Files.write(
|
||||
Path.of(outDir, "document.docx"),
|
||||
"Fallback DOCX content".getBytes());
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
pdfToFileWithUno.processPdfToOfficeFormat(pdfFile, "docx", "writer_pdf_import");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().length > 0);
|
||||
assertTrue(
|
||||
response.getHeaders()
|
||||
.getContentDisposition()
|
||||
.toString()
|
||||
.contains("document.docx"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'com.posthog.java:posthog:1.2.0'
|
||||
implementation 'commons-io:commons-io:2.20.0'
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.15.5'
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation 'io.micrometer:micrometer-core:1.16.0'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
|
||||
|
||||
@@ -257,6 +257,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "html-to-pdf");
|
||||
addEndpointToGroup("Convert", "url-to-pdf");
|
||||
addEndpointToGroup("Convert", "markdown-to-pdf");
|
||||
addEndpointToGroup("Convert", "ebook-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-epub");
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
@@ -446,6 +448,10 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "eml-to-pdf");
|
||||
|
||||
// Calibre dependent endpoints
|
||||
addEndpointToGroup("Calibre", "ebook-to-pdf");
|
||||
addEndpointToGroup("Calibre", "pdf-to-epub");
|
||||
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
|
||||
@@ -475,7 +481,7 @@ public class EndpointConfiguration {
|
||||
disableGroup("enterprise");
|
||||
}
|
||||
|
||||
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
|
||||
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
|
||||
disableEndpoint("url-to-pdf");
|
||||
}
|
||||
}
|
||||
@@ -498,6 +504,7 @@ public class EndpointConfiguration {
|
||||
|| "Javascript".equals(group)
|
||||
|| "Weasyprint".equals(group)
|
||||
|| "Pdftohtml".equals(group)
|
||||
|| "Calibre".equals(group)
|
||||
|| "rar".equals(group)
|
||||
|| "FFmpeg".equals(group);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ public class ExternalAppDepConfig {
|
||||
|
||||
private final String weasyprintPath;
|
||||
private final String unoconvPath;
|
||||
private final String calibrePath;
|
||||
private final String ocrMyPdfPath;
|
||||
private final String sOfficePath;
|
||||
|
||||
/**
|
||||
* Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid
|
||||
@@ -56,17 +59,21 @@ public class ExternalAppDepConfig {
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.weasyprintPath = runtimePathConfig.getWeasyPrintPath();
|
||||
this.unoconvPath = runtimePathConfig.getUnoConvertPath();
|
||||
this.calibrePath = runtimePathConfig.getCalibrePath();
|
||||
this.ocrMyPdfPath = runtimePathConfig.getOcrMyPdfPath();
|
||||
this.sOfficePath = runtimePathConfig.getSOfficePath();
|
||||
|
||||
Map<String, List<String>> tmp = new HashMap<>();
|
||||
tmp.put("gs", List.of("Ghostscript"));
|
||||
tmp.put("ocrmypdf", List.of("OCRmyPDF"));
|
||||
tmp.put("soffice", List.of("LibreOffice"));
|
||||
tmp.put(ocrMyPdfPath, List.of("OCRmyPDF"));
|
||||
tmp.put(sOfficePath, List.of("LibreOffice"));
|
||||
tmp.put(weasyprintPath, List.of("Weasyprint"));
|
||||
tmp.put("pdftohtml", List.of("Pdftohtml"));
|
||||
tmp.put(unoconvPath, List.of("Unoconvert"));
|
||||
tmp.put("qpdf", List.of("qpdf"));
|
||||
tmp.put("tesseract", List.of("tesseract"));
|
||||
tmp.put("rar", List.of("rar"));
|
||||
tmp.put(calibrePath, List.of("Calibre"));
|
||||
tmp.put("ffmpeg", List.of("FFmpeg"));
|
||||
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
|
||||
}
|
||||
@@ -145,6 +152,31 @@ public class ExternalAppDepConfig {
|
||||
"WeasyPrint version could not be determined ({} --version)",
|
||||
command));
|
||||
}
|
||||
|
||||
// Extra: enforce minimum qpdf version if command matches
|
||||
if (isQpdf(command)) {
|
||||
Optional<String> version = getVersionSafe(command, "--version");
|
||||
version.ifPresentOrElse(
|
||||
v -> {
|
||||
Version installed = new Version(v);
|
||||
Version required = new Version("12.0.0");
|
||||
if (installed.compareTo(required) < 0) {
|
||||
List<String> affectedGroups =
|
||||
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
}
|
||||
log.warn(
|
||||
"qpdf version {} is below required {} - disabling group(s): {}",
|
||||
installed,
|
||||
required,
|
||||
String.join(", ", affectedGroups));
|
||||
} else {
|
||||
log.info("qpdf {} meets minimum {}", installed, required);
|
||||
}
|
||||
},
|
||||
() -> log.warn("qpdf version could not be determined ({} --version)", command));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWeasyprint(String command) {
|
||||
@@ -152,6 +184,10 @@ public class ExternalAppDepConfig {
|
||||
|| command.toLowerCase(Locale.ROOT).contains("weasyprint");
|
||||
}
|
||||
|
||||
private boolean isQpdf(String command) {
|
||||
return command.toLowerCase(Locale.ROOT).contains("qpdf");
|
||||
}
|
||||
|
||||
private List<String> getAffectedFeatures(String group) {
|
||||
List<String> endpoints = new ArrayList<>(endpointConfiguration.getEndpointsForGroup(group));
|
||||
return endpoints.stream().map(this::formatEndpointAsFeature).toList();
|
||||
|
||||
@@ -61,11 +61,9 @@ public class InitialSetup {
|
||||
public void initEnableCSRFSecurity() throws IOException {
|
||||
if (GeneralUtils.isVersionHigher(
|
||||
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
|
||||
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
|
||||
boolean csrf = applicationProperties.getSecurity().isCsrfDisabled();
|
||||
if (!csrf) {
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
|
||||
applicationProperties.getSecurity().setCsrfDisabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public class OpenApiConfig {
|
||||
.url("https://www.stirlingpdf.com")
|
||||
.email("contact@stirlingpdf.com"))
|
||||
.description(DEFAULT_DESCRIPTION);
|
||||
if (!applicationProperties.getSecurity().getEnableLogin()) {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return new OpenAPI().components(new Components()).info(info);
|
||||
} else {
|
||||
SecurityScheme apiKeyScheme =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -13,6 +14,7 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
@@ -28,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -39,8 +42,86 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@Slf4j
|
||||
public class CropController {
|
||||
|
||||
private static final int DEFAULT_RENDER_DPI = 150;
|
||||
private static final int WHITE_THRESHOLD = 250;
|
||||
private static final String TEMP_INPUT_PREFIX = "crop_input";
|
||||
private static final String TEMP_OUTPUT_PREFIX = "crop_output";
|
||||
private static final String PDF_EXTENSION = ".pdf";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private static int[] detectContentBounds(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
|
||||
// Early exit if image is too small
|
||||
if (width < 1 || height < 1) {
|
||||
return new int[] {0, 0, width - 1, height - 1};
|
||||
}
|
||||
|
||||
// Sample every nth pixel for large images to reduce processing time
|
||||
int step = (width > 2000 || height > 2000) ? 2 : 1;
|
||||
|
||||
int top = 0;
|
||||
boolean found = false;
|
||||
for (int y = 0; y < height && !found; y += step) {
|
||||
for (int x = 0; x < width; x += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
top = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int bottom = height - 1;
|
||||
found = false;
|
||||
for (int y = height - 1; y >= 0 && !found; y -= step) {
|
||||
for (int x = 0; x < width; x += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
bottom = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int left = 0;
|
||||
found = false;
|
||||
for (int x = 0; x < width && !found; x += step) {
|
||||
for (int y = top; y <= bottom; y += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
left = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int right = width - 1;
|
||||
found = false;
|
||||
for (int x = width - 1; x >= 0 && !found; x -= step) {
|
||||
for (int y = top; y <= bottom; y += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
right = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return bounds in format: [left, bottom, right, top]
|
||||
// Note: Image coordinates are top-down, PDF coordinates are bottom-up
|
||||
return new int[] {left, height - bottom - 1, right, height - top - 1};
|
||||
}
|
||||
|
||||
private static boolean isWhite(int rgb, int threshold) {
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
@@ -48,6 +129,18 @@ public class CropController {
|
||||
"This operation takes an input PDF file and crops it according to the given"
|
||||
+ " coordinates. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
|
||||
if (request.isAutoCrop()) {
|
||||
return cropWithAutomaticDetection(request);
|
||||
}
|
||||
|
||||
if (request.getX() == null
|
||||
|| request.getY() == null
|
||||
|| request.getWidth() == null
|
||||
|| request.getHeight() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
|
||||
}
|
||||
|
||||
if (request.isRemoveDataOutsideCrop()) {
|
||||
return cropWithGhostscript(request);
|
||||
} else {
|
||||
@@ -55,86 +148,145 @@ public class CropController {
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDFRenderer renderer = new PDFRenderer(sourceDocument);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
|
||||
BufferedImage image = renderer.renderImageWithDPI(i, DEFAULT_RENDER_DPI);
|
||||
int[] bounds = detectContentBounds(image);
|
||||
|
||||
float scaleX = mediaBox.getWidth() / image.getWidth();
|
||||
float scaleY = mediaBox.getHeight() / image.getHeight();
|
||||
|
||||
CropBounds cropBounds = CropBounds.fromPixels(bounds, scaleX, scaleY);
|
||||
|
||||
PDPage newPage = new PDPage(mediaBox);
|
||||
newDocument.addPage(newPage);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(
|
||||
cropBounds.x, cropBounds.y, cropBounds.width, cropBounds.height);
|
||||
contentStream.clip();
|
||||
contentStream.drawForm(formXObject);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
cropBounds.x,
|
||||
cropBounds.y,
|
||||
cropBounds.width,
|
||||
cropBounds.height));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> cropWithPDFBox(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
// Create a new page with the size of the source page
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
newDocument.addPage(newPage);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
// Import the source page as a form XObject
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, i);
|
||||
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
contentStream.saveGraphicsState();
|
||||
|
||||
// Create a new page with the size of the source page
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
newDocument.addPage(newPage);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(newDocument, newPage, AppendMode.OVERWRITE, true, true);
|
||||
// Define the crop area
|
||||
contentStream.addRect(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
contentStream.clip();
|
||||
|
||||
// Import the source page as a form XObject
|
||||
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
// Draw the entire formXObject
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
// Define the crop area
|
||||
contentStream.addRect(
|
||||
request.getX(), request.getY(), request.getWidth(), request.getHeight());
|
||||
contentStream.clip();
|
||||
// Now, set the new page's media box to the cropped size
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight()));
|
||||
}
|
||||
|
||||
// Draw the entire formXObject
|
||||
contentStream.drawForm(formXObject);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// Now, set the new page's media box to the cropped size
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight()));
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> cropWithGhostscript(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
Path tempInputFile = null;
|
||||
Path tempOutputFile = null;
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage page = sourceDocument.getPage(i);
|
||||
PDRectangle cropBox =
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
page.setCropBox(cropBox);
|
||||
}
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage page = sourceDocument.getPage(i);
|
||||
PDRectangle cropBox =
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
page.setCropBox(cropBox);
|
||||
}
|
||||
|
||||
Path tempInputFile = Files.createTempFile("crop_input", ".pdf");
|
||||
Path tempOutputFile = Files.createTempFile("crop_output", ".pdf");
|
||||
tempInputFile = Files.createTempFile(TEMP_INPUT_PREFIX, PDF_EXTENSION);
|
||||
tempOutputFile = Files.createTempFile(TEMP_OUTPUT_PREFIX, PDF_EXTENSION);
|
||||
|
||||
try {
|
||||
// Save the source document with crop boxes
|
||||
sourceDocument.save(tempInputFile.toFile());
|
||||
sourceDocument.close();
|
||||
|
||||
// Execute Ghostscript to process the crop boxes
|
||||
ProcessExecutor processExecutor =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
List<String> command =
|
||||
@@ -152,19 +304,34 @@ public class CropController {
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
request.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
|
||||
+ "_cropped.pdf");
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Ghostscript processing was interrupted", e);
|
||||
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
|
||||
} finally {
|
||||
try {
|
||||
if (tempInputFile != null) {
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
}
|
||||
if (tempOutputFile != null) {
|
||||
Files.deleteIfExists(tempOutputFile);
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to delete temporary files", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record CropBounds(float x, float y, float width, float height) {
|
||||
|
||||
static CropBounds fromPixels(int[] pixelBounds, float scaleX, float scaleY) {
|
||||
if (pixelBounds.length != 4) {
|
||||
throw new IllegalArgumentException(
|
||||
"pixelBounds array must contain exactly 4 elements: [x1, y1, x2, y2]");
|
||||
}
|
||||
float x = pixelBounds[0] * scaleX;
|
||||
float y = pixelBounds[1] * scaleY;
|
||||
float width = (pixelBounds[2] - pixelBounds[0]) * scaleX;
|
||||
float height = (pixelBounds[3] - pixelBounds[1]) * scaleY;
|
||||
return new CropBounds(x, y, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-18
@@ -10,7 +10,6 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -49,9 +48,7 @@ public class EditTableOfContentsController {
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
PDDocument document = null;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
|
||||
|
||||
if (outline == null) {
|
||||
@@ -60,10 +57,6 @@ public class EditTableOfContentsController {
|
||||
}
|
||||
|
||||
return extractBookmarkItems(document, outline);
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +85,6 @@ public class EditTableOfContentsController {
|
||||
PDOutlineItem child = current.getFirstChild();
|
||||
if (child != null) {
|
||||
List<Map<String, Object>> children = new ArrayList<>();
|
||||
PDOutlineNode parent = current;
|
||||
|
||||
while (child != null) {
|
||||
// Recursively process child items
|
||||
@@ -157,10 +149,9 @@ public class EditTableOfContentsController {
|
||||
public ResponseEntity<byte[]> editTableOfContents(
|
||||
@ModelAttribute EditTableOfContentsRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
PDDocument document = null;
|
||||
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
// Parse the bookmark data from JSON
|
||||
List<BookmarkItem> bookmarks =
|
||||
@@ -175,18 +166,12 @@ public class EditTableOfContentsController {
|
||||
addBookmarksToOutline(document, outline, bookmarks);
|
||||
|
||||
// Save the document to a byte array
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_with_toc.pdf"),
|
||||
MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,19 +72,29 @@ public class MergeController {
|
||||
// fileOrder is newline-delimited original filenames in the desired order.
|
||||
private static MultipartFile[] reorderFilesByProvidedOrder(
|
||||
MultipartFile[] files, String fileOrder) {
|
||||
String[] desired = fileOrder.split("\n", -1);
|
||||
// Split by various line endings and trim each entry
|
||||
String[] desired =
|
||||
stirling.software.common.util.RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
.split(fileOrder);
|
||||
|
||||
List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
|
||||
List<MultipartFile> ordered = new ArrayList<>(files.length);
|
||||
|
||||
for (String name : desired) {
|
||||
if (name == null || name.isEmpty()) continue;
|
||||
name = name.trim();
|
||||
if (name.isEmpty()) {
|
||||
log.debug("Skipping empty entry");
|
||||
continue;
|
||||
}
|
||||
int idx = indexOfByOriginalFilename(remaining, name);
|
||||
if (idx >= 0) {
|
||||
ordered.add(remaining.remove(idx));
|
||||
} else {
|
||||
log.debug("Filename from order list not found in uploaded files: {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any files not explicitly listed, preserving their relative order
|
||||
ordered.addAll(remaining);
|
||||
return ordered.toArray(new MultipartFile[0]);
|
||||
}
|
||||
@@ -252,8 +262,10 @@ public class MergeController {
|
||||
|
||||
// If front-end provided explicit visible order, honor it and override backend sorting
|
||||
if (fileOrder != null && !fileOrder.isBlank()) {
|
||||
log.info("Reordering files based on fileOrder parameter");
|
||||
files = reorderFilesByProvidedOrder(files, fileOrder);
|
||||
} else {
|
||||
log.info("Sorting files based on sortType: {}", request.getSortType());
|
||||
Arrays.sort(
|
||||
files,
|
||||
getSortComparator(
|
||||
|
||||
+6
-1
@@ -27,6 +27,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.FormUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -56,7 +57,11 @@ public class MultiPageLayoutController {
|
||||
if (pagesPerSheet != 2
|
||||
&& pagesPerSheet != 3
|
||||
&& pagesPerSheet != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
|
||||
throw new IllegalArgumentException("pagesPerSheet must be 2, 3 or a perfect square");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"pagesPerSheet",
|
||||
"must be 2, 3 or a perfect square");
|
||||
}
|
||||
|
||||
int cols =
|
||||
|
||||
+12
-11
@@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -63,7 +64,8 @@ public class PdfOverlayController {
|
||||
int[] counts = request.getCounts(); // Used for FixedRepeatOverlay mode
|
||||
|
||||
try (PDDocument basePdf = pdfDocumentFactory.load(baseFile);
|
||||
Overlay overlay = new Overlay()) {
|
||||
Overlay overlay = new Overlay();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
Map<Integer, String> overlayGuide =
|
||||
prepareOverlayGuide(
|
||||
basePdf.getNumberOfPages(),
|
||||
@@ -79,7 +81,6 @@ public class PdfOverlayController {
|
||||
overlay.setOverlayPosition(Overlay.Position.BACKGROUND);
|
||||
}
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
overlay.overlay(overlayGuide).save(outputStream);
|
||||
byte[] data = outputStream.toByteArray();
|
||||
String outputFilename =
|
||||
@@ -118,7 +119,8 @@ public class PdfOverlayController {
|
||||
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Invalid overlay mode");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", mode);
|
||||
}
|
||||
return overlayGuide;
|
||||
}
|
||||
@@ -140,12 +142,11 @@ public class PdfOverlayController {
|
||||
overlayFileIndex = (overlayFileIndex + 1) % overlayFiles.length;
|
||||
}
|
||||
|
||||
try (PDDocument overlayPdf = Loader.loadPDF(overlayFiles[overlayFileIndex])) {
|
||||
PDDocument singlePageDocument = new PDDocument();
|
||||
try (PDDocument overlayPdf = Loader.loadPDF(overlayFiles[overlayFileIndex]);
|
||||
PDDocument singlePageDocument = new PDDocument()) {
|
||||
singlePageDocument.addPage(overlayPdf.getPage(pageCountInCurrentOverlay));
|
||||
File tempFile = Files.createTempFile("overlay-page-", ".pdf").toFile();
|
||||
singlePageDocument.save(tempFile);
|
||||
singlePageDocument.close();
|
||||
|
||||
overlayGuide.put(basePageIndex, tempFile.getAbsolutePath());
|
||||
tempFiles.add(tempFile); // Keep track of the temporary file for cleanup
|
||||
@@ -181,8 +182,11 @@ public class PdfOverlayController {
|
||||
Map<Integer, String> overlayGuide, File[] overlayFiles, int[] counts, int basePageCount)
|
||||
throws IOException {
|
||||
if (overlayFiles.length != counts.length) {
|
||||
throw new IllegalArgumentException(
|
||||
"Counts array length must match the number of overlay files");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"counts array",
|
||||
"length must match the number of overlay files");
|
||||
}
|
||||
int currentPage = 1;
|
||||
for (int i = 0; i < overlayFiles.length; i++) {
|
||||
@@ -202,6 +206,3 @@ public class PdfOverlayController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional classes like OverlayPdfsRequest, WebResponseUtils, etc. are assumed to be defined
|
||||
// elsewhere.
|
||||
|
||||
+6
-1
@@ -217,7 +217,12 @@ public class RearrangePagesPDFController {
|
||||
case REMOVE_LAST -> removeLast(totalPages);
|
||||
case REMOVE_FIRST_AND_LAST -> removeFirstAndLast(totalPages);
|
||||
case DUPLICATE -> duplicate(totalPages, pageOrder);
|
||||
default -> throw new IllegalArgumentException("Unsupported custom mode");
|
||||
default ->
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"custom mode",
|
||||
"unsupported");
|
||||
};
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Unsupported custom mode", e);
|
||||
|
||||
+102
-71
@@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -34,88 +35,22 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ScalePagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
"This operation takes an input PDF file and the size to scale the pages to in"
|
||||
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String targetPDRectangle = request.getPageSize();
|
||||
float scaleFactor = request.getScaleFactor();
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
|
||||
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
|
||||
|
||||
PDPage newPage = new PDPage(targetSize);
|
||||
outputDocument.addPage(newPage);
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
|
||||
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
|
||||
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(form);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
contentStream.close();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
outputDocument.save(baos);
|
||||
outputDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
|
||||
}
|
||||
|
||||
private PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
|
||||
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
|
||||
if ("KEEP".equals(targetPDRectangle)) {
|
||||
if (sourceDocument.getNumberOfPages() == 0) {
|
||||
// Do not return null here; throw a clear exception so callers don't get a nullable
|
||||
// PDRectangle.
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
}
|
||||
|
||||
// use the first page to determine the target page size
|
||||
PDPage sourcePage = sourceDocument.getPage(0);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
if (sourceSize == null) {
|
||||
// If media box is unexpectedly null, treat it as invalid
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
}
|
||||
|
||||
@@ -131,9 +66,10 @@ public class ScalePagesController {
|
||||
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
|
||||
}
|
||||
|
||||
private Map<String, PDRectangle> getSizeMap() {
|
||||
private static Map<String, PDRectangle> getSizeMap() {
|
||||
Map<String, PDRectangle> sizeMap = new HashMap<>();
|
||||
// Add A0 - A6
|
||||
|
||||
// Portrait sizes (A0-A6)
|
||||
sizeMap.put("A0", PDRectangle.A0);
|
||||
sizeMap.put("A1", PDRectangle.A1);
|
||||
sizeMap.put("A2", PDRectangle.A2);
|
||||
@@ -142,10 +78,105 @@ public class ScalePagesController {
|
||||
sizeMap.put("A5", PDRectangle.A5);
|
||||
sizeMap.put("A6", PDRectangle.A6);
|
||||
|
||||
// Add other sizes
|
||||
// Landscape sizes (A0-A6)
|
||||
sizeMap.put(
|
||||
"A0_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
|
||||
sizeMap.put(
|
||||
"A1_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
|
||||
sizeMap.put(
|
||||
"A2_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
|
||||
sizeMap.put(
|
||||
"A3_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
|
||||
sizeMap.put(
|
||||
"A4_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
|
||||
sizeMap.put(
|
||||
"A5_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
|
||||
sizeMap.put(
|
||||
"A6_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
|
||||
|
||||
// Portrait US sizes
|
||||
sizeMap.put("LETTER", PDRectangle.LETTER);
|
||||
sizeMap.put("LEGAL", PDRectangle.LEGAL);
|
||||
|
||||
// Landscape US sizes
|
||||
sizeMap.put(
|
||||
"LETTER_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
|
||||
sizeMap.put(
|
||||
"LEGAL_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
|
||||
|
||||
return sizeMap;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
"This operation takes an input PDF file and the size to scale the pages to in"
|
||||
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String targetPDRectangle = request.getPageSize();
|
||||
float scaleFactor = request.getScaleFactor();
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
|
||||
// Create LayerUtility once outside the loop for better performance
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
|
||||
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
|
||||
|
||||
PDPage newPage = new PDPage(targetSize);
|
||||
outputDocument.addPage(newPage);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
|
||||
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(form);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
}
|
||||
|
||||
outputDocument.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-33
@@ -54,15 +54,12 @@ public class SplitPDFController {
|
||||
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
|
||||
throws IOException {
|
||||
|
||||
PDDocument document = null;
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
TempFile outputTempFile = null;
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
try {
|
||||
outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
document = pdfDocumentFactory.load(file);
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
|
||||
@@ -80,7 +77,8 @@ public class SplitPDFController {
|
||||
int previousPageNumber = 0;
|
||||
for (int splitPoint : pageNumbers) {
|
||||
try (PDDocument splitDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = previousPageNumber; i <= splitPoint; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
splitDocument.addPage(page);
|
||||
@@ -91,7 +89,6 @@ public class SplitPDFController {
|
||||
// Transfer metadata to split pdf
|
||||
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
splitDocument.save(baos);
|
||||
splitDocumentsBoas.add(baos);
|
||||
} catch (Exception e) {
|
||||
@@ -100,8 +97,6 @@ public class SplitPDFController {
|
||||
}
|
||||
}
|
||||
|
||||
document.close();
|
||||
|
||||
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
|
||||
try (ZipOutputStream zipOut =
|
||||
@@ -133,28 +128,6 @@ public class SplitPDFController {
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip");
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
} finally {
|
||||
try {
|
||||
// Close the main document
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
|
||||
// Close all ByteArrayOutputStreams
|
||||
for (ByteArrayOutputStream baos : splitDocumentsBoas) {
|
||||
if (baos != null) {
|
||||
baos.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Close the output temporary file
|
||||
if (outputTempFile != null) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error while cleaning up resources", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -33,6 +33,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.model.SplitTypes;
|
||||
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PDFService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
@@ -126,7 +127,11 @@ public class SplitPdfBySectionsController {
|
||||
switch (splitMode) {
|
||||
case CUSTOM:
|
||||
if (pageNumbers == null || pageNumbers.isBlank()) {
|
||||
throw new IllegalArgumentException("Custom mode requires page numbers input.");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.argumentRequired",
|
||||
"{0} is required for {1} mode",
|
||||
"page numbers",
|
||||
"custom");
|
||||
}
|
||||
String[] pageOrderArr = pageNumbers.split(",");
|
||||
List<Integer> pageListToSplit =
|
||||
@@ -159,7 +164,8 @@ public class SplitPdfBySectionsController {
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported split mode: " + splitMode);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
|
||||
}
|
||||
|
||||
return pagesToSplit;
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertEbookToPDFController {
|
||||
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isCalibreEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Calibre");
|
||||
}
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@Operation(
|
||||
summary = "Convert an eBook file to PDF",
|
||||
description =
|
||||
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
|
||||
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> convertEbookToPdf(
|
||||
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
|
||||
if (!isCalibreEnabled()) {
|
||||
throw new IllegalStateException("Calibre support is disabled");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("No input file provided");
|
||||
}
|
||||
|
||||
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
|
||||
if (optimizeForEbook && !isGhostscriptEnabled()) {
|
||||
log.warn(
|
||||
"Ghostscript optimization requested but Ghostscript is not enabled/available"
|
||||
+ " for ebook conversion");
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
|
||||
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
|
||||
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "document";
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension == null || extension.isBlank()) {
|
||||
throw new IllegalArgumentException("Unable to determine file type");
|
||||
}
|
||||
|
||||
String lowerExtension = extension.toLowerCase(Locale.ROOT);
|
||||
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
|
||||
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
|
||||
}
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "document";
|
||||
}
|
||||
|
||||
Path workingDirectory = tempFileManager.createTempDirectory();
|
||||
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
|
||||
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
|
||||
|
||||
try (InputStream inputStream = inputFile.getInputStream()) {
|
||||
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
List<String> command =
|
||||
buildCalibreCommand(
|
||||
inputPath,
|
||||
outputPath,
|
||||
embedAllFonts,
|
||||
includeTableOfContents,
|
||||
includePageNumbers);
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
|
||||
.runCommandWithOutputHandling(command, workingDirectory.toFile());
|
||||
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Calibre conversion returned no result");
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
String errorMessage = result.getMessages();
|
||||
if (errorMessage == null || errorMessage.isBlank()) {
|
||||
errorMessage = "Calibre conversion failed";
|
||||
}
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
|
||||
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
|
||||
throw new IllegalStateException("Calibre did not produce a PDF output");
|
||||
}
|
||||
|
||||
String outputFilename =
|
||||
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
|
||||
|
||||
try {
|
||||
if (optimizeForEbook) {
|
||||
byte[] pdfBytes = Files.readAllBytes(outputPath);
|
||||
try {
|
||||
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Ghostscript optimization failed for ebook conversion, returning"
|
||||
+ " original PDF",
|
||||
e);
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
|
||||
}
|
||||
} finally {
|
||||
cleanupTempFiles(workingDirectory, inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> buildCalibreCommand(
|
||||
Path inputPath,
|
||||
Path outputPath,
|
||||
boolean embedAllFonts,
|
||||
boolean includeTableOfContents,
|
||||
boolean includePageNumbers) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("ebook-convert");
|
||||
command.add(inputPath.toString());
|
||||
command.add(outputPath.toString());
|
||||
|
||||
if (embedAllFonts) {
|
||||
command.add("--embed-all-fonts");
|
||||
}
|
||||
if (includeTableOfContents) {
|
||||
command.add("--pdf-add-toc");
|
||||
}
|
||||
if (includePageNumbers) {
|
||||
command.add("--pdf-page-numbers");
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
|
||||
List<Path> pathsToDelete = new ArrayList<>();
|
||||
pathsToDelete.add(inputPath);
|
||||
pathsToDelete.add(outputPath);
|
||||
|
||||
for (Path path : pathsToDelete) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", path, e);
|
||||
}
|
||||
}
|
||||
tempFileManager.deleteTempDirectory(workingDirectory);
|
||||
}
|
||||
}
|
||||
+8
-50
@@ -9,7 +9,6 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -19,7 +18,6 @@ import org.apache.commons.io.FileUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
@@ -280,19 +278,9 @@ public class ConvertImgPDFController {
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes =
|
||||
CbzUtils.convertCbzToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid CBZ file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid CBZ file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] pdfBytes =
|
||||
CbzUtils.convertCbzToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
|
||||
|
||||
@@ -314,17 +302,7 @@ public class ConvertImgPDFController {
|
||||
dpi = 300;
|
||||
}
|
||||
|
||||
byte[] cbzBytes;
|
||||
try {
|
||||
cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
|
||||
|
||||
@@ -349,19 +327,9 @@ public class ConvertImgPDFController {
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes =
|
||||
CbrUtils.convertCbrToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid CBR file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid CBR file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] pdfBytes =
|
||||
CbrUtils.convertCbrToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
|
||||
|
||||
@@ -383,17 +351,7 @@ public class ConvertImgPDFController {
|
||||
dpi = 300;
|
||||
}
|
||||
|
||||
byte[] cbrBytes;
|
||||
try {
|
||||
cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbr");
|
||||
|
||||
|
||||
+11
-3
@@ -33,6 +33,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
@@ -60,13 +61,14 @@ public class ConvertOfficeController {
|
||||
// Check for valid file extension and sanitize filename
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
throw new IllegalArgumentException("Missing original filename");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
// Check for valid file extension
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension == null || !isValidFileExtension(extension)) {
|
||||
throw new IllegalArgumentException("Invalid file extension");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalid.extension", "Invalid file extension: " + extension);
|
||||
}
|
||||
String extensionLower = extension.toLowerCase(Locale.ROOT);
|
||||
|
||||
@@ -91,6 +93,7 @@ public class ConvertOfficeController {
|
||||
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
Path libreOfficeProfile = null;
|
||||
try {
|
||||
ProcessExecutorResult result;
|
||||
// Run Unoconvert command
|
||||
@@ -110,8 +113,10 @@ public class ConvertOfficeController {
|
||||
.runCommandWithOutputHandling(command);
|
||||
} // Run soffice command
|
||||
else {
|
||||
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("soffice");
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--convert-to");
|
||||
@@ -167,6 +172,9 @@ public class ConvertOfficeController {
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp input file: {}", inputPath, e);
|
||||
}
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertPDFToEpubController {
|
||||
|
||||
private static final String CALIBRE_GROUP = "Calibre";
|
||||
private static final String DEFAULT_EXTENSION = "pdf";
|
||||
private static final String FILTERED_CSS =
|
||||
"font-family,color,background-color,margin-left,margin-right";
|
||||
private static final String SMART_CHAPTER_EXPRESSION =
|
||||
"//h:*[re:test(., '\\s*Chapter\\s+', 'i')]";
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private static List<String> buildCalibreCommand(
|
||||
Path inputPath, Path outputPath, boolean detectChapters, TargetDevice targetDevice) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("ebook-convert");
|
||||
command.add(inputPath.toString());
|
||||
command.add(outputPath.toString());
|
||||
|
||||
// Golden defaults
|
||||
command.add("--enable-heuristics");
|
||||
command.add("--insert-blank-line");
|
||||
command.add("--filter-css");
|
||||
command.add(FILTERED_CSS);
|
||||
|
||||
if (detectChapters) {
|
||||
command.add("--chapter");
|
||||
command.add(SMART_CHAPTER_EXPRESSION);
|
||||
}
|
||||
|
||||
if (targetDevice != null) {
|
||||
command.add("--output-profile");
|
||||
command.add(targetDevice.getCalibreProfile());
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
|
||||
@Operation(
|
||||
summary = "Convert PDF to EPUB/AZW3",
|
||||
description =
|
||||
"Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF"
|
||||
+ " Output:EPUB/AZW3 Type:SISO")
|
||||
public ResponseEntity<byte[]> convertPdfToEpub(@ModelAttribute ConvertPdfToEpubRequest request)
|
||||
throws Exception {
|
||||
|
||||
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
|
||||
throw new IllegalStateException(
|
||||
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
|
||||
+ " this feature.");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("No input file provided");
|
||||
}
|
||||
|
||||
boolean detectChapters = !Boolean.FALSE.equals(request.getDetectChapters());
|
||||
TargetDevice targetDevice =
|
||||
request.getTargetDevice() == null
|
||||
? TargetDevice.TABLET_PHONE_IMAGES
|
||||
: request.getTargetDevice();
|
||||
OutputFormat outputFormat =
|
||||
request.getOutputFormat() == null ? OutputFormat.EPUB : request.getOutputFormat();
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "document." + DEFAULT_EXTENSION;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension.isBlank()) {
|
||||
throw new IllegalArgumentException("Unable to determine file type");
|
||||
}
|
||||
|
||||
if (!DEFAULT_EXTENSION.equalsIgnoreCase(extension)) {
|
||||
throw new IllegalArgumentException("Input file must be a PDF");
|
||||
}
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "document";
|
||||
}
|
||||
|
||||
Path workingDirectory = null;
|
||||
Path inputPath = null;
|
||||
Path outputPath = null;
|
||||
|
||||
try {
|
||||
workingDirectory = tempFileManager.createTempDirectory();
|
||||
inputPath = workingDirectory.resolve(baseName + "." + DEFAULT_EXTENSION);
|
||||
outputPath = workingDirectory.resolve(baseName + "." + outputFormat.getExtension());
|
||||
|
||||
try (InputStream inputStream = inputFile.getInputStream()) {
|
||||
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
List<String> command =
|
||||
buildCalibreCommand(inputPath, outputPath, detectChapters, targetDevice);
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
|
||||
.runCommandWithOutputHandling(command, workingDirectory.toFile());
|
||||
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Calibre conversion returned no result");
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
String errorMessage = result.getMessages();
|
||||
if (errorMessage == null || errorMessage.isBlank()) {
|
||||
errorMessage = "Calibre conversion failed";
|
||||
}
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
|
||||
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
|
||||
throw new IllegalStateException(
|
||||
"Calibre did not produce a " + outputFormat.name() + " output");
|
||||
}
|
||||
|
||||
String outputFilename =
|
||||
GeneralUtils.generateFilename(
|
||||
originalFilename,
|
||||
"_convertedTo"
|
||||
+ outputFormat.name()
|
||||
+ "."
|
||||
+ outputFormat.getExtension());
|
||||
|
||||
byte[] outputBytes = Files.readAllBytes(outputPath);
|
||||
MediaType mediaType = MediaType.valueOf(outputFormat.getMediaType());
|
||||
return WebResponseUtils.bytesToWebResponse(outputBytes, outputFilename, mediaType);
|
||||
} finally {
|
||||
cleanupTempFiles(workingDirectory, inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
|
||||
if (workingDirectory == null) {
|
||||
return;
|
||||
}
|
||||
List<Path> pathsToDelete = new ArrayList<>();
|
||||
if (inputPath != null) {
|
||||
pathsToDelete.add(inputPath);
|
||||
}
|
||||
if (outputPath != null) {
|
||||
pathsToDelete.add(outputPath);
|
||||
}
|
||||
for (Path path : pathsToDelete) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
tempFileManager.deleteTempDirectory(workingDirectory);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete temporary directory: {}", workingDirectory, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -24,6 +25,7 @@ import stirling.software.common.util.TempFileManager;
|
||||
public class ConvertPDFToHtml {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@Operation(
|
||||
@@ -32,7 +34,7 @@ public class ConvertPDFToHtml {
|
||||
"This endpoint converts a PDF file to HTML format. Input:PDF Output:HTML Type:SISO")
|
||||
public ResponseEntity<byte[]> processPdfToHTML(@ModelAttribute PDFFile file) throws Exception {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToHtml(inputFile);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -35,6 +36,7 @@ public class ConvertPDFToOffice {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@Operation(
|
||||
@@ -47,7 +49,7 @@ public class ConvertPDFToOffice {
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
String outputFormat = request.getOutputFormat();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
|
||||
}
|
||||
|
||||
@@ -72,7 +74,7 @@ public class ConvertPDFToOffice {
|
||||
MediaType.TEXT_PLAIN);
|
||||
}
|
||||
} else {
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
}
|
||||
@@ -87,7 +89,7 @@ public class ConvertPDFToOffice {
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
String outputFormat = request.getOutputFormat();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ public class ConvertPDFToOffice {
|
||||
public ResponseEntity<byte[]> processPdfToXML(@ModelAttribute PDFFile file) throws Exception {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, "xml", "writer_pdf_import");
|
||||
}
|
||||
}
|
||||
|
||||
+1278
-259
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -185,7 +185,19 @@ public class ConvertPdfToVideoController {
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "PDF", "no pages");
|
||||
}
|
||||
for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) {
|
||||
BufferedImage image = renderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
final int currentPageIndex = pageIndex;
|
||||
|
||||
// Validate dimensions BEFORE attempting to render to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(currentPageIndex), currentPageIndex + 1, dpi);
|
||||
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
renderer.renderImageWithDPI(
|
||||
currentPageIndex, dpi, ImageType.RGB));
|
||||
if (isWatermarkEnabled) {
|
||||
applyWatermark(image, opacity, watermarkText);
|
||||
}
|
||||
|
||||
+7
-3
@@ -36,6 +36,7 @@ import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -71,7 +72,7 @@ public class ConvertWebsiteToPDF {
|
||||
URI location = null;
|
||||
HttpStatus status = HttpStatus.SEE_OTHER;
|
||||
|
||||
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
|
||||
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
|
||||
location =
|
||||
uriComponentsBuilder
|
||||
.queryParam("error", "error.endpointDisabled")
|
||||
@@ -188,8 +189,11 @@ public class ConvertWebsiteToPDF {
|
||||
client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
|
||||
if (response.statusCode() >= 400 || response.body() == null) {
|
||||
throw new IOException(
|
||||
"Failed to retrieve remote HTML. Status: " + response.statusCode());
|
||||
throw ExceptionUtils.createIOException(
|
||||
"error.httpRequestFailed",
|
||||
"Failed to retrieve remote HTML. Status: {0}",
|
||||
null,
|
||||
response.statusCode());
|
||||
}
|
||||
|
||||
return response.body();
|
||||
|
||||
+33
-18
@@ -155,24 +155,18 @@ public class PdfVectorExportController {
|
||||
command.add("gs");
|
||||
|
||||
// Set device based on output format
|
||||
String device;
|
||||
switch (outputFormat.toLowerCase(Locale.ROOT)) {
|
||||
case "eps":
|
||||
device = "eps2write";
|
||||
break;
|
||||
case "ps":
|
||||
device = "ps2write";
|
||||
break;
|
||||
case "pcl":
|
||||
device = "pxlcolor"; // PCL XL color
|
||||
break;
|
||||
case "xps":
|
||||
device = "xpswrite";
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Unsupported output format: {0}", outputFormat);
|
||||
}
|
||||
String device =
|
||||
switch (outputFormat.toLowerCase(Locale.ROOT)) {
|
||||
case "eps" -> "eps2write";
|
||||
case "ps" -> "ps2write";
|
||||
case "pcl" -> "pxlcolor"; // PCL XL color
|
||||
case "xps" -> "xpswrite";
|
||||
default ->
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Unsupported output format: {0}",
|
||||
outputFormat);
|
||||
};
|
||||
|
||||
command.add("-sDEVICE=" + device);
|
||||
command.add("-dNOPAUSE");
|
||||
@@ -187,6 +181,17 @@ public class PdfVectorExportController {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
ExceptionUtils.GhostscriptException criticalError =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (criticalError != null) {
|
||||
log.error(
|
||||
"Ghostscript PDF to {} conversion detected critical error: {}. Command: {}",
|
||||
outputFormat.toUpperCase(),
|
||||
criticalError.getMessage(),
|
||||
String.join(" ", command));
|
||||
throw criticalError;
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
|
||||
@@ -225,6 +230,16 @@ public class PdfVectorExportController {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
ExceptionUtils.GhostscriptException criticalError =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (criticalError != null) {
|
||||
log.error(
|
||||
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
|
||||
criticalError.getMessage(),
|
||||
String.join(" ", command));
|
||||
throw criticalError;
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
|
||||
|
||||
+7
-7
@@ -141,14 +141,14 @@ public class AutoSplitPdfController {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
|
||||
try {
|
||||
bim = pdfRenderer.renderImageWithDPI(page, renderDpi);
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
}
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
String result = decodeQRCode(bim);
|
||||
|
||||
boolean isValidQrCode = VALID_QR_CONTENTS.contains(result);
|
||||
|
||||
+11
-9
@@ -126,16 +126,16 @@ public class BlankPageController {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int currentPageIndex = pageIndex;
|
||||
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(pageIndex, renderDpi);
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(
|
||||
pageIndex + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(
|
||||
pageIndex + 1, renderDpi, e);
|
||||
}
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPageIndex, dpi));
|
||||
blank = isBlankImage(image, threshold, whitePercent, threshold);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,8 @@ public class BlankPageController {
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
||||
+45
-37
@@ -5,7 +5,6 @@ import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -104,7 +103,6 @@ public class CompressController {
|
||||
long totalCompressedBytes = 0;
|
||||
}
|
||||
|
||||
// Replace all instances of original images with their compressed versions
|
||||
private static void replaceImages(
|
||||
PDDocument doc,
|
||||
Map<ImageIdentity, List<ImageReference>> uniqueImages,
|
||||
@@ -115,11 +113,9 @@ public class CompressController {
|
||||
ImageIdentity imageIdentity = entry.getKey();
|
||||
List<ImageReference> references = entry.getValue();
|
||||
|
||||
// Skip if no compressed version exists
|
||||
PDImageXObject compressedImage = compressedVersions.get(imageIdentity);
|
||||
if (compressedImage == null) continue;
|
||||
|
||||
// Replace ALL instances with the compressed version
|
||||
for (ImageReference ref : references) {
|
||||
replaceImageReference(doc, ref, compressedImage);
|
||||
}
|
||||
@@ -305,19 +301,15 @@ public class CompressController {
|
||||
return compressedVersions;
|
||||
}
|
||||
|
||||
// Enhanced hash function to identify identical images with more data
|
||||
private static String generateImageHash(PDImageXObject image) {
|
||||
try {
|
||||
// Create a stream for the raw stream data
|
||||
try (InputStream stream = image.getCOSObject().createRawInputStream()) {
|
||||
// Read more data for better hash accuracy (16KB instead of 8KB)
|
||||
byte[] buffer = new byte[16384];
|
||||
int bytesRead = stream.read(buffer);
|
||||
if (bytesRead > 0) {
|
||||
byte[] dataToHash =
|
||||
bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead);
|
||||
|
||||
// Also include image dimensions and color space in the hash
|
||||
String enhancedData =
|
||||
new String(dataToHash, StandardCharsets.UTF_8)
|
||||
+ "_"
|
||||
@@ -394,10 +386,8 @@ public class CompressController {
|
||||
}
|
||||
}
|
||||
|
||||
// Hash function to identify identical masks
|
||||
private static String generateMaskHash(PDImageXObject image) {
|
||||
try {
|
||||
// Try to get mask data from either getMask() or getSoftMask()
|
||||
PDImageXObject mask = image.getMask();
|
||||
if (mask == null) {
|
||||
mask = image.getSoftMask();
|
||||
@@ -405,7 +395,6 @@ public class CompressController {
|
||||
|
||||
if (mask != null) {
|
||||
try (InputStream stream = mask.getCOSObject().createRawInputStream()) {
|
||||
// Read up to first 4KB of mask data for the hash
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead = stream.read(buffer);
|
||||
if (bytesRead > 0) {
|
||||
@@ -774,11 +763,11 @@ public class CompressController {
|
||||
this.streamLength = image.getCOSObject().getLength();
|
||||
this.imageType = getImageType(image);
|
||||
this.decodeParams = generateDecodeParamsHash(image);
|
||||
this.metadataHash = this.generateMetadataHash(image);
|
||||
this.metadataHash = ImageIdentity.generateMetadataHash(image);
|
||||
}
|
||||
|
||||
// Generate hash of image metadata
|
||||
private String generateMetadataHash(PDImageXObject image) {
|
||||
private static String generateMetadataHash(PDImageXObject image) {
|
||||
try {
|
||||
StringBuilder metadata = new StringBuilder();
|
||||
|
||||
@@ -931,11 +920,18 @@ public class CompressController {
|
||||
public ResponseEntity<byte[]> optimizePdf(@ModelAttribute OptimizePdfRequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
// Validate input file
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
Integer optimizeLevel = request.getOptimizeLevel();
|
||||
String expectedOutputSizeString = request.getExpectedOutputSize();
|
||||
Boolean convertToGrayscale = request.getGrayscale();
|
||||
if (expectedOutputSizeString == null && optimizeLevel == null) {
|
||||
throw new Exception("Both expected output size and optimize level are not specified");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
ExceptionUtils.ErrorCode.COMPRESSION_OPTIONS);
|
||||
}
|
||||
|
||||
Long expectedOutputSize = 0L;
|
||||
@@ -967,7 +963,6 @@ public class CompressController {
|
||||
|
||||
boolean sizeMet = false;
|
||||
boolean imageCompressionApplied = false;
|
||||
boolean externalCompressionApplied = false;
|
||||
|
||||
while (!sizeMet && optimizeLevel <= 9) {
|
||||
// Apply external compression first
|
||||
@@ -978,8 +973,14 @@ public class CompressController {
|
||||
applyGhostscriptCompression(request, optimizeLevel, currentFile);
|
||||
log.info("Ghostscript compression applied successfully");
|
||||
ghostscriptSuccess = true;
|
||||
} catch (ExceptionUtils.GhostscriptException e) {
|
||||
// Critical Ghostscript errors should be propagated
|
||||
log.error("Ghostscript encountered a critical error: {}", e.getMessage());
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript compression failed, continuing with other methods");
|
||||
log.warn(
|
||||
"Ghostscript compression failed, continuing with other methods: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,7 +990,7 @@ public class CompressController {
|
||||
applyQpdfCompression(request, optimizeLevel, currentFile);
|
||||
log.info("QPDF compression applied successfully");
|
||||
} catch (IOException e) {
|
||||
log.warn("QPDF compression failed");
|
||||
log.warn("QPDF compression failed: {}", e.getMessage());
|
||||
}
|
||||
} else if (!ghostscriptSuccess) {
|
||||
log.info(
|
||||
@@ -997,8 +998,6 @@ public class CompressController {
|
||||
+ " only");
|
||||
}
|
||||
|
||||
externalCompressionApplied = true;
|
||||
|
||||
// Skip image compression if Ghostscript succeeded
|
||||
if (ghostscriptSuccess) {
|
||||
imageCompressionApplied = true;
|
||||
@@ -1044,7 +1043,6 @@ public class CompressController {
|
||||
} else {
|
||||
// Reset flags for next iteration with higher optimization level
|
||||
imageCompressionApplied = false;
|
||||
externalCompressionApplied = false;
|
||||
optimizeLevel = newOptimizeLevel;
|
||||
}
|
||||
}
|
||||
@@ -1067,8 +1065,12 @@ public class CompressController {
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_Optimized.pdf");
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
|
||||
try {
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.handlePdfException(e, "PDF optimization");
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Clean up all temporary files
|
||||
@@ -1176,6 +1178,16 @@ public class CompressController {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
// Check for critical errors in the output before checking return code
|
||||
String gsOutput = returnCode.getMessages();
|
||||
ExceptionUtils.GhostscriptException criticalError =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(gsOutput);
|
||||
if (criticalError != null) {
|
||||
log.error(
|
||||
"Ghostscript critical error detected: {}", criticalError.getMessage());
|
||||
throw criticalError;
|
||||
}
|
||||
|
||||
if (returnCode.getRc() == 0) {
|
||||
// Update current file to the Ghostscript output
|
||||
Files.copy(gsOutputPath, currentFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
@@ -1190,20 +1202,17 @@ public class CompressController {
|
||||
log.warn(
|
||||
"Ghostscript compression failed with return code: {}",
|
||||
returnCode.getRc());
|
||||
throw new IOException("Ghostscript compression failed");
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(gsOutput);
|
||||
}
|
||||
|
||||
// replace the existing catch with these two catches
|
||||
} catch (InterruptedException e) {
|
||||
// restore interrupted status and propagate as an IOException
|
||||
Thread.currentThread().interrupt();
|
||||
InterruptedIOException ie =
|
||||
new InterruptedIOException("Ghostscript command interrupted");
|
||||
ie.initCause(e);
|
||||
throw ie;
|
||||
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
|
||||
} catch (ExceptionUtils.GhostscriptException e) {
|
||||
// Re-throw Ghostscript-specific exceptions
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("Ghostscript compression failed, will fallback to other methods", e);
|
||||
throw new IOException("Ghostscript compression failed", e);
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1296,16 +1305,15 @@ public class CompressController {
|
||||
|
||||
} catch (IOException e) {
|
||||
if (returnCode != null && returnCode.getRc() != 3) {
|
||||
throw new IOException("QPDF command failed", e);
|
||||
throw ExceptionUtils.createIOException(
|
||||
ExceptionUtils.ErrorCode.QPDF_COMPRESSION.getMessageKey(),
|
||||
ExceptionUtils.ErrorCode.QPDF_COMPRESSION.getDefaultMessage(),
|
||||
e);
|
||||
}
|
||||
// If QPDF fails, keep using the current file
|
||||
log.warn("QPDF compression failed, continuing with current file", e);
|
||||
} catch (InterruptedException e) {
|
||||
// restore interrupted status and propagate as an IOException
|
||||
Thread.currentThread().interrupt();
|
||||
InterruptedIOException ie = new InterruptedIOException("QPDF command interrupted");
|
||||
ie.initCause(e);
|
||||
throw ie;
|
||||
throw ExceptionUtils.createProcessingInterruptedException("QPDF", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -108,14 +108,14 @@ public class ExtractImageScansController {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageIndex = i;
|
||||
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(i, renderDpi);
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
}
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageIndex, dpi));
|
||||
ImageIO.write(image, "png", tempFile.toFile());
|
||||
|
||||
// Add temp file path to images list
|
||||
@@ -202,7 +202,8 @@ public class ExtractImageScansController {
|
||||
zipBytes, outputZipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
if (processedImageBytes.isEmpty()) {
|
||||
throw new IllegalArgumentException("No images detected");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.noContent", "No {0} detected", "images");
|
||||
} else {
|
||||
|
||||
// Return the processed image as a response
|
||||
|
||||
+33
-12
@@ -82,24 +82,31 @@ public class FlattenController {
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpi = maxDpi;
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpi = Math.min(requestedDpi, maxDpi);
|
||||
renderDpi = Math.max(renderDpi, 72);
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
BufferedImage image;
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(i, renderDpi, ImageType.RGB);
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
}
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
@@ -111,8 +118,22 @@ public class FlattenController {
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
}
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
|
||||
+24
-17
@@ -37,10 +37,17 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.*;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempDirectory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@@ -53,6 +60,7 @@ public class OCRController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private boolean isOcrMyPdfEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("OCRmyPDF");
|
||||
@@ -64,7 +72,7 @@ public class OCRController {
|
||||
|
||||
/** Gets the list of available Tesseract languages from the tessdata directory */
|
||||
public List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
String tessdataDir = runtimePathConfig.getTessDataPath();
|
||||
File[] files = new File(tessdataDir).listFiles();
|
||||
if (files == null) {
|
||||
return Collections.emptyList();
|
||||
@@ -80,9 +88,10 @@ public class OCRController {
|
||||
@Operation(
|
||||
summary = "Process a PDF file with OCR",
|
||||
description =
|
||||
"This endpoint processes a PDF file using OCR (Optical Character Recognition). "
|
||||
+ "Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType, and removeImagesAfter options. "
|
||||
+ "Uses OCRmyPDF if available, falls back to Tesseract. Input:PDF Output:PDF Type:SI-Conditional")
|
||||
"This endpoint processes a PDF file using OCR (Optical Character Recognition). Users can"
|
||||
+ " specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType,"
|
||||
+ " and removeImagesAfter options. Uses OCRmyPDF if available, falls back to"
|
||||
+ " Tesseract. Input:PDF Output:PDF Type:SI-Conditional")
|
||||
public ResponseEntity<byte[]> processPdfWithOCR(
|
||||
@ModelAttribute ProcessPdfWithOcrRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
@@ -101,7 +110,7 @@ public class OCRController {
|
||||
}
|
||||
|
||||
if (!"hocr".equals(ocrRenderType) && !"sandwich".equals(ocrRenderType)) {
|
||||
throw new IOException("ocrRenderType wrong");
|
||||
throw ExceptionUtils.createOcrInvalidRenderTypeException();
|
||||
}
|
||||
|
||||
// Get available Tesseract languages
|
||||
@@ -217,7 +226,7 @@ public class OCRController {
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"ocrmypdf",
|
||||
runtimePathConfig.getOcrMyPdfPath(),
|
||||
"--verbose",
|
||||
"2",
|
||||
"--output-type",
|
||||
@@ -270,7 +279,7 @@ public class OCRController {
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
throw new IOException("OCRmyPDF failed with return code: " + result.getRc());
|
||||
throw ExceptionUtils.createOcrProcessingFailedException(result.getRc());
|
||||
}
|
||||
|
||||
// Remove images from the OCR processed PDF if the flag is set to true
|
||||
@@ -351,16 +360,14 @@ public class OCRController {
|
||||
&& applicationProperties.getSystem() != null) {
|
||||
renderDpi = applicationProperties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int currentPageNum = pageNum;
|
||||
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(pageNum, renderDpi);
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(
|
||||
pageNum + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(
|
||||
pageNum + 1, renderDpi, e);
|
||||
}
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(currentPageNum, dpi));
|
||||
File imagePath =
|
||||
new File(
|
||||
tempImagesDir,
|
||||
|
||||
+3
-1
@@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.PrintFileRequest;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@@ -51,7 +52,8 @@ public class PrintFileController {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename != null
|
||||
&& (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) {
|
||||
throw new IOException("Invalid file path detected: " + originalFilename);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalid.filepath", "Invalid file path detected: " + originalFilename);
|
||||
}
|
||||
String printerName = request.getPrinterName();
|
||||
String contentType = file.getContentType();
|
||||
|
||||
+4
-1
@@ -21,6 +21,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
@@ -116,7 +117,9 @@ public class RepairController {
|
||||
repairSuccess = true;
|
||||
}
|
||||
} else {
|
||||
throw new IOException("PDF repair failed with available tools");
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"PDF repair",
|
||||
new IOException("PDF repair failed with available tools"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-12
@@ -45,6 +45,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -58,6 +59,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous PDF APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ScannerEffectController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@@ -95,11 +97,8 @@ public class ScannerEffectController {
|
||||
|
||||
private static BufferedImage renderPageSafely(PDFRenderer renderer, int pageIndex, int dpi)
|
||||
throws IOException {
|
||||
try {
|
||||
return renderer.renderImageWithDPI(pageIndex, dpi);
|
||||
} catch (OutOfMemoryError | NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
}
|
||||
return ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1, dpi, () -> renderer.renderImageWithDPI(pageIndex, dpi));
|
||||
}
|
||||
|
||||
private static BufferedImage convertColorspace(
|
||||
@@ -490,7 +489,8 @@ public class ScannerEffectController {
|
||||
float noise,
|
||||
boolean yellowish,
|
||||
int renderResolution,
|
||||
ScannerEffectRequest.Colorspace colorspace) {
|
||||
ScannerEffectRequest.Colorspace colorspace)
|
||||
throws ExceptionUtils.OutOfMemoryDpiException {
|
||||
|
||||
try {
|
||||
PDRectangle pageSize = renderingResources.getPageMediaBox(pageIndex);
|
||||
@@ -548,7 +548,8 @@ public class ScannerEffectController {
|
||||
}
|
||||
return new ProcessedPage(adjusted, origW, origH, offsetX, offsetY, drawW, drawH);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to process page " + (pageIndex + 1), e);
|
||||
throw ExceptionUtils.wrapException(
|
||||
e, "scanner effect processing for page " + (pageIndex + 1));
|
||||
} catch (OutOfMemoryError | NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, renderResolution, e);
|
||||
}
|
||||
@@ -621,7 +622,8 @@ public class ScannerEffectController {
|
||||
sharedPdfBytes != null
|
||||
? pdfDocumentFactory.load(sharedPdfBytes)
|
||||
: pdfDocumentFactory.load(processingInput);
|
||||
PDDocument outputDocument = new PDDocument()) {
|
||||
PDDocument outputDocument = new PDDocument();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
if (totalPages == 0) {
|
||||
@@ -689,9 +691,12 @@ public class ScannerEffectController {
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Parallel page processing interrupted", e);
|
||||
throw ExceptionUtils.createProcessingInterruptedException(
|
||||
"scanner effect parallel page processing", e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new IOException("Parallel page processing failed", e.getCause());
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"scanner effect parallel page processing",
|
||||
new IOException("Processing failed", e.getCause()));
|
||||
} finally {
|
||||
renderingResources.remove();
|
||||
for (RenderingResources resources : renderingResourcesToClose) {
|
||||
@@ -701,7 +706,6 @@ public class ScannerEffectController {
|
||||
|
||||
writeProcessedPagesToDocument(processedPages, outputDocument);
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
outputDocument.save(outputStream);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
@@ -801,7 +805,7 @@ public class ScannerEffectController {
|
||||
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
|
||||
pool.shutdownNow();
|
||||
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
|
||||
throw new RuntimeException("ForkJoinPool did not terminate");
|
||||
log.warn("ForkJoinPool did not terminate within timeout");
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
|
||||
+10
-3
@@ -46,6 +46,7 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddStampRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
@@ -90,7 +91,8 @@ public class StampController {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
String pdfFileName = pdfFile.getOriginalFilename();
|
||||
if (pdfFileName.contains("..") || pdfFileName.startsWith("/")) {
|
||||
throw new IllegalArgumentException("Invalid PDF file path");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalid.filepath", "Invalid PDF file path: " + pdfFileName);
|
||||
}
|
||||
|
||||
String stampType = request.getStampType();
|
||||
@@ -98,14 +100,19 @@ public class StampController {
|
||||
MultipartFile stampImage = request.getStampImage();
|
||||
if ("image".equalsIgnoreCase(stampType)) {
|
||||
if (stampImage == null) {
|
||||
throw new IllegalArgumentException(
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.stamp.image.required",
|
||||
"Stamp image file must be provided when stamp type is 'image'");
|
||||
}
|
||||
String stampImageName = stampImage.getOriginalFilename();
|
||||
if (stampImageName == null
|
||||
|| stampImageName.contains("..")
|
||||
|| stampImageName.startsWith("/")) {
|
||||
throw new IllegalArgumentException("Invalid stamp image file path");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"stamp image file path",
|
||||
stampImageName);
|
||||
}
|
||||
}
|
||||
String alphabet = request.getAlphabet();
|
||||
|
||||
+5
-4
@@ -474,10 +474,11 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
|
||||
if (file.getSize() > MAX_FILE_SIZE) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format(
|
||||
"File size (%d bytes) exceeds maximum allowed size (%d bytes)",
|
||||
file.getSize(), MAX_FILE_SIZE));
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileSizeLimit",
|
||||
"File size ({0} bytes) exceeds maximum allowed size ({1} bytes)",
|
||||
file.getSize(),
|
||||
MAX_FILE_SIZE);
|
||||
}
|
||||
|
||||
String contentType = file.getContentType();
|
||||
|
||||
+11
-1
@@ -43,7 +43,17 @@ public class PasswordController {
|
||||
throws IOException {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String password = request.getPassword();
|
||||
PDDocument document = pdfDocumentFactory.load(fileInput, password);
|
||||
|
||||
PDDocument document;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(fileInput, password);
|
||||
} catch (IOException e) {
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
|
||||
+6
-3
@@ -62,6 +62,7 @@ import stirling.software.SPDF.utils.text.TextFinderUtils;
|
||||
import stirling.software.SPDF.utils.text.WidthCalculator;
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -508,7 +509,8 @@ public class RedactController {
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
|
||||
throw new IllegalArgumentException("No text patterns provided for redaction");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
PDDocument document = null;
|
||||
@@ -517,14 +519,15 @@ public class RedactController {
|
||||
try {
|
||||
if (request.getFileInput() == null) {
|
||||
log.error("File input is null");
|
||||
throw new IllegalArgumentException("File input cannot be null");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
document = pdfDocumentFactory.load(request.getFileInput());
|
||||
|
||||
if (document == null) {
|
||||
log.error("Failed to load PDF document");
|
||||
throw new IllegalArgumentException("Failed to load PDF document");
|
||||
throw ExceptionUtils.createPdfCorruptedException(
|
||||
"during redaction", new IOException("Failed to load PDF document"));
|
||||
}
|
||||
|
||||
Map<Integer, List<PDFText>> allFoundTextsByPage =
|
||||
|
||||
+18
@@ -47,6 +47,24 @@ public class ConverterWebController {
|
||||
return "convert/cbr-to-pdf";
|
||||
}
|
||||
|
||||
@GetMapping("/ebook-to-pdf")
|
||||
@Hidden
|
||||
public String convertEbookToPdfForm(Model model) {
|
||||
model.addAttribute("currentPage", "ebook-to-pdf");
|
||||
return "convert/ebook-to-pdf";
|
||||
}
|
||||
|
||||
@GetMapping("/pdf-to-epub")
|
||||
@Hidden
|
||||
public String convertPdfToEpubForm(Model model) {
|
||||
if (!ApplicationContextProvider.getBean(EndpointConfiguration.class)
|
||||
.isEndpointEnabled("pdf-to-epub")) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
model.addAttribute("currentPage", "pdf-to-epub");
|
||||
return "convert/pdf-to-epub";
|
||||
}
|
||||
|
||||
@GetMapping("/pdf-to-cbr")
|
||||
@Hidden
|
||||
public String convertPdfToCbrForm(Model model) {
|
||||
|
||||
@@ -84,8 +84,8 @@ public class HomeWebController {
|
||||
@ResponseBody
|
||||
@Hidden
|
||||
public String getRobotsTxt() {
|
||||
Boolean allowGoogle = applicationProperties.getSystem().getGooglevisibility();
|
||||
if (Boolean.TRUE.equals(allowGoogle)) {
|
||||
boolean allowGoogle = applicationProperties.getSystem().isGooglevisibility();
|
||||
if (allowGoogle) {
|
||||
return "User-agent: Googlebot\nAllow: /\n\nUser-agent: *\nAllow: /";
|
||||
} else {
|
||||
return "User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /";
|
||||
|
||||
@@ -42,9 +42,7 @@ public class MetricsController {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Boolean metricsEnabled = applicationProperties.getMetrics().getEnabled();
|
||||
if (metricsEnabled == null) metricsEnabled = true;
|
||||
this.metricsEnabled = metricsEnabled;
|
||||
metricsEnabled = applicationProperties.getMetrics().isEnabled();
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
|
||||
+5
-1
@@ -14,16 +14,20 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
|
||||
@Controller
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class OtherWebController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@GetMapping("/compress-pdf")
|
||||
@Hidden
|
||||
@@ -120,7 +124,7 @@ public class OtherWebController {
|
||||
}
|
||||
|
||||
public List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
String tessdataDir = runtimePathConfig.getTessDataPath();
|
||||
File[] files = new File(tessdataDir).listFiles();
|
||||
if (files == null) {
|
||||
return Collections.emptyList();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user