Merge remote-tracking branch 'origin/main' into feature/react-overhaul

This commit is contained in:
Anthony Stirling
2025-07-11 15:14:11 +01:00
173 changed files with 9649 additions and 3214 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ labels:
- '.editorconfig' - '.editorconfig'
- '.pre-commit-config' - '.pre-commit-config'
- '.github/workflows/pre_commit.yml' - '.github/workflows/pre_commit.yml'
- 'HowToAddNewLanguage.md' - 'devGuide/.*'
- label: 'Test' - label: 'Test'
files: files:
+6 -4
View File
@@ -1,5 +1,6 @@
# Description of Changes # Description of Changes
<!--
Please provide a summary of the changes, including: Please provide a summary of the changes, including:
- What was changed - What was changed
@@ -7,6 +8,7 @@ Please provide a summary of the changes, including:
- Any challenges encountered - Any challenges encountered
Closes #(issue_number) Closes #(issue_number)
-->
--- ---
@@ -15,15 +17,15 @@ Closes #(issue_number)
### General ### General
- [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable)
- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable)
- [ ] I have performed a self-review of my own code - [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings - [ ] My changes generate no new warnings
### Documentation ### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed)
- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only)
### UI Changes (if applicable) ### UI Changes (if applicable)
@@ -31,4 +33,4 @@ Closes #(issue_number)
### Testing (if applicable) ### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
+16 -16
View File
@@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.10 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' '.github\scripts\requirements_pre_commit.in' # pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
# #
cfgv==3.4.0 \ cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
@@ -12,25 +12,25 @@ distlib==0.3.9 \
--hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \ --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \
--hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403
# via virtualenv # via virtualenv
filelock==3.16.1 \ filelock==3.18.0 \
--hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \ --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
--hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435 --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
# via virtualenv # via virtualenv
identify==2.6.5 \ identify==2.6.12 \
--hash=sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566 \ --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \
--hash=sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6
# via pre-commit # via pre-commit
nodeenv==1.9.1 \ nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit # via pre-commit
platformdirs==4.3.6 \ platformdirs==4.3.8 \
--hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
--hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4
# via virtualenv # via virtualenv
pre-commit==4.0.1 \ pre-commit==4.2.0 \
--hash=sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2 \ --hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \
--hash=sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878 --hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd
# via -r .github\scripts\requirements_pre_commit.in # via -r .github\scripts\requirements_pre_commit.in
pyyaml==6.0.2 \ pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
@@ -87,7 +87,7 @@ pyyaml==6.0.2 \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via pre-commit # via pre-commit
virtualenv==20.28.1 \ virtualenv==20.31.2 \
--hash=sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb \ --hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \
--hash=sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329 --hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af
# via pre-commit # via pre-commit
+4 -4
View File
@@ -2,9 +2,9 @@
# This file is autogenerated by pip-compile with Python 3.10 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' '.github\scripts\requirements_sync_readme.in' # pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
# #
tomlkit==0.13.2 \ tomlkit==0.13.3 \
--hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde \ --hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
--hash=sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79 --hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
# via -r .github\scripts\requirements_sync_readme.in # via -r .github\scripts\requirements_sync_readme.in
@@ -30,6 +30,7 @@ jobs:
github.event.comment.user.login == 'sbplat' || github.event.comment.user.login == 'sbplat' ||
github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'ConnorYoh' github.event.comment.user.login == 'ConnorYoh'
) )
outputs: outputs:
@@ -42,7 +43,7 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -149,7 +150,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -135,7 +135,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write # Allow writing to pull requests pull-requests: write # Allow writing to pull requests
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
repository-projects: write # Required for enabling automerge repository-projects: write # Required for enabling automerge
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write issues: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+19 -15
View File
@@ -21,27 +21,31 @@ jobs:
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }} versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Get version number - name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'temurin'
java-version: '21'
# ✅ Get version from Gradle
- name: Get version number - name: Get version number
id: versionNumber id: versionNumber
run: | run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') VERSION=$(./gradlew printVersion --quiet | tail -1)
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
# ✅ Get Mac-specific version from Gradle
- name: Get version number mac - name: Get version number mac
id: versionNumberMac id: versionNumberMac
run: | run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
CURRENT_YEAR=$(date +'%Y') echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
IFS='.' read -r -a VERSION_PARTS <<< "$VERSION"
MAC_VERSION="$CURRENT_YEAR.${VERSION_PARTS[1]:-0}.${VERSION_PARTS[2]:-0}"
echo "versionNumberMac=$MAC_VERSION" >> $GITHUB_OUTPUT
build-portable: build-portable:
needs: read_versions needs: read_versions
@@ -56,7 +60,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -82,7 +86,7 @@ jobs:
run: | run: |
mkdir ./binaries mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar mv ./stirling-pdf/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -106,7 +110,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -144,7 +148,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -234,7 +238,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -248,7 +252,7 @@ jobs:
- name: Install Cosign - name: Install Cosign
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0 uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair - name: Generate key pair
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
@@ -297,7 +301,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+5 -5
View File
@@ -18,7 +18,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -42,7 +42,7 @@ jobs:
- name: Install cosign - name: Install cosign
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0 uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
with: with:
cosign-release: "v2.4.1" cosign-release: "v2.4.1"
@@ -77,6 +77,7 @@ jobs:
- name: Generate tags - name: Generate tags
id: meta id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with: with:
images: | images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -86,11 +87,11 @@ jobs:
tags: | tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push main Dockerfile - name: Build and push main Dockerfile
id: build-push-regular id: build-push-regular
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with: with:
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
context: . context: .
@@ -153,7 +154,6 @@ jobs:
- name: Generate tags fat - name: Generate tags fat
id: meta3 id: meta3
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with: with:
images: | images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -163,11 +163,11 @@ jobs:
tags: | tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push main Dockerfile fat - name: Build and push main Dockerfile fat
id: build-push-fat id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with: with:
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
context: . context: .
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }} version: ${{ steps.versionNumber.outputs.versionNumber }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -83,7 +83,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -95,7 +95,7 @@ jobs:
run: ls -R run: ls -R
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0 uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair - name: Generate key pair
run: cosign generate-key-pair run: cosign generate-key-pair
@@ -161,7 +161,7 @@ jobs:
file_suffix: "" file_suffix: ""
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -74,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -57,8 +57,8 @@ jobs:
- name: Run git add - name: Run git add
run: | run: |
git add README.md git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md" || echo "No changes detected" git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request - name: Create Pull Request
if: always() if: always()
+3 -3
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -105,7 +105,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
@@ -134,7 +134,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
+13 -2
View File
@@ -25,7 +25,7 @@ Please make sure your Pull Request adheres to the following guidelines:
## Translations ## Translations
If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it! If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](devGuide/HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it!
## Docs ## Docs
@@ -37,7 +37,18 @@ First, make sure you've read the section [Pull Requests](#pull-requests).
If, at any point in time, you have a question, please feel free to ask in the same issue thread or in our [Discord](https://discord.gg/FJUSXUSYec). If, at any point in time, you have a question, please feel free to ask in the same issue thread or in our [Discord](https://discord.gg/FJUSXUSYec).
Developers should review our [Developer Guide](DeveloperGuide.md) ## Developer Documentation
For technical guides, setup instructions, and development resources, please see our [Developer Documentation](devGuide/) which includes:
- [Developer Guide](devGuide/DeveloperGuide.md) - Main setup and architecture guide
- [Exception Handling Guide](devGuide/EXCEPTION_HANDLING_GUIDE.md) - Error handling patterns and i18n
- [Translation Guide](devGuide/HowToAddNewLanguage.md) - Adding new languages
- And more in the [devGuide folder](devGuide/)
For configuration and usage guides, see:
- [Database Guide](DATABASE.md) - Database setup and configuration
- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration
## License ## License
+103
View File
@@ -0,0 +1,103 @@
# Main stage
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY stirling-pdf/build/libs/*.jar app.jar
ARG VERSION_TAG
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"
# Set 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_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
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
# 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 \
# 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 --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/* && \
chmod +x /scripts/init.sh && \
# 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
EXPOSE 8080/tcp
# Set user and run command
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"]
+61
View File
@@ -0,0 +1,61 @@
# dockerfile.dev
# Basisimage: Gradle mit JDK 17 (Debian-basiert)
FROM gradle:8.14-jdk17
# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren
USER root
# Set GRADLE_HOME und füge Gradle zum PATH hinzu
ENV GRADLE_HOME=/opt/gradle
ENV PATH="$GRADLE_HOME/bin:$PATH"
# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert)
RUN apt-get update && apt-get install -y \
sudo \
libreoffice \
poppler-utils \
qpdf \
# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata
tesseract-ocr \
tesseract-ocr-eng \
fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \
python3-uno \
python3-venv \
# ss -tln
iproute2 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools
ENV SETUPTOOLS_USE_DISTUTILS=local \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Installation der benötigten Python-Pakete
RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \
&& pip install --upgrade pip setuptools \
&& pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
ENV PATH="/opt/venv/bin:$PATH"
COPY . /workspace
RUN mkdir -p /tmp/stirling-pdf \
&& fc-cache -f -v \
&& adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf
RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
&& chmod 0440 /etc/sudoers.d/devuser
# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben)
WORKDIR /workspace
RUN chmod +x /workspace/.devcontainer/git-init.sh
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
# Wechsel zum NichtRoot Benutzer
USER devuser
+114
View File
@@ -0,0 +1,114 @@
# Build the application
FROM gradle:8.14-jdk21 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY stirling-pdf/build.gradle stirling-pdf/.
COPY common/build.gradle common/.
COPY proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
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
# Main stage
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY --from=build /app/stirling-pdf/build/libs/*.jar app.jar
ARG VERSION_TAG
# Set 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_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
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
# 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 \
# 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 --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/* && \
chmod +x /scripts/init.sh && \
# 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
EXPOSE 8080/tcp
# Set user and run command
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"]
+55
View File
@@ -0,0 +1,55 @@
# use alpine
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
ARG VERSION_TAG
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
HOME=/home/stirlingpdfuser \
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_CUSTOM_OPTS="" \
PUID=1000 \
PGID=1000 \
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Copy necessary files
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY pipeline /pipeline
COPY stirling-pdf/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 && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openjdk21-jre && \
# User permissions
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI
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"]
+40 -40
View File
@@ -29,7 +29,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
- API for integration with external scripts - API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation) - Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation) - Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO see [here](https://docs.stirlingpdf.com/Enterprise%20Edition) - Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
## PDF Features ## PDF Features
@@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress | | Language | Progress |
| -------------------------------------------- | -------------------------------------- | | -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![65%](https://geps.dev/progress/65) | | Arabic (العربية) (ar_AR) | ![63%](https://geps.dev/progress/63) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![65%](https://geps.dev/progress/65) | | Azerbaijani (Azərbaycan Dili) (az_AZ) | ![63%](https://geps.dev/progress/63) |
| Basque (Euskara) (eu_ES) | ![38%](https://geps.dev/progress/38) | | Basque (Euskara) (eu_ES) | ![37%](https://geps.dev/progress/37) |
| Bulgarian (Български) (bg_BG) | ![72%](https://geps.dev/progress/72) | | Bulgarian (Български) (bg_BG) | ![70%](https://geps.dev/progress/70) |
| Catalan (Català) (ca_CA) | ![71%](https://geps.dev/progress/71) | | Catalan (Català) (ca_CA) | ![69%](https://geps.dev/progress/69) |
| Croatian (Hrvatski) (hr_HR) | ![64%](https://geps.dev/progress/64) | | Croatian (Hrvatski) (hr_HR) | ![62%](https://geps.dev/progress/62) |
| Czech (Česky) (cs_CZ) | ![74%](https://geps.dev/progress/74) | | Czech (Česky) (cs_CZ) | ![71%](https://geps.dev/progress/71) |
| Danish (Dansk) (da_DK) | ![65%](https://geps.dev/progress/65) | | Danish (Dansk) (da_DK) | ![63%](https://geps.dev/progress/63) |
| Dutch (Nederlands) (nl_NL) | ![63%](https://geps.dev/progress/63) | | Dutch (Nederlands) (nl_NL) | ![61%](https://geps.dev/progress/61) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![73%](https://geps.dev/progress/73) | | French (Français) (fr_FR) | ![91%](https://geps.dev/progress/91) |
| German (Deutsch) (de_DE) | ![92%](https://geps.dev/progress/92) | | German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) |
| Greek (Ελληνικά) (el_GR) | ![71%](https://geps.dev/progress/71) | | Greek (Ελληνικά) (el_GR) | ![69%](https://geps.dev/progress/69) |
| Hindi (हिंदी) (hi_IN) | ![71%](https://geps.dev/progress/71) | | Hindi (हिंदी) (hi_IN) | ![68%](https://geps.dev/progress/68) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) | | Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![65%](https://geps.dev/progress/65) | | Indonesian (Bahasa Indonesia) (id_ID) | ![63%](https://geps.dev/progress/63) |
| Irish (Gaeilge) (ga_IE) | ![72%](https://geps.dev/progress/72) | | Irish (Gaeilge) (ga_IE) | ![70%](https://geps.dev/progress/70) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) | | Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) | | Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![71%](https://geps.dev/progress/71) | | Korean (한국어) (ko_KR) | ![69%](https://geps.dev/progress/69) |
| Norwegian (Norsk) (no_NB) | ![70%](https://geps.dev/progress/70) | | Norwegian (Norsk) (no_NB) | ![67%](https://geps.dev/progress/67) |
| Persian (فارسی) (fa_IR) | ![68%](https://geps.dev/progress/68) | | Persian (فارسی) (fa_IR) | ![66%](https://geps.dev/progress/66) |
| Polish (Polski) (pl_PL) | ![76%](https://geps.dev/progress/76) | | Polish (Polski) (pl_PL) | ![73%](https://geps.dev/progress/73) |
| Portuguese (Português) (pt_PT) | ![72%](https://geps.dev/progress/72) | | Portuguese (Português) (pt_PT) | ![70%](https://geps.dev/progress/70) |
| Portuguese Brazilian (Português) (pt_BR) | ![80%](https://geps.dev/progress/80) | | Portuguese Brazilian (Português) (pt_BR) | ![77%](https://geps.dev/progress/77) |
| Romanian (Română) (ro_RO) | ![61%](https://geps.dev/progress/61) | | Romanian (Română) (ro_RO) | ![59%](https://geps.dev/progress/59) |
| Russian (Русский) (ru_RU) | ![72%](https://geps.dev/progress/72) | | Russian (Русский) (ru_RU) | ![70%](https://geps.dev/progress/70) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![46%](https://geps.dev/progress/46) | | Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) | | Simplified Chinese (简体中文) (zh_CN) | ![90%](https://geps.dev/progress/90) |
| Slovakian (Slovensky) (sk_SK) | ![54%](https://geps.dev/progress/54) | | Slovakian (Slovensky) (sk_SK) | ![53%](https://geps.dev/progress/53) |
| Slovenian (Slovenščina) (sl_SI) | ![75%](https://geps.dev/progress/75) | | Slovenian (Slovenščina) (sl_SI) | ![73%](https://geps.dev/progress/73) |
| Spanish (Español) (es_ES) | ![78%](https://geps.dev/progress/78) | | Spanish (Español) (es_ES) | ![75%](https://geps.dev/progress/75) |
| Swedish (Svenska) (sv_SE) | ![69%](https://geps.dev/progress/69) | | Swedish (Svenska) (sv_SE) | ![67%](https://geps.dev/progress/67) |
| Thai (ไทย) (th_TH) | ![62%](https://geps.dev/progress/62) | | Thai (ไทย) (th_TH) | ![60%](https://geps.dev/progress/60) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![68%](https://geps.dev/progress/68) | | Tibetan (བོད་ཡིག་) (bo_CN) | ![66%](https://geps.dev/progress/66) |
| Traditional Chinese (繁體中文) (zh_TW) | ![80%](https://geps.dev/progress/80) | | Traditional Chinese (繁體中文) (zh_TW) | ![77%](https://geps.dev/progress/77) |
| Turkish (Türkçe) (tr_TR) | ![85%](https://geps.dev/progress/85) | | Turkish (Türkçe) (tr_TR) | ![82%](https://geps.dev/progress/82) |
| Ukrainian (Українська) (uk_UA) | ![75%](https://geps.dev/progress/75) | | Ukrainian (Українська) (uk_UA) | ![72%](https://geps.dev/progress/72) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![60%](https://geps.dev/progress/60) | | Vietnamese (Tiếng Việt) (vi_VN) | ![58%](https://geps.dev/progress/58) |
| Malayalam (മലയാളം) (ml_IN) | ![77%](https://geps.dev/progress/77) | | Malayalam (മലയാളം) (ml_IN) | ![75%](https://geps.dev/progress/75) |
## Stirling PDF Enterprise ## Stirling PDF Enterprise
@@ -168,7 +168,7 @@ Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
Join our community: Join our community:
- [Contribution Guidelines](CONTRIBUTING.md) - [Contribution Guidelines](CONTRIBUTING.md)
- [Translation Guide (How to add custom languages)](HowToAddNewLanguage.md) - [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
- [Developer Guide](devGuide/DeveloperGuide.md)
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues) - [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- [Discord Community](https://discord.gg/HYmhKj45pU) - [Discord Community](https://discord.gg/HYmhKj45pU)
- [Developer Guide](DeveloperGuide.md)
+46 -42
View File
@@ -9,12 +9,14 @@ plugins {
id "com.diffplug.spotless" version "7.0.4" id "com.diffplug.spotless" version "7.0.4"
id "com.github.jk1.dependency-license-report" version "2.9" id "com.github.jk1.dependency-license-report" version "2.9"
//id "nebula.lint" version "19.0.3" //id "nebula.lint" version "19.0.3"
id "org.panteleyev.jpackageplugin" version "1.6.1" id "org.panteleyev.jpackageplugin" version "1.7.3"
id "org.sonarqube" version "6.2.0.5505" id "org.sonarqube" version "6.2.0.5505"
} }
import com.github.jk1.license.render.* import com.github.jk1.license.render.*
import org.gradle.internal.os.OperatingSystem import org.gradle.internal.os.OperatingSystem
import org.panteleyev.jpackage.ImageType
import java.nio.file.Files import java.nio.file.Files
import java.time.Year import java.time.Year
@@ -26,7 +28,7 @@ ext {
bouncycastleVersion = "1.81" bouncycastleVersion = "1.81"
springSecuritySamlVersion = "6.5.1" springSecuritySamlVersion = "6.5.1"
openSamlVersion = "4.3.2" openSamlVersion = "4.3.2"
commonmarkVersion = "0.24.0" commonmarkVersion = "0.25.0"
googleJavaFormatVersion = "1.27.0" googleJavaFormatVersion = "1.27.0"
tempJrePath = null tempJrePath = null
} }
@@ -43,39 +45,19 @@ bootJar {
enabled = false enabled = false
} }
sourceSets { // Configure main class for the root project
main { springBoot {
java { mainClass = 'stirling.software.SPDF.SPDFApplication'
if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' }
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
exclude 'stirling/software/proprietary/security/**'
}
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
exclude 'stirling/software/SPDF/UI/impl/**'
}
} repositories {
} mavenCentral()
maven { url = 'https://build.shibboleth.net/maven/releases' }
test {
java {
if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true'
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
exclude 'stirling/software/proprietary/security/**'
}
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
exclude 'stirling/software/SPDF/UI/impl/**'
}
}
}
} }
allprojects { allprojects {
group = 'stirling.software' group = 'stirling.software'
version = '1.0.0' version = '1.0.2'
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging' exclude group: 'commons-logging', module: 'commons-logging'
@@ -83,7 +65,6 @@ allprojects {
} }
} }
tasks.register('writeVersion') { tasks.register('writeVersion') {
def propsFile = file("$projectDir/common/src/main/resources/version.properties") def propsFile = file("$projectDir/common/src/main/resources/version.properties")
def propsDir = propsFile.parentFile def propsDir = propsFile.parentFile
@@ -108,6 +89,10 @@ tasks.register('writeVersion') {
} }
} }
tasks.named('createExe') {
dependsOn(":stirling-pdf:bootJar")
}
subprojects { subprojects {
apply plugin: 'java' apply plugin: 'java'
apply plugin: 'java-library' apply plugin: 'java-library'
@@ -120,9 +105,11 @@ subprojects {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
} }
if (project.name != "stirling-pdf") {
bootJar { bootJar {
enabled = false enabled = false
} }
}
repositories { repositories {
mavenCentral() mavenCentral()
@@ -224,6 +211,14 @@ openApi {
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
} }
// Configure the forked spring boot run task to properly delegate to the stirling-pdf module
tasks.named('forkedSpringBootRun') {
dependsOn ':stirling-pdf:bootRun'
doFirst {
println "Delegating forkedSpringBootRun to :stirling-pdf:bootRun"
}
}
//0.11.5 to 2024.11.5 //0.11.5 to 2024.11.5
static def getMacVersion(String version) { static def getMacVersion(String version) {
def currentYear = Year.now().getValue() def currentYear = Year.now().getValue()
@@ -232,14 +227,15 @@ static def getMacVersion(String version) {
} }
jpackage { jpackage {
input = "build/libs" dependsOn(":stirling-pdf:bootJar")
destination = "${projectDir}/build/jpackage" input = layout.projectDirectory.dir("stirling-pdf/build/libs")
destination = layout.projectDirectory.dir("build/jpackage")
mainJar = "Stirling-PDF-${project.version}.jar" mainJar = "Stirling-PDF-${project.version}.jar"
appName = "Stirling PDF" appName = "Stirling PDF"
appVersion = project.version appVersion = project.version
vendor = "Stirling PDF Inc" vendor = "Stirling PDF Inc"
appDescription = "Stirling PDF - Your Local PDF Editor" appDescription = "Stirling PDF - Your Local PDF Editor"
icon = "stirling-pdf/src/main/resources/static/favicon.ico" icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.ico")
verbose = true verbose = true
// mainClass = "org.springframework.boot.loader.launch.JarLauncher" // mainClass = "org.springframework.boot.loader.launch.JarLauncher"
@@ -274,15 +270,15 @@ jpackage {
winUpgradeUuid = "2a43ed0c-b8c2-40cf-89e1-751129b87641" // Unique identifier for updates winUpgradeUuid = "2a43ed0c-b8c2-40cf-89e1-751129b87641" // Unique identifier for updates
winHelpUrl = "https://github.com/Stirling-Tools/Stirling-PDF" winHelpUrl = "https://github.com/Stirling-Tools/Stirling-PDF"
winUpdateUrl = "https://github.com/Stirling-Tools/Stirling-PDF/releases" winUpdateUrl = "https://github.com/Stirling-Tools/Stirling-PDF/releases"
type = "exe" type = ImageType.EXE
installDir = "C:/Program Files/Stirling-PDF" installDir = "C:/Program Files/Stirling-PDF"
} }
// MacOS-specific configuration // MacOS-specific configuration
mac { mac {
appVersion = getMacVersion(project.version.toString()) appVersion = getMacVersion(project.version.toString())
icon = "stirling-pdf/src/main/resources/static/favicon.icns" icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.icns")
type = "dmg" type = ImageType.DMG
macPackageIdentifier = "Stirling PDF" macPackageIdentifier = "Stirling PDF"
macPackageName = "Stirling PDF" macPackageName = "Stirling PDF"
macAppCategory = "public.app-category.productivity" macAppCategory = "public.app-category.productivity"
@@ -303,8 +299,8 @@ jpackage {
// Linux-specific configuration // Linux-specific configuration
linux { linux {
appVersion = project.version appVersion = project.version
icon = "stirling-pdf/src/main/resources/static/favicon.png" icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.png")
type = "deb" // Can also use "rpm" for Red Hat-based systems type = ImageType.DEB // Can also use "rpm" for Red Hat-based systems
// Debian package configuration // Debian package configuration
//linuxPackageName = "stirlingpdf" //linuxPackageName = "stirlingpdf"
@@ -340,7 +336,7 @@ jpackage {
// Add copyright and license information // Add copyright and license information
copyright = "Copyright © 2025 Stirling PDF Inc." copyright = "Copyright © 2025 Stirling PDF Inc."
licenseFile = "LICENSE" licenseFile = layout.projectDirectory.file("LICENSE")
} }
//tasks.wrapper { //tasks.wrapper {
@@ -375,7 +371,7 @@ tasks.register('jpackageMacX64') {
commandLine 'jpackage', commandLine 'jpackage',
'--type', 'dmg', '--type', 'dmg',
'--name', 'Stirling PDF (x86_64)', '--name', 'Stirling PDF (x86_64)',
'--input', 'build/libs', '--input', 'stirling-pdf/build/libs',
'--main-jar', "Stirling-PDF-${project.version}.jar", '--main-jar', "Stirling-PDF-${project.version}.jar",
'--main-class', 'org.springframework.boot.loader.launch.JarLauncher', '--main-class', 'org.springframework.boot.loader.launch.JarLauncher',
'--runtime-image', file(jrePath + "/zulu-17.jre/Contents/Home"), '--runtime-image', file(jrePath + "/zulu-17.jre/Contents/Home"),
@@ -475,7 +471,7 @@ launch4j {
} else { } else {
headerType = "console" headerType = "console"
} }
jarTask = tasks.bootJar jarTask = project(":stirling-pdf").tasks.bootJar
errTitle="Encountered error, do you have Java 21?" errTitle="Encountered error, do you have Java 21?"
downloadUrl="https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.exe" downloadUrl="https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.exe"
@@ -537,6 +533,14 @@ swaggerhubUpload {
} }
dependencies { dependencies {
implementation project(':stirling-pdf')
implementation project(':common')
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
implementation project(':proprietary')
}
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2'
} }
@@ -10,7 +10,6 @@ import java.util.Properties;
import java.util.function.Predicate; import java.util.function.Predicate;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -21,6 +20,7 @@ import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.springframework.util.ClassUtils;
import org.thymeleaf.spring6.SpringTemplateEngine; import org.thymeleaf.spring6.SpringTemplateEngine;
import lombok.Getter; import lombok.Getter;
@@ -148,23 +148,10 @@ public class AppConfig {
} }
@Bean(name = "activeSecurity") @Bean(name = "activeSecurity")
public boolean activeSecurity() {
String disableAdditionalFeatures = env.getProperty("DISABLE_ADDITIONAL_FEATURES");
if (disableAdditionalFeatures != null) {
// DISABLE_ADDITIONAL_FEATURES=true means security OFF, so return false
// DISABLE_ADDITIONAL_FEATURES=false means security ON, so return true
return !Boolean.parseBoolean(disableAdditionalFeatures);
}
return env.getProperty("DOCKER_ENABLE_SECURITY", Boolean.class, true);
}
@Bean(name = "missingActiveSecurity")
@ConditionalOnMissingClass(
"stirling.software.proprietary.security.configuration.SecurityConfiguration")
public boolean missingActiveSecurity() { public boolean missingActiveSecurity() {
return true; return ClassUtils.isPresent(
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
this.getClass().getClassLoader());
} }
@Bean(name = "directoryFilter") @Bean(name = "directoryFilter")
@@ -545,6 +545,8 @@ public class ApplicationProperties {
private int calibreSessionLimit; private int calibreSessionLimit;
private int qpdfSessionLimit; private int qpdfSessionLimit;
private int tesseractSessionLimit; private int tesseractSessionLimit;
private int ghostscriptSessionLimit;
private int ocrMyPdfSessionLimit;
public int getQpdfSessionLimit() { public int getQpdfSessionLimit() {
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2; return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
@@ -577,6 +579,14 @@ public class ApplicationProperties {
public int getCalibreSessionLimit() { public int getCalibreSessionLimit() {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1; return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
} }
public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
}
public int getOcrMyPdfSessionLimit() {
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
}
} }
@Data @Data
@@ -589,6 +599,8 @@ public class ApplicationProperties {
private long calibreTimeoutMinutes; private long calibreTimeoutMinutes;
private long tesseractTimeoutMinutes; private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes; private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
private long ocrMyPdfTimeoutMinutes;
public long getTesseractTimeoutMinutes() { public long getTesseractTimeoutMinutes() {
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30; return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
@@ -621,6 +633,14 @@ public class ApplicationProperties {
public long getCalibreTimeoutMinutes() { public long getCalibreTimeoutMinutes() {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30; return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
} }
public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
}
public long getOcrMyPdfTimeoutMinutes() {
return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30;
}
} }
} }
} }
@@ -1,6 +1,7 @@
package stirling.software.common.model.job; package stirling.software.common.model.job;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
@@ -26,14 +27,8 @@ public class JobResult {
/** Error message if the job failed */ /** Error message if the job failed */
private String error; private String error;
/** The file ID of the result file, if applicable */ /** List of result files for jobs that produce files */
private String fileId; private List<ResultFile> resultFiles;
/** Original file name, if applicable */
private String originalFileName;
/** MIME type of the result, if applicable */
private String contentType;
/** Time when the job was created */ /** Time when the job was created */
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -64,21 +59,6 @@ public class JobResult {
.build(); .build();
} }
/**
* Mark this job as complete with a file result
*
* @param fileId The file ID of the result
* @param originalFileName The original file name
* @param contentType The content type of the file
*/
public void completeWithFile(String fileId, String originalFileName, String contentType) {
this.complete = true;
this.fileId = fileId;
this.originalFileName = originalFileName;
this.contentType = contentType;
this.completedAt = LocalDateTime.now();
}
/** /**
* Mark this job as complete with a general result * Mark this job as complete with a general result
* *
@@ -101,6 +81,67 @@ public class JobResult {
this.completedAt = LocalDateTime.now(); this.completedAt = LocalDateTime.now();
} }
/**
* Mark this job as complete with multiple file results
*
* @param resultFiles The list of result files
*/
public void completeWithFiles(List<ResultFile> resultFiles) {
this.complete = true;
this.resultFiles = new ArrayList<>(resultFiles);
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with a single file result (convenience method)
*
* @param fileId The file ID of the result
* @param fileName The file name
* @param contentType The content type of the file
* @param fileSize The size of the file in bytes
*/
public void completeWithSingleFile(
String fileId, String fileName, String contentType, long fileSize) {
ResultFile resultFile =
ResultFile.builder()
.fileId(fileId)
.fileName(fileName)
.contentType(contentType)
.fileSize(fileSize)
.build();
completeWithFiles(List.of(resultFile));
}
/**
* Check if this job has file results
*
* @return true if this job has file results, false otherwise
*/
public boolean hasFiles() {
return resultFiles != null && !resultFiles.isEmpty();
}
/**
* Check if this job has multiple file results
*
* @return true if this job has multiple file results, false otherwise
*/
public boolean hasMultipleFiles() {
return resultFiles != null && resultFiles.size() > 1;
}
/**
* Get all result files
*
* @return List of result files
*/
public List<ResultFile> getAllResultFiles() {
if (resultFiles != null && !resultFiles.isEmpty()) {
return Collections.unmodifiableList(resultFiles);
}
return Collections.emptyList();
}
/** /**
* Add a note to this job * Add a note to this job
* *
@@ -0,0 +1,26 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents a single file result from a job execution */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResultFile {
/** The file ID for accessing the file */
private String fileId;
/** The original file name */
private String fileName;
/** MIME type of the file */
private String contentType;
/** Size of the file in bytes */
private long fileSize;
}
@@ -24,6 +24,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry; import stirling.software.common.util.TempFileRegistry;
@@ -82,7 +83,7 @@ public class CustomPDFDocumentFactory {
*/ */
public PDDocument load(File file, boolean readOnly) throws IOException { public PDDocument load(File file, boolean readOnly) throws IOException {
if (file == null) { if (file == null) {
throw new IllegalArgumentException("File cannot be null"); throw ExceptionUtils.createNullArgumentException("File");
} }
long fileSize = file.length(); long fileSize = file.length();
@@ -109,7 +110,7 @@ public class CustomPDFDocumentFactory {
*/ */
public PDDocument load(Path path, boolean readOnly) throws IOException { public PDDocument load(Path path, boolean readOnly) throws IOException {
if (path == null) { if (path == null) {
throw new IllegalArgumentException("File cannot be null"); throw ExceptionUtils.createNullArgumentException("File");
} }
long fileSize = Files.size(path); long fileSize = Files.size(path);
@@ -130,7 +131,7 @@ public class CustomPDFDocumentFactory {
/** Load a PDF from byte array with automatic optimization and read-only option. */ /** Load a PDF from byte array with automatic optimization and read-only option. */
public PDDocument load(byte[] input, boolean readOnly) throws IOException { public PDDocument load(byte[] input, boolean readOnly) throws IOException {
if (input == null) { if (input == null) {
throw new IllegalArgumentException("Input bytes cannot be null"); throw ExceptionUtils.createNullArgumentException("Input bytes");
} }
long dataSize = input.length; long dataSize = input.length;
@@ -151,7 +152,7 @@ public class CustomPDFDocumentFactory {
/** Load a PDF from InputStream with automatic optimization and read-only option. */ /** Load a PDF from InputStream with automatic optimization and read-only option. */
public PDDocument load(InputStream input, boolean readOnly) throws IOException { public PDDocument load(InputStream input, boolean readOnly) throws IOException {
if (input == null) { if (input == null) {
throw new IllegalArgumentException("InputStream cannot be null"); throw ExceptionUtils.createNullArgumentException("InputStream");
} }
// Since we don't know the size upfront, buffer to a temp file // Since we don't know the size upfront, buffer to a temp file
@@ -174,7 +175,7 @@ public class CustomPDFDocumentFactory {
public PDDocument load(InputStream input, String password, boolean readOnly) public PDDocument load(InputStream input, String password, boolean readOnly)
throws IOException { throws IOException {
if (input == null) { if (input == null) {
throw new IllegalArgumentException("InputStream cannot be null"); throw ExceptionUtils.createNullArgumentException("InputStream");
} }
// Since we don't know the size upfront, buffer to a temp file // Since we don't know the size upfront, buffer to a temp file
@@ -292,9 +293,32 @@ public class CustomPDFDocumentFactory {
} else { } else {
throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
} }
configureResourceCacheIfNeeded(document, contentSize);
return document; return document;
} }
/**
* Configure resource cache based on content size and memory constraints. Disables resource
* cache for large files or when memory is low to prevent OOM errors.
*/
private void configureResourceCacheIfNeeded(PDDocument document, long contentSize) {
if (contentSize > LARGE_FILE_THRESHOLD) {
document.setResourceCache(null);
} else {
// Check current memory status for smaller files
long maxMemory = Runtime.getRuntime().maxMemory();
long usedMemory =
Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100;
if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE) {
document.setResourceCache(null);
}
}
}
/** Load a PDF with password protection using adaptive loading strategies */ /** Load a PDF with password protection using adaptive loading strategies */
private PDDocument loadAdaptivelyWithPassword(Object source, long contentSize, String password) private PDDocument loadAdaptivelyWithPassword(Object source, long contentSize, String password)
throws IOException { throws IOException {
@@ -313,6 +337,9 @@ public class CustomPDFDocumentFactory {
} else { } else {
throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
} }
configureResourceCacheIfNeeded(document, contentSize);
return document; return document;
} }
@@ -354,7 +381,12 @@ public class CustomPDFDocumentFactory {
private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache) private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache)
throws IOException { throws IOException {
try {
return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache); return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache);
} catch (IOException e) {
ExceptionUtils.logException("PDF loading from file", e);
throw ExceptionUtils.handlePdfException(e);
}
} }
private PDDocument loadFromBytes(byte[] bytes, long size, StreamCacheCreateFunction cache) private PDDocument loadFromBytes(byte[] bytes, long size, StreamCacheCreateFunction cache)
@@ -366,7 +398,13 @@ public class CustomPDFDocumentFactory {
Files.write(tempFile, bytes); Files.write(tempFile, bytes);
return loadFromFile(tempFile.toFile(), size, cache); return loadFromFile(tempFile.toFile(), size, cache);
} }
try {
return Loader.loadPDF(bytes, "", null, null, cache); return Loader.loadPDF(bytes, "", null, null, cache);
} catch (IOException e) {
ExceptionUtils.logException("PDF loading from bytes", e);
throw ExceptionUtils.handlePdfException(e);
}
} }
public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException { public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException {
@@ -399,7 +437,7 @@ public class CustomPDFDocumentFactory {
try { try {
document.setAllSecurityToBeRemoved(true); document.setAllSecurityToBeRemoved(true);
} catch (Exception e) { } catch (Exception e) {
log.error("Decryption failed", e); ExceptionUtils.logException("PDF decryption", e);
throw new IOException("PDF decryption failed", e); throw new IOException("PDF decryption failed", e);
} }
} }
@@ -131,14 +131,46 @@ public class FileStorage {
return Files.exists(filePath); return Files.exists(filePath);
} }
/**
* Get the size of a file by its ID without loading the content into memory
*
* @param fileId The ID of the file
* @return The size of the file in bytes
* @throws IOException If the file doesn't exist or can't be read
*/
public long getFileSize(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
/** /**
* Get the path for a file ID * Get the path for a file ID
* *
* @param fileId The ID of the file * @param fileId The ID of the file
* @return The path to the file * @return The path to the file
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
* outside base directory
*/ */
private Path getFilePath(String fileId) { private Path getFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId); // Validate fileId to prevent path traversal
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
// Ensure resolved path is within the base directory
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
} }
/** /**
@@ -1,15 +1,25 @@
package stirling.software.common.service; package stirling.software.common.service;
import io.github.pixee.security.ZipSecurity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
@@ -17,6 +27,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats; import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
/** Manages async tasks and their results */ /** Manages async tasks and their results */
@Service @Service
@@ -80,8 +91,53 @@ public class TaskManager {
public void setFileResult( public void setFileResult(
String jobId, String fileId, String originalFileName, String contentType) { String jobId, String fileId, String originalFileName, String contentType) {
JobResult jobResult = getOrCreateJobResult(jobId); JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFile(fileId, originalFileName, contentType);
log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId); // Check if this is a ZIP file that should be extracted
if (isZipFile(contentType, originalFileName)) {
try {
List<ResultFile> extractedFiles =
extractZipToIndividualFiles(fileId, originalFileName);
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
jobId,
extractedFiles.size());
return;
}
} catch (Exception e) {
log.warn(
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
jobId,
e.getMessage());
}
}
// Handle as single file using new ResultFile approach
try {
long fileSize = fileStorage.getFileSize(fileId);
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, fileSize);
log.debug("Set single file result for job ID: {} with file ID: {}", jobId, fileId);
} catch (Exception e) {
log.warn(
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
}
}
/**
* Set the result of a task as multiple files
*
* @param jobId The job ID
* @param resultFiles The list of result files
*/
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFiles(resultFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files",
jobId,
resultFiles.size());
} }
/** /**
@@ -104,7 +160,7 @@ public class TaskManager {
public void setComplete(String jobId) { public void setComplete(String jobId) {
JobResult jobResult = getOrCreateJobResult(jobId); JobResult jobResult = getOrCreateJobResult(jobId);
if (jobResult.getResult() == null if (jobResult.getResult() == null
&& jobResult.getFileId() == null && !jobResult.hasFiles()
&& jobResult.getError() == null) { && jobResult.getError() == null) {
// If no result or error has been set, mark it as complete with an empty result // If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully"); jobResult.completeWithResult("Task completed successfully");
@@ -186,7 +242,7 @@ public class TaskManager {
failedJobs++; failedJobs++;
} else { } else {
successfulJobs++; successfulJobs++;
if (result.getFileId() != null) { if (result.hasFiles()) {
fileResultJobs++; fileResultJobs++;
} }
} }
@@ -250,17 +306,8 @@ public class TaskManager {
&& result.getCompletedAt() != null && result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) { && result.getCompletedAt().isBefore(expiryThreshold)) {
// If the job has a file result, delete the file // Clean up file results
if (result.getFileId() != null) { cleanupJobFiles(result, entry.getKey());
try {
fileStorage.deleteFile(result.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file for job {}: {}",
entry.getKey(),
e.getMessage());
}
}
// Remove the job result // Remove the job result
jobResults.remove(entry.getKey()); jobResults.remove(entry.getKey());
@@ -290,4 +337,128 @@ public class TaskManager {
cleanupExecutor.shutdownNow(); cleanupExecutor.shutdownNow();
} }
} }
/** Check if a file is a ZIP file based on content type and filename */
private boolean isZipFile(String contentType, String fileName) {
if (contentType != null
&& (contentType.equals("application/zip")
|| contentType.equals("application/x-zip-compressed"))) {
return true;
}
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
return true;
}
return false;
}
/** Extract a ZIP file into individual files and store them */
private List<ResultFile> extractZipToIndividualFiles(
String zipFileId, String originalZipFileName) throws IOException {
List<ResultFile> extractedFiles = new ArrayList<>();
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
// Use buffered reading for memory safety
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = zipIn.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
byte[] fileContent = out.toByteArray();
String contentType = determineContentType(entry.getName());
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
ResultFile resultFile =
ResultFile.builder()
.fileId(individualFileId)
.fileName(entry.getName())
.contentType(contentType)
.fileSize(fileContent.length)
.build();
extractedFiles.add(resultFile);
log.debug(
"Extracted file: {} (size: {} bytes)",
entry.getName(),
fileContent.length);
}
zipIn.closeEntry();
}
}
// Clean up the original ZIP file after extraction
try {
fileStorage.deleteFile(zipFileId);
log.debug("Cleaned up original ZIP file: {}", zipFileId);
} catch (Exception e) {
log.warn("Failed to clean up original ZIP file {}: {}", zipFileId, e.getMessage());
}
return extractedFiles;
}
/** Determine content type based on file extension */
private String determineContentType(String fileName) {
if (fileName == null) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".pdf")) {
return MediaType.APPLICATION_PDF_VALUE;
} else if (lowerName.endsWith(".txt")) {
return MediaType.TEXT_PLAIN_VALUE;
} else if (lowerName.endsWith(".json")) {
return MediaType.APPLICATION_JSON_VALUE;
} else if (lowerName.endsWith(".xml")) {
return MediaType.APPLICATION_XML_VALUE;
} else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) {
return MediaType.IMAGE_JPEG_VALUE;
} else if (lowerName.endsWith(".png")) {
return MediaType.IMAGE_PNG_VALUE;
} else {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
/** Clean up files associated with a job result */
private void cleanupJobFiles(JobResult result, String jobId) {
// Clean up all result files
if (result.hasFiles()) {
for (ResultFile resultFile : result.getAllResultFiles()) {
try {
fileStorage.deleteFile(resultFile.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file {} for job {}: {}",
resultFile.getFileId(),
jobId,
e.getMessage());
}
}
}
}
/** Find the ResultFile metadata for a given file ID by searching through all job results */
public ResultFile findResultFileByFileId(String fileId) {
for (JobResult jobResult : jobResults.values()) {
if (jobResult.hasFiles()) {
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
if (fileId.equals(resultFile.getFileId())) {
return resultFile;
}
}
}
}
return null;
}
} }
@@ -154,19 +154,22 @@ public class TempFileCleanupService {
boolean containerMode = isContainerMode(); boolean containerMode = isContainerMode();
int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis); int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis);
if (registeredDeletedCount > 0
|| unregisteredDeletedCount > 0
|| directoriesDeletedCount > 0) {
log.info( log.info(
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories", "Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
registeredDeletedCount, registeredDeletedCount,
unregisteredDeletedCount, unregisteredDeletedCount,
directoriesDeletedCount); directoriesDeletedCount);
} }
}
/** /**
* Perform startup cleanup of stale temporary files from previous runs. This is especially * Perform startup cleanup of stale temporary files from previous runs. This is especially
* important in Docker environments where temp files persist between container restarts. * important in Docker environments where temp files persist between container restarts.
*/ */
private void runStartupCleanup() { private void runStartupCleanup() {
log.info("Running startup temporary file cleanup");
boolean containerMode = isContainerMode(); boolean containerMode = isContainerMode();
log.info( log.info(
@@ -178,7 +181,6 @@ public class TempFileCleanupService {
long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours
int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis); int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis);
log.info( log.info(
"Startup cleanup complete. Deleted {} temporary files/directories", "Startup cleanup complete. Deleted {} temporary files/directories",
totalDeletedCount); totalDeletedCount);
@@ -225,7 +227,7 @@ public class TempFileCleanupService {
tempDir -> { tempDir -> {
try { try {
String phase = isScheduled ? "scheduled" : "startup"; String phase = isScheduled ? "scheduled" : "startup";
log.info( log.debug(
"Scanning directory for {} cleanup: {}", "Scanning directory for {} cleanup: {}",
phase, phase,
tempDir); tempDir);
@@ -0,0 +1,327 @@
package stirling.software.common.util;
import java.io.IOException;
import java.text.MessageFormat;
import lombok.extern.slf4j.Slf4j;
/**
* Utility class for handling exceptions with internationalized error messages. Provides consistent
* error handling and user-friendly messages across the application.
*/
@Slf4j
public class ExceptionUtils {
/**
* Create an IOException with internationalized message for PDF corruption.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfCorruptedException(Exception cause) {
return createPdfCorruptedException(null, cause);
}
/**
* Create an IOException with internationalized message for PDF corruption with context.
*
* @param context additional context (e.g., "during merge", "during image extraction")
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfCorruptedException(String context, Exception cause) {
String message;
if (context != null && !context.isEmpty()) {
message =
String.format(
"Error %s: 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.",
context);
} else {
message =
"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.";
}
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for multiple corrupted PDFs.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createMultiplePdfCorruptedException(Exception cause) {
String message =
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for PDF encryption issues.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfEncryptionException(Exception cause) {
String message =
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for PDF password issues.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfPasswordException(Exception cause) {
String message =
"The PDF Document is passworded and either the password was not provided or was incorrect";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for file processing errors.
*
* @param operation the operation being performed (e.g., "merge", "split", "convert")
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createFileProcessingException(String operation, Exception cause) {
String message =
String.format(
"An error occurred while processing the file during %s operation: %s",
operation, cause.getMessage());
return new IOException(message, cause);
}
/**
* Create a generic IOException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param cause the original exception
* @param args optional arguments for the message
* @return IOException with user-friendly message
*/
public static IOException createIOException(
String messageKey, String defaultMessage, Exception cause, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new IOException(message, cause);
}
/**
* Create a generic RuntimeException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param cause the original exception
* @param args optional arguments for the message
* @return RuntimeException with user-friendly message
*/
public static RuntimeException createRuntimeException(
String messageKey, String defaultMessage, Exception cause, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new RuntimeException(message, cause);
}
/**
* Create an IllegalArgumentException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param args optional arguments for the message
* @return IllegalArgumentException with user-friendly message
*/
public static IllegalArgumentException createIllegalArgumentException(
String messageKey, String defaultMessage, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new IllegalArgumentException(message);
}
/** Create file validation exceptions. */
public static IllegalArgumentException createHtmlFileRequiredException() {
return createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "HTML or ZIP");
}
public static IllegalArgumentException createPdfFileRequiredException() {
return createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "PDF");
}
public static IllegalArgumentException createInvalidPageSizeException(String size) {
return createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "page size", size);
}
/** Create OCR-related exceptions. */
public static IOException createOcrLanguageRequiredException() {
return createIOException(
"error.optionsNotSpecified", "{0} options are not specified", null, "OCR language");
}
public static IOException createOcrInvalidLanguagesException() {
return createIOException(
"error.invalidFormat",
"Invalid {0} format: {1}",
null,
"OCR languages",
"none of the selected languages are valid");
}
public static IOException createOcrToolsUnavailableException() {
return createIOException(
"error.toolNotInstalled", "{0} is not installed", null, "OCR tools");
}
/** Create system requirement exceptions. */
public static IOException createPythonRequiredForWebpException() {
return createIOException(
"error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion");
}
/** Create file operation exceptions. */
public static IOException createFileNotFoundException(String fileId) {
return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId);
}
public static RuntimeException createPdfaConversionFailedException() {
return createRuntimeException(
"error.conversionFailed", "{0} conversion failed", null, "PDF/A");
}
public static IllegalArgumentException createInvalidComparatorException() {
return createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"comparator",
"only 'greater', 'equal', and 'less' are supported");
}
/** Create compression-related exceptions. */
public static RuntimeException createMd5AlgorithmException(Exception cause) {
return createRuntimeException(
"error.algorithmNotAvailable", "{0} algorithm not available", cause, "MD5");
}
public static IllegalArgumentException createCompressionOptionsException() {
return createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"compression (expected output size and optimize level)");
}
public static IOException createGhostscriptCompressionException() {
return createIOException(
"error.commandFailed", "{0} command failed", null, "Ghostscript compression");
}
public static IOException createGhostscriptCompressionException(Exception cause) {
return createIOException(
"error.commandFailed", "{0} command failed", cause, "Ghostscript compression");
}
public static IOException createQpdfCompressionException(Exception cause) {
return createIOException("error.commandFailed", "{0} command failed", cause, "QPDF");
}
/**
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
*
* @param e the exception to check
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
* message
*/
public static IOException handlePdfException(IOException e) {
return handlePdfException(e, null);
}
/**
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
*
* @param e the exception to check
* @param context additional context for the error
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
* message
*/
public static IOException handlePdfException(IOException e, String context) {
if (PdfErrorUtils.isCorruptedPdfError(e)) {
return createPdfCorruptedException(context, e);
}
if (isEncryptionError(e)) {
return createPdfEncryptionException(e);
}
if (isPasswordError(e)) {
return createPdfPasswordException(e);
}
return e; // Return original exception if no specific handling needed
}
/**
* Check if an exception indicates a PDF encryption/decryption error.
*
* @param e the exception to check
* @return true if it's an encryption error, false otherwise
*/
public static boolean isEncryptionError(IOException e) {
String message = e.getMessage();
if (message == null) return false;
return message.contains("BadPaddingException")
|| message.contains("Given final block not properly padded")
|| message.contains("AES initialization vector not fully read")
|| message.contains("Failed to decrypt");
}
/**
* Check if an exception indicates a PDF password error.
*
* @param e the exception to check
* @return true if it's a password error, false otherwise
*/
public static boolean isPasswordError(IOException e) {
String message = e.getMessage();
if (message == null) return false;
return message.contains("password is incorrect")
|| message.contains("Password is not provided")
|| message.contains("PDF contains an encryption dictionary");
}
/**
* Log an exception with appropriate level based on its type.
*
* @param operation the operation being performed
* @param e the exception that occurred
*/
public static void logException(String operation, Exception e) {
if (PdfErrorUtils.isCorruptedPdfError(e)) {
log.warn("PDF corruption detected during {}: {}", operation, e.getMessage());
} else if (e instanceof IOException
&& (isEncryptionError((IOException) e) || isPasswordError((IOException) e))) {
log.info("PDF security issue during {}: {}", operation, e.getMessage());
} else {
log.error("Unexpected error during {}", operation, e);
}
}
/** Create common validation exceptions. */
public static IllegalArgumentException createInvalidArgumentException(String argumentName) {
return createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", argumentName);
}
public static IllegalArgumentException createInvalidArgumentException(
String argumentName, String value) {
return createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", argumentName, value);
}
public static IllegalArgumentException createNullArgumentException(String argumentName) {
return createIllegalArgumentException(
"error.argumentRequired", "{0} must not be null", argumentName);
}
}
@@ -32,21 +32,23 @@ public class FileToPdf {
try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) { try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
try (TempFile tempInputFile = try (TempFile tempInputFile =
new TempFile(tempFileManager, fileName.endsWith(".html") ? ".html" : ".zip")) { new TempFile(
tempFileManager,
fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) {
if (fileName.endsWith(".html")) { if (fileName.toLowerCase().endsWith(".html")) {
String sanitizedHtml = String sanitizedHtml =
sanitizeHtmlContent( sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8), disableSanitize); new String(fileBytes, StandardCharsets.UTF_8), disableSanitize);
Files.write( Files.write(
tempInputFile.getPath(), tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8)); sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.endsWith(".zip")) { } else if (fileName.toLowerCase().endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes); Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip( sanitizeHtmlFilesInZip(
tempInputFile.getPath(), disableSanitize, tempFileManager); tempInputFile.getPath(), disableSanitize, tempFileManager);
} else { } else {
throw new IllegalArgumentException("Unsupported file format: " + fileName); throw ExceptionUtils.createHtmlFileRequiredException();
} }
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
@@ -0,0 +1,55 @@
package stirling.software.common.util;
import java.io.IOException;
/** Utility class for detecting and handling PDF-related errors. */
public class PdfErrorUtils {
/**
* Checks if an IOException indicates a corrupted PDF file.
*
* @param e the IOException to check
* @return true if the error indicates PDF corruption, false otherwise
*/
public static boolean isCorruptedPdfError(IOException e) {
return isCorruptedPdfError(e.getMessage());
}
/**
* Checks if any Exception indicates a corrupted PDF file.
*
* @param e the Exception to check
* @return true if the error indicates PDF corruption, false otherwise
*/
public static boolean isCorruptedPdfError(Exception e) {
return isCorruptedPdfError(e.getMessage());
}
/**
* Checks if an error message indicates a corrupted PDF file.
*
* @param message the error message to check
* @return true if the message indicates PDF corruption, false otherwise
*/
private static boolean isCorruptedPdfError(String message) {
if (message == null) return false;
// Check for common corruption indicators
return message.contains("Missing root object specification")
|| message.contains("Header doesn't contain versioninfo")
|| message.contains("Expected trailer")
|| message.contains("Invalid PDF")
|| message.contains("Corrupted")
|| message.contains("damaged")
|| message.contains("Unknown dir object")
|| message.contains("Can't dereference COSObject")
|| message.contains("parseCOSString string should start with")
|| message.contains("ICCBased colorspace array must have a stream")
|| message.contains("1-based index not found")
|| message.contains("Invalid dictionary, found:")
|| message.contains("AES initialization vector not fully read")
|| message.contains("BadPaddingException")
|| message.contains("Given final block not properly padded")
|| message.contains("End-of-File, expected line");
}
}
@@ -42,26 +42,34 @@ public class PdfUtils {
public static PDRectangle textToPageSize(String size) { public static PDRectangle textToPageSize(String size) {
switch (size.toUpperCase()) { switch (size.toUpperCase()) {
case "A0": case "A0" -> {
return PDRectangle.A0; return PDRectangle.A0;
case "A1": }
case "A1" -> {
return PDRectangle.A1; return PDRectangle.A1;
case "A2": }
case "A2" -> {
return PDRectangle.A2; return PDRectangle.A2;
case "A3": }
case "A3" -> {
return PDRectangle.A3; return PDRectangle.A3;
case "A4": }
case "A4" -> {
return PDRectangle.A4; return PDRectangle.A4;
case "A5": }
case "A5" -> {
return PDRectangle.A5; return PDRectangle.A5;
case "A6": }
case "A6" -> {
return PDRectangle.A6; return PDRectangle.A6;
case "LETTER": }
case "LETTER" -> {
return PDRectangle.LETTER; return PDRectangle.LETTER;
case "LEGAL": }
case "LEGAL" -> {
return PDRectangle.LEGAL; return PDRectangle.LEGAL;
default: }
throw new IllegalArgumentException("Invalid standard page size: " + size); default -> throw ExceptionUtils.createInvalidPageSizeException(size);
} }
} }
@@ -135,6 +143,17 @@ public class PdfUtils {
int DPI, int DPI,
String filename) String filename)
throws IOException, Exception { throws IOException, Exception {
// Validate and limit DPI to prevent excessive memory usage
final int MAX_SAFE_DPI = 500; // Maximum safe DPI to prevent memory issues
if (DPI > MAX_SAFE_DPI) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dpiExceedsLimit",
"DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.",
DPI,
MAX_SAFE_DPI);
}
try (PDDocument document = pdfDocumentFactory.load(inputStream)) { try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); pdfRenderer.setSubsamplingAllowed(true);
@@ -158,7 +177,21 @@ public class PdfUtils {
writer.prepareWriteSequence(null); writer.prepareWriteSequence(null);
for (int i = 0; i < pageCount; ++i) { for (int i = 0; i < pageCount; ++i) {
BufferedImage image = pdfRenderer.renderImageWithDPI(i, DPI, colorType); BufferedImage image;
try {
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
writer.writeToSequence(new IIOImage(image, null, null), param); writer.writeToSequence(new IIOImage(image, null, null), param);
} }
@@ -190,7 +223,20 @@ public class PdfUtils {
PdfImageDimensionValue dimension = pageSizes.get(settings); PdfImageDimensionValue dimension = pageSizes.get(settings);
if (dimension == null) { if (dimension == null) {
// Render the image to get the dimensions // Render the image to get the dimensions
try {
pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType); pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigExceedsArray",
"PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
pdfSizeImageIndex = i; pdfSizeImageIndex = i;
dimension = dimension =
new PdfImageDimensionValue( new PdfImageDimensionValue(
@@ -218,7 +264,20 @@ public class PdfUtils {
if (firstImageAlreadyRendered && i == 0) { if (firstImageAlreadyRendered && i == 0) {
pageImage = pdfSizeImage; pageImage = pdfSizeImage;
} else { } else {
try {
pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType); pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
} }
// Calculate the x-coordinate to center the image // Calculate the x-coordinate to center the image
@@ -238,7 +297,20 @@ public class PdfUtils {
// Zip the images and return as byte array // Zip the images and return as byte array
try (ZipOutputStream zos = new ZipOutputStream(baos)) { try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < pageCount; ++i) { for (int i = 0; i < pageCount; ++i) {
BufferedImage image = pdfRenderer.renderImageWithDPI(i, DPI, colorType); BufferedImage image;
try {
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) { try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
ImageIO.write(image, imageType, baosImage); ImageIO.write(image, imageType, baosImage);
@@ -276,7 +348,19 @@ public class PdfUtils {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) { for (int page = 0; page < document.getNumberOfPages(); ++page) {
BufferedImage bim = pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB); BufferedImage bim;
try {
bim = pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"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);
}
throw e;
}
PDPage originalPage = document.getPage(page); PDPage originalPage = document.getPage(page);
float width = originalPage.getMediaBox().getWidth(); float width = originalPage.getMediaBox().getWidth();
@@ -349,7 +433,7 @@ public class PdfUtils {
} }
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
doc.save(byteArrayOutputStream); doc.save(byteArrayOutputStream);
log.info("PDF successfully saved to byte array"); log.debug("PDF successfully saved to byte array");
return byteArrayOutputStream.toByteArray(); return byteArrayOutputStream.toByteArray();
} }
} }
@@ -495,8 +579,7 @@ public class PdfUtils {
case "less": case "less":
return actualPageCount < pageCount; return actualPageCount < pageCount;
default: default:
throw new IllegalArgumentException( throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
"Invalid comparator. Only 'greater', 'equal', and 'less' are supported.");
} }
} }
@@ -84,6 +84,16 @@ public class ProcessExecutor {
.getProcessExecutor() .getProcessExecutor()
.getSessionLimit() .getSessionLimit()
.getCalibreSessionLimit(); .getCalibreSessionLimit();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getGhostscriptSessionLimit();
case OCR_MY_PDF ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getOcrMyPdfSessionLimit();
}; };
long timeoutMinutes = long timeoutMinutes =
@@ -128,6 +138,16 @@ public class ProcessExecutor {
.getProcessExecutor() .getProcessExecutor()
.getTimeoutMinutes() .getTimeoutMinutes()
.getCalibreTimeoutMinutes(); .getCalibreTimeoutMinutes();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getGhostscriptTimeoutMinutes();
case OCR_MY_PDF ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getOcrMyPdfTimeoutMinutes();
}; };
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes); return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
}); });
@@ -278,7 +298,9 @@ public class ProcessExecutor {
INSTALL_APP, INSTALL_APP,
CALIBRE, CALIBRE,
TESSERACT, TESSERACT,
QPDF QPDF,
GHOSTSCRIPT,
OCR_MY_PDF
} }
public class ProcessExecutorResult { public class ProcessExecutorResult {
@@ -108,6 +108,12 @@ public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy {
} catch (IllegalArgumentException ie) { } catch (IllegalArgumentException ie) {
log.info("text not supported by font "); log.info("text not supported by font ");
font = checkSupportedFontForCharacter(unicodeText); font = checkSupportedFontForCharacter(unicodeText);
} catch (UnsupportedOperationException ue) {
log.info(
"font does not support encoding operation: {} for text: '{}'",
font.getClass().getSimpleName(),
unicodeText);
font = checkSupportedFontForCharacter(unicodeText);
} finally { } finally {
// if any other font is not supported, then replace default character * // if any other font is not supported, then replace default character *
if (font == null) { if (font == null) {
@@ -18,6 +18,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats; import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
class TaskManagerTest { class TaskManagerTest {
@@ -73,13 +74,17 @@ class TaskManagerTest {
} }
@Test @Test
void testSetFileResult() { void testSetFileResult() throws Exception {
// Arrange // Arrange
String jobId = UUID.randomUUID().toString(); String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId); taskManager.createTask(jobId);
String fileId = "file-id"; String fileId = "file-id";
String originalFileName = "test.pdf"; String originalFileName = "test.pdf";
String contentType = "application/pdf"; String contentType = "application/pdf";
long fileSize = 1024L;
// Mock the fileStorage.getFileSize() call
when(fileStorage.getFileSize(fileId)).thenReturn(fileSize);
// Act // Act
taskManager.setFileResult(jobId, fileId, originalFileName, contentType); taskManager.setFileResult(jobId, fileId, originalFileName, contentType);
@@ -88,9 +93,17 @@ class TaskManagerTest {
JobResult result = taskManager.getJobResult(jobId); JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result); assertNotNull(result);
assertTrue(result.isComplete()); assertTrue(result.isComplete());
assertEquals(fileId, result.getFileId()); assertTrue(result.hasFiles());
assertEquals(originalFileName, result.getOriginalFileName()); assertFalse(result.hasMultipleFiles());
assertEquals(contentType, result.getContentType());
var resultFiles = result.getAllResultFiles();
assertEquals(1, resultFiles.size());
ResultFile resultFile = resultFiles.get(0);
assertEquals(fileId, resultFile.getFileId());
assertEquals(originalFileName, resultFile.getFileName());
assertEquals(contentType, resultFile.getContentType());
assertEquals(fileSize, resultFile.getFileSize());
assertNotNull(result.getCompletedAt()); assertNotNull(result.getCompletedAt());
} }
@@ -163,8 +176,11 @@ class TaskManagerTest {
} }
@Test @Test
void testGetJobStats() { void testGetJobStats() throws Exception {
// Arrange // Arrange
// Mock fileStorage.getFileSize for file operations
when(fileStorage.getFileSize("file-id")).thenReturn(1024L);
// 1. Create active job // 1. Create active job
String activeJobId = "active-job"; String activeJobId = "active-job";
taskManager.createTask(activeJobId); taskManager.createTask(activeJobId);
@@ -216,9 +232,15 @@ class TaskManagerTest {
LocalDateTime oldTime = LocalDateTime.now().minusHours(1); LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
ReflectionTestUtils.setField(oldJob, "complete", true); ReflectionTestUtils.setField(oldJob, "complete", true);
ReflectionTestUtils.setField(oldJob, "fileId", "file-id");
ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf"); // Create a ResultFile and set it using the new approach
ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf"); ResultFile resultFile = ResultFile.builder()
.fileId("file-id")
.fileName("test.pdf")
.contentType("application/pdf")
.fileSize(1024L)
.build();
ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile));
when(fileStorage.deleteFile("file-id")).thenReturn(true); when(fileStorage.deleteFile("file-id")).thenReturn(true);
View File
+588
View File
@@ -0,0 +1,588 @@
# Stirling-PDF Developer Guide
## 1. Introduction
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This guide focuses on Docker-based development and testing, which is the recommended approach for working with the full version of Stirling-PDF.
## 2. Project Overview
Stirling-PDF is built using:
- Spring Boot + Thymeleaf
- PDFBox
- LibreOffice
- qpdf
- HTML, CSS, JavaScript
- Docker
- PDF.js
- PDF-LIB.js
- Lombok
## 3. Development Environment Setup
### Prerequisites
- Docker
- Git
- Java JDK 17 or later
- Gradle 7.0 or later (Included within the repo)
### Setup Steps
1. Clone the repository:
```bash
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
cd Stirling-PDF
```
2. Install Docker and JDK17 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
```sh
Extensions: Show Recommended Extensions
```
4. Install the required extensions from the list.
4. Lombok Setup
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
## 4. Project Structure
```bash
Stirling-PDF/
├── .github/ # GitHub-specific files (workflows, issue templates)
├── configs/ # Configuration files used by stirling at runtime (generated at runtime)
├── cucumber/ # Cucumber test files
│ ├── features/
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
│ │ │ └── software/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ├── static/
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/
│ └── test/
│ └── java/
│ └── stirling/
│ └── software/
│ └── SPDF/
├── build.gradle # Gradle build configuration
├── Dockerfile # Main Dockerfile
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version
├── Dockerfile.fat # Dockerfile for fat version
├── docker-compose.yml # Docker Compose configuration
└── test.sh # Test script to deploy all docker versions and run cuke tests
```
## 5. Docker-based Development
Stirling-PDF offers several Docker versions:
- Full: All features included
- Ultra-Lite: Basic PDF operations only
- Fat: Includes additional libraries and fonts predownloaded
### Example Docker Compose Files
Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as:
- `docker-compose-latest.yml`: Latest version without login and security features
- `docker-compose-latest-security.yml`: Latest version with login and security features enabled
- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
```yaml
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080"
volumes:
- ./stirling/latest/data:/usr/share/tessdata:rw
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
UMASK: "022"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security
UI_APPNAMENAVBAR: Stirling-PDF Latest
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
restart: on-failure:5
```
To use these example files, copy the desired file to your project root and rename it to `docker-compose.yml`, or specify the file explicitly when running Docker Compose:
```bash
docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up
```
### Building Docker Images
Stirling-PDF uses different Docker images for various configurations. The build process is controlled by environment variables and uses specific Dockerfile variants. Here's how to build the Docker images:
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
```bash
./gradlew clean build
```
3. Build the Docker images:
For the latest version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .
```
For the ultra-lite version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .
```
For the fat version (with login and security features enabled):
```bash
export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
## 6. Testing
### Comprehensive Testing Script
Stirling-PDF provides a `test.sh` script in the root directory. This script builds all versions of Stirling-PDF, checks that each version works, and runs Cucumber tests. It's recommended to run this script before submitting a final pull request.
To run the test script:
```bash
./test.sh
```
This script performs the following actions:
1. Builds all Docker images (full, ultra-lite, fat).
2. Runs each version to ensure it starts correctly.
3. Executes Cucumber tests against the main version and ensures feature compatibility. In the event these tests fail, your PR will not be merged.
Note: The `test.sh` script will run automatically when you raise a PR. However, it's recommended to run it locally first to save resources and catch any issues early.
### Full Testing with Docker
1. Build and run the Docker container per the above instructions:
2. Access the application at `http://localhost:8080` and manually test all features developed.
### Local Testing (Java and UI Components)
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
```bash
./gradlew bootRun
```
2. Access the application at `http://localhost:8080` in your web browser.
3. Manually test the features you're working on through the UI.
4. For API changes, use tools like Postman or curl to test endpoints directly.
Important notes:
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
## 7. Contributing
1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix.
3. Make your changes and commit them with clear, descriptive messages and ensure any documentation is updated related to your changes.
4. Test your changes thoroughly in the Docker environment.
5. Run the `test.sh` script to ensure all versions build correctly and pass the Cucumber tests:
```bash
./test.sh
```
6. Push your changes to your fork.
7. Submit a pull request to the main repository.
8. See additional [contributing guidelines](../CONTRIBUTING.md).
When you raise a PR:
- The `test.sh` script will run automatically against your PR.
- The PR checks will verify versioning and dependency updates.
- Documentation will be automatically updated for dependency changes.
- Security issues will be checked using Snyk and PixeeBot.
Address any issues that arise from these checks before finalizing your pull request.
## 8. API Documentation
API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/).
## 9. Customization
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
- Application name and branding
- Security settings
- UI customization
- Endpoint management
When using Docker, pass environment variables using the `-e` flag or in your `docker-compose.yml` file.
Example:
```bash
docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
```
Refer to the main README for a full list of customization options.
## 10. Language Translations
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
```bash
/scripts/replace_translation_line.sh
```
This script helps you make consistent replacements across language files.
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
1. **Create a New Controller:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
}
}
```
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
package stirling.software.SPDF.service;
import org.springframework.stereotype.Service;
@Service
public class NewFeatureService {
public String getNewFeatureData() {
// Implement business logic here
return "New Feature Data";
}
}
```
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
```
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### 3. Use Translations in Thymeleaf Templates
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
+66
View File
@@ -0,0 +1,66 @@
# Exception Handling Guide
This guide outlines the common error handling patterns used within Stirling-PDF and provides tips for internationalising error messages. The examples cover the main languages found in the project: Java, JavaScript, HTML/CSS, and a small amount of Python.
## General Principles
- **Fail fast and log clearly.** Exceptions should provide enough information for debugging without exposing sensitive data.
- **Use consistent user messages.** Text shown to users must be pulled from the localisation files so that translations are centrally managed.
- **Avoid silent failures.** Always log unexpected errors and provide the user with a helpful message.
## Java
Java forms the core of Stirling-PDF. When adding new features or handling errors:
1. **Create custom exceptions** to represent specific failure cases. This keeps the code self-documenting and easier to handle at higher levels.
2. **Use `try-with-resources`** when working with streams or other closable resources to ensure clean-up even on failure.
3. **Return meaningful HTTP status codes** in controllers by throwing `ResponseStatusException` or using `@ExceptionHandler` methods.
4. **Log with context** using the projects logging framework. Include identifiers or IDs that help trace the issue.
5. **Internationalise messages** by placing user-facing text in `messages_en_GB.properties` and referencing them with message keys.
## JavaScript
On the client side, JavaScript handles form validation and user interactions.
- Use `try`/`catch` around asynchronous operations (e.g., `fetch`) and display a translated error notice if the call fails.
- Validate input before sending it to the server and provide inline feedback with messages from the translation files.
- Log unexpected errors to the browser console for easier debugging, but avoid revealing sensitive information.
## HTML & CSS
HTML templates should reserve a space for displaying error messages. Example pattern:
```html
<div class="error" role="alert" th:text="${errorMessage}"></div>
```
Use CSS classes (e.g., `.error`) to style the message so it is clearly visible and accessible. Keep the markup simple to ensure screen readers can announce the error correctly.
## Python
Python scripts in this project are mainly for utility tasks. Follow these guidelines:
- Wrap file operations or external calls in `try`/`except` blocks.
- Print or log errors in a consistent format. If the script outputs messages to end users, ensure they are translatable.
Example:
```python
try:
perform_task()
except Exception as err:
logger.error("Task failed: %s", err)
print(gettext("task.error"))
```
## Internationalisation (i18n)
All user-visible error strings should be defined in the main translation file (`messages_en_GB.properties`). Other language files will use the same keys. Refer to messages in code rather than hard-coding text.
When creating new messages:
1. Add the English phrase to `messages_en_GB.properties`.
2. Reference the message key in your Java, JavaScript, or Python code.
3. Update other localisation files as needed.
Following these patterns helps keep Stirling-PDF stable, easier to debug, and friendly to users in all supported languages.
+29
View File
@@ -0,0 +1,29 @@
# Developer Guide Directory
This directory contains all development-related documentation for Stirling PDF.
## 📚 Documentation Index
### Core Development
- **[DeveloperGuide.md](./DeveloperGuide.md)** - Main developer setup and architecture guide
- **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices
- **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide
### Features & Documentation
- **[AGENTS.md](./AGENTS.md)** - Agent-based functionality documentation
- **[USERS.md](./USERS.md)** - User-focused documentation and guides
## 🔗 Related Files in Root
- **[README.md](../README.md)** - Project overview and quick start
- **[CONTRIBUTING.md](../CONTRIBUTING.md)** - Contribution guidelines
- **[SECURITY.md](../SECURITY.md)** - Security policies and reporting
- **[DATABASE.md](../DATABASE.md)** - Database setup and configuration (usage guide)
- **[HowToUseOCR.md](../HowToUseOCR.md)** - OCR setup and configuration (usage guide)
## 📝 Contributing to Documentation
When adding new development documentation:
1. Place technical guides in this `devGuide/` directory
2. Update this index file with a brief description
3. Keep user-facing docs (README, CONTRIBUTING, SECURITY) in the root
4. Follow existing naming conventions (PascalCase for guides)
View File
@@ -17,7 +17,6 @@ services:
- ./stirling/latest/config:/configs:rw - ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw - ./stirling/latest/logs:/logs:rw
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF-Ultra-lite UI_APPNAME: Stirling-PDF-Ultra-lite
@@ -18,7 +18,6 @@ services:
- ./stirling/latest/config:/configs:rw - ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw - ./stirling/latest/logs:/logs:rw
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false" SECURITY_ENABLELOGIN: "false"
LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID" LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID"
SYSTEM_DEFAULTLOCALE: en-US SYSTEM_DEFAULTLOCALE: en-US
+1 -1
View File
@@ -29,7 +29,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-client' api 'org.springframework.boot:spring-boot-starter-oauth2-client'
api 'org.springframework.boot:spring-boot-starter-mail' api 'org.springframework.boot:spring-boot-starter-mail'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.33' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.34'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
@@ -168,7 +168,7 @@
<div class="data-form-group"> <div class="data-form-group">
<label for="userId" class="data-form-label" th:text="#{team.selectUser}">Select User</label> <label for="userId" class="data-form-label" th:text="#{team.selectUser}">Select User</label>
<select name="userId" id="userId" class="data-form-control" required onchange="checkUserTeam(this.value)"> <select name="userId" id="userId" class="data-form-control" required onchange="checkUserTeam(this.value)">
<option value="" disabled selected th:text="#{selectFillter}">-- Select User --</option> <option value="" disabled selected th:text="#{selectFilter}">-- Select User --</option>
<option th:each="user : ${availableUsers}" th:value="${user.id}" th:text="${user.username}" <option th:each="user : ${availableUsers}" th:value="${user.id}" th:text="${user.username}"
th:data-team="${user.team != null ? user.team.name : ''}" th:data-team="${user.team != null ? user.team.name : ''}"
th:data-team-id="${user.team != null ? user.team.id : ''}"> th:data-team-id="${user.team != null ? user.team.id : ''}">
+3 -1
View File
@@ -1,6 +1,7 @@
echo "Running Stirling PDF with DISABLE_ADDITIONAL_FEATURES=${DISABLE_ADDITIONAL_FEATURES} and VERSION_TAG=${VERSION_TAG}" echo "Running Stirling PDF with DISABLE_ADDITIONAL_FEATURES=${DISABLE_ADDITIONAL_FEATURES} and VERSION_TAG=${VERSION_TAG}"
# Check for DISABLE_ADDITIONAL_FEATURES and download the appropriate JAR if required # Check for DISABLE_ADDITIONAL_FEATURES and download the appropriate JAR if required
if [ "$DISABLE_ADDITIONAL_FEATURES" = "false" ] && [ "$VERSION_TAG" != "alpha" ]; then if [ "$VERSION_TAG" != "alpha" ] && [ "$VERSION_TAG" != "ALPHA" ]; then
if [ "$DISABLE_ADDITIONAL_FEATURES" = "false" ] || [ "$DISABLE_ADDITIONAL_FEATURES" = "FALSE" ] || [ "$DOCKER_ENABLE_SECURITY" = "true" ] || [ "$DOCKER_ENABLE_SECURITY" = "TRUE" ]; then
if [ ! -f app-security.jar ]; then if [ ! -f app-security.jar ]; then
echo "Trying to download from: https://files.stirlingpdf.com/v$VERSION_TAG/Stirling-PDF-with-login.jar" echo "Trying to download from: https://files.stirlingpdf.com/v$VERSION_TAG/Stirling-PDF-with-login.jar"
curl -L -o app-security.jar https://files.stirlingpdf.com/v$VERSION_TAG/Stirling-PDF-with-login.jar curl -L -o app-security.jar https://files.stirlingpdf.com/v$VERSION_TAG/Stirling-PDF-with-login.jar
@@ -18,4 +19,5 @@ if [ "$DISABLE_ADDITIONAL_FEATURES" = "false" ] && [ "$VERSION_TAG" != "alpha" ]
chmod 755 app.jar || true chmod 755 app.jar || true
fi fi
fi fi
fi
fi fi
+48
View File
@@ -0,0 +1,48 @@
@echo off
REM --------------------------------------------------
REM Batch script to (re-)generate all requirements
REM with check for pip-compile and user confirmation
REM --------------------------------------------------
REM Check if pip-compile is available
pip-compile --version >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo ERROR: pip-compile was not found.
echo Please install pip-tools:
echo pip install pip-tools
echo and ensure that pip-compile is in your PATH.
pause
exit /b 1
)
echo pip-compile detected.
REM Prompt user for confirmation (default = Yes on ENTER)
set /p confirm="Do you want to generate all requirements? [Y/n] "
if /I "%confirm%"=="" set confirm=Y
if /I not "%confirm%"=="Y" (
echo Generation cancelled by user.
pause
exit /b 0
)
echo Starting generation...
echo Generating .github\scripts\requirements_pre_commit.txt
pip-compile --generate-hashes --upgrade --strip-extras ^
--output-file=".github\scripts\requirements_pre_commit.txt" ^
".github\scripts\requirements_pre_commit.in"
echo Generating .github\scripts\requirements_sync_readme.txt
pip-compile --generate-hashes --upgrade --strip-extras ^
--output-file=".github\scripts\requirements_sync_readme.txt" ^
".github\scripts\requirements_sync_readme.in"
echo Generating testing\cucumber\requirements.txt
pip-compile --generate-hashes --upgrade --strip-extras ^
--output-file="testing\cucumber\requirements.txt" ^
"testing\cucumber\requirements.in"
echo All done!
pause
+35 -17
View File
@@ -195,11 +195,19 @@ ignore = [
'PDFToBook.selectText.1', 'PDFToBook.selectText.1',
'PDFToText.tags', 'PDFToText.tags',
'addPageNumbers.selectText.3', 'addPageNumbers.selectText.3',
'adminUserSettings.team',
'alphabet', 'alphabet',
'audit.dashboard.modal.id',
'audit.dashboard.status',
'audit.dashboard.tab.dashboard',
'audit.dashboard.tab.export',
'audit.dashboard.table.details',
'audit.dashboard.table.id',
'certSign.name', 'certSign.name',
'cookieBanner.popUp.acceptAllBtn', 'cookieBanner.popUp.acceptAllBtn',
'endpointStatistics.top10', 'endpointStatistics.top10',
'endpointStatistics.top20', 'endpointStatistics.top20',
'fakeScan.quality.medium',
'fileChooser.dragAndDrop', 'fileChooser.dragAndDrop',
'home.pipeline.title', 'home.pipeline.title',
'lang.afr', 'lang.afr',
@@ -235,6 +243,7 @@ ignore = [
'pro', 'pro',
'redact.zoom', 'redact.zoom',
'sponsor', 'sponsor',
'team.status',
'text', 'text',
'validateSignature.cert.bits', 'validateSignature.cert.bits',
'validateSignature.cert.version', 'validateSignature.cert.version',
@@ -420,27 +429,17 @@ ignore = [
[hu_HU] [hu_HU]
ignore = [ ignore = [
'AddStampRequest.alphabet',
'AddStampRequest.position',
'adminUserSettings.admin',
'alphabet',
'audit.dashboard.export.json', 'audit.dashboard.export.json',
'audit.dashboard.modal.id', 'audit.dashboard.modal.id',
'audit.dashboard.table.id', 'audit.dashboard.table.id',
'certSign.name',
'cookieBanner.popUp.acceptAllBtn',
'endpointStatistics.top10', 'endpointStatistics.top10',
'endpointStatistics.top20', 'endpointStatistics.top20',
'home.pipeline.title',
'language.direction', 'language.direction',
'licenses.version', 'pipeline.title',
'poweredBy', 'pipelineOptions.pipelineHeader',
'pro', 'pro',
'sponsor', 'showJS.tags',
'text',
'validateSignature.cert.bits',
'validateSignature.cert.version',
'validateSignature.status',
'watermark.type.1',
] ]
[id_ID] [id_ID]
@@ -495,7 +494,6 @@ ignore = [
[it_IT] [it_IT]
ignore = [ ignore = [
'lang.asm', 'lang.asm',
'lang.aze',
'lang.ceb', 'lang.ceb',
'lang.chr', 'lang.chr',
'lang.div', 'lang.div',
@@ -858,9 +856,29 @@ ignore = [
[sr_LATN_RS] [sr_LATN_RS]
ignore = [ ignore = [
'audit.dashboard.modal.id',
'audit.dashboard.status',
'audit.dashboard.table.id',
'endpointStatistics.top',
'endpointStatistics.top10',
'endpointStatistics.top20',
'font',
'info',
'lang.div',
'lang.epo',
'lang.hin',
'lang.iku',
'lang.mar',
'lang.san',
'lang.snd',
'lang.tel',
'lang.tgl',
'lang.urd',
'language.direction', 'language.direction',
'licenses.version', 'pro',
'poweredBy', 'showJS.tags',
'team.status',
'validateSignature.status',
] ]
[sv_SE] [sv_SE]
+7 -1
View File
@@ -94,9 +94,15 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min
cropped_image = image[y:y+h, x:x+w] cropped_image = image[y:y+h, x:x+w]
cropped_image = auto_rotate(cropped_image, angle_threshold) cropped_image = auto_rotate(cropped_image, angle_threshold)
# Remove the added border # Remove the added border, but ensure we don't create an empty image
if border_size > 0 and cropped_image.shape[0] > 2 * border_size and cropped_image.shape[1] > 2 * border_size:
cropped_image = cropped_image[border_size:-border_size, border_size:-border_size] cropped_image = cropped_image[border_size:-border_size, border_size:-border_size]
# Check if the cropped image is valid before saving
if cropped_image.size == 0 or cropped_image.shape[0] == 0 or cropped_image.shape[1] == 0:
print(f"Warning: Skipping empty image for region {idx+1}")
continue
output_path = os.path.join(output_directory, f"{input_file_basename}_{idx+1}.png") output_path = os.path.join(output_directory, f"{input_file_basename}_{idx+1}.png")
cv2.imwrite(output_path, cropped_image) cv2.imwrite(output_path, cropped_image)
print(f"Saved {output_path}") print(f"Saved {output_path}")
+6 -1
View File
@@ -62,7 +62,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson' exclude group: 'com.google.code.gson', module: 'gson'
} }
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4' implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.11.1' // https://mvnrepository.com/artifact/com.opencsv/opencsv implementation 'com.opencsv:opencsv:5.11.2' // https://mvnrepository.com/artifact/com.opencsv/opencsv
// Batik // Batik
implementation 'org.apache.xmlgraphics:batik-all:1.19' implementation 'org.apache.xmlgraphics:batik-all:1.19'
@@ -146,5 +146,10 @@ bootJar {
} }
} }
// Configure main class for Spring Boot
springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
bootJar.dependsOn ':common:jar' bootJar.dependsOn ':common:jar'
bootJar.dependsOn ':proprietary:jar' bootJar.dependsOn ':proprietary:jar'
@@ -21,6 +21,8 @@ public class EndpointConfiguration {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>(); private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>(); private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher; private final boolean runningProOrHigher;
public EndpointConfiguration( public EndpointConfiguration(
@@ -34,13 +36,14 @@ public class EndpointConfiguration {
public void enableEndpoint(String endpoint) { public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true); endpointStatuses.put(endpoint, true);
log.debug("Enabled endpoint: {}", endpoint);
} }
public void disableEndpoint(String endpoint) { public void disableEndpoint(String endpoint) {
if (!endpointStatuses.containsKey(endpoint) || endpointStatuses.get(endpoint) != false) { if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
log.debug("Disabling {}", endpoint); log.debug("Disabling endpoint: {}", endpoint);
endpointStatuses.put(endpoint, false);
} }
endpointStatuses.put(endpoint, false);
} }
public Map<String, Boolean> getEndpointStatuses() { public Map<String, Boolean> getEndpointStatuses() {
@@ -48,25 +51,96 @@ public class EndpointConfiguration {
} }
public boolean isEndpointEnabled(String endpoint) { public boolean isEndpointEnabled(String endpoint) {
String original = endpoint;
if (endpoint.startsWith("/")) { if (endpoint.startsWith("/")) {
endpoint = endpoint.substring(1); endpoint = endpoint.substring(1);
} }
return endpointStatuses.getOrDefault(endpoint, true);
// Rule 1: Explicit flag wins - if disabled via disableEndpoint(), stay disabled
Boolean explicitStatus = endpointStatuses.get(endpoint);
if (Boolean.FALSE.equals(explicitStatus)) {
log.debug("isEndpointEnabled('{}') -> false (explicitly disabled)", original);
return false;
}
// Rule 2: Functional-group override - check if endpoint belongs to any disabled functional
// group
for (String group : endpointGroups.keySet()) {
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
// Skip tool groups (qpdf, OCRmyPDF, Ghostscript, LibreOffice, etc.)
if (!isToolGroup(group)) {
log.debug(
"isEndpointEnabled('{}') -> false (functional group '{}' disabled)",
original,
group);
return false;
}
}
}
// Rule 3: Tool-group fallback - check if at least one alternative tool group is enabled
Set<String> alternatives = endpointAlternatives.get(endpoint);
if (alternatives != null && !alternatives.isEmpty()) {
boolean hasEnabledToolGroup =
alternatives.stream()
.anyMatch(toolGroup -> !disabledGroups.contains(toolGroup));
log.debug(
"isEndpointEnabled('{}') -> {} (tool groups check)",
original,
hasEnabledToolGroup);
return hasEnabledToolGroup;
}
// Rule 4: Single-dependency check - if no alternatives defined, check if endpoint belongs
// to any disabled tool groups
for (String group : endpointGroups.keySet()) {
if (isToolGroup(group)
&& disabledGroups.contains(group)
&& endpointGroups.get(group).contains(endpoint)) {
log.debug(
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
original,
group);
return false;
}
}
// Default: enabled if not explicitly disabled
boolean enabled = !Boolean.FALSE.equals(explicitStatus);
log.debug("isEndpointEnabled('{}') -> {} (default)", original, enabled);
return enabled;
} }
public boolean isGroupEnabled(String group) { public boolean isGroupEnabled(String group) {
// Rule 1: If group is explicitly disabled, it stays disabled
if (disabledGroups.contains(group)) {
log.debug("isGroupEnabled('{}') -> false (explicitly disabled)", group);
return false;
}
Set<String> endpoints = endpointGroups.get(group); Set<String> endpoints = endpointGroups.get(group);
if (endpoints == null || endpoints.isEmpty()) { if (endpoints == null || endpoints.isEmpty()) {
log.debug("Group '{}' does not exist or has no endpoints", group); log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
return false; return false;
} }
// Rule 2: For functional groups, check if all endpoints are enabled
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// For functional groups, check each endpoint individually
for (String endpoint : endpoints) { for (String endpoint : endpoints) {
if (!isEndpointEnabled(endpoint)) { if (!isEndpointEnabledDirectly(endpoint)) {
log.debug(
"isGroupEnabled('{}') -> false (endpoint '{}' disabled)", group, endpoint);
return false; return false;
} }
} }
log.debug("isGroupEnabled('{}') -> true (all endpoints enabled)", group);
return true; return true;
} }
@@ -74,38 +148,84 @@ public class EndpointConfiguration {
endpointGroups.computeIfAbsent(group, k -> new HashSet<>()).add(endpoint); endpointGroups.computeIfAbsent(group, k -> new HashSet<>()).add(endpoint);
} }
public void enableGroup(String group) { public void addEndpointAlternative(String endpoint, String toolGroup) {
Set<String> endpoints = endpointGroups.get(group); endpointAlternatives.computeIfAbsent(endpoint, k -> new HashSet<>()).add(toolGroup);
if (endpoints != null) {
for (String endpoint : endpoints) {
enableEndpoint(endpoint);
}
}
} }
public void disableGroup(String group) { public void disableGroup(String group) {
if (disabledGroups.add(group)) {
if (isToolGroup(group)) {
log.debug(
"Disabling tool group: {} (endpoints with alternatives remain available)",
group);
} else {
log.debug(
"Disabling functional group: {} (will disable all endpoints in group)",
group);
}
}
// Only cascade to endpoints for *functional* groups
if (!isToolGroup(group)) {
Set<String> endpoints = endpointGroups.get(group); Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) { if (endpoints != null) {
for (String endpoint : endpoints) { endpoints.forEach(this::disableEndpoint);
disableEndpoint(endpoint);
} }
} }
} }
public void enableGroup(String group) {
if (disabledGroups.remove(group)) {
log.debug("Enabling group: {}", group);
}
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::enableEndpoint);
}
}
public Set<String> getDisabledGroups() {
return new HashSet<>(disabledGroups);
}
public void logDisabledEndpointsSummary() { public void logDisabledEndpointsSummary() {
List<String> disabledList = // Get all unique endpoints across all groups
endpointStatuses.entrySet().stream() Set<String> allEndpoints =
.filter(entry -> !entry.getValue()) // only get disabled endpoints (value endpointGroups.values().stream()
// is false) .flatMap(Set::stream)
.map(Map.Entry::getKey) .collect(java.util.stream.Collectors.toSet());
// Check which endpoints are actually disabled (functionally unavailable)
List<String> functionallyDisabledEndpoints =
allEndpoints.stream()
.filter(endpoint -> !isEndpointEnabled(endpoint))
.sorted() .sorted()
.toList(); .toList();
if (!disabledList.isEmpty()) { // Separate tool groups from functional groups
List<String> disabledToolGroups =
disabledGroups.stream().filter(this::isToolGroup).sorted().toList();
List<String> disabledFunctionalGroups =
disabledGroups.stream().filter(group -> !isToolGroup(group)).sorted().toList();
if (!disabledToolGroups.isEmpty()) {
log.info(
"Disabled tool groups: {} (endpoints may have alternative implementations)",
String.join(", ", disabledToolGroups));
}
if (!disabledFunctionalGroups.isEmpty()) {
log.info("Disabled functional groups: {}", String.join(", ", disabledFunctionalGroups));
}
if (!functionallyDisabledEndpoints.isEmpty()) {
log.info( log.info(
"Total disabled endpoints: {}. Disabled endpoints: {}", "Total disabled endpoints: {}. Disabled endpoints: {}",
disabledList.size(), functionallyDisabledEndpoints.size(),
String.join(", ", disabledList)); String.join(", ", functionallyDisabledEndpoints));
} else if (!disabledToolGroups.isEmpty()) {
log.info(
"No endpoints disabled despite missing tools - fallback implementations available");
} }
} }
@@ -212,8 +332,6 @@ public class EndpointConfiguration {
// Unoconvert // Unoconvert
addEndpointToGroup("Unoconvert", "file-to-pdf"); addEndpointToGroup("Unoconvert", "file-to-pdf");
addEndpointToGroup("tesseract", "ocr-pdf");
// Java // Java
addEndpointToGroup("Java", "merge-pdfs"); addEndpointToGroup("Java", "merge-pdfs");
addEndpointToGroup("Java", "remove-pages"); addEndpointToGroup("Java", "remove-pages");
@@ -254,6 +372,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "remove-image-pdf"); addEndpointToGroup("Java", "remove-image-pdf");
addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
// Javascript // Javascript
addEndpointToGroup("Javascript", "pdf-organizer"); addEndpointToGroup("Javascript", "pdf-organizer");
@@ -261,8 +380,42 @@ public class EndpointConfiguration {
addEndpointToGroup("Javascript", "compare"); addEndpointToGroup("Javascript", "compare");
addEndpointToGroup("Javascript", "adjust-contrast"); addEndpointToGroup("Javascript", "adjust-contrast");
// qpdf dependent endpoints /* qpdf */
addEndpointToGroup("qpdf", "repair"); addEndpointToGroup("qpdf", "repair");
addEndpointToGroup("qpdf", "compress-pdf");
/* Ghostscript */
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
/* OCRmyPDF */
addEndpointToGroup("OCRmyPDF", "ocr-pdf");
// Multi-tool endpoints - endpoints that can be handled by multiple tools
addEndpointAlternative("repair", "qpdf");
addEndpointAlternative("repair", "Ghostscript");
addEndpointAlternative("compress-pdf", "qpdf");
addEndpointAlternative("compress-pdf", "Ghostscript");
addEndpointAlternative("compress-pdf", "Java");
addEndpointAlternative("ocr-pdf", "tesseract");
addEndpointAlternative("ocr-pdf", "OCRmyPDF");
// file-to-pdf has multiple implementations
addEndpointAlternative("file-to-pdf", "LibreOffice");
addEndpointAlternative("file-to-pdf", "Python");
addEndpointAlternative("file-to-pdf", "Unoconvert");
// pdf-to-html and pdf-to-markdown can use either LibreOffice or Pdftohtml
addEndpointAlternative("pdf-to-html", "LibreOffice");
addEndpointAlternative("pdf-to-html", "Pdftohtml");
addEndpointAlternative("pdf-to-markdown", "Pdftohtml");
// markdown-to-pdf can use either Weasyprint or Java
addEndpointAlternative("markdown-to-pdf", "Weasyprint");
addEndpointAlternative("markdown-to-pdf", "Java");
// Weasyprint dependent endpoints // Weasyprint dependent endpoints
addEndpointToGroup("Weasyprint", "html-to-pdf"); addEndpointToGroup("Weasyprint", "html-to-pdf");
@@ -304,4 +457,43 @@ public class EndpointConfiguration {
public Set<String> getEndpointsForGroup(String group) { public Set<String> getEndpointsForGroup(String group) {
return endpointGroups.getOrDefault(group, new HashSet<>()); return endpointGroups.getOrDefault(group, new HashSet<>());
} }
private boolean isToolGroup(String group) {
return "qpdf".equals(group)
|| "OCRmyPDF".equals(group)
|| "Ghostscript".equals(group)
|| "LibreOffice".equals(group)
|| "tesseract".equals(group)
|| "CLI".equals(group)
|| "Python".equals(group)
|| "OpenCV".equals(group)
|| "Unoconvert".equals(group)
|| "Java".equals(group)
|| "Javascript".equals(group)
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group);
}
private boolean isEndpointEnabledDirectly(String endpoint) {
if (endpoint.startsWith("/")) {
endpoint = endpoint.substring(1);
}
// Check explicit disable flag
Boolean explicitStatus = endpointStatuses.get(endpoint);
if (Boolean.FALSE.equals(explicitStatus)) {
return false;
}
// Check if endpoint belongs to any disabled functional group
for (String group : endpointGroups.keySet()) {
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
if (!isToolGroup(group)) {
return false;
}
}
}
return true;
}
} }
@@ -34,6 +34,8 @@ public class ExternalAppDepConfig {
new HashMap<>() { new HashMap<>() {
{ {
put("gs", List.of("Ghostscript"));
put("ocrmypdf", List.of("OCRmyPDF"));
put("soffice", List.of("LibreOffice")); put("soffice", List.of("LibreOffice"));
put(weasyprintPath, List.of("Weasyprint")); put(weasyprintPath, List.of("Weasyprint"));
put("pdftohtml", List.of("Pdftohtml")); put("pdftohtml", List.of("Pdftohtml"));
@@ -109,6 +111,8 @@ public class ExternalAppDepConfig {
@PostConstruct @PostConstruct
public void checkDependencies() { public void checkDependencies() {
// Check core dependencies // Check core dependencies
checkDependencyAndDisableGroup("gs");
checkDependencyAndDisableGroup("ocrmypdf");
checkDependencyAndDisableGroup("tesseract"); checkDependencyAndDisableGroup("tesseract");
checkDependencyAndDisableGroup("soffice"); checkDependencyAndDisableGroup("soffice");
checkDependencyAndDisableGroup("qpdf"); checkDependencyAndDisableGroup("qpdf");
@@ -26,8 +26,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addResourceHandler("/**") registry.addResourceHandler("/**")
.addResourceLocations( .addResourceLocations(
"file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/"); "file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/");
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
// .setCachePeriod(0); // Optional: disable caching // .setCachePeriod(0); // Optional: disable caching
} }
} }
@@ -35,7 +35,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.MergePdfsRequest; import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfErrorUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -146,7 +148,7 @@ public class MergeController {
try (PDDocument doc = pdfDocumentFactory.load(file)) { try (PDDocument doc = pdfDocumentFactory.load(file)) {
pageIndex += doc.getNumberOfPages(); pageIndex += doc.getNumberOfPages();
} catch (IOException e) { } catch (IOException e) {
log.error("Error loading document for TOC generation", e); ExceptionUtils.logException("document loading for TOC generation", e);
pageIndex++; // Increment by at least one if we can't determine page count pageIndex++; // Increment by at least one if we can't determine page count
} }
} }
@@ -189,8 +191,17 @@ public class MergeController {
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile(); mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath()); mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
try {
mergerUtility.mergeDocuments( mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(totalSize)); // Merge the documents pdfDocumentFactory.getStreamCacheFunction(
totalSize)); // Merge the documents
} catch (IOException e) {
ExceptionUtils.logException("PDF merge", e);
if (PdfErrorUtils.isCorruptedPdfError(e)) {
throw ExceptionUtils.createMultiplePdfCorruptedException(e);
}
throw e;
}
// Load the merged PDF document // Load the merged PDF document
mergedDocument = pdfDocumentFactory.load(mergedTempFile); mergedDocument = pdfDocumentFactory.load(mergedTempFile);
@@ -229,7 +240,11 @@ public class MergeController {
baos, mergedFileName); // Return the modified PDF baos, mergedFileName); // Return the modified PDF
} catch (Exception ex) { } catch (Exception ex) {
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
log.warn("Corrupted PDF detected in merge pdf process: {}", ex.getMessage());
} else {
log.error("Error in merge pdf process", ex); log.error("Error in merge pdf process", ex);
}
throw ex; throw ex;
} finally { } finally {
if (mergedDocument != null) { if (mergedDocument != null) {
@@ -25,6 +25,7 @@ import stirling.software.SPDF.model.SortTypes;
import stirling.software.SPDF.model.api.PDFWithPageNums; import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.general.RearrangePagesRequest; import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -288,8 +289,8 @@ public class RearrangePagesPDFController {
.replaceFirst("[.][^.]+$", "") .replaceFirst("[.][^.]+$", "")
+ "_rearranged.pdf"); + "_rearranged.pdf");
} catch (IOException e) { } catch (IOException e) {
log.error("Failed rearranging documents", e); ExceptionUtils.logException("document rearrangement", e);
return null; throw e;
} }
} }
} }
@@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.RotatePDFRequest; import stirling.software.SPDF.model.api.general.RotatePDFRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -43,7 +44,8 @@ public class RotationController {
// Validate the angle is a multiple of 90 // Validate the angle is a multiple of 90
if (angle % 90 != 0) { if (angle % 90 != 0) {
throw new IllegalArgumentException("Angle must be a multiple of 90"); throw ExceptionUtils.createIllegalArgumentException(
"error.angleNotMultipleOf90", "Angle must be a multiple of 90");
} }
// Load the PDF document // Load the PDF document
@@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.ScalePagesRequest; import stirling.software.SPDF.model.api.general.ScalePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -120,9 +121,7 @@ public class ScalePagesController {
return sizeMap.get(targetPDRectangle); return sizeMap.get(targetPDRectangle);
} }
throw new IllegalArgumentException( throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
"Invalid PDRectangle. It must be one of the following: A0, A1, A2, A3, A4, A5, A6,"
+ " LETTER, LEGAL, KEEP");
} }
private Map<String, PDRectangle> getSizeMap() { private Map<String, PDRectangle> getSizeMap() {
@@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums; import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -71,7 +72,7 @@ public class SplitPDFController {
pageNumbers.add(totalPages - 1); pageNumbers.add(totalPages - 1);
} }
log.info( log.debug(
"Splitting PDF into pages: {}", "Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(","))); pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
@@ -84,7 +85,7 @@ public class SplitPDFController {
for (int i = previousPageNumber; i <= splitPoint; i++) { for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i); PDPage page = document.getPage(i);
splitDocument.addPage(page); splitDocument.addPage(page);
log.info("Adding page {} to split document", i); log.debug("Adding page {} to split document", i);
} }
previousPageNumber = splitPoint + 1; previousPageNumber = splitPoint + 1;
@@ -96,7 +97,7 @@ public class SplitPDFController {
splitDocumentsBoas.add(baos); splitDocumentsBoas.add(baos);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed splitting documents and saving them", e); ExceptionUtils.logException("document splitting and saving", e);
throw e; throw e;
} }
} }
@@ -122,14 +123,14 @@ public class SplitPDFController {
zipOut.write(pdf); zipOut.write(pdf);
zipOut.closeEntry(); zipOut.closeEntry();
log.info("Wrote split document {} to zip file", fileName); log.debug("Wrote split document {} to zip file", fileName);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed writing to zip", e); log.error("Failed writing to zip", e);
throw e; throw e;
} }
log.info("Successfully created zip file with split documents: {}", zipFile.toString()); log.debug("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile); byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile); Files.deleteIfExists(zipFile);
@@ -35,6 +35,7 @@ import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.model.PdfMetadata; import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -131,7 +132,8 @@ public class SplitPdfByChaptersController {
Integer bookmarkLevel = Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks) request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) { if (bookmarkLevel < 0) {
throw new IllegalArgumentException("Invalid bookmark level"); throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
} }
sourceDocument = pdfDocumentFactory.load(file); sourceDocument = pdfDocumentFactory.load(file);
@@ -139,7 +141,8 @@ public class SplitPdfByChaptersController {
if (outline == null) { if (outline == null) {
log.warn("No outline found for {}", file.getOriginalFilename()); log.warn("No outline found for {}", file.getOriginalFilename());
throw new IllegalArgumentException("No outline found"); throw ExceptionUtils.createIllegalArgumentException(
"error.pdfBookmarksNotFound", "No PDF bookmarks/outline found in document");
} }
List<Bookmark> bookmarks = new ArrayList<>(); List<Bookmark> bookmarks = new ArrayList<>();
try { try {
@@ -156,7 +159,7 @@ public class SplitPdfByChaptersController {
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1); Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
} catch (Exception e) { } catch (Exception e) {
log.error("Unable to extract outline items", e); ExceptionUtils.logException("outline extraction", e);
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()
.body("Unable to extract outline items".getBytes()); .body("Unable to extract outline items".getBytes());
} }
@@ -252,7 +255,7 @@ public class SplitPdfByChaptersController {
zipOut.write(pdf); zipOut.write(pdf);
zipOut.closeEntry(); zipOut.closeEntry();
log.info("Wrote split document {} to zip file", fileName); log.debug("Wrote split document {} to zip file", fileName);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed writing to zip", e); log.error("Failed writing to zip", e);
@@ -280,7 +283,7 @@ public class SplitPdfByChaptersController {
i++) { i++) {
PDPage page = sourceDocument.getPage(i); PDPage page = sourceDocument.getPage(i);
splitDocument.addPage(page); splitDocument.addPage(page);
log.info("Adding page {} to split document", i); log.debug("Adding page {} to split document", i);
} }
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (includeMetadata) { if (includeMetadata) {
@@ -291,7 +294,7 @@ public class SplitPdfByChaptersController {
splitDocumentsBoas.add(baos); splitDocumentsBoas.add(baos);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed splitting documents and saving them", e); ExceptionUtils.logException("document splitting and saving", e);
throw e; throw e;
} }
} }
@@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest; import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -96,13 +97,15 @@ public class SplitPdfBySizeController {
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename); handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else { } else {
log.error("Invalid split type: {}", type); log.error("Invalid split type: {}", type);
throw new IllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"Invalid argument for split type: " + type); "error.invalidArgument",
"Invalid argument: {0}",
"split type: " + type);
} }
log.debug("PDF splitting completed successfully"); log.debug("PDF splitting completed successfully");
} catch (Exception e) { } catch (Exception e) {
log.error("Error loading or processing PDF document", e); ExceptionUtils.logException("PDF document loading or processing", e);
throw e; throw e;
} }
} catch (IOException e) { } catch (IOException e) {
@@ -111,7 +114,7 @@ public class SplitPdfBySizeController {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Exception during PDF splitting process", e); ExceptionUtils.logException("PDF splitting process", e);
throw e; // Re-throw to ensure proper error response throw e; // Re-throw to ensure proper error response
} finally { } finally {
try { try {
@@ -275,8 +278,8 @@ public class SplitPdfBySizeController {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.debug("Successfully created initial output document"); log.debug("Successfully created initial output document");
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating initial output document", e); ExceptionUtils.logException("initial output document creation", e);
throw new IOException("Failed to create initial output document", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
int fileIndex = 1; int fileIndex = 1;
@@ -295,7 +298,7 @@ public class SplitPdfBySizeController {
log.debug("Successfully added page {} to current document", pageIndex); log.debug("Successfully added page {} to current document", pageIndex);
} catch (Exception e) { } catch (Exception e) {
log.error("Error adding page {} to current document", pageIndex, e); log.error("Error adding page {} to current document", pageIndex, e);
throw new IOException("Failed to add page to document", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
currentPageCount++; currentPageCount++;
@@ -320,7 +323,7 @@ public class SplitPdfBySizeController {
log.debug("Successfully created new document"); log.debug("Successfully created new document");
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating new document for next part", e); log.error("Error creating new document for next part", e);
throw new IOException("Failed to create new document", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
currentPageCount = 0; currentPageCount = 0;
@@ -329,7 +332,7 @@ public class SplitPdfBySizeController {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error iterating through pages", e); log.error("Error iterating through pages", e);
throw new IOException("Failed to iterate through pages", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
// Add the last document if it contains any pages // Add the last document if it contains any pages
@@ -351,7 +354,7 @@ public class SplitPdfBySizeController {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error checking or saving final document", e); log.error("Error checking or saving final document", e);
throw new IOException("Failed to process final document", e); throw ExceptionUtils.createFileProcessingException("split", e);
} finally { } finally {
try { try {
log.debug("Closing final document"); log.debug("Closing final document");
@@ -390,7 +393,7 @@ public class SplitPdfBySizeController {
log.debug("Successfully created document {} of {}", i + 1, documentCount); log.debug("Successfully created document {} of {}", i + 1, documentCount);
} catch (Exception e) { } catch (Exception e) {
log.error("Error creating document {} of {}", i + 1, documentCount, e); log.error("Error creating document {} of {}", i + 1, documentCount, e);
throw new IOException("Failed to create document", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
@@ -408,7 +411,7 @@ public class SplitPdfBySizeController {
currentPageIndex++; currentPageIndex++;
} catch (Exception e) { } catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e); log.error("Error adding page {} to document {}", j + 1, i + 1, e);
throw new IOException("Failed to add page to document", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
} }
@@ -437,7 +440,7 @@ public class SplitPdfBySizeController {
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size()); log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
} catch (Exception e) { } catch (Exception e) {
log.error("Error saving document part {} to byte array", index, e); log.error("Error saving document part {} to byte array", index, e);
throw new IOException("Failed to save document to byte array", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
try { try {
@@ -465,7 +468,7 @@ public class SplitPdfBySizeController {
log.debug("Successfully added document part {} to ZIP", index); log.debug("Successfully added document part {} to ZIP", index);
} catch (Exception e) { } catch (Exception e) {
log.error("Error adding document part {} to ZIP", index, e); log.error("Error adding document part {} to ZIP", index, e);
throw new IOException("Failed to add document to ZIP file", e); throw ExceptionUtils.createFileProcessingException("split", e);
} }
} }
} }
@@ -70,17 +70,17 @@ public class ToSinglePageController {
float yOffset = totalHeight; float yOffset = totalHeight;
// For each page, copy its content to the new page at the correct offset // For each page, copy its content to the new page at the correct offset
int pageIndex = 0;
for (PDPage page : sourceDocument.getPages()) { for (PDPage page : sourceDocument.getPages()) {
PDFormXObject form = PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
layerUtility.importPageAsForm(
sourceDocument, sourceDocument.getPages().indexOf(page));
AffineTransform af = AffineTransform af =
AffineTransform.getTranslateInstance( AffineTransform.getTranslateInstance(
0, yOffset - page.getMediaBox().getHeight()); 0, yOffset - page.getMediaBox().getHeight());
layerUtility.wrapInSaveRestore(newPage); layerUtility.wrapInSaveRestore(newPage);
String defaultLayerName = "Layer" + sourceDocument.getPages().indexOf(page); String defaultLayerName = "Layer" + pageIndex;
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName); layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
yOffset -= page.getMediaBox().getHeight(); yOffset -= page.getMediaBox().getHeight();
pageIndex++;
} }
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -17,6 +17,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.converters.HTMLToPdfRequest; import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FileToPdf; import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -46,14 +47,14 @@ public class ConvertHtmlToPDF {
MultipartFile fileInput = request.getFileInput(); MultipartFile fileInput = request.getFileInput();
if (fileInput == null) { if (fileInput == null) {
throw new IllegalArgumentException( throw ExceptionUtils.createHtmlFileRequiredException();
"Please provide an HTML or ZIP file for conversion.");
} }
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename()); String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null if (originalFilename == null
|| (!originalFilename.endsWith(".html") && !originalFilename.endsWith(".zip"))) { || (!originalFilename.endsWith(".html") && !originalFilename.endsWith(".zip"))) {
throw new IllegalArgumentException("File must be either .html or .zip format."); throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".html or .zip");
} }
boolean disableSanitize = boolean disableSanitize =
@@ -34,6 +34,7 @@ import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest; import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall; import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils; import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
@@ -104,7 +105,7 @@ public class ConvertImgPDFController {
log.error("resultant bytes for {} is null, error converting ", filename); log.error("resultant bytes for {} is null, error converting ", filename);
} }
if ("webp".equalsIgnoreCase(imageFormat) && !CheckProgramInstall.isPythonAvailable()) { if ("webp".equalsIgnoreCase(imageFormat) && !CheckProgramInstall.isPythonAvailable()) {
throw new IOException("Python is not installed. Required for WebP conversion."); throw ExceptionUtils.createPythonRequiredForWebpException();
} else if ("webp".equalsIgnoreCase(imageFormat) } else if ("webp".equalsIgnoreCase(imageFormat)
&& CheckProgramInstall.isPythonAvailable()) { && CheckProgramInstall.isPythonAvailable()) {
// Write the output stream to a temp file // Write the output stream to a temp file
@@ -27,6 +27,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.GeneralFile; import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FileToPdf; import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -55,12 +56,14 @@ public class ConvertMarkdownToPdf {
MultipartFile fileInput = generalFile.getFileInput(); MultipartFile fileInput = generalFile.getFileInput();
if (fileInput == null) { if (fileInput == null) {
throw new IllegalArgumentException("Please provide a Markdown file for conversion."); throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "Markdown");
} }
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename()); String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null || !originalFilename.endsWith(".md")) { if (originalFilename == null || !originalFilename.endsWith(".md")) {
throw new IllegalArgumentException("File must be in .md format."); throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md");
} }
// Convert Markdown to HTML using CommonMark // Convert Markdown to HTML using CommonMark
@@ -67,6 +67,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -90,7 +91,7 @@ public class ConvertPDFToPDFA {
// Validate input file type // Validate input file type
if (!"application/pdf".equals(inputFile.getContentType())) { if (!"application/pdf".equals(inputFile.getContentType())) {
log.error("Invalid input file type: {}", inputFile.getContentType()); log.error("Invalid input file type: {}", inputFile.getContentType());
throw new IllegalArgumentException("Input file must be a PDF"); throw ExceptionUtils.createPdfFileRequiredException();
} }
// Get the original filename without extension // Get the original filename without extension
@@ -241,15 +242,13 @@ public class ConvertPDFToPDFA {
if (returnCode.getRc() != 0) { if (returnCode.getRc() != 0) {
log.error("PDF/A conversion failed with return code: {}", returnCode.getRc()); log.error("PDF/A conversion failed with return code: {}", returnCode.getRc());
throw new RuntimeException("PDF/A conversion failed"); throw ExceptionUtils.createPdfaConversionFailedException();
} }
// Get the output file // Get the output file
File[] outputFiles = tempOutputDir.toFile().listFiles(); File[] outputFiles = tempOutputDir.toFile().listFiles();
if (outputFiles == null || outputFiles.length != 1) { if (outputFiles == null || outputFiles.length != 1) {
throw new RuntimeException( throw ExceptionUtils.createPdfaConversionFailedException();
"Expected one output PDF, found "
+ (outputFiles == null ? "none" : outputFiles.length));
} }
return outputFiles[0].toPath(); return outputFiles[0].toPath();
} }
@@ -23,6 +23,7 @@ import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -50,16 +51,19 @@ public class ConvertWebsiteToPDF {
String URL = request.getUrlInput(); String URL = request.getUrlInput();
if (!applicationProperties.getSystem().getEnableUrlToPDF()) { if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
throw new IllegalArgumentException("This endpoint has been disabled by the admin."); throw ExceptionUtils.createIllegalArgumentException(
"error.endpointDisabled", "This endpoint has been disabled by the admin");
} }
// Validate the URL format // Validate the URL format
if (!URL.matches("^https?://.*") || !GeneralUtils.isValidURL(URL)) { if (!URL.matches("^https?://.*") || !GeneralUtils.isValidURL(URL)) {
throw new IllegalArgumentException("Invalid URL format provided."); throw ExceptionUtils.createInvalidArgumentException(
"URL", "provided format is invalid");
} }
// validate the URL is reachable // validate the URL is reachable
if (!GeneralUtils.isURLReachable(URL)) { if (!GeneralUtils.isURLReachable(URL)) {
throw new IllegalArgumentException("URL is not reachable, please provide a valid URL."); throw ExceptionUtils.createIllegalArgumentException(
"error.urlNotReachable", "URL is not reachable, please provide a valid URL");
} }
Path tempOutputFile = null; Path tempOutputFile = null;
@@ -25,6 +25,7 @@ import stirling.software.SPDF.model.api.filter.FileSizeRequest;
import stirling.software.SPDF.model.api.filter.PageRotationRequest; import stirling.software.SPDF.model.api.filter.PageRotationRequest;
import stirling.software.SPDF.model.api.filter.PageSizeRequest; import stirling.software.SPDF.model.api.filter.PageSizeRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils; import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -96,7 +97,7 @@ public class FilterController {
valid = actualPageCount < pageCount; valid = actualPageCount < pageCount;
break; break;
default: default:
throw new IllegalArgumentException("Invalid comparator: " + comparator); throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
} }
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile); if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
@@ -139,7 +140,7 @@ public class FilterController {
valid = actualArea < standardArea; valid = actualArea < standardArea;
break; break;
default: default:
throw new IllegalArgumentException("Invalid comparator: " + comparator); throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
} }
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile); if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
@@ -172,7 +173,7 @@ public class FilterController {
valid = actualFileSize < fileSize; valid = actualFileSize < fileSize;
break; break;
default: default:
throw new IllegalArgumentException("Invalid comparator: " + comparator); throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
} }
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile); if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
@@ -208,7 +209,7 @@ public class FilterController {
valid = actualRotation < rotation; valid = actualRotation < rotation;
break; break;
default: default:
throw new IllegalArgumentException("Invalid comparator: " + comparator); throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
} }
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile); if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
@@ -47,11 +47,13 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest; import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -61,16 +63,18 @@ import stirling.software.common.util.WebResponseUtils;
@RequestMapping("/api/v1/misc") @RequestMapping("/api/v1/misc")
@Slf4j @Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs") @Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class CompressController { public class CompressController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final boolean qpdfEnabled; private final EndpointConfiguration endpointConfiguration;
public CompressController( private boolean isQpdfEnabled() {
CustomPDFDocumentFactory pdfDocumentFactory, return endpointConfiguration.isGroupEnabled("qpdf");
EndpointConfiguration endpointConfiguration) { }
this.pdfDocumentFactory = pdfDocumentFactory;
this.qpdfEnabled = endpointConfiguration.isGroupEnabled("qpdf"); private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
} }
@Data @Data
@@ -597,12 +601,12 @@ public class CompressController {
if (bytesRead > 0) { if (bytesRead > 0) {
byte[] dataToHash = byte[] dataToHash =
bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead); bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead);
return bytesToHexString(generatMD5(dataToHash)); return bytesToHexString(generateMD5(dataToHash));
} }
return "empty-stream"; return "empty-stream";
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error generating image hash", e); ExceptionUtils.logException("image hash generation", e);
return "fallback-" + System.identityHashCode(image); return "fallback-" + System.identityHashCode(image);
} }
} }
@@ -615,12 +619,12 @@ public class CompressController {
return sb.toString(); return sb.toString();
} }
private byte[] generatMD5(byte[] data) throws IOException { private byte[] generateMD5(byte[] data) throws IOException {
try { try {
MessageDigest md = MessageDigest.getInstance("MD5"); MessageDigest md = MessageDigest.getInstance("MD5");
return md.digest(data); // Get the MD5 hash of the image bytes return md.digest(data); // Get the MD5 hash of the image bytes
} catch (NoSuchAlgorithmException e) { } catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 algorithm not available", e); throw ExceptionUtils.createMd5AlgorithmException(e);
} }
} }
@@ -697,25 +701,69 @@ public class CompressController {
boolean sizeMet = false; boolean sizeMet = false;
boolean imageCompressionApplied = false; boolean imageCompressionApplied = false;
boolean qpdfCompressionApplied = false; boolean externalCompressionApplied = false;
if (qpdfEnabled && optimizeLevel <= 3) {
optimizeLevel = 4;
}
while (!sizeMet && optimizeLevel <= 9) { while (!sizeMet && optimizeLevel <= 9) {
// Apply image compression for levels 4-9 // Apply external compression first
if ((optimizeLevel >= 3 || Boolean.TRUE.equals(convertToGrayscale)) if (!externalCompressionApplied) {
&& !imageCompressionApplied) { boolean ghostscriptSuccess = false;
double scaleFactor = getScaleFactorForLevel(optimizeLevel);
float jpegQuality = getJpegQualityForLevel(optimizeLevel);
// Compress images // Try Ghostscript first if available - for ANY compression level
if (isGhostscriptEnabled()) {
try {
applyGhostscriptCompression(
request, optimizeLevel, currentFile, tempFiles);
log.info("Ghostscript compression applied successfully");
ghostscriptSuccess = true;
} catch (IOException e) {
log.warn("Ghostscript compression failed, trying fallback methods");
}
}
// Fallback to QPDF if Ghostscript failed or not available (levels 1-3 only)
if (!ghostscriptSuccess && isQpdfEnabled() && optimizeLevel <= 3) {
try {
applyQpdfCompression(request, optimizeLevel, currentFile, tempFiles);
log.info("QPDF compression applied successfully");
} catch (IOException e) {
log.warn("QPDF compression also failed");
}
}
if (!ghostscriptSuccess && !isQpdfEnabled()) {
log.info(
"No external compression tools available, using image compression only");
}
externalCompressionApplied = true;
// Skip image compression if Ghostscript succeeded
if (ghostscriptSuccess) {
imageCompressionApplied = true;
}
}
// Apply image compression for levels 4+ only if Ghostscript didn't run
if ((optimizeLevel >= 4 || Boolean.TRUE.equals(convertToGrayscale))
&& !imageCompressionApplied) {
// Use different scale factors based on level
double scaleFactor =
switch (optimizeLevel) {
case 4 -> 0.95; // 95% of original size
case 5 -> 0.9; // 90% of original size
case 6 -> 0.8; // 80% of original size
case 7 -> 0.7; // 70% of original size
case 8 -> 0.65; // 65% of original size
case 9 -> 0.5; // 50% of original size
default -> 1.0;
};
log.info("Applying image compression with scale factor: {}", scaleFactor);
Path compressedImageFile = Path compressedImageFile =
compressImagesInPDF( compressImagesInPDF(
currentFile, currentFile,
scaleFactor, scaleFactor,
jpegQuality, 0.7f, // Default JPEG quality
Boolean.TRUE.equals(convertToGrayscale)); Boolean.TRUE.equals(convertToGrayscale));
tempFiles.add(compressedImageFile); tempFiles.add(compressedImageFile);
@@ -723,18 +771,6 @@ public class CompressController {
imageCompressionApplied = true; imageCompressionApplied = true;
} }
// Apply QPDF compression for all levels
if (!qpdfCompressionApplied && qpdfEnabled) {
applyQpdfCompression(request, optimizeLevel, currentFile, tempFiles);
qpdfCompressionApplied = true;
} else if (!qpdfCompressionApplied) {
// If QPDF is disabled, mark as applied and log
if (!qpdfEnabled) {
log.info("Skipping QPDF compression as QPDF group is disabled");
}
qpdfCompressionApplied = true;
}
// Check if target size reached or not in auto mode // Check if target size reached or not in auto mode
long outputFileSize = Files.size(currentFile); long outputFileSize = Files.size(currentFile);
if (outputFileSize <= expectedOutputSize || !autoMode) { if (outputFileSize <= expectedOutputSize || !autoMode) {
@@ -754,7 +790,7 @@ public class CompressController {
} else { } else {
// Reset flags for next iteration with higher optimization level // Reset flags for next iteration with higher optimization level
imageCompressionApplied = false; imageCompressionApplied = false;
qpdfCompressionApplied = false; externalCompressionApplied = false;
optimizeLevel = newOptimizeLevel; optimizeLevel = newOptimizeLevel;
} }
} }
@@ -788,6 +824,96 @@ public class CompressController {
} }
} }
// Run Ghostscript compression
private void applyGhostscriptCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
throws IOException {
long preGsSize = Files.size(currentFile);
log.info("Pre-Ghostscript file size: {}", GeneralUtils.formatBytes(preGsSize));
// Create output file for Ghostscript
Path gsOutputFile = Files.createTempFile("gs_output_", ".pdf");
tempFiles.add(gsOutputFile);
// Build Ghostscript command based on optimization level
List<String> command = new ArrayList<>();
command.add("gs");
command.add("-sDEVICE=pdfwrite");
command.add("-dCompatibilityLevel=1.5");
command.add("-dNOPAUSE");
command.add("-dQUIET");
command.add("-dBATCH");
// Map optimization levels to Ghostscript settings
switch (optimizeLevel) {
case 1:
command.add("-dPDFSETTINGS=/prepress");
break;
case 2:
command.add("-dPDFSETTINGS=/printer");
break;
case 3:
command.add("-dPDFSETTINGS=/ebook");
break;
case 4:
case 5:
command.add("-dPDFSETTINGS=/screen");
break;
case 6:
case 7:
command.add("-dPDFSETTINGS=/screen");
command.add("-dColorImageResolution=150");
command.add("-dGrayImageResolution=150");
command.add("-dMonoImageResolution=300");
break;
case 8:
case 9:
command.add("-dPDFSETTINGS=/screen");
command.add("-dColorImageResolution=100");
command.add("-dGrayImageResolution=100");
command.add("-dMonoImageResolution=200");
break;
case 10:
command.add("-dPDFSETTINGS=/screen");
command.add("-dColorImageResolution=72");
command.add("-dGrayImageResolution=72");
command.add("-dMonoImageResolution=150");
break;
default:
command.add("-dPDFSETTINGS=/screen");
break;
}
command.add("-sOutputFile=" + gsOutputFile.toString());
command.add(currentFile.toString());
ProcessExecutorResult returnCode = null;
try {
returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
if (returnCode.getRc() == 0) {
// Update current file to the Ghostscript output
Files.copy(gsOutputFile, currentFile, StandardCopyOption.REPLACE_EXISTING);
long postGsSize = Files.size(currentFile);
double gsReduction = 100.0 - ((postGsSize * 100.0) / preGsSize);
log.info(
"Post-Ghostscript file size: {} (reduced by {}%)",
GeneralUtils.formatBytes(postGsSize), String.format("%.1f", gsReduction));
} else {
log.warn("Ghostscript compression failed with return code: {}", returnCode.getRc());
throw new IOException("Ghostscript compression failed");
}
} catch (Exception e) {
log.warn("Ghostscript compression failed, will fallback to other methods", e);
throw new IOException("Ghostscript compression failed", e);
}
}
// Run QPDF compression // Run QPDF compression
private void applyQpdfCompression( private void applyQpdfCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles) OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
@@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -134,7 +135,7 @@ public class DecompressPdfController {
stream.setInt(COSName.LENGTH, decompressedBytes.length); stream.setInt(COSName.LENGTH, decompressedBytes.length);
} }
} catch (IOException e) { } catch (IOException e) {
log.error("Error decompressing stream", e); ExceptionUtils.logException("stream decompression", e);
// Continue processing other streams even if this one fails // Continue processing other streams even if this one fails
} }
} }
@@ -33,6 +33,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest; import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall; import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -72,7 +73,8 @@ public class ExtractImageScansController {
List<Path> tempDirs = new ArrayList<>(); List<Path> tempDirs = new ArrayList<>();
if (!CheckProgramInstall.isPythonAvailable()) { if (!CheckProgramInstall.isPythonAvailable()) {
throw new IOException("Python is not installed."); throw ExceptionUtils.createIOException(
"error.toolNotInstalled", "{0} is not installed", null, "Python");
} }
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand(); String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
@@ -41,6 +41,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFExtractImagesRequest; import stirling.software.SPDF.model.api.PDFExtractImagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ImageProcessingUtils; import stirling.software.common.util.ImageProcessingUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@@ -90,32 +91,37 @@ public class ExtractImagesController {
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
Set<Future<Void>> futures = new HashSet<>(); Set<Future<Void>> futures = new HashSet<>();
// Iterate over each page // Safely iterate over each page, handling corrupt PDFs where page count might be wrong
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) { try {
int pageCount = document.getPages().getCount();
log.debug("Document reports {} pages", pageCount);
int consecutiveFailures = 0;
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
try {
PDPage page = document.getPage(pgNum); PDPage page = document.getPage(pgNum);
consecutiveFailures = 0; // Reset on success
final int currentPageNum = pgNum + 1; // Convert to 1-based page numbering
Future<Void> future = Future<Void> future =
executor.submit( executor.submit(
() -> { () -> {
// Use the page number directly from the iterator, so no need to
// calculate manually
int pageNum = document.getPages().indexOf(page) + 1;
try { try {
// Call the image extraction method for each page // Call the image extraction method for each page
extractImagesFromPage( extractImagesFromPage(
page, page,
format, format,
filename, filename,
pageNum, currentPageNum,
processedImages, processedImages,
zos, zos,
allowDuplicates); allowDuplicates);
} catch (IOException e) { } catch (Exception e) {
// Log the error and continue processing other pages // Log the error and continue processing other pages
log.error( ExceptionUtils.logException(
"Error extracting images from page {}: {}", "image extraction from page "
pageNum, + currentPageNum,
e.getMessage()); e);
} }
return null; // Callable requires a return type return null; // Callable requires a return type
@@ -123,6 +129,19 @@ public class ExtractImagesController {
// Add the Future object to the list to track completion // Add the Future object to the list to track completion
futures.add(future); futures.add(future);
} catch (Exception e) {
consecutiveFailures++;
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
if (consecutiveFailures >= 3) {
log.warn("Stopping page iteration after 3 consecutive failures");
break;
}
}
}
} catch (Exception e) {
ExceptionUtils.logException("page count determination", e);
throw e;
} }
// Wait for all tasks to complete // Wait for all tasks to complete
@@ -180,6 +199,7 @@ public class ExtractImagesController {
} }
int count = 1; int count = 1;
for (COSName name : page.getResources().getXObjectNames()) { for (COSName name : page.getResources().getXObjectNames()) {
try {
if (page.getResources().isImageXObject(name)) { if (page.getResources().isImageXObject(name)) {
PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name); PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name);
if (!allowDuplicates) { if (!allowDuplicates) {
@@ -209,6 +229,10 @@ public class ExtractImagesController {
zos.closeEntry(); zos.closeEntry();
} }
} }
} catch (IOException e) {
ExceptionUtils.logException("image extraction", e);
throw ExceptionUtils.handlePdfException(e, "during image extraction");
}
} }
} }
@@ -47,6 +47,11 @@ public class FakeScanController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final Random RANDOM = new Random(); private static final Random RANDOM = new Random();
// Size limits to prevent OutOfMemoryError
private static final int MAX_IMAGE_WIDTH = 8192;
private static final int MAX_IMAGE_HEIGHT = 8192;
private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096
@PostMapping(value = "/fake-scan", consumes = "multipart/form-data") @PostMapping(value = "/fake-scan", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Convert PDF to look like a scanned document", summary = "Convert PDF to look like a scanned document",
@@ -82,8 +87,46 @@ public class FakeScanController {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int i = 0; i < document.getNumberOfPages(); i++) { for (int i = 0; i < document.getNumberOfPages(); i++) {
// Render page to image with specified resolution // Get page dimensions to calculate safe resolution
BufferedImage image = pdfRenderer.renderImageWithDPI(i, resolution); PDRectangle pageSize = document.getPage(i).getMediaBox();
float pageWidthPts = pageSize.getWidth();
float pageHeightPts = pageSize.getHeight();
// Calculate what the image dimensions would be at the requested resolution
int projectedWidth = (int) Math.ceil(pageWidthPts * resolution / 72.0);
int projectedHeight = (int) Math.ceil(pageHeightPts * resolution / 72.0);
long projectedPixels = (long) projectedWidth * projectedHeight;
// Calculate safe resolution that stays within limits
int safeResolution = resolution;
if (projectedWidth > MAX_IMAGE_WIDTH
|| projectedHeight > MAX_IMAGE_HEIGHT
|| projectedPixels > MAX_IMAGE_PIXELS) {
double widthScale = (double) MAX_IMAGE_WIDTH / projectedWidth;
double heightScale = (double) MAX_IMAGE_HEIGHT / projectedHeight;
double pixelScale = Math.sqrt((double) MAX_IMAGE_PIXELS / projectedPixels);
double minScale = Math.min(Math.min(widthScale, heightScale), pixelScale);
safeResolution = (int) Math.max(72, resolution * minScale);
log.warn(
"Page {} would be too large at {}dpi ({}x{} pixels). Reducing to {}dpi",
i + 1,
resolution,
projectedWidth,
projectedHeight,
safeResolution);
}
// Render page to image with safe resolution
BufferedImage image = pdfRenderer.renderImageWithDPI(i, safeResolution);
log.debug(
"Processing page {} with dimensions {}x{} ({} pixels) at {}dpi",
i + 1,
image.getWidth(),
image.getHeight(),
(long) image.getWidth() * image.getHeight(),
safeResolution);
// 1. Convert to grayscale or keep color // 1. Convert to grayscale or keep color
BufferedImage processed; BufferedImage processed;
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.*; import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
@@ -29,12 +30,17 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest; import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; 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.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@RequestMapping("/api/v1/misc") @RequestMapping("/api/v1/misc")
@@ -46,6 +52,15 @@ public class OCRController {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private boolean isOcrMyPdfEnabled() {
return endpointConfiguration.isGroupEnabled("OCRmyPDF");
}
private boolean isTesseractEnabled() {
return endpointConfiguration.isGroupEnabled("tesseract");
}
/** Gets the list of available Tesseract languages from the tessdata directory */ /** Gets the list of available Tesseract languages from the tessdata directory */
public List<String> getAvailableTesseractLanguages() { public List<String> getAvailableTesseractLanguages() {
@@ -63,39 +78,261 @@ public class OCRController {
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf") @PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
@Operation( @Operation(
summary = "Process PDF files with OCR using Tesseract", summary = "Process a PDF file with OCR",
description = description =
"Takes a PDF file as input, performs OCR using specified languages and OCR type" "This endpoint processes a PDF file using OCR (Optical Character Recognition). "
+ " (skip-text/force-ocr), and returns the processed PDF. Input:PDF" + "Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType, and removeImagesAfter options. "
+ " Output:PDF Type:SISO") + "Uses OCRmyPDF if available, falls back to Tesseract. Input:PDF Output:PDF Type:SI-Conditional")
public ResponseEntity<byte[]> processPdfWithOCR( public ResponseEntity<byte[]> processPdfWithOCR(
@ModelAttribute ProcessPdfWithOcrRequest request) @ModelAttribute ProcessPdfWithOcrRequest request)
throws IOException, InterruptedException { throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput(); MultipartFile inputFile = request.getFileInput();
List<String> languages = request.getLanguages(); List<String> selectedLanguages = request.getLanguages();
Boolean sidecar = request.isSidecar();
Boolean deskew = request.isDeskew();
Boolean clean = request.isClean();
Boolean cleanFinal = request.isCleanFinal();
String ocrType = request.getOcrType(); String ocrType = request.getOcrType();
String ocrRenderType = request.getOcrRenderType();
Boolean removeImagesAfter = request.isRemoveImagesAfter();
// Create a temp directory using TempFileManager directly if (selectedLanguages == null || selectedLanguages.isEmpty()) {
Path tempDirPath = tempFileManager.createTempDirectory(); throw ExceptionUtils.createOcrLanguageRequiredException();
File tempDir = tempDirPath.toFile(); }
if (!"hocr".equals(ocrRenderType) && !"sandwich".equals(ocrRenderType)) {
throw new IOException("ocrRenderType wrong");
}
// Get available Tesseract languages
List<String> availableLanguages = getAvailableTesseractLanguages();
// Validate selected languages
selectedLanguages =
selectedLanguages.stream().filter(availableLanguages::contains).toList();
if (selectedLanguages.isEmpty()) {
throw ExceptionUtils.createOcrInvalidLanguagesException();
}
// Use try-with-resources for proper temp file management
try (TempFile tempInputFile = new TempFile(tempFileManager, ".pdf");
TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
inputFile.transferTo(tempInputFile.getFile());
TempFile sidecarTextFile = null;
try { try {
File tempInputFile = new File(tempDir, "input.pdf"); // Use OCRmyPDF if available (no fallback - error if it fails)
File tempOutputDir = new File(tempDir, "output"); if (isOcrMyPdfEnabled()) {
File tempImagesDir = new File(tempDir, "images"); if (sidecar != null && sidecar) {
File finalOutputFile = new File(tempDir, "final_output.pdf"); sidecarTextFile = new TempFile(tempFileManager, ".txt");
}
processWithOcrMyPdf(
selectedLanguages,
sidecar,
deskew,
clean,
cleanFinal,
ocrType,
ocrRenderType,
removeImagesAfter,
tempInputFile.getPath(),
tempOutputFile.getPath(),
sidecarTextFile != null ? sidecarTextFile.getPath() : null);
log.info("OCRmyPDF processing completed successfully");
}
// Use Tesseract only if OCRmyPDF is not available
else if (isTesseractEnabled()) {
processWithTesseract(
selectedLanguages,
ocrType,
tempInputFile.getPath(),
tempOutputFile.getPath());
log.info("Tesseract processing completed successfully");
} else {
throw ExceptionUtils.createOcrToolsUnavailableException();
}
// Read the processed PDF file
byte[] pdfBytes = Files.readAllBytes(tempOutputFile.getPath());
// Return the OCR processed PDF as a response
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_OCR.pdf";
if (sidecar != null && sidecar && sidecarTextFile != null) {
// Create a zip file containing both the PDF and the text file
String outputZipFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_OCR.zip";
try (TempFile tempZipFile = new TempFile(tempFileManager, ".zip");
ZipOutputStream zipOut =
new ZipOutputStream(
Files.newOutputStream(tempZipFile.getPath()))) {
// Add PDF file to the zip
ZipEntry pdfEntry = new ZipEntry(outputFilename);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdfBytes);
zipOut.closeEntry();
// Add text file to the zip
ZipEntry txtEntry = new ZipEntry(outputFilename.replace(".pdf", ".txt"));
zipOut.putNextEntry(txtEntry);
Files.copy(sidecarTextFile.getPath(), zipOut);
zipOut.closeEntry();
zipOut.finish();
byte[] zipBytes = Files.readAllBytes(tempZipFile.getPath());
// Return the zip file containing both the PDF and the text file
return WebResponseUtils.bytesToWebResponse(
zipBytes, outputZipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
} else {
// Return the OCR processed PDF as a response
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
} finally {
// Clean up sidecar temp file if created
if (sidecarTextFile != null) {
try {
sidecarTextFile.close();
} catch (Exception e) {
log.warn("Failed to close sidecar temp file", e);
}
}
}
}
}
private void processWithOcrMyPdf(
List<String> selectedLanguages,
Boolean sidecar,
Boolean deskew,
Boolean clean,
Boolean cleanFinal,
String ocrType,
String ocrRenderType,
Boolean removeImagesAfter,
Path tempInputFile,
Path tempOutputFile,
Path sidecarTextPath)
throws IOException, InterruptedException {
// Build OCRmyPDF command
String languageOption = String.join("+", selectedLanguages);
List<String> command =
new ArrayList<>(
Arrays.asList(
"ocrmypdf",
"--verbose",
"2",
"--output-type",
"pdf",
"--pdf-renderer",
ocrRenderType));
if (sidecar != null && sidecar && sidecarTextPath != null) {
command.add("--sidecar");
command.add(sidecarTextPath.toString());
}
if (deskew != null && deskew) {
command.add("--deskew");
}
if (clean != null && clean) {
command.add("--clean");
}
if (cleanFinal != null && cleanFinal) {
command.add("--clean-final");
}
if (ocrType != null && !"".equals(ocrType)) {
if ("skip-text".equals(ocrType)) {
command.add("--skip-text");
} else if ("force-ocr".equals(ocrType)) {
command.add("--force-ocr");
}
}
command.addAll(
Arrays.asList(
"--language",
languageOption,
tempInputFile.toString(),
tempOutputFile.toString()));
// Run CLI command
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF)
.runCommandWithOutputHandling(command);
if (result.getRc() != 0
&& result.getMessages().contains("multiprocessing/synchronize.py")
&& result.getMessages().contains("OSError: [Errno 38] Function not implemented")) {
command.add("--jobs");
command.add("1");
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF)
.runCommandWithOutputHandling(command);
}
if (result.getRc() != 0) {
throw new IOException("OCRmyPDF failed with return code: " + result.getRc());
}
// Remove images from the OCR processed PDF if the flag is set to true
if (removeImagesAfter != null && removeImagesAfter) {
try (TempFile tempPdfWithoutImages = new TempFile(tempFileManager, "_no_images.pdf")) {
List<String> gsCommand =
Arrays.asList(
"gs",
"-sDEVICE=pdfwrite",
"-dFILTERIMAGE",
"-o",
tempPdfWithoutImages.getPath().toString(),
tempOutputFile.toString());
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(gsCommand);
// Replace output file with version without images
Files.copy(
tempPdfWithoutImages.getPath(),
tempOutputFile,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
}
private void processWithTesseract(
List<String> selectedLanguages, String ocrType, Path tempInputFile, Path tempOutputFile)
throws IOException, InterruptedException {
// Create temp directory for Tesseract processing
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
File tempOutputDir = new File(tempDir.getPath().toFile(), "output");
File tempImagesDir = new File(tempDir.getPath().toFile(), "images");
File finalOutputFile = new File(tempDir.getPath().toFile(), "final_output.pdf");
// Create directories // Create directories
tempOutputDir.mkdirs(); tempOutputDir.mkdirs();
tempImagesDir.mkdirs(); tempImagesDir.mkdirs();
// Save input file
inputFile.transferTo(tempInputFile);
PDFMergerUtility merger = new PDFMergerUtility(); PDFMergerUtility merger = new PDFMergerUtility();
merger.setDestinationFileName(finalOutputFile.toString()); merger.setDestinationFileName(finalOutputFile.toString());
try (PDDocument document = pdfDocumentFactory.load(tempInputFile)) { try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
int pageCount = document.getNumberOfPages(); int pageCount = document.getNumberOfPages();
@@ -135,35 +372,24 @@ public class OCRController {
new File(tempOutputDir, String.format("page_%d", pageNum)) new File(tempOutputDir, String.format("page_%d", pageNum))
.toString()); .toString());
command.add("-l"); command.add("-l");
command.add(String.join("+", languages)); command.add(String.join("+", selectedLanguages));
// Always output PDF command.add("pdf"); // Always output PDF
command.add("pdf");
// Use ProcessExecutor to run tesseract command
try {
ProcessExecutorResult result = ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT) ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT)
.runCommandWithOutputHandling(command); .runCommandWithOutputHandling(command);
log.debug( if (result.getRc() != 0) {
"Tesseract OCR completed for page {} with exit code {}", throw ExceptionUtils.createRuntimeException(
pageNum, "error.commandFailed",
"{0} command failed with exit code: {1}",
null,
"Tesseract",
result.getRc()); result.getRc());
}
// Add OCR'd PDF to merger // Add OCR'd PDF to merger
merger.addSource(pageOutputPath); merger.addSource(pageOutputPath);
} catch (IOException | InterruptedException e) {
log.error(
"Error processing page {} with tesseract: {}",
pageNum,
e.getMessage());
// If OCR fails, fall back to the original page
try (PDDocument pageDoc = new PDDocument()) {
pageDoc.addPage(page);
pageDoc.save(pageOutputPath);
merger.addSource(pageOutputPath);
}
}
} else { } else {
// Save original page without OCR // Save original page without OCR
try (PDDocument pageDoc = new PDDocument()) { try (PDDocument pageDoc = new PDDocument()) {
@@ -178,40 +404,11 @@ public class OCRController {
// Merge all pages into final PDF // Merge all pages into final PDF
merger.mergeDocuments(null); merger.mergeDocuments(null);
// Read the final PDF file // Copy final output to the expected location
byte[] pdfContent = java.nio.file.Files.readAllBytes(finalOutputFile.toPath()); Files.copy(
String outputFilename = finalOutputFile.toPath(),
Filenames.toSimpleFileName(inputFile.getOriginalFilename()) tempOutputFile,
.replaceFirst("[.][^.]+$", "") java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ "_OCR.pdf";
return ResponseEntity.ok()
.header(
"Content-Disposition",
"attachment; filename=\"" + outputFilename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdfContent);
} finally {
// Clean up the temp directory and all its contents
tempFileManager.deleteTempDirectory(tempDirPath);
}
}
private void addFileToZip(File file, String filename, ZipOutputStream zipOut)
throws IOException {
if (!file.exists()) {
log.warn("File {} does not exist, skipping", file);
return;
}
try (FileInputStream fis = new FileInputStream(file)) {
ZipEntry zipEntry = new ZipEntry(filename);
zipOut.putNextEntry(zipEntry);
byte[] buffer = new byte[1024];
int length;
while ((length = fis.read(buffer)) >= 0) {
zipOut.write(buffer, 0, length);
}
zipOut.closeEntry();
} }
} }
} }
@@ -16,7 +16,9 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
@@ -28,17 +30,27 @@ import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@RequestMapping("/api/v1/misc") @RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs") @Tag(name = "Misc", description = "Miscellaneous APIs")
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class RepairController { public class RepairController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
private boolean isQpdfEnabled() {
return endpointConfiguration.isGroupEnabled("qpdf");
}
@PostMapping(consumes = "multipart/form-data", value = "/repair") @PostMapping(consumes = "multipart/form-data", value = "/repair")
@Operation( @Operation(
summary = "Repair a PDF file", summary = "Repair a PDF file",
description = description =
"This endpoint repairs a given PDF file by running qpdf command. The PDF is" "This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf (fallback), or PDFBox (if no external tools available). The PDF is"
+ " first saved to a temporary location, repaired, read back, and then" + " first saved to a temporary location, repaired, read back, and then"
+ " returned as a response. Input:PDF Output:PDF Type:SISO") + " returned as a response. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> repairPdf(@ModelAttribute PDFFile file) public ResponseEntity<byte[]> repairPdf(@ModelAttribute PDFFile file)
@@ -46,25 +58,71 @@ public class RepairController {
MultipartFile inputFile = file.getFileInput(); MultipartFile inputFile = file.getFileInput();
// Use TempFile with try-with-resources for automatic cleanup // Use TempFile with try-with-resources for automatic cleanup
try (TempFile tempFile = new TempFile(tempFileManager, ".pdf")) { try (TempFile tempInputFile = new TempFile(tempFileManager, ".pdf");
TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
// Save the uploaded file to the temporary location // Save the uploaded file to the temporary location
inputFile.transferTo(tempFile.getFile()); inputFile.transferTo(tempInputFile.getFile());
List<String> command = new ArrayList<>(); boolean repairSuccess = false;
command.add("qpdf");
command.add("--replace-input"); // Automatically fixes problems it can
command.add("--qdf"); // Linearizes and normalizes PDF structure
command.add("--object-streams=disable"); // Can help with some corruptions
command.add(tempFile.getFile().getAbsolutePath());
ProcessExecutorResult returnCode = // Try Ghostscript first if available
if (isGhostscriptEnabled()) {
try {
List<String> gsCommand = new ArrayList<>();
gsCommand.add("gs");
gsCommand.add("-o");
gsCommand.add(tempOutputFile.getPath().toString());
gsCommand.add("-sDEVICE=pdfwrite");
gsCommand.add(tempInputFile.getPath().toString());
ProcessExecutorResult gsResult =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(gsCommand);
if (gsResult.getRc() == 0) {
repairSuccess = true;
}
} catch (Exception e) {
// Log and continue to QPDF fallback
log.warn("Ghostscript repair failed, trying QPDF fallback: ", e);
}
}
// Fallback to QPDF if Ghostscript failed or not available
if (!repairSuccess && isQpdfEnabled()) {
List<String> qpdfCommand = new ArrayList<>();
qpdfCommand.add("qpdf");
qpdfCommand.add("--replace-input"); // Automatically fixes problems it can
qpdfCommand.add("--qdf"); // Linearizes and normalizes PDF structure
qpdfCommand.add("--object-streams=disable"); // Can help with some corruptions
qpdfCommand.add(tempInputFile.getPath().toString());
qpdfCommand.add(tempOutputFile.getPath().toString());
ProcessExecutorResult qpdfResult =
ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF) ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
.runCommandWithOutputHandling(command); .runCommandWithOutputHandling(qpdfCommand);
// Read the optimized PDF file repairSuccess = true;
byte[] pdfBytes = pdfDocumentFactory.loadToBytes(tempFile.getFile()); }
// Return the optimized PDF as a response // Use PDFBox as last resort if no external tools are available
if (!repairSuccess) {
if (!isGhostscriptEnabled() && !isQpdfEnabled()) {
// Basic PDFBox repair - load and save to fix structural issues
try (var document = pdfDocumentFactory.load(tempInputFile.getFile())) {
document.save(tempOutputFile.getFile());
repairSuccess = true;
}
} else {
throw new IOException("PDF repair failed with available tools");
}
}
// Read the repaired PDF file
byte[] pdfBytes = pdfDocumentFactory.loadToBytes(tempOutputFile.getFile());
// Return the repaired PDF as a response
String outputFilename = String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename()) Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "") .replaceFirst("[.][^.]+$", "")
@@ -150,11 +150,22 @@ public class PipelineProcessor {
} }
} }
if (!hasInputFileType) { if (!hasInputFileType) {
String filename = file.getFilename();
String providedExtension = "no extension";
if (filename != null && filename.contains(".")) {
providedExtension =
filename.substring(filename.lastIndexOf(".")).toLowerCase();
}
logPrintStream.println( logPrintStream.println(
"No files with extension " "No files with extension "
+ String.join(", ", inputFileTypes) + String.join(", ", inputFileTypes)
+ " found for operation " + " found for operation "
+ operation); + operation
+ ". Provided file '"
+ filename
+ "' has extension: "
+ providedExtension);
hasErrors = true; hasErrors = true;
} }
} }
@@ -203,11 +214,32 @@ public class PipelineProcessor {
hasErrors = true; hasErrors = true;
} }
} else { } else {
// Get details about what files were actually provided
List<String> providedExtensions =
outputFiles.stream()
.map(
file -> {
String filename = file.getFilename();
if (filename != null && filename.contains(".")) {
return filename.substring(
filename.lastIndexOf("."))
.toLowerCase();
}
return "no extension";
})
.distinct()
.toList();
logPrintStream.println( logPrintStream.println(
"No files with extension " "No files with extension "
+ String.join(", ", inputFileTypes) + String.join(", ", inputFileTypes)
+ " found for multi-input operation " + " found for multi-input operation "
+ operation); + operation
+ ". Provided files have extensions: "
+ String.join(", ", providedExtensions)
+ " (total files: "
+ outputFiles.size()
+ ")");
hasErrors = true; hasErrors = true;
} }
} }
@@ -65,6 +65,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames; import io.github.pixee.security.Filenames;
import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -73,6 +74,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest; import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -132,7 +134,7 @@ public class CertSignController {
} }
doc.saveIncremental(output); doc.saveIncremental(output);
} catch (Exception e) { } catch (Exception e) {
log.error("exception", e); ExceptionUtils.logException("PDF signing", e);
} }
} }
@@ -165,8 +167,11 @@ public class CertSignController {
Integer pageNumber = request.getPageNumber() != null ? (request.getPageNumber() - 1) : null; Integer pageNumber = request.getPageNumber() != null ? (request.getPageNumber() - 1) : null;
Boolean showLogo = request.getShowLogo(); Boolean showLogo = request.getShowLogo();
if (certType == null) { if (StringUtils.isBlank(certType)) {
throw new IllegalArgumentException("Cert type must be provided"); throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"certificate type");
} }
KeyStore ks = null; KeyStore ks = null;
@@ -189,7 +194,10 @@ public class CertSignController {
ks.load(jksfile.getInputStream(), password.toCharArray()); ks.load(jksfile.getInputStream(), password.toCharArray());
break; break;
default: default:
throw new IllegalArgumentException("Invalid cert type: " + certType); throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"certificate type: " + certType);
} }
CreateSignature createSignature = new CreateSignature(ks, password.toCharArray()); CreateSignature createSignature = new CreateSignature(ks, password.toCharArray());
@@ -63,6 +63,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -149,7 +150,19 @@ public class GetInfoOnPDF {
try { try {
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata(); PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
if (pdMetadata != null) { if (pdMetadata != null) {
COSInputStream metaStream = pdMetadata.createInputStream(); try (COSInputStream metaStream = pdMetadata.createInputStream()) {
// First try to read raw metadata as string to check for standard keywords
byte[] metadataBytes = metaStream.readAllBytes();
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
if (rawMetadata.contains(standardKeyword)) {
return true;
}
}
// If raw check doesn't find it, try parsing with XMP parser
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
try {
DomXmpParser domXmpParser = new DomXmpParser(); DomXmpParser domXmpParser = new DomXmpParser();
XMPMetadata xmpMeta = domXmpParser.parse(metaStream); XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
@@ -160,12 +173,16 @@ public class GetInfoOnPDF {
if (xmpString.contains(standardKeyword)) { if (xmpString.contains(standardKeyword)) {
return true; return true;
} }
} catch (XmpParsingException e) {
// XMP parsing failed, but we already checked raw metadata above
log.debug(
"XMP parsing failed for standard check, but raw metadata was already checked: {}",
e.getMessage());
} }
} catch ( }
Exception }
e) { // Catching general exception for brevity, ideally you'd catch specific } catch (Exception e) {
// exceptions. ExceptionUtils.logException("PDF standard checking", e);
log.error("exception", e);
} }
return false; return false;
@@ -391,14 +408,22 @@ public class GetInfoOnPDF {
if (pdMetadata != null) { if (pdMetadata != null) {
try { try {
COSInputStream is = pdMetadata.createInputStream(); try (COSInputStream is = pdMetadata.createInputStream()) {
DomXmpParser domXmpParser = new DomXmpParser(); DomXmpParser domXmpParser = new DomXmpParser();
XMPMetadata xmpMeta = domXmpParser.parse(is); XMPMetadata xmpMeta = domXmpParser.parse(is);
ByteArrayOutputStream os = new ByteArrayOutputStream(); ByteArrayOutputStream os = new ByteArrayOutputStream();
new XmpSerializer().serialize(xmpMeta, os, true); new XmpSerializer().serialize(xmpMeta, os, true);
xmpString = new String(os.toByteArray(), StandardCharsets.UTF_8); xmpString = new String(os.toByteArray(), StandardCharsets.UTF_8);
} catch (XmpParsingException | IOException e) { }
} catch (XmpParsingException e) {
// XMP parsing failed, try to read raw metadata instead
log.debug("XMP parsing failed, reading raw metadata: {}", e.getMessage());
try (COSInputStream is = pdMetadata.createInputStream()) {
byte[] metadataBytes = is.readAllBytes();
xmpString = new String(metadataBytes, StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.error("exception", e); log.error("exception", e);
} }
} }
@@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.security.AddPasswordRequest; import stirling.software.SPDF.model.api.security.AddPasswordRequest;
import stirling.software.SPDF.model.api.security.PDFPasswordRequest; import stirling.software.SPDF.model.api.security.PDFPasswordRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
@RestController @RestController
@@ -42,12 +43,19 @@ public class PasswordController {
MultipartFile fileInput = request.getFileInput(); MultipartFile fileInput = request.getFileInput();
String password = request.getPassword(); String password = request.getPassword();
PDDocument document = pdfDocumentFactory.load(fileInput, password); PDDocument document = pdfDocumentFactory.load(fileInput, password);
try {
document.setAllSecurityToBeRemoved(true); document.setAllSecurityToBeRemoved(true);
return WebResponseUtils.pdfDocToWebResponse( return WebResponseUtils.pdfDocToWebResponse(
document, document,
Filenames.toSimpleFileName(fileInput.getOriginalFilename()) Filenames.toSimpleFileName(fileInput.getOriginalFilename())
.replaceFirst("[.][^.]+$", "") .replaceFirst("[.][^.]+$", "")
+ "_password_removed.pdf"); + "_password_removed.pdf");
} catch (IOException e) {
document.close();
ExceptionUtils.logException("password removal", e);
throw ExceptionUtils.handlePdfException(e);
}
} }
@PostMapping(consumes = "multipart/form-data", value = "/add-password") @PostMapping(consumes = "multipart/form-data", value = "/add-password")
@@ -41,6 +41,7 @@ import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
import stirling.software.SPDF.model.api.security.SignatureValidationResult; import stirling.software.SPDF.model.api.security.SignatureValidationResult;
import stirling.software.SPDF.service.CertificateValidationService; import stirling.software.SPDF.service.CertificateValidationService;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@RestController @RestController
@RequestMapping("/api/v1/security") @RequestMapping("/api/v1/security")
@@ -82,7 +83,12 @@ public class ValidateSignatureController {
CertificateFactory cf = CertificateFactory.getInstance("X.509"); CertificateFactory cf = CertificateFactory.getInstance("X.509");
customCert = (X509Certificate) cf.generateCertificate(certStream); customCert = (X509Certificate) cf.generateCertificate(certStream);
} catch (CertificateException e) { } catch (CertificateException e) {
throw new RuntimeException("Invalid certificate file: " + e.getMessage()); throw ExceptionUtils.createRuntimeException(
"error.invalidFormat",
"Invalid {0} format: {1}",
e,
"certificate file",
e.getMessage());
} }
} }
@@ -29,6 +29,7 @@ import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface; import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
@Controller @Controller
@@ -263,13 +264,17 @@ public class GeneralWebController {
} }
return null; return null;
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Error processing filename", e); throw ExceptionUtils.createRuntimeException(
"error.fontLoadingFailed",
"Error processing font file",
e);
} }
}) })
.filter(Objects::nonNull) .filter(Objects::nonNull)
.toList(); .toList();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to read font directory from " + locationPattern, e); throw ExceptionUtils.createRuntimeException(
"error.fontDirectoryReadFailed", "Failed to read font directory", e);
} }
} }
@@ -19,6 +19,18 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
defaultValue = "[\"eng\"]") defaultValue = "[\"eng\"]")
private List<String> languages; private List<String> languages;
@Schema(description = "Include OCR text in a sidecar text file if set to true")
private boolean sidecar;
@Schema(description = "Deskew the input file if set to true")
private boolean deskew;
@Schema(description = "Clean the input file if set to true")
private boolean clean;
@Schema(description = "Clean the final output if set to true")
private boolean cleanFinal;
@Schema( @Schema(
description = "Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'", description = "Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'",
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
@@ -31,4 +43,7 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
allowableValues = {"hocr", "sandwich"}, allowableValues = {"hocr", "sandwich"},
defaultValue = "hocr") defaultValue = "hocr")
private String ocrRenderType = "hocr"; private String ocrRenderType = "hocr";
@Schema(description = "Remove images from the output PDF if set to true")
private boolean removeImagesAfter;
} }
@@ -120,8 +120,8 @@ public class ApiDocService {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
apiDocsJsonRootNode = mapper.readTree(apiDocsJson); apiDocsJsonRootNode = mapper.readTree(apiDocsJson);
JsonNode paths = apiDocsJsonRootNode.path("paths"); JsonNode paths = apiDocsJsonRootNode.path("paths");
paths.fields() paths.propertyStream()
.forEachRemaining( .forEach(
entry -> { entry -> {
String path = entry.getKey(); String path = entry.getKey();
JsonNode pathNode = entry.getValue(); JsonNode pathNode = entry.getValue();
@@ -77,7 +77,7 @@ public class MetricsAggregatorService {
double lastCount = lastSentMetrics.getOrDefault(key, 0.0); double lastCount = lastSentMetrics.getOrDefault(key, 0.0);
double difference = currentCount - lastCount; double difference = currentCount - lastCount;
if (difference > 0) { if (difference > 0) {
logger.info("{}, {}", key, difference); logger.debug("{}, {}", key, difference);
metrics.put(key, difference); metrics.put(key, difference);
lastSentMetrics.put(key, currentCount); lastSentMetrics.put(key, currentCount);
} }
@@ -1,7 +1,11 @@
package stirling.software.common.controller; package stirling.software.common.controller;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -14,6 +18,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage; import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobQueue; import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager; import stirling.software.common.service.TaskManager;
@@ -78,16 +83,31 @@ public class JobController {
return ResponseEntity.badRequest().body("Job failed: " + result.getError()); return ResponseEntity.badRequest().body("Job failed: " + result.getError());
} }
if (result.getFileId() != null) { // Handle multiple files - return metadata for client to download individually
try { if (result.hasMultipleFiles()) {
byte[] fileContent = fileStorage.retrieveBytes(result.getFileId());
return ResponseEntity.ok() return ResponseEntity.ok()
.header("Content-Type", result.getContentType()) .contentType(MediaType.APPLICATION_JSON)
.body(
Map.of(
"jobId",
jobId,
"hasMultipleFiles",
true,
"files",
result.getAllResultFiles()));
}
// Handle single file (download directly)
if (result.hasFiles() && !result.hasMultipleFiles()) {
try {
List<ResultFile> files = result.getAllResultFiles();
ResultFile singleFile = files.get(0);
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
return ResponseEntity.ok()
.header("Content-Type", singleFile.getContentType())
.header( .header(
"Content-Disposition", "Content-Disposition",
"form-data; name=\"attachment\"; filename=\"" createContentDispositionHeader(singleFile.getFileName()))
+ result.getOriginalFileName()
+ "\"")
.body(fileContent); .body(fileContent);
} catch (Exception e) { } catch (Exception e) {
log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e); log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e);
@@ -170,4 +190,127 @@ public class JobController {
} }
} }
} }
/**
* Get the list of files for a job
*
* @param jobId The job ID
* @return List of files for the job
*/
@GetMapping("/api/v1/general/job/{jobId}/result/files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
}
if (!result.isComplete()) {
return ResponseEntity.badRequest().body("Job is not complete yet");
}
if (result.getError() != null) {
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
}
List<ResultFile> files = result.getAllResultFiles();
return ResponseEntity.ok(
Map.of(
"jobId", jobId,
"fileCount", files.size(),
"files", files));
}
/**
* Get metadata for an individual file by its file ID
*
* @param fileId The file ID
* @return The file metadata
*/
@GetMapping("/api/v1/general/files/{fileId}/metadata")
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
try {
// Verify file exists
if (!fileStorage.fileExists(fileId)) {
return ResponseEntity.notFound().build();
}
// Find the file metadata from any job that contains this file
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
if (resultFile != null) {
return ResponseEntity.ok(resultFile);
} else {
// File exists but no metadata found, get basic info efficiently
long fileSize = fileStorage.getFileSize(fileId);
return ResponseEntity.ok(
Map.of(
"fileId",
fileId,
"fileName",
"unknown",
"contentType",
"application/octet-stream",
"fileSize",
fileSize));
}
} catch (Exception e) {
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
return ResponseEntity.internalServerError()
.body("Error retrieving file metadata: " + e.getMessage());
}
}
/**
* Download an individual file by its file ID
*
* @param fileId The file ID
* @return The file content
*/
@GetMapping("/api/v1/general/files/{fileId}")
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
try {
// Verify file exists
if (!fileStorage.fileExists(fileId)) {
return ResponseEntity.notFound().build();
}
// Retrieve file content
byte[] fileContent = fileStorage.retrieveBytes(fileId);
// Find the file metadata from any job that contains this file
// This is for getting the original filename and content type
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
String fileName = resultFile != null ? resultFile.getFileName() : "download";
String contentType =
resultFile != null ? resultFile.getContentType() : "application/octet-stream";
return ResponseEntity.ok()
.header("Content-Type", contentType)
.header("Content-Disposition", createContentDispositionHeader(fileName))
.body(fileContent);
} catch (Exception e) {
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
return ResponseEntity.internalServerError()
.body("Error retrieving file: " + e.getMessage());
}
}
/**
* Create Content-Disposition header with UTF-8 filename support
*
* @param fileName The filename to encode
* @return Content-Disposition header value
*/
private String createContentDispositionHeader(String fileName) {
try {
String encodedFileName =
URLEncoder.encode(fileName, StandardCharsets.UTF_8)
.replace("+", "%20"); // URLEncoder uses + for spaces, but we want %20
return "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encodedFileName;
} catch (Exception e) {
// Fallback to basic filename if encoding fails
return "attachment; filename=\"" + fileName + "\"";
}
}
} }
@@ -163,13 +163,74 @@ alphabet=الأبجدية
downloadPdf=تنزيل PDF downloadPdf=تنزيل PDF
text=نص text=نص
font=الخط font=الخط
selectFillter=- حدد - selectFilter=- حدد -
pageNum=رقم الصفحة pageNum=رقم الصفحة
sizes.small=صغير sizes.small=صغير
sizes.medium=وسط sizes.medium=وسط
sizes.large=كبير sizes.large=كبير
sizes.x-large=كبير جدا sizes.x-large=كبير جدا
error.pdfPassword=ملف PDF محمي بكلمة مرور ولم يتم تقديم كلمة المرور أو كانت غير صحيحة error.pdfPassword=ملف PDF محمي بكلمة مرور ولم يتم تقديم كلمة المرور أو كانت غير صحيحة
error.pdfCorrupted=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.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: 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.
# Frontend corruption error messages
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
error.tryRepair=Try using the Repair PDF feature to fix corrupted files.
# Additional error messages
error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=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.
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
# File processing messages
error.fileNotFound=File not found with ID: {0}
# Database and configuration messages
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
delete=حذف delete=حذف
username=اسم المستخدم username=اسم المستخدم
password=كلمة المرور password=كلمة المرور
@@ -181,7 +242,6 @@ red=أحمر
green=أخضر green=أخضر
blue=أزرق blue=أزرق
custom=مخصص... custom=مخصص...
WorkInProgess=العمل قيد التقدم، قد لا يعمل أو يحتوي على أخطاء، يرجى الإبلاغ عن أي مشاكل!
poweredBy=مدعوم بواسطة poweredBy=مدعوم بواسطة
yes=نعم yes=نعم
no=لا no=لا
@@ -525,6 +585,10 @@ home.addImage.title=إضافة صورة إلى ملف PDF
home.addImage.desc=إضافة صورة إلى موقع معين في PDF (العمل قيد التقدم) home.addImage.desc=إضافة صورة إلى موقع معين في PDF (العمل قيد التقدم)
addImage.tags=صورة,jpg,صورة,صورة فوتوغرافية addImage.tags=صورة,jpg,صورة,صورة فوتوغرافية
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=إضافة علامة مائية home.watermark.title=إضافة علامة مائية
home.watermark.desc=أضف علامة مائية مخصصة إلى مستند PDF الخاص بك. home.watermark.desc=أضف علامة مائية مخصصة إلى مستند PDF الخاص بك.
watermark.tags=نص,تكرار,تسمية,خاص,حقوق النشر,علامة تجارية,صورة,jpg,صورة,صورة فوتوغرافية watermark.tags=نص,تكرار,تسمية,خاص,حقوق النشر,علامة تجارية,صورة,jpg,صورة,صورة فوتوغرافية
@@ -1205,6 +1269,12 @@ addImage.everyPage=كل صفحة؟
addImage.upload=إضافة صورة addImage.upload=إضافة صورة
addImage.submit=إضافة صورة addImage.submit=إضافة صورة
#attachments
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
#merge #merge
merge.title=دمج merge.title=دمج
@@ -1594,6 +1664,7 @@ fileChooser.dragAndDropPDF=Drag & Drop PDF file
fileChooser.dragAndDropImage=Drag & Drop Image file fileChooser.dragAndDropImage=Drag & Drop Image file
fileChooser.hoveredDragAndDrop=قم بسحب المفات وإفلاتها هنا fileChooser.hoveredDragAndDrop=قم بسحب المفات وإفلاتها هنا
fileChooser.extractPDF=جاري الاستخراج... fileChooser.extractPDF=جاري الاستخراج...
fileChooser.addAttachments=drag & drop attachments here
#release notes #release notes
releases.footer=Releases releases.footer=Releases
@@ -163,13 +163,74 @@ alphabet=Əlifba
downloadPdf=PDF Yüklə downloadPdf=PDF Yüklə
text=Yazı text=Yazı
font=Şrift font=Şrift
selectFillter=-- Seç -- selectFilter=-- Seç --
pageNum=Səhifə nömrəsi pageNum=Səhifə nömrəsi
sizes.small=Kiçik sizes.small=Kiçik
sizes.medium=Orta sizes.medium=Orta
sizes.large=Böyük sizes.large=Böyük
sizes.x-large=Ekstra Böyük sizes.x-large=Ekstra Böyük
error.pdfPassword=PDF sənədi şifrlənmişdir və şifr təmin edilməmişdir və ya yanlışdır. error.pdfPassword=PDF sənədi şifrlənmişdir və şifr təmin edilməmişdir və ya yanlışdır.
error.pdfCorrupted=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.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: 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.
# Frontend corruption error messages
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
error.tryRepair=Try using the Repair PDF feature to fix corrupted files.
# Additional error messages
error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=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.
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
# File processing messages
error.fileNotFound=File not found with ID: {0}
# Database and configuration messages
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
delete=Sil delete=Sil
username=İstifadəçi Adı username=İstifadəçi Adı
password=Şifr password=Şifr
@@ -181,7 +242,6 @@ red=Qırmızı
green=Yaşıl green=Yaşıl
blue=Mavi blue=Mavi
custom=Xüsusi... custom=Xüsusi...
WorkInProgess=İş davam edir, İşləməyə bilər və ya xətalarla üzləşə bilərsiniz, Zəhmət olmasa problemləri bildirin!
poweredBy=Təchiz edilmişdir poweredBy=Təchiz edilmişdir
yes=Bəli yes=Bəli
no=Xeyr no=Xeyr
@@ -525,6 +585,10 @@ home.addImage.title=Şəkil əlavə et
home.addImage.desc=PDF-də təyin edilmiş yerə şəkil əlavə edir home.addImage.desc=PDF-də təyin edilmiş yerə şəkil əlavə edir
addImage.tags=şəkil,jpg,fotoşəkil,foto addImage.tags=şəkil,jpg,fotoşəkil,foto
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=Watermark əlavə et home.watermark.title=Watermark əlavə et
home.watermark.desc=PDF sənədinə fərdi watermark əlavə et. home.watermark.desc=PDF sənədinə fərdi watermark əlavə et.
watermark.tags=Mətn,təkrarlanan,nişan,sahib olmaq,müəllif hüquqları,əmtəə nişanı,şəkil,jpg,fotoşəkil,foto watermark.tags=Mətn,təkrarlanan,nişan,sahib olmaq,müəllif hüquqları,əmtəə nişanı,şəkil,jpg,fotoşəkil,foto
@@ -1205,6 +1269,12 @@ addImage.everyPage=Bütün Səhifələr?
addImage.upload=Şəkli Əlavə Et addImage.upload=Şəkli Əlavə Et
addImage.submit=Şəkli Əlavə Et addImage.submit=Şəkli Əlavə Et
#attachments
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
#merge #merge
merge.title=Birləşdirin merge.title=Birləşdirin
@@ -1594,6 +1664,7 @@ fileChooser.dragAndDropPDF=Drag & Drop PDF file
fileChooser.dragAndDropImage=Drag & Drop Image file fileChooser.dragAndDropImage=Drag & Drop Image file
fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here
fileChooser.extractPDF=Extracting... fileChooser.extractPDF=Extracting...
fileChooser.addAttachments=drag & drop attachments here
#release notes #release notes
releases.footer=Buraxılışlar releases.footer=Buraxılışlar
@@ -163,13 +163,74 @@ alphabet=Азбука
downloadPdf=Изтеглете PDF downloadPdf=Изтеглете PDF
text=Текст text=Текст
font=Шрифт font=Шрифт
selectFillter=-- Изберете -- selectFilter=-- Изберете --
pageNum=Брой страница pageNum=Брой страница
sizes.small=Малък sizes.small=Малък
sizes.medium=Среден sizes.medium=Среден
sizes.large=Голям sizes.large=Голям
sizes.x-large=X-Голям sizes.x-large=X-Голям
error.pdfPassword=PDF документът е с парола и или паролата не е предоставена, или е неправилна error.pdfPassword=PDF документът е с парола и или паролата не е предоставена, или е неправилна
error.pdfCorrupted=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.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: 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.
# Frontend corruption error messages
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
error.tryRepair=Try using the Repair PDF feature to fix corrupted files.
# Additional error messages
error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=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.
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
# File processing messages
error.fileNotFound=File not found with ID: {0}
# Database and configuration messages
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
delete=Изтрий delete=Изтрий
username=Потребителско име username=Потребителско име
password=Парола password=Парола
@@ -181,7 +242,6 @@ red=Червено
green=Зелено green=Зелено
blue=Синьо blue=Синьо
custom=Персонализиране... custom=Персонализиране...
WorkInProgess=Работата е в ход, може да не работи или да има грешки, моля, докладвайте за проблеми!
poweredBy=Задвижван чрез poweredBy=Задвижван чрез
yes=Да yes=Да
no=Не no=Не
@@ -525,6 +585,10 @@ home.addImage.title=Добавяне на изображение
home.addImage.desc=Добавя изображение към зададено място към PDF файла home.addImage.desc=Добавя изображение към зададено място към PDF файла
addImage.tags=img,jpg,изображение,снимка addImage.tags=img,jpg,изображение,снимка
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=Добавяне на воден знак home.watermark.title=Добавяне на воден знак
home.watermark.desc=Добавете персонализиран воден знак към вашия PDF документ. home.watermark.desc=Добавете персонализиран воден знак към вашия PDF документ.
watermark.tags=Текст,повтарящ се,етикет,собствено,авторско право,търговска марка,img,jpg,изображение,снимка watermark.tags=Текст,повтарящ се,етикет,собствено,авторско право,търговска марка,img,jpg,изображение,снимка
@@ -1205,6 +1269,12 @@ addImage.everyPage=Всяка страница?
addImage.upload=Добавяне на изображение addImage.upload=Добавяне на изображение
addImage.submit=Добавяне на изображение addImage.submit=Добавяне на изображение
#attachments
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
#merge #merge
merge.title=Обединяване merge.title=Обединяване
@@ -1594,6 +1664,7 @@ fileChooser.dragAndDropPDF=Влачете и пуснете PDF файл
fileChooser.dragAndDropImage=Влачете и пуснете изображение fileChooser.dragAndDropImage=Влачете и пуснете изображение
fileChooser.hoveredDragAndDrop=Влачете и пуснете файл(ове) тук fileChooser.hoveredDragAndDrop=Влачете и пуснете файл(ове) тук
fileChooser.extractPDF=Извличане... fileChooser.extractPDF=Извличане...
fileChooser.addAttachments=drag & drop attachments here
#release notes #release notes
releases.footer=Версии releases.footer=Версии
@@ -163,13 +163,74 @@ alphabet=གསལ་བྱེད།
downloadPdf=PDF ཕབ་ལེན། downloadPdf=PDF ཕབ་ལེན།
text=ཡི་གེ text=ཡི་གེ
font=ཡིག་གཟུགས་ཌྷ font=ཡིག་གཟུགས་ཌྷ
selectFillter=-- འདེམས་རོགས། -- selectFilter=-- འདེམས་རོགས། --
pageNum=ཤོག་གིངས། pageNum=ཤོག་གིངས།
sizes.small=ཆུང་ཆང་། sizes.small=ཆུང་ཆང་།
sizes.medium=འབྲིང་ཚད། sizes.medium=འབྲིང་ཚད།
sizes.large=ཆེན་པོ། sizes.large=ཆེན་པོ།
sizes.x-large=ཧ་ཅང་ཆེན་པོ། sizes.x-large=ཧ་ཅང་ཆེན་པོ།
error.pdfPassword=PDF ཡིག་ཆར་གསང་ཚིག་བཀོད་ཡོད་པ་དང་། གསང་ཚིག་མ་བཀོད་པའམ་ནོར་འདུག error.pdfPassword=PDF ཡིག་ཆར་གསང་ཚིག་བཀོད་ཡོད་པ་དང་། གསང་ཚིག་མ་བཀོད་པའམ་ནོར་འདུག
error.pdfCorrupted=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.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: 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.
# Frontend corruption error messages
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
error.tryRepair=Try using the Repair PDF feature to fix corrupted files.
# Additional error messages
error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=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.
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
# File processing messages
error.fileNotFound=File not found with ID: {0}
# Database and configuration messages
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
delete=སུབ་པ། delete=སུབ་པ།
username=སྤྱོད་མཁན་མིང་། username=སྤྱོད་མཁན་མིང་།
password=གསང་ཚིག། password=གསང་ཚིག།
@@ -181,7 +242,6 @@ red=དམར་པོ
green=ལྗང་ཁུ། green=ལྗང་ཁུ།
blue=སྔོན་པོ blue=སྔོན་པོ
custom=མཚན་ཉིད་རང་སྒྲིག... custom=མཚན་ཉིད་རང་སྒྲིག...
WorkInProgess=ལས་ཀ་བྱེད་བཞིན་པ། ནོར་འཁྲུལ་ཡོང་སྲིད། དཀའ་ངལ་ཡོད་ཚེ་སྙན་སེང་གནང་རོགས།
poweredBy=མཁོ་སྲོད་བྱེད་མཁན། poweredBy=མཁོ་སྲོད་བྱེད་མཁན།
yes=ཡིན། yes=ཡིན།
no=མིན། no=མིན།
@@ -525,6 +585,10 @@ home.addImage.title=པར་རིས་སྣོན་པ།
home.addImage.desc=PDF ནང་གནས་ས་ངེས་ཅན་ཞིག་ཏུ་པར་རིས་སྣོན་པ། home.addImage.desc=PDF ནང་གནས་ས་ངེས་ཅན་ཞིག་ཏུ་པར་རིས་སྣོན་པ།
addImage.tags=པར་རིས།,jpg,པར།,འདྲ་པར། addImage.tags=པར་རིས།,jpg,པར།,འདྲ་པར།
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=ཆུ་རྟགས་སྣོན་པ། home.watermark.title=ཆུ་རྟགས་སྣོན་པ།
home.watermark.desc=PDF ཡིག་ཆར་རང་སྒྲིག་གི་ཆུ་རྟགས་སྣོན་པ། home.watermark.desc=PDF ཡིག་ཆར་རང་སྒྲིག་གི་ཆུ་རྟགས་སྣོན་པ།
watermark.tags=ཡི་གེ,བསྐྱར་ཟློས།,ཁ་ཡིག,རང་དབང་།,པར་དབང་།,ཚོང་རྟགས།,པར་རིས།,jpg,པར།,འདྲ་པར། watermark.tags=ཡི་གེ,བསྐྱར་ཟློས།,ཁ་ཡིག,རང་དབང་།,པར་དབང་།,ཚོང་རྟགས།,པར་རིས།,jpg,པར།,འདྲ་པར།
@@ -1205,6 +1269,12 @@ addImage.everyPage=ཤོག་ངོས་ཚང་མར་ཡིན་ནམ
addImage.upload=པར་རིས་སྣོན་པ། addImage.upload=པར་རིས་སྣོན་པ།
addImage.submit=པར་རིས་སྣོན་པ། addImage.submit=པར་རིས་སྣོན་པ།
#attachments
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
#merge #merge
merge.title=སྡེབ་སྦྱོར། merge.title=སྡེབ་སྦྱོར།
@@ -1594,6 +1664,7 @@ fileChooser.dragAndDropPDF=PDF ཡིག་ཆ་འཐེན་ནས་འཇ
fileChooser.dragAndDropImage=པར་རིས་ཡིག་ཆ་འཐེན་ནས་འཇོག་པ། fileChooser.dragAndDropImage=པར་རིས་ཡིག་ཆ་འཐེན་ནས་འཇོག་པ།
fileChooser.hoveredDragAndDrop=ཡིག་ཆ་འདིར་འཐེན་ནས་འཇོག་པ། fileChooser.hoveredDragAndDrop=ཡིག་ཆ་འདིར་འཐེན་ནས་འཇོག་པ།
fileChooser.extractPDF=འདོན་རིས་འགྱུར་བའི་སྒྲིག་བཏང་བ། fileChooser.extractPDF=འདོན་རིས་འགྱུར་བའི་སྒྲིག་བཏང་བ།
fileChooser.addAttachments=drag & drop attachments here
#release notes #release notes
releases.footer=པར་གཞི། releases.footer=པར་གཞི།
@@ -163,13 +163,74 @@ alphabet=Alfabet
downloadPdf=Descarregueu PDF downloadPdf=Descarregueu PDF
text=Text text=Text
font=Tipus de lletra font=Tipus de lletra
selectFillter=-- Selecciona -- selectFilter=-- Selecciona --
pageNum=Número de pàgina pageNum=Número de pàgina
sizes.small=Petit sizes.small=Petit
sizes.medium=Mitjà sizes.medium=Mitjà
sizes.large=Llarg sizes.large=Llarg
sizes.x-large=X-Large sizes.x-large=X-Large
error.pdfPassword=El PDF està protegit o bé el password és incorrecte error.pdfPassword=El PDF està protegit o bé el password és incorrecte
error.pdfCorrupted=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.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: 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.
# Frontend corruption error messages
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
error.tryRepair=Try using the Repair PDF feature to fix corrupted files.
# Additional error messages
error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=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.
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
# File processing messages
error.fileNotFound=File not found with ID: {0}
# Database and configuration messages
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
delete=Esborra delete=Esborra
username=Usuari username=Usuari
password=Contrasenya password=Contrasenya
@@ -181,7 +242,6 @@ red=Vermell
green=Verd green=Verd
blue=Blau blue=Blau
custom=Personalitzat... custom=Personalitzat...
WorkInProgess=En desenvolupament, pot no funcionar o contenir errors. Si us plau, informa de qualsevol problema!
poweredBy=Impulsat per poweredBy=Impulsat per
yes=Si yes=Si
no=No no=No
@@ -525,6 +585,10 @@ home.addImage.title=Afegir imatge a PDF
home.addImage.desc=Afegeix una imatge en un PDF (en progrés) home.addImage.desc=Afegeix una imatge en un PDF (en progrés)
addImage.tags=img,jpg,imatge,foto addImage.tags=img,jpg,imatge,foto
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=Afegir Marca d'aigua home.watermark.title=Afegir Marca d'aigua
home.watermark.desc=Afegir una marca d'aigua personalitzada en un PDF home.watermark.desc=Afegir una marca d'aigua personalitzada en un PDF
watermark.tags=text,repetició,etiqueta,propia,copyright,marca registrada,img,jpg,imatge,foto watermark.tags=text,repetició,etiqueta,propia,copyright,marca registrada,img,jpg,imatge,foto
@@ -1205,6 +1269,12 @@ addImage.everyPage=Totes les pàgines?
addImage.upload=Afegir Imatge addImage.upload=Afegir Imatge
addImage.submit=Afegir Imatge addImage.submit=Afegir Imatge
#attachments
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
#merge #merge
merge.title=Fusiona merge.title=Fusiona
@@ -1594,6 +1664,7 @@ fileChooser.dragAndDropPDF=Arrossega i deixa anar un fitxer PDF
fileChooser.dragAndDropImage=Arrossega i deixa anar un fitxer d'imatge fileChooser.dragAndDropImage=Arrossega i deixa anar un fitxer d'imatge
fileChooser.hoveredDragAndDrop=Arrossega i deixa anar fitxer(s) aquí fileChooser.hoveredDragAndDrop=Arrossega i deixa anar fitxer(s) aquí
fileChooser.extractPDF=Extracting... fileChooser.extractPDF=Extracting...
fileChooser.addAttachments=drag & drop attachments here
#release notes #release notes
releases.footer=Llançaments releases.footer=Llançaments

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