Compare commits

..
776 changed files with 292146 additions and 336945 deletions
+65 -42
View File
@@ -1,52 +1,75 @@
# Version control
.git/
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# Build outputs
build/
*/build/
**/build/
out/
target/
# Gradle caches (local, not what's in the container)
.gradle/
**/.gradle/
# Node / frontend
node_modules/
**/node_modules/
.npm/
.yarn/
# IDE and editor
.idea/
.vscode/
# IDE
.vscode
.idea
*.iml
*.ipr
*.iws
*.ipr
# Logs and temp files
# Logs
*.log
*.tmp
*.pid
.DS_Store
Thumbs.db
logs
# Docker itself
Dockerfile*
.dockerignore
# CI / CD configs (not needed in build context)
.github/
.circleci/
.gitlab-ci.yml
# Test reports
**/test-results/
**/jacoco/
# Local env
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
-5
View File
@@ -68,8 +68,3 @@ updates:
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: cargo
directory: /frontend/src-tauri/provisioner
schedule:
interval: daily
-17
View File
@@ -259,27 +259,10 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
report.append("")
report.append(" Use the following command to remove them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
)
report.append("")
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
report.append("")
report.append(" Use the following command to add them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
)
report.append("")
if missing_keys_list or extra_keys_list:
report.append(
" See: https://github.com/Stirling-Tools/Stirling-PDF/tree/main/scripts/translations#2-translation_mergerpy"
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
+101 -101
View File
@@ -9,7 +9,7 @@
# CVE-2025-6176 mitigation: pin brotli to a specific commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r requirements_dev.in
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
@@ -256,105 +256,105 @@ opencv-python-headless==4.13.0.90 \
--hash=sha256:e13790342591557050157713af17a7435ac1b50c65282715093c9297fa045d8f \
--hash=sha256:eba38bc255d0b7d1969c5bcc90a060ca2b61a3403b613872c750bfa5dfe9e03b \
--hash=sha256:f46b17ea0aa7e4124ca6ad71143f89233ae9557f61d2326bcdb34329a1ddf9bd
# via -r requirements_dev.in
# via -r .github/scripts/requirements_dev.in
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r requirements_dev.in
pillow==12.1.1 \
--hash=sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9 \
--hash=sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da \
--hash=sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f \
--hash=sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642 \
--hash=sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713 \
--hash=sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850 \
--hash=sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9 \
--hash=sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0 \
--hash=sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9 \
--hash=sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8 \
--hash=sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6 \
--hash=sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd \
--hash=sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5 \
--hash=sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c \
--hash=sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35 \
--hash=sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1 \
--hash=sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff \
--hash=sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38 \
--hash=sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4 \
--hash=sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af \
--hash=sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60 \
--hash=sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986 \
--hash=sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13 \
--hash=sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717 \
--hash=sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e \
--hash=sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b \
--hash=sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15 \
--hash=sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a \
--hash=sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb \
--hash=sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d \
--hash=sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b \
--hash=sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e \
--hash=sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a \
--hash=sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f \
--hash=sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a \
--hash=sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce \
--hash=sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc \
--hash=sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f \
--hash=sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586 \
--hash=sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f \
--hash=sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9 \
--hash=sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8 \
--hash=sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40 \
--hash=sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60 \
--hash=sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c \
--hash=sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0 \
--hash=sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334 \
--hash=sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af \
--hash=sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735 \
--hash=sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524 \
--hash=sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf \
--hash=sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b \
--hash=sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2 \
--hash=sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9 \
--hash=sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7 \
--hash=sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e \
--hash=sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4 \
--hash=sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4 \
--hash=sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b \
--hash=sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397 \
--hash=sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c \
--hash=sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e \
--hash=sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029 \
--hash=sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3 \
--hash=sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052 \
--hash=sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984 \
--hash=sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293 \
--hash=sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523 \
--hash=sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f \
--hash=sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b \
--hash=sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80 \
--hash=sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f \
--hash=sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79 \
--hash=sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23 \
--hash=sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8 \
--hash=sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e \
--hash=sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3 \
--hash=sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e \
--hash=sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36 \
--hash=sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f \
--hash=sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5 \
--hash=sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f \
--hash=sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6 \
--hash=sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32 \
--hash=sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20 \
--hash=sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202 \
--hash=sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0 \
--hash=sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3 \
--hash=sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563 \
--hash=sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090 \
--hash=sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289
# via -r .github/scripts/requirements_dev.in
pillow==12.1.0 \
--hash=sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d \
--hash=sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc \
--hash=sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84 \
--hash=sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de \
--hash=sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0 \
--hash=sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef \
--hash=sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4 \
--hash=sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82 \
--hash=sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9 \
--hash=sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030 \
--hash=sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0 \
--hash=sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18 \
--hash=sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a \
--hash=sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef \
--hash=sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b \
--hash=sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6 \
--hash=sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179 \
--hash=sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e \
--hash=sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72 \
--hash=sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64 \
--hash=sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451 \
--hash=sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd \
--hash=sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924 \
--hash=sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616 \
--hash=sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a \
--hash=sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94 \
--hash=sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc \
--hash=sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8 \
--hash=sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9 \
--hash=sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91 \
--hash=sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a \
--hash=sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c \
--hash=sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670 \
--hash=sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea \
--hash=sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91 \
--hash=sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c \
--hash=sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc \
--hash=sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0 \
--hash=sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b \
--hash=sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65 \
--hash=sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661 \
--hash=sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19 \
--hash=sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1 \
--hash=sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0 \
--hash=sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e \
--hash=sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75 \
--hash=sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4 \
--hash=sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8 \
--hash=sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd \
--hash=sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7 \
--hash=sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61 \
--hash=sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51 \
--hash=sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551 \
--hash=sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45 \
--hash=sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1 \
--hash=sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644 \
--hash=sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796 \
--hash=sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587 \
--hash=sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304 \
--hash=sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b \
--hash=sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8 \
--hash=sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17 \
--hash=sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171 \
--hash=sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3 \
--hash=sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7 \
--hash=sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988 \
--hash=sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a \
--hash=sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0 \
--hash=sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c \
--hash=sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2 \
--hash=sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14 \
--hash=sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5 \
--hash=sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a \
--hash=sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377 \
--hash=sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0 \
--hash=sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5 \
--hash=sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b \
--hash=sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d \
--hash=sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac \
--hash=sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c \
--hash=sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554 \
--hash=sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643 \
--hash=sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13 \
--hash=sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09 \
--hash=sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208 \
--hash=sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda \
--hash=sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea \
--hash=sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e \
--hash=sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0 \
--hash=sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831 \
--hash=sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd
# via
# -r requirements_dev.in
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.5.1 \
@@ -364,7 +364,7 @@ platformdirs==4.5.1 \
pre-commit==4.5.1 \
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61
# via -r requirements_dev.in
# via -r .github/scripts/requirements_dev.in
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
@@ -465,7 +465,7 @@ tinyhtml5==2.0.0 \
unoserver==3.6 \
--hash=sha256:25c360fa194396a89cb79b4edd2735f8e4f0fd8531e59db3952114585bd7df05 \
--hash=sha256:e446bcb3638c51880f002aaeecab1cf74dfa9df81035f027f7ff2e081b6d7015
# via -r requirements_dev.in
# via -r .github/scripts/requirements_dev.in
virtualenv==20.36.1 \
--hash=sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f \
--hash=sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba
@@ -473,7 +473,7 @@ virtualenv==20.36.1 \
weasyprint==68.0 \
--hash=sha256:447f40898b747cb44ac31a5d493d512e7441fd56e13f63744c099383bbf9cda9 \
--hash=sha256:c2cb40c71b50837c5971f00171c9e4078e8c9912dd7c217f3e90e068f11e8aa1
# via -r requirements_dev.in
# via -r .github/scripts/requirements_dev.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
@@ -500,8 +500,8 @@ zopfli==0.4.0 \
pip==26.0 \
--hash=sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa \
--hash=sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754
# via -r requirements_dev.in
# via -r .github/scripts/requirements_dev.in
setuptools==80.10.2 \
--hash=sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70 \
--hash=sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173
# via -r requirements_dev.in
# via -r .github/scripts/requirements_dev.in
+4 -6
View File
@@ -116,7 +116,7 @@ jobs:
egress-policy: audit
- name: Checkout main repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: ${{ github.repository }}
ref: main
@@ -172,7 +172,7 @@ jobs:
return newComment.id;
- name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }}
@@ -189,7 +189,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -231,8 +231,6 @@ jobs:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
@@ -364,7 +362,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -45,7 +45,7 @@ jobs:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -133,7 +133,7 @@ jobs:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -145,19 +145,19 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -179,7 +179,7 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -190,8 +190,6 @@ jobs:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
platforms: linux/amd64
@@ -377,7 +375,7 @@ jobs:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
id: setup-bot
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
id: setup-bot
+35 -120
View File
@@ -36,7 +36,7 @@ jobs:
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -52,7 +52,7 @@ jobs:
strategy:
fail-fast: false
matrix:
jdk-version: [21, 25]
jdk-version: [17, 21]
spring-security: [true, false]
steps:
- name: Harden Runner
@@ -60,28 +60,18 @@ jobs:
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
cache-disabled: true
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew build -PnoSpotless
@@ -111,7 +101,7 @@ jobs:
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
@@ -145,28 +135,18 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
cache-disabled: true
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
@@ -177,7 +157,7 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-docs
path: ./SwaggerDoc.json
@@ -192,9 +172,9 @@ jobs:
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "22"
cache: "npm"
@@ -210,7 +190,7 @@ jobs:
- name: Run frontend tests
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: frontend-build
path: frontend/dist/
@@ -227,36 +207,21 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
cache-disabled: true
- name: check the licenses for compatibility
# NOTE: --no-parallel is intentional here. Running the checkLicense task in parallel with other
# Gradle tasks has been observed to cause intermittent failures with the dependency license
# checking plugin on this Gradle version. Disabling parallel execution trades some build speed
# for more reliable, deterministic license checks. If upgrading Gradle or the plugin, consider
# re-evaluating whether this flag is still required before removing it.
run: ./gradlew checkLicense --no-parallel
run: ./gradlew checkLicense
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
@@ -264,7 +229,7 @@ jobs:
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -288,10 +253,6 @@ jobs:
# )
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
checks: write
steps:
- name: Harden Runner
@@ -300,36 +261,22 @@ jobs:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
cache-disabled: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
@@ -345,7 +292,6 @@ jobs:
- name: Pip requirements
run: |
pip install --require-hashes -r ./testing/cucumber/requirements.txt
pip install behave-html-formatter
- name: Run Docker Compose Tests
run: |
@@ -358,24 +304,6 @@ jobs:
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Upload Cucumber Report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: cucumber-report
path: testing/cucumber/report.html
retention-days: 7
if-no-files-found: warn
- name: Cucumber Test Report
if: always()
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
with:
name: Cucumber Tests
path: testing/cucumber/junit/*.xml
reporter: java-junit
fail-on-error: false
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
@@ -386,13 +314,10 @@ jobs:
include:
- docker-rev: docker/embedded/Dockerfile
artifact-suffix: Dockerfile
cache-scope: stirling-pdf-latest
- docker-rev: docker/embedded/Dockerfile.ultra-lite
artifact-suffix: Dockerfile.ultra-lite
cache-scope: stirling-pdf-ultra-lite
- docker-rev: docker/embedded/Dockerfile.fat
artifact-suffix: Dockerfile.fat
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
@@ -400,7 +325,7 @@ jobs:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Free disk space on runner
run: |
@@ -409,26 +334,16 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
cache-disabled: true
- name: Build application
run: ./gradlew build
@@ -453,15 +368,15 @@ jobs:
context: .
file: ./${{ matrix.docker-rev }}
push: false
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
cache-from: type=gha,scope=${{ matrix.artifact-suffix }}
cache-to: type=gha,mode=max,scope=${{ matrix.artifact-suffix }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Upload Reports
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
egress-policy: audit
- name: Checkout main branch first
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
id: setup-bot
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
egress-policy: audit
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "Dependency Review"
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
+2 -6
View File
@@ -23,7 +23,7 @@ jobs:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
@@ -85,7 +85,7 @@ jobs:
fi
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -97,8 +97,6 @@ jobs:
context: .
file: ./docker/frontend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-frontend
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
@@ -112,8 +110,6 @@ jobs:
context: .
file: ./docker/backend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-backend
cache-to: type=gha,mode=max,scope=stirling-v2-backend
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
@@ -30,7 +30,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -54,7 +54,7 @@ jobs:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -69,7 +69,7 @@ jobs:
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
@@ -77,7 +77,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "22"
cache: "npm"
@@ -273,7 +273,7 @@ jobs:
- name: Create Pull Request (Push only)
id: cpr
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
@@ -317,7 +317,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -330,26 +330,21 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Check licenses and generate report
id: license-check
run: |
# NOTE: --no-parallel is intentional here. Running the license-checking tasks in parallel has
# previously caused intermittent concurrency issues in CI (e.g. flaky failures in the license
# plugin/Gradle when multiple projects are evaluated concurrently). Disabling parallelism trades
# some build speed for more reliable license reports. If the underlying issues are resolved in
# future Gradle or plugin versions, this flag can be reconsidered.
./gradlew checkLicense generateLicenseReport --no-parallel || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
./gradlew checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
@@ -369,7 +364,7 @@ jobs:
- name: Upload artifact on license issues
if: env.LICENSE_WARNINGS_EXIST == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: backend-dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -495,7 +490,7 @@ jobs:
- name: Create Pull Request (push only)
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
id: cpr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Backend 3rd Party Licenses"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
+24 -33
View File
@@ -21,6 +21,8 @@ on:
- windows
- macos
- linux
push:
branches: [main, V2-master]
release:
types: [created]
@@ -40,26 +42,16 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -123,22 +115,22 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: "npm"
@@ -162,7 +154,7 @@ jobs:
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
@@ -184,7 +176,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
@@ -193,7 +185,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: "npm"
@@ -205,14 +197,14 @@ jobs:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -299,7 +291,7 @@ jobs:
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
uses: digicert/ssm-code-signing@9476ceec3ea1c63298d4403b983e1ccf2556ff4c # v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
@@ -419,9 +411,8 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
# Only enable Windows signing in Tauri when on release or V2-master
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
@@ -542,7 +533,7 @@ jobs:
fi
- name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
+7 -15
View File
@@ -26,10 +26,9 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
@@ -49,24 +48,17 @@ jobs:
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit
run: |
pre-commit run ruff --all-files -c .pre-commit-config.yaml
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
pre-commit run codespell --all-files -c .pre-commit-config.yaml
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: 21
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -84,7 +76,7 @@ jobs:
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
+13 -23
View File
@@ -37,26 +37,16 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -85,13 +75,13 @@ jobs:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -126,8 +116,8 @@ jobs:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
@@ -172,8 +162,8 @@ jobs:
context: .
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha,scope=stirling-pdf-fat
cache-to: type=gha,mode=max,scope=stirling-pdf-fat
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta-fat.outputs.tags }}
labels: ${{ steps.meta-fat.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
@@ -216,8 +206,8 @@ jobs:
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha,scope=stirling-pdf-ultra-lite
cache-to: type=gha,mode=max,scope=stirling-pdf-ultra-lite
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta-lite.outputs.tags }}
labels: ${{ steps.meta-lite.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -67,7 +67,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: SARIF file
path: results.sarif
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
with:
sarif_file: results.sarif
+5 -5
View File
@@ -31,16 +31,16 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
+3 -9
View File
@@ -39,9 +39,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup GitHub App Bot
id: setup-bot
@@ -57,16 +55,12 @@ jobs:
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
- name: pre-commit run
run: |
pre-commit run toml-sort-fix --all-files
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.toml
@@ -83,7 +77,7 @@ jobs:
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
+31 -145
View File
@@ -14,20 +14,17 @@ on:
- macos
- linux
pull_request:
branches: [main]
types: [opened, reopened, synchronize, ready_for_review]
branches: [main, V2-tauri-windows]
paths:
- "frontend/src-tauri/**"
- "frontend/src/desktop/**"
- "frontend/tsconfig.desktop.json"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/vite.config.ts"
- ".github/workflows/tauri-build.yml"
push:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
determine-matrix:
@@ -43,42 +40,27 @@ jobs:
- name: Determine build matrix
id: set-matrix
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
# Resolve requested platform (non-dispatch events always build all)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PLATFORM="${{ github.event.inputs.platform }}"
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
PLATFORM="all"
# For PR/push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
# Build candidate list
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
linux) ENTRIES=("$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
esac
# Drop macOS entries when Apple certificate secret is unavailable
if [ -z "$APPLE_CERTIFICATE" ]; then
echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds"
FILTERED=()
for entry in "${ENTRIES[@]}"; do
[[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry")
done
ENTRIES=("${FILTERED[@]}")
fi
JOINED=$(IFS=','; echo "${ENTRIES[*]}")
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
build:
needs: determine-matrix
strategy:
@@ -88,7 +70,6 @@ jobs:
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
@@ -96,7 +77,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
@@ -105,7 +86,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: "npm"
@@ -117,14 +98,14 @@ jobs:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -212,7 +193,7 @@ jobs:
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
uses: digicert/ssm-code-signing@9476ceec3ea1c63298d4403b983e1ccf2556ff4c # v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
@@ -287,7 +268,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -308,7 +289,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -343,9 +324,8 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
@@ -606,7 +586,7 @@ jobs:
}
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
@@ -659,100 +639,6 @@ jobs:
fi
done
pr-comment:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && needs.build.result == 'success'
permissions:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Post/Update PR Comment with Download Links
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.issue.number;
const runId = context.runId;
// Fetch artifacts for this workflow run
const { data: artifactsList } = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: runId
});
// Map of expected artifact names to display info
const artifactMap = {
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .AppImage' }
};
let commentBody = `## 📦 Tauri Desktop Builds Ready!\n\n`;
commentBody += `The desktop applications have been built and are ready for testing.\n\n`;
commentBody += `### Download Artifacts:\n\n`;
// Add links for each found artifact
let foundArtifacts = 0;
for (const artifact of artifactsList.artifacts) {
const info = artifactMap[artifact.name];
if (info) {
foundArtifacts++;
// GitHub doesn't provide direct download URLs via API, but we can link to the artifact on the Actions page
const artifactUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${artifact.id}`;
commentBody += `${info.icon} **${info.platform}**: [Download ${artifact.name}](${artifactUrl}) `;
commentBody += `(${info.files}) - ${(artifact.size_in_bytes / 1024 / 1024).toFixed(1)} MB\n`;
}
}
if (foundArtifacts === 0) {
commentBody += `⚠️ **Warning**: No artifacts found in workflow run.\n`;
commentBody += `[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})\n`;
}
commentBody += `\n---\n`;
commentBody += `_Built from commit ${context.sha.substring(0, 7)}_\n`;
commentBody += `_Artifacts expire in 7 days_`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('📦 Tauri Desktop Builds Ready!')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner,
repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
console.log('Created new comment');
}
report:
needs: build
runs-on: ubuntu-latest
+9 -11
View File
@@ -30,16 +30,16 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
@@ -61,7 +61,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -72,8 +72,6 @@ jobs:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
@@ -135,7 +133,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -154,10 +152,10 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
+1 -1
View File
@@ -149,7 +149,6 @@ app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
frontend/src-tauri/provisioner/target
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -244,3 +243,4 @@ docs/type3/signatures/
# Type3 sample PDFs (development only)
**/type3/samples/
+3 -9
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.14
rev: v0.14.8
hooks:
- id: ruff
args:
@@ -16,11 +16,11 @@ repos:
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist
- --ignore-words-list=thirdParty,tabEl,tabEls
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
@@ -34,12 +34,6 @@ repos:
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- repo: https://github.com/pappasam/toml-sort
rev: v0.24.3
hooks:
- id: toml-sort-fix
files: frontend/public/locales/.*\.toml$
args: ['--in-place', '--all', '--ignore-case']
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
+1 -1
View File
@@ -13,7 +13,7 @@
"vscjava.vscode-spring-boot-dashboard", // Spring Boot dashboard for managing and visualizing Spring Boot applications
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
"GitHub.copilot-chat", // GitHub Copilot AI pair programmer for Visual Studio Code
"GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
+4 -116
View File
@@ -30,91 +30,6 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
import { useFileContext } from "@app/contexts/FileContext";
import { FileContext } from "@app/contexts/FileContext";
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
import { AppLayout } from "@core/components/AppLayout";
import { useFileContext } from "@proprietary/contexts/FileContext";
```
**Only use explicit aliases when:**
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
**Example - Desktop-specific footer:**
```typescript
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
return null; // Stub - does nothing in web builds
}
```
```typescript
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
import { Box } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box className={className}>
<BackendHealthIndicator />
</Box>
);
}
```
```typescript
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
export function RightRail() {
return (
<div>
{/* In web builds: renders nothing (stub returns null) */}
{/* In desktop builds: renders BackendHealthIndicator */}
<RightRailFooterExtensions className="right-rail-footer" />
</div>
);
}
```
**Build resolution:**
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
**Benefits:**
- No runtime checks or feature flags
- Type-safe across all builds
- Clean, readable code
- Build-time optimization (dead code elimination)
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
@@ -122,7 +37,7 @@ Frontend designed for **stateful document processing**:
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `frontend/src/core/contexts/FileContext.tsx`
**Location**: `src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
@@ -147,7 +62,7 @@ Without cleanup: browser crashes with memory leaks.
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
@@ -232,34 +147,8 @@ return useToolOperation({
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
- `core/components/pageEditor/`: Page manipulation UI
- `core/components/tooltips/`: Help tooltips for tools
- `core/components/shared/`: Reusable UI components
- **`core/contexts/`**: React Context providers
- `FileContext.tsx`: Central file state management
- `file/`: File reducer and selectors
- `toolWorkflow/`: Tool workflow state
- **`core/hooks/`**: Custom React hooks
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
- **`core/constants/`**: Application constants and configuration
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
@@ -308,13 +197,12 @@ The frontend is organized with a clear separation of concerns:
## Important Notes
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
+3 -3
View File
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
**Stirling 2.0** is built using:
**Backend:**
- Spring Boot (Java 21+, JDK 25 recommended)
- Spring Boot (Java 17+, JDK 21 recommended)
- PDFBox for core PDF operations
- LibreOffice for document conversions
- qpdf for PDF optimization
@@ -44,7 +44,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Docker
- Git
- Java JDK 21 or later (JDK 25 recommended)
- Java JDK 17 or later (JDK 21 recommended)
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
- Rust and Cargo (required for Tauri desktop app development)
@@ -59,7 +59,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
cd Stirling-PDF
```
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
2. Install Docker and JDK17 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
+6 -7
View File
@@ -5,10 +5,10 @@ bootRun {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/java/org/apache/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -27,10 +27,9 @@ spotless {
}
}
dependencies {
api 'com.google.guava:guava:33.4.8-jre'
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-aop'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0'
@@ -40,10 +39,10 @@ dependencies {
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
@@ -103,9 +103,8 @@ public class EndpointConfiguration {
// Rule 2: Functional-group override - check if endpoint belongs to any disabled functional
// group
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
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(
@@ -132,11 +131,10 @@ public class EndpointConfiguration {
// Rule 4: Single-dependency check - if no alternatives defined, check if endpoint belongs
// to any disabled tool groups
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
for (String group : endpointGroups.keySet()) {
if (isToolGroup(group)
&& disabledGroups.contains(group)
&& entry.getValue().contains(endpoint)) {
&& endpointGroups.get(group).contains(endpoint)) {
log.debug(
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
original,
@@ -393,24 +391,13 @@ public class EndpointConfiguration {
addEndpointToGroup("Advance", "extract-image-scans");
addEndpointToGroup("Advance", "repair");
addEndpointToGroup("Advance", "auto-rename");
addEndpointToGroup("Advance", "handleData");
addEndpointToGroup("Advance", "scanner-effect");
addEndpointToGroup("Advance", "show-javascript");
addEndpointToGroup("Advance", "overlay-pdf");
// Backend-only endpoints
addEndpointToGroup("Advance", "adjust-contrast");
// Adding endpoints to "Automation" group
addEndpointToGroup("Automation", "handleData");
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
addEndpointToGroup("Advance", "pipeline");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
@@ -608,12 +595,6 @@ public class EndpointConfiguration {
return endpointGroups.getOrDefault(group, new HashSet<>());
}
public Set<String> getAllEndpoints() {
return endpointGroups.values().stream()
.flatMap(Set::stream)
.collect(java.util.stream.Collectors.toSet());
}
private boolean isToolGroup(String group) {
return "qpdf".equals(group)
|| "OCRmyPDF".equals(group)
@@ -647,9 +628,8 @@ public class EndpointConfiguration {
}
// Check if endpoint belongs to any disabled functional group
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
for (String group : endpointGroups.keySet()) {
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
if (!isToolGroup(group)) {
return false;
}
@@ -2,14 +2,12 @@ package stirling.software.common.aop;
import java.io.IOException;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@@ -28,7 +26,7 @@ import stirling.software.common.service.JobExecutorService;
@Component
@RequiredArgsConstructor
@Slf4j
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
@Order(0) // Highest precedence - executes before audit aspects
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
@@ -72,29 +70,26 @@ public class AutoJobAspect {
// No retries needed, simple execution
return jobExecutorService.runJobGeneric(
async,
wrapWithMDC(
() -> {
try {
// Note: Progress tracking is handled in
// TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is
// stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// Rethrow RuntimeException as-is to preserve exception type
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(ex);
}
}),
() -> {
try {
// Note: Progress tracking is handled in TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// Rethrow RuntimeException as-is to preserve exception type
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(ex);
}
},
timeout,
queueable,
resourceWeight);
@@ -128,108 +123,114 @@ public class AutoJobAspect {
return jobExecutorService.runJobGeneric(
async,
wrapWithMDC(
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1;
currentAttempt <= maxRetries;
currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
currentAttempt,
maxRetries);
// Progress is tracked in TaskManager for REST API
// access
// No WebSocket notifications sent here
}
}
// Attempt to execute the operation
return joinPoint.proceed(args);
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt"
+ " {}/{}): {}",
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
currentAttempt,
maxRetries,
ex.getMessage(),
ex);
maxRetries);
// Progress is tracked in TaskManager for REST API access
// No WebSocket notifications sent here
}
}
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
// Attempt to execute the operation
return joinPoint.proceed(args);
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API
// access
}
}
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt"
+ " {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
ex);
// Use sleep for retry delay
// For sync jobs, both sleep and async are blocking at this
// point
// For async jobs, the delay occurs in the executor thread
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.debug(
"Retry delay interrupted for attempt {}/{}",
currentAttempt,
maxRetries);
break;
}
} else {
// No more retries, we'll throw the exception after the loop
break;
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API access
}
}
}
// If we get here, all retries failed
if (lastException != null) {
// Rethrow RuntimeException as-is to preserve exception type
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
// Use non-blocking delay for all retry attempts to avoid blocking
// threads
// For sync jobs this avoids starving the tomcat thread pool under
// load
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
// Execute the retry after a delay through the JobExecutorService
// rather than blocking the current thread with sleep
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
// Use a delayed executor for non-blocking delay
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.execute(
() -> {
// Continue the retry loop in the next iteration
// We can't return from here directly since
// we're in a Runnable
delayedRetry.complete(null);
});
// Wait for the delay to complete before continuing
try {
delayedRetry.join();
} catch (Exception e) {
Thread.currentThread().interrupt();
break;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
} else {
// No more retries, we'll throw the exception after the loop
break;
}
}
}
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
}),
// If we get here, all retries failed
if (lastException != null) {
// Rethrow RuntimeException as-is to preserve exception type
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
}
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
},
timeout,
queueable,
resourceWeight);
@@ -298,32 +299,4 @@ public class AutoJobAspect {
return null;
}
}
/**
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
* leakage across jobs in thread pools.
*/
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
final Map<String, String> captured = MDC.getCopyOfContextMap();
return () -> {
final Map<String, String> previous = MDC.getCopyOfContextMap();
try {
// Set the captured context (or clear if none was captured)
if (captured != null) {
MDC.setContextMap(new HashMap<>(captured));
} else {
MDC.clear();
}
return supplier.get();
} finally {
// Restore previous state (or clear if there was none)
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
};
}
}
@@ -91,9 +91,9 @@ public class RuntimePathConfig {
// Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
String defaultUnoConvertPath = isDocker ? "/usr/local/bin/unoconvert" : "unoconvert";
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
String defaultOcrMyPdfPath = isDocker ? "/opt/venv/bin/ocrmypdf" : "ocrmypdf";
String defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf";
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
Operations operations = customPaths.getOperations();
@@ -1,23 +0,0 @@
package stirling.software.common.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
/**
* Configures the scheduler used by all {@code @Scheduled} methods. Uses virtual threads so that
* long-running scheduled tasks (e.g. cleanup, license checks, file monitoring) never block each
* other — each runs on its own lightweight virtual thread.
*/
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
scheduler.setThreadNamePrefix("scheduled-vt-");
return scheduler;
}
}
@@ -1,49 +0,0 @@
package stirling.software.common.constants;
/**
* Centralized constants for JWT token management.
*
* <p>These defaults are used when configuration values are not explicitly set.
*/
public final class JwtConstants {
private JwtConstants() {
throw new UnsupportedOperationException("Utility class");
}
/** Default JWT access token lifetime in minutes (24 hours). */
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
/** Default desktop client token lifetime in minutes (30 days). */
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
/**
* Default refresh grace period in minutes.
*
* <p>Allows refresh of expired tokens within this window after expiration.
*/
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
/**
* Default allowed clock skew in seconds.
*
* <p>Tolerates small time drift between client and server clocks during validation.
*/
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
/** Milliseconds per minute. */
public static final long MILLIS_PER_MINUTE = 60_000L;
/** Seconds per minute. */
public static final long SECONDS_PER_MINUTE = 60L;
/** JWT issuer identifier. */
public static final String ISSUER = "https://stirling.com";
/**
* Maximum refresh attempts allowed within the grace period window.
*
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
*/
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
}
@@ -29,8 +29,6 @@ import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
@@ -39,7 +37,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
@@ -101,52 +98,6 @@ public class ApplicationProperties {
return propertySource;
}
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
*/
@PostConstruct
public void initializeFileUploadLimitFromEnv() {
// Only override if fileUploadLimit is not already set in settings.yml
if (system.getFileUploadLimit() == null || system.getFileUploadLimit().isEmpty()) {
String fileUploadLimit = null;
// Check SYSTEMFILEUPLOADLIMIT first (format: "100MB", "1GB", etc.)
String systemFileUploadLimit = java.lang.System.getenv("SYSTEMFILEUPLOADLIMIT");
if (systemFileUploadLimit != null && !systemFileUploadLimit.trim().isEmpty()) {
fileUploadLimit = systemFileUploadLimit.trim();
log.info("Setting fileUploadLimit from SYSTEMFILEUPLOADLIMIT: {}", fileUploadLimit);
} else {
// Check SYSTEM_MAXFILESIZE (format: number in MB, e.g., "100")
String systemMaxFileSize = java.lang.System.getenv("SYSTEM_MAXFILESIZE");
if (systemMaxFileSize != null && !systemMaxFileSize.trim().isEmpty()) {
try {
// Validate it's a number
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
if (sizeInMB > 0 && sizeInMB <= 999) {
fileUploadLimit = sizeInMB + "MB";
log.info(
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
sizeInMB);
} else {
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
}
} catch (NumberFormatException e) {
log.warn(
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
systemMaxFileSize);
}
}
}
if (fileUploadLimit != null) {
system.setFileUploadLimit(fileUploadLimit);
}
}
}
@Data
public static class AutoPipeline {
private String outputFolder;
@@ -365,11 +316,11 @@ public class ApplicationProperties {
}
public boolean isSettingsValid() {
return !ValidationUtils.isStringEmpty(this.issuer)
&& !ValidationUtils.isStringEmpty(this.clientId)
&& !ValidationUtils.isStringEmpty(this.clientSecret)
&& !ValidationUtils.isCollectionEmpty(this.scopes)
&& !ValidationUtils.isStringEmpty(this.useAsUsername);
return !ValidationUtils.isStringEmpty(this.getIssuer())
&& !ValidationUtils.isStringEmpty(this.getClientId())
&& !ValidationUtils.isStringEmpty(this.getClientSecret())
&& !ValidationUtils.isCollectionEmpty(this.getScopes())
&& !ValidationUtils.isStringEmpty(this.getUseAsUsername());
}
@Data
@@ -394,107 +345,12 @@ public class ApplicationProperties {
}
}
/**
* JWT token configuration.
*
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
* {@code tokenExpiryMinutes: 720} in your configuration.
*/
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
* JWT access token lifetime in minutes for web clients.
*
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
*
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
* defaults to 1440 minutes (24 hours).
*/
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
/**
* JWT access token lifetime in minutes for desktop clients (Tauri app).
*
* <p>Desktop clients are automatically detected via User-Agent header and receive
* longer-lived tokens because they run on personal devices with OS-level encrypted
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
*
* <p>This provides better UX (login once per month) while maintaining security through
* device encryption and secure storage, matching the behavior of popular desktop apps
* like Slack, Discord, VS Code, etc.
*
* <p>Default: 43200 minutes (30 days).
*/
private int desktopTokenExpiryMinutes = 43200;
/**
* Allowed clock skew in seconds for JWT validation.
*
* <p>Tolerates small time drift between client and server clocks. Tokens that are
* slightly expired or slightly in the future (within this window) will still be
* accepted.
*
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
*/
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
/**
* Grace period in minutes for refreshing expired tokens.
*
* <p>Allows token refresh using an expired access token if the token expired within
* this many minutes. This provides better UX by allowing users to refresh slightly
* expired tokens without re-authentication.
*
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
*
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
*/
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
/**
* Calculate number of days to retain old JWT signing keys.
*
* <p>Automatically calculated based on the longest token lifetime plus a proportional
* safety buffer. Keys must be retained for at least as long as the tokens they signed
* remain valid, otherwise token verification will fail.
*
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
*
* <p>The buffer includes:
*
* <ul>
* <li>10% of token lifetime (scales with token duration)
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
* </ul>
*
* @return calculated key retention period in days
*/
public int getKeyRetentionDays() {
final int MINUTES_PER_DAY = 1440;
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
// Add 10% buffer (scales with token lifetime)
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
// Add refresh grace period
bufferMinutes += refreshGraceMinutes;
// Add clock skew (convert seconds to minutes, round up)
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
// Total retention in minutes, convert to days (round up)
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
}
private int keyRetentionDays = 7;
}
@Data
@@ -575,17 +431,19 @@ public class ApplicationProperties {
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
}
public boolean isPosthogEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled() && (this.enablePosthog == null || this.enablePosthog);
return this.isAnalyticsEnabled()
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
}
public boolean isScarfEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled() && (this.enableScarf == null || this.enableScarf);
return this.isAnalyticsEnabled()
&& (this.getEnableScarf() == null || this.getEnableScarf());
}
}
@@ -696,8 +554,7 @@ public class ApplicationProperties {
@Override
public String toString() {
return
"""
return """
Driver {
driverName='%s'
}
@@ -711,9 +568,6 @@ public class ApplicationProperties {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false;
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -726,12 +580,6 @@ public class ApplicationProperties {
}
return "classic"; // default
}
@Data
public static class HideDisabledTools {
private boolean googleDrive = false;
private boolean mobileQRScanner = false;
}
}
@Data
@@ -919,15 +767,6 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class GoogleDrive {
private boolean enabled = false;
private String clientId = "";
private String apiKey = "";
private String appId = "";
}
@Data
public static class CustomMetadata {
@@ -977,12 +816,6 @@ public class ApplicationProperties {
private boolean enabled = true;
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
private int retentionDays = 90;
private boolean captureFileHash =
false; // Capture SHA-256 hash of files (increases processing time)
private boolean capturePdfAuthor =
false; // Capture PDF author metadata (increases processing time)
private boolean captureOperationResults =
false; // Capture operation return values (not recommended, high volume)
}
@Data
@@ -1,98 +0,0 @@
package stirling.software.common.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Form field information with coordinates for interactive form viewer. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Form field with coordinates and metadata")
public class FormFieldWithCoordinates {
@Schema(description = "Fully qualified field name", example = "form1.firstName")
private String name;
@Schema(description = "Display label for the field", example = "First Name")
private String label;
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
private String type;
@Schema(description = "Current field value")
private String value;
@Schema(
description =
"Available options (export values) for choice fields"
+ " (dropdown, radio, listbox)")
private List<String> options;
@Schema(
description =
"Human-readable display labels for choice field options,"
+ " parallel to the 'options' list. Null when identical to options.")
private List<String> displayOptions;
@Schema(description = "Whether the field is required")
private boolean required;
@Schema(description = "Whether the field is read-only")
private boolean readOnly;
@Schema(description = "Whether this is a multi-select list box")
private boolean multiSelect;
@Schema(description = "Whether this is a multi-line text field")
private boolean multiline;
@Schema(description = "Tooltip/alternate name for the field")
private String tooltip;
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Widget coordinates in PDF space")
public static class WidgetCoordinates {
@Schema(description = "Page index (0-based)", example = "0")
private int pageIndex;
@Schema(description = "X coordinate in PDF points (lower-left origin)")
private float x;
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
private float y;
@Schema(description = "Width in PDF points")
private float width;
@Schema(description = "Height in PDF points")
private float height;
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
private String exportValue;
@Schema(description = "Font size in PDF points")
private Float fontSize;
}
}
@@ -42,7 +42,7 @@ public enum Role {
// Using the fromString method to get the Role enum based on the roleId
Role role = fromString(roleId);
// Return the roleName of the found Role enum
return role.roleName;
return role.getRoleName();
}
// Method to retrieve all role IDs and role names
@@ -50,14 +50,14 @@ public enum Role {
// Using LinkedHashMap to preserve order
Map<String, String> roleDetails = new LinkedHashMap<>();
for (Role role : Role.values()) {
roleDetails.put(role.roleId, role.roleName);
roleDetails.put(role.getRoleId(), role.getRoleName());
}
return roleDetails;
}
public static Role fromString(String roleId) {
for (Role role : Role.values()) {
if (role.roleId.equalsIgnoreCase(roleId)) {
if (role.getRoleId().equalsIgnoreCase(roleId)) {
return role;
}
}
@@ -117,13 +117,13 @@ public class Provider {
+ ", clientName="
+ getClientName()
+ ", clientId="
+ clientId
+ getClientId()
+ ", clientSecret="
+ (clientSecret != null && !clientSecret.isEmpty() ? "*****" : "NULL")
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
+ ", scopes="
+ getScopes()
+ ", useAsUsername="
+ useAsUsername
+ getUseAsUsername()
+ "]";
}
}
@@ -1,8 +1,6 @@
package stirling.software.common.service;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
@@ -23,9 +21,6 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class FileStorage {
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
public record StoredFile(String fileId, long size) {}
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
@@ -109,40 +104,6 @@ public class FileStorage {
return Files.readAllBytes(filePath);
}
/**
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
* the returned stream.
*
* @param fileId The ID of the file to retrieve
* @return A buffered InputStream for the file
* @throws IOException If the file doesn't exist or can't be read
*/
public InputStream retrieveInputStream(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
// between exists-check and open when another thread may delete concurrently.
return new BufferedInputStream(Files.newInputStream(filePath));
}
/**
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
* directly to disk without buffering the entire content in heap.
*
* @param inputStream The input stream to read from
* @param originalName The original name of the file (unused, kept for API symmetry)
* @return A {@link StoredFile} containing the file ID and the number of bytes written
* @throws IOException If there is an error storing the file
*/
public StoredFile storeInputStream(InputStream inputStream, String originalName)
throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.copy(inputStream, filePath);
log.debug("Stored input stream with ID: {}", fileId);
return new StoredFile(fileId, size);
}
/**
* Delete a file by its ID
*
@@ -35,7 +35,7 @@ public class JobExecutorService {
private final HttpServletRequest request;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
@Autowired(required = false)
@@ -254,13 +254,10 @@ public class JobExecutorService {
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
} catch (RuntimeException e) {
// Check if this is a typed exception that should be handled by
// GlobalExceptionHandler (either directly or wrapped)
// Check if this is a wrapped typed exception that should be handled by
// GlobalExceptionHandler
Throwable cause = e.getCause();
if (e instanceof IllegalArgumentException
|| cause
instanceof
stirling.software.common.util.ExceptionUtils.BaseAppException
if (cause instanceof stirling.software.common.util.ExceptionUtils.BaseAppException
|| cause
instanceof
stirling.software.common.util.ExceptionUtils
@@ -316,7 +313,7 @@ public class JobExecutorService {
filename =
disposition.substring(
disposition.indexOf("filename=") + 9,
disposition.lastIndexOf('"'));
disposition.lastIndexOf("\""));
}
}
@@ -44,10 +44,8 @@ public class JobQueue implements SmartLifecycle {
private volatile BlockingQueue<QueuedJob> jobQueue;
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("job-queue-scheduler-", 0).factory());
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualThreadExecutor();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final Object queueLock = new Object(); // Lock for synchronizing queue operations
private boolean shuttingDown = false;
@@ -1,29 +0,0 @@
package stirling.software.common.service;
/**
* Interface for checking license status dynamically. Implementation provided by proprietary module
* when available.
*/
public interface LicenseServiceInterface {
/**
* Get the license type as a string.
*
* @return "NORMAL", "SERVER", or "ENTERPRISE"
*/
String getLicenseTypeName();
/**
* Check if running Pro or higher (SERVER or ENTERPRISE license).
*
* @return true if SERVER or ENTERPRISE license is active
*/
boolean isRunningProOrHigher();
/**
* Check if running Enterprise edition.
*
* @return true if ENTERPRISE license is active
*/
boolean isRunningEE();
}
@@ -9,7 +9,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -26,9 +25,6 @@ import lombok.extern.slf4j.Slf4j;
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
@@ -125,11 +121,10 @@ public class MobileScannerService {
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt =
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf('.'))
? safeFilename.substring(safeFilename.lastIndexOf("."))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
@@ -276,14 +271,14 @@ public class MobileScannerService {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
// Ensure we have a non-empty, safe filename
if (sanitized.isBlank()) {
sanitized = "upload-" + System.currentTimeMillis();
@@ -169,10 +169,7 @@ public class PdfMetadataService {
.getAuthor();
if (userService != null) {
String username = userService.getCurrentUsername();
if (username != null) {
author = author.replace("username", username);
}
author = author.replace("username", userService.getCurrentUsername());
}
}
pdf.getDocumentInformation().setAuthor(author);
@@ -43,9 +43,7 @@ public class ResourceMonitor {
@Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("resource-monitor-", 0).factory());
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
@@ -1,8 +1,8 @@
package stirling.software.common.service;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
@@ -19,6 +19,7 @@ import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.ZipSecurity;
@@ -41,8 +42,7 @@ public class TaskManager {
private final FileStorage fileStorage;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("task-cleanup-", 0).factory());
Executors.newSingleThreadScheduledExecutor();
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
@@ -362,29 +362,39 @@ public class TaskManager {
String zipFileId, String originalZipFileName) throws IOException {
List<ResultFile> extractedFiles = new ArrayList<>();
try (InputStream fileStream = fileStorage.retrieveInputStream(zipFileId);
ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new BufferedInputStream(fileStream))) {
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());
// storeInputStream returns the fileId and byte count — no extra stat needed
FileStorage.StoredFile stored =
fileStorage.storeInputStream(zipIn, entry.getName());
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
ResultFile resultFile =
ResultFile.builder()
.fileId(stored.fileId())
.fileId(individualFileId)
.fileName(entry.getName())
.contentType(contentType)
.fileSize(stored.size())
.fileSize(fileContent.length)
.build();
extractedFiles.add(resultFile);
log.debug(
"Extracted file: {} (size: {} bytes)", entry.getName(), stored.size());
"Extracted file: {} (size: {} bytes)",
entry.getName(),
fileContent.length);
}
zipIn.closeEntry();
}
@@ -456,24 +466,4 @@ public class TaskManager {
}
return null;
}
/**
* Find the job key that owns a given file ID.
*
* @param fileId file identifier to look up
* @return scoped job key if found, otherwise null
*/
public String findJobKeyByFileId(String fileId) {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult jobResult = entry.getValue();
if (jobResult.hasFiles()) {
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
if (fileId.equals(resultFile.getFileId())) {
return entry.getKey();
}
}
}
}
return null;
}
}
@@ -4,7 +4,6 @@ import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
@@ -31,7 +30,15 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class CbzUtils {
public TempFile convertCbzToPdf(
public byte[] convertCbzToPdf(
MultipartFile cbzFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
throws IOException {
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
}
public byte[] convertCbzToPdf(
MultipartFile cbzFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
@@ -57,90 +64,70 @@ public class CbzUtils {
try (PDDocument document = pdfDocumentFactory.createNewDocument();
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
// Pass 1: collect sorted image names (cheap just strings, no image data)
List<String> sortedImageNames = new ArrayList<>();
Enumeration<? extends ZipEntry> entries = zipFile.entries();
List<ImageEntryData> imageEntries = new ArrayList<>();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.isDirectory() && isImageFile(entry.getName())) {
sortedImageNames.add(entry.getName());
try (InputStream is = zipFile.getInputStream(entry)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
is.transferTo(baos);
imageEntries.add(
new ImageEntryData(entry.getName(), baos.toByteArray()));
} catch (IOException e) {
log.warn("Error reading image {}: {}", entry.getName(), e.getMessage());
}
}
}
sortedImageNames.sort(new NaturalOrderComparator());
if (sortedImageNames.isEmpty()) {
imageEntries.sort(
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
if (imageEntries.isEmpty()) {
throw ExceptionUtils.createCbzNoImagesException();
}
// Pass 2: load ONE image at a time peak memory = max(single image)
for (String imageName : sortedImageNames) {
ZipEntry entry = zipFile.getEntry(imageName);
try (InputStream is = zipFile.getInputStream(entry)) {
ByteArrayOutputStream imgBaos = new ByteArrayOutputStream();
is.transferTo(imgBaos);
byte[] imageBytes = imgBaos.toByteArray();
try {
PDImageXObject pdImage =
PDImageXObject.createFromByteArray(
document, imageBytes, imageName);
PDPage page =
new PDPage(
new PDRectangle(
pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0);
}
} catch (IOException e) {
log.warn("Error processing image {}: {}", imageName, e.getMessage());
for (ImageEntryData imageEntry : imageEntries) {
try {
PDImageXObject pdImage =
PDImageXObject.createFromByteArray(
document, imageEntry.data(), imageEntry.name());
PDPage page =
new PDPage(
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0);
}
// imageBytes eligible for GC after each iteration
} catch (IOException e) {
log.warn("Error reading image {}: {}", imageName, e.getMessage());
log.warn(
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
}
}
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createCbzCorruptedImagesException();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] pdfBytes = baos.toByteArray();
// Write to TempFile (not BAOS)
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf");
try {
document.save(pdfTempFile.getFile());
if (optimizeForEbook) {
try {
byte[] pdfBytes = Files.readAllBytes(pdfTempFile.getPath());
byte[] optimized = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
pdfTempFile.close();
TempFile optimizedFile = new TempFile(tempFileManager, ".pdf");
try {
Files.write(optimizedFile.getPath(), optimized);
return optimizedFile;
} catch (Exception e) {
optimizedFile.close();
throw e;
}
} catch (IOException e) {
log.warn(
"Ghostscript optimization failed, returning unoptimized PDF",
e);
}
// Apply Ghostscript optimization if requested
if (optimizeForEbook) {
try {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
}
return pdfTempFile;
} catch (Exception e) {
pdfTempFile.close();
throw e;
}
return pdfBytes;
}
}
}
@@ -188,6 +175,8 @@ public class CbzUtils {
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
}
private record ImageEntryData(String name, byte[] data) {}
private class NaturalOrderComparator implements Comparator<String> {
@Override
public int compare(String s1, String s2) {
@@ -2,28 +2,30 @@ package stirling.software.common.util;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
/**
* Factory for creating executors backed by virtual threads (Java 21+). Virtual threads are
* lightweight, managed by the JVM, and ideal for I/O-bound tasks. They eliminate the need for
* thread pool sizing since thousands can run concurrently with minimal overhead.
*/
public final class ExecutorFactory {
import lombok.extern.slf4j.Slf4j;
private ExecutorFactory() {}
/** Creates an {@link ExecutorService} that starts a new virtual thread for each task. */
public static ExecutorService newVirtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
@Slf4j
public class ExecutorFactory {
/**
* Creates a {@link ScheduledExecutorService} backed by a single virtual thread. Useful for
* periodic/delayed tasks that should not pin a platform thread.
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
* cached thread pool on older Java versions.
*/
public static ScheduledExecutorService newSingleVirtualThreadScheduledExecutor() {
return Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("scheduled-vt-", 0).factory());
public static ExecutorService newVirtualOrCachedThreadExecutor() {
try {
ExecutorService executor =
(ExecutorService)
Executors.class
.getMethod("newVirtualThreadPerTaskExecutor")
.invoke(null);
return executor;
} catch (NoSuchMethodException e) {
log.debug("Virtual threads not available; falling back to cached thread pool.");
} catch (Exception e) {
log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
}
return Executors.newCachedThreadPool();
}
}
@@ -68,17 +68,6 @@ public class FileMonitor {
if (this.rootDirs.isEmpty()) {
log.error("No valid directories to monitor - FileMonitor will not function");
} else {
// Register directories eagerly so the first @Scheduled tick does not warn.
for (Path rootDir : this.rootDirs) {
if (Files.exists(rootDir)) {
try {
recursivelyRegisterEntry(rootDir);
} catch (IOException e) {
log.error("Failed to register monitoring for {}", rootDir, e);
}
}
}
}
}
@@ -44,7 +44,9 @@ public class FileToPdf {
sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8),
customHtmlSanitizer);
Files.writeString(tempInputFile.getPath(), sanitizedHtml);
Files.write(
tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip(
@@ -98,13 +100,6 @@ public class FileToPdf {
while (entry != null) {
Path filePath =
tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName()));
Path normalizedTargetDir =
tempUnzippedDir.getPath().toAbsolutePath().normalize();
Path normalizedFilePath = filePath.toAbsolutePath().normalize();
if (!normalizedFilePath.startsWith(normalizedTargetDir)) {
throw new IOException(
"Zip entry path escapes target directory: " + entry.getName());
}
if (!entry.isDirectory()) {
Files.createDirectories(filePath.getParent());
if (entry.getName().toLowerCase(Locale.ROOT).endsWith(".html")
@@ -113,7 +108,8 @@ public class FileToPdf {
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
String sanitizedContent =
sanitizeHtmlContent(content, customHtmlSanitizer);
Files.writeString(filePath, sanitizedContent);
Files.write(
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
} else {
Files.copy(zipIn, filePath);
}
@@ -649,10 +649,8 @@ public class GeneralUtils {
}
public List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
// Use LinkedHashSet to prevent duplicates from inflating size and triggering maxSize guard
Set<Integer> result = new LinkedHashSet<>();
List<Integer> result = new ArrayList<>();
int offset = oneBased ? 1 : 0;
int maxSize = Math.max(1000, totalPages * 3);
for (String page : pages) {
if ("all".equalsIgnoreCase(page)) {
@@ -668,12 +666,8 @@ public class GeneralUtils {
} else {
result.addAll(handlePart(page, totalPages, offset));
}
if (result.size() > maxSize) {
throw new IllegalArgumentException(
"Page list exceeds maximum allowed size of " + maxSize);
}
}
return new ArrayList<>(result);
return result;
}
/*
@@ -873,36 +867,6 @@ public class GeneralUtils {
settingsYaml.saveOverride(settingsPath);
}
/**
* Updates multiple settings in a single transaction. This ensures that nested settings (e.g.,
* oauth2.client.google.*) don't lose sibling values when partial updates are made.
*
* <p>Instead of multiple read-update-write cycles (which could cause race conditions), this
* method loads the YAML once, applies all updates, and saves once.
*
* @param settingsMap Map of dotted-notation keys to values to update
* @throws IOException if file read/write fails
*/
public void updateSettingsTransactional(Map<String, Object> settingsMap) throws IOException {
if (settingsMap == null || settingsMap.isEmpty()) {
return;
}
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
YamlHelper settingsYaml = new YamlHelper(settingsPath);
// Apply all updates to the same YamlHelper instance
for (Map.Entry<String, Object> entry : settingsMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
String[] keyArray = key.split("\\.");
settingsYaml.updateValue(Arrays.asList(keyArray), value);
}
// Save only once after all updates are applied
settingsYaml.saveOverride(settingsPath);
}
/*
* Machine fingerprint generation with better error logging and fallbacks.
*
@@ -10,7 +10,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -34,8 +33,6 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
public class PDFToFile {
private static final Pattern PATTERN =
Pattern.compile("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)");
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@@ -110,65 +107,56 @@ public class PDFToFile {
File[] outputFiles =
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
List<File> markdownFiles = new ArrayList<>();
List<File> imageFiles = new ArrayList<>();
// Convert HTML files to Markdown and collect image files
// Convert HTML files to Markdown
for (File outputFile : outputFiles) {
if (outputFile.getName().endsWith(".html")) {
String html = Files.readString(outputFile.toPath());
String markdown = htmlToMarkdownConverter.convert(html);
// Update image references to point to images/ folder
markdown = updateImageReferences(markdown);
String mdFileName = outputFile.getName().replace(".html", ".md");
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
Files.writeString(mdFile.toPath(), markdown);
markdownFiles.add(mdFile);
} else if (!outputFile.getName().endsWith(".md")) {
// Collect non-HTML, non-MD files as images/assets
imageFiles.add(outputFile);
}
}
// Always create a ZIP file
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// If there's only one markdown file, return it directly
if (markdownFiles.size() == 1) {
fileName = pdfBaseName + ".md";
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
} else {
// Multiple files - create a zip
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files to root of ZIP
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
// Add images and other assets
for (File file : outputFiles) {
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
ZipEntry assetEntry = new ZipEntry(file.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(file.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
}
// Add images and other assets to images/ folder
for (File imageFile : imageFiles) {
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(imageFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
fileBytes = byteArrayOutputStream.toByteArray();
}
fileBytes = byteArrayOutputStream.toByteArray();
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Updates image references in markdown to point to the images/ folder. Matches patterns like
* ![alt](filename.png) and converts to ![alt](images/filename.png)
*/
private String updateImageReferences(String markdown) {
// Match markdown image syntax: ![alt text](image.png)
// Only update if the path doesn't already start with images/
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
@@ -1,8 +1,8 @@
package stirling.software.common.util;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -22,11 +22,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
public class PdfToCbzUtils {
public static TempFile convertPdfToCbz(
MultipartFile pdfFile,
int dpi,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
public static byte[] convertPdfToCbz(
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
validatePdfFile(pdfFile);
@@ -36,7 +33,7 @@ public class PdfToCbzUtils {
throw ExceptionUtils.createPdfNoPages();
}
return createCbzFromPdf(document, dpi, tempFileManager);
return createCbzFromPdf(document, dpi);
}
}
@@ -56,49 +53,46 @@ public class PdfToCbzUtils {
}
}
private static TempFile createCbzFromPdf(
PDDocument document, int dpi, TempFileManager tempFileManager) throws IOException {
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
TempFile cbzTempFile = new TempFile(tempFileManager, ".cbz");
try {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(cbzTempFile.getPath()))) {
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
int totalPages = document.getNumberOfPages();
int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
zipOut.putNextEntry(new ZipEntry(imageFilename));
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
ZipEntry zipEntry = new ZipEntry(imageFilename);
zipOut.putNextEntry(zipEntry);
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBZ creation for page " + (currentPage + 1), e);
}
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBZ creation for page " + (currentPage + 1), e);
}
}
return cbzTempFile;
} catch (Exception e) {
cbzTempFile.close();
throw e;
zipOut.finish();
return cbzOutputStream.toByteArray();
}
}
@@ -219,31 +219,58 @@ public class PdfUtils {
int maxWidth = 0;
int totalHeight = 0;
// Using a map to store the calculated dimensions of each page size
BufferedImage pdfSizeImage = null;
int pdfSizeImageIndex = -1;
// Using a map to store the rendered dimensions of each page size
// to avoid rendering the same page sizes multiple times
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
new HashMap<>();
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
PDPage page = document.getPage(i);
PDRectangle cropBox = page.getCropBox();
PDRectangle mediaBox = page.getMediaBox();
int rotation = page.getRotation();
PdfRenderSettingsKey settings =
new PdfRenderSettingsKey(
cropBox.getWidth(), cropBox.getHeight(), rotation);
mediaBox.getWidth(), mediaBox.getHeight(), rotation);
PdfImageDimensionValue dimension = pageSizes.get(settings);
if (dimension == null) {
float scale = DPI / 72f;
int widthPx = (int) Math.max(Math.floor(cropBox.getWidth() * scale), 1);
int heightPx =
(int) Math.max(Math.floor(cropBox.getHeight() * scale), 1);
if (rotation == 90 || rotation == 270) {
int tmp = widthPx;
widthPx = heightPx;
heightPx = tmp;
// Render the image to get the dimensions
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
page, pageIndex + 1, DPI);
pdfSizeImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigExceedsArray",
"PDF page {0} is too large to render at {1} DPI. The"
+ " resulting image would exceed Java's maximum"
+ " array size. Please try a lower DPI value"
+ " (recommended: 150 or less).",
i + 1,
DPI);
}
throw e;
}
dimension = new PdfImageDimensionValue(widthPx, heightPx);
pdfSizeImageIndex = i;
dimension =
new PdfImageDimensionValue(
pdfSizeImage.getWidth(), pdfSizeImage.getHeight());
pageSizes.put(settings, dimension);
if (widthPx > maxWidth) {
maxWidth = widthPx;
if (pdfSizeImage.getWidth() > maxWidth) {
maxWidth = pdfSizeImage.getWidth();
}
}
totalHeight += dimension.height();
@@ -257,32 +284,40 @@ public class PdfUtils {
int currentHeight = 0;
BufferedImage pageImage;
// Check if the first image is the last rendered image
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
if (firstImageAlreadyRendered && i == 0) {
pageImage = pdfSizeImage;
} else {
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
pageImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please"
+ " try a lower DPI value (recommended: 150 or"
+ " less).",
i + 1,
DPI);
pageImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigForDpi",
"PDF page {0} is too large to render at {1} DPI. Please"
+ " try a lower DPI value (recommended: 150 or"
+ " less).",
i + 1,
DPI);
}
throw e;
}
throw e;
}
// Calculate the x-coordinate to center the image
@@ -539,39 +574,34 @@ public class PdfUtils {
boolean everyPage)
throws IOException {
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Create an image object from the image bytes
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
PDDocument document = pdfDocumentFactory.load(pdfBytes);
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
// Create an image object from the image bytes
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
@@ -226,54 +226,50 @@ public class ProcessExecutor {
List<String> outputLines = new ArrayList<>();
Thread errorReaderThread =
Thread.ofVirtual()
.unstarted(
() -> {
try (BufferedReader errorReader =
new BufferedReader(
new InputStreamReader(
process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
errorReader, 5_000_000))
!= null) {
errorLines.add(line);
if (liveUpdates) log.info(line);
}
} catch (InterruptedIOException e) {
log.warn(
"Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
});
new Thread(
() -> {
try (BufferedReader errorReader =
new BufferedReader(
new InputStreamReader(
process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
errorReader, 5_000_000))
!= null) {
errorLines.add(line);
if (liveUpdates) log.info(line);
}
} catch (InterruptedIOException e) {
log.warn("Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
});
Thread outputReaderThread =
Thread.ofVirtual()
.unstarted(
() -> {
try (BufferedReader outputReader =
new BufferedReader(
new InputStreamReader(
process.getInputStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
outputReader, 5_000_000))
!= null) {
outputLines.add(line);
if (liveUpdates) log.info(line);
}
} catch (InterruptedIOException e) {
log.warn(
"Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
});
new Thread(
() -> {
try (BufferedReader outputReader =
new BufferedReader(
new InputStreamReader(
process.getInputStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line =
BoundedLineReader.readLine(
outputReader, 5_000_000))
!= null) {
outputLines.add(line);
if (liveUpdates) log.info(line);
}
} catch (InterruptedIOException e) {
log.warn("Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
});
errorReaderThread.start();
outputReaderThread.start();
@@ -374,7 +370,7 @@ public class ProcessExecutor {
}
// Match common unoconvert variants (but NOT soffice)
String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT);
if (lowerBasename.contains("unoconvert") || "unoconv".equals(lowerBasename)) {
if (lowerBasename.contains("unoconvert") || lowerBasename.equals("unoconv")) {
return true;
}
}
@@ -47,7 +47,6 @@ public class SvgSanitizer {
private static final Pattern DATA_SCRIPT_PATTERN =
Pattern.compile(
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
@@ -86,7 +85,7 @@ public class SvgSanitizer {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
@@ -211,7 +210,7 @@ public class SvgSanitizer {
String result = url.trim();
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
result = result.replaceAll("\u0000", "");
for (int i = 0; i < 3; i++) {
try {
@@ -191,7 +191,7 @@ public class YamlHelper {
mappingNode.getValue().clear();
mappingNode.getValue().addAll(updatedTuples);
}
updatedRootNode = node;
setNewNode(node);
return updated;
}
@@ -19,7 +19,7 @@ public abstract class ReplaceAndInvertColorStrategy extends PDFFile {
public ReplaceAndInvertColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
setFileInput(file);
this.replaceAndInvert = replaceAndInvert;
setReplaceAndInvert(replaceAndInvert);
}
public abstract InputStreamResource replace() throws IOException;
@@ -4,23 +4,18 @@ import java.beans.PropertyEditorSupport;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.security.RedactionArea;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@Slf4j
public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
private final ObjectMapper objectMapper =
JsonMapper.builder()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void setAsText(String text) throws IllegalArgumentException {
@@ -29,6 +24,7 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
return;
}
try {
objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
TypeReference<ArrayList<RedactionArea>> typeRef = new TypeReference<>() {};
List<RedactionArea> list = objectMapper.readValue(text, typeRef);
setValue(list);
@@ -4,13 +4,12 @@ import java.beans.PropertyEditorSupport;
import java.util.HashMap;
import java.util.Map;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
public class StringToMapPropertyEditor extends PropertyEditorSupport {
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void setAsText(String text) throws IllegalArgumentException {
@@ -12,12 +12,11 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
public StrategyType lastStrategyUsed;
public SpyPDFDocumentFactory(PdfMetadataService service) {
super(service); // TempFileManager falls back to Files.createTempFile in tests
super(service);
}
@Override
protected StreamCacheCreateFunction getStreamCacheFunction(
long contentSize, MemorySnapshot mem) {
public StreamCacheCreateFunction getStreamCacheFunction(long contentSize) {
StrategyType type;
if (contentSize < 10 * 1024 * 1024) {
type = StrategyType.MEMORY_ONLY;
@@ -27,6 +26,6 @@ class SpyPDFDocumentFactory extends CustomPDFDocumentFactory {
type = StrategyType.TEMP_FILE;
}
this.lastStrategyUsed = type;
return super.getStreamCacheFunction(contentSize, mem);
return super.getStreamCacheFunction(contentSize); // delegate to real behavior
}
}
@@ -153,12 +153,11 @@ class PDFToFileTest {
MediaType.APPLICATION_PDF_VALUE,
"Fake PDF content".getBytes());
// Create a mock HTML output file with image references
// Create a mock HTML output file
Path htmlOutputFile = tempDir.resolve("test.html");
Files.write(
htmlOutputFile,
"<html><body><h1>Test</h1><p>This is a test.</p><img src=\"image1.png\" /></body></html>"
.getBytes());
"<html><body><h1>Test</h1><p>This is a test.</p></body></html>".getBytes());
// Setup ProcessExecutor mock
mockedStaticProcessExecutor
@@ -175,61 +174,18 @@ class PDFToFileTest {
Files.copy(
htmlOutputFile, Path.of(outputDir.getPath(), "test.html"));
// Create a mock image file
Files.write(
Path.of(outputDir.getPath(), "image1.png"),
"Fake image data".getBytes());
return mockExecutorResult;
});
// Execute the method
ResponseEntity<byte[]> response = pdfToFile.processPdfToMarkdown(pdfFile);
// Verify - should now return a ZIP file instead of plain markdown
// Verify
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().length > 0);
// Verify content disposition indicates a ZIP file
assertTrue(
response.getHeaders()
.getContentDisposition()
.toString()
.contains("ToMarkdown.zip"));
// Verify the content by unzipping it
try (ZipInputStream zipStream =
ZipSecurity.createHardenedInputStream(
new java.io.ByteArrayInputStream(response.getBody()))) {
ZipEntry entry;
boolean foundMdFile = false;
boolean foundImageInFolder = false;
String markdownContent = null;
while ((entry = zipStream.getNextEntry()) != null) {
if (entry.getName().endsWith(".md")) {
foundMdFile = true;
// Read markdown content to verify image references
markdownContent =
new String(
zipStream.readAllBytes(),
java.nio.charset.StandardCharsets.UTF_8);
} else if (entry.getName().startsWith("images/")
&& entry.getName().endsWith(".png")) {
foundImageInFolder = true;
}
zipStream.closeEntry();
}
assertTrue(foundMdFile, "ZIP should contain Markdown file");
assertTrue(foundImageInFolder, "ZIP should contain image in images/ folder");
assertNotNull(markdownContent, "Markdown content should be present");
// Verify markdown references images with images/ prefix
assertTrue(
markdownContent.contains("images/"),
"Markdown should reference images with images/ prefix");
}
response.getHeaders().getContentDisposition().toString().contains("test.md"));
}
}
@@ -300,15 +256,14 @@ class PDFToFileTest {
while ((entry = zipStream.getNextEntry()) != null) {
if (entry.getName().endsWith(".md")) {
foundMdFiles = true;
} else if (entry.getName().startsWith("images/")
&& entry.getName().endsWith(".png")) {
} else if (entry.getName().endsWith(".png")) {
foundImage = true;
}
zipStream.closeEntry();
}
assertTrue(foundMdFiles, "ZIP should contain Markdown files");
assertTrue(foundImage, "ZIP should contain image files in images/ folder");
assertTrue(foundImage, "ZIP should contain image files");
}
}
}
@@ -121,19 +121,17 @@ public class UnoServerPoolTest {
// Try to acquire a third endpoint in separate thread (should block)
Thread blockingThread =
Thread.ofVirtual()
.unstarted(
() -> {
try {
acquireLatch.countDown(); // Signal we're about to block
UnoServerPool.UnoServerLease lease3 =
pool.acquireEndpoint();
acquired.incrementAndGet();
lease3.close();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
new Thread(
() -> {
try {
acquireLatch.countDown(); // Signal we're about to block
UnoServerPool.UnoServerLease lease3 = pool.acquireEndpoint();
acquired.incrementAndGet();
lease3.close();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
blockingThread.start();
acquireLatch.await(); // Wait for thread to start
@@ -143,21 +143,11 @@ class StringToArrayListPropertyEditorTest {
}
@Test
void testSetAsText_UnknownProperties() {
// Arrange - this JSON contains properties not in RedactionArea
// With FAIL_ON_UNKNOWN_PROPERTIES disabled, this should ignore the unknown properties
void testSetAsText_InvalidStructure() {
// Arrange - this JSON doesn't match RedactionArea structure
String json = "[{\"invalid\":\"structure\"}]";
// Act
editor.setAsText(json);
Object value = editor.getValue();
// Assert
assertNotNull(value, "Value should not be null");
assertInstanceOf(List.class, value, "Value should be a List");
@SuppressWarnings("unchecked")
List<RedactionArea> list = (List<RedactionArea>) value;
assertEquals(1, list.size(), "List should have 1 entry (empty object)");
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> editor.setAsText(json));
}
}
+7 -32
View File
@@ -12,10 +12,11 @@ configurations {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
targetExclude 'src/main/resources/static/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -45,23 +46,8 @@ dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
implementation ('org.telegram:telegrambots:6.9.7.1') {
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
exclude group: 'org.glassfish.jersey.inject'
exclude group: 'org.glassfish.jersey.media'
exclude group: 'org.glassfish.jersey.containers'
exclude group: 'org.glassfish.jersey.core'
exclude group: 'org.glassfish.jersey.ext'
exclude group: 'org.glassfish.grizzly'
exclude group: 'org.glassfish.hk2'
exclude group: 'org.glassfish.hk2.external'
exclude group: 'org.javassist', module: 'javassist'
// Old javax JAX-RS Jackson bindings (not needed, project uses jakarta)
exclude group: 'com.fasterxml.jackson.jaxrs'
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
}
implementation 'com.posthog.java:posthog:1.2.0'
implementation 'org.telegram:telegrambots:6.9.7.1'
implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
@@ -75,14 +61,11 @@ dependencies {
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
implementation 'org.verapdf:validation-model:1.28.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation 'org.mozilla:rhino:1.9.1'
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
implementation ('technology.tabula:tabula:1.0.5') {
@@ -90,17 +73,11 @@ dependencies {
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
exclude group: 'com.google.code.gson', module: 'gson'
}
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
// Batik
implementation 'org.apache.xmlgraphics:batik-all:1.19'
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
@@ -191,7 +168,6 @@ def generatedFrontendPaths = [
]
tasks.register('npmInstall', Exec) {
doNotTrackState("node_modules contains symlinks that Gradle cannot snapshot on Windows/WSL")
enabled = buildWithFrontend
group = 'frontend'
description = 'Install frontend dependencies'
@@ -238,7 +214,6 @@ tasks.register('npmInstall', Exec) {
}
tasks.register('npmBuild', Exec) {
doNotTrackState("Frontend build depends on untracked npmInstall task")
enabled = buildWithFrontend
group = 'frontend'
description = 'Build frontend application'
@@ -88,10 +88,10 @@ public abstract class CreateSignatureBase implements SignatureInterface {
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
alias = aliases.nextElement();
privateKey = (PrivateKey) keystore.getKey(alias, pin);
setPrivateKey((PrivateKey) keystore.getKey(alias, pin));
Certificate[] certChain = keystore.getCertificateChain(alias);
if (certChain != null) {
certificateChain = certChain;
setCertificateChain(certChain);
cert = certChain[0];
if (cert instanceof X509Certificate) {
// avoid expired certificate
@@ -49,8 +49,8 @@ public class LibreOfficeListener {
process = SystemCommand.runCommand(Runtime.getRuntime(), "unoconv --listener");
lastActivityTime = System.currentTimeMillis();
// Start a virtual thread to monitor the activity timeout
executorService = Executors.newVirtualThreadPerTaskExecutor();
// Start a background thread to monitor the activity timeout
executorService = Executors.newSingleThreadExecutor();
executorService.submit(
() -> {
while (true) {
@@ -9,11 +9,10 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -39,10 +38,6 @@ import stirling.software.common.model.ApplicationProperties;
})
public class SPDFApplication {
private static final Pattern PORT_SUFFIX_PATTERN = Pattern.compile(".+:\\d+$");
private static final Pattern URL_SCHEME_PATTERN =
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/+$");
private static String serverPortStatic;
private static String baseUrlStatic;
private static String contextPathStatic;
@@ -134,7 +129,7 @@ public class SPDFApplication {
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = buildFullUrl(baseUrlStatic, serverPortStatic, contextPathStatic);
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -176,19 +171,16 @@ public class SPDFApplication {
}
@EventListener
public void onApplicationReady(ApplicationReadyEvent event) {
String port =
event.getApplicationContext().getEnvironment().getProperty("local.server.port");
if (port != null) {
serverPortStatic = port;
}
public void onWebServerInitialized(WebServerInitializedEvent event) {
int actualPort = event.getWebServer().getPort();
serverPortStatic = String.valueOf(actualPort);
// Log the actual runtime port for Tauri to parse
log.info("Stirling-PDF running on port: {}", serverPortStatic);
log.info("Stirling-PDF running on port: {}", actualPort);
}
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = buildFullUrl(baseUrlStatic, serverPortStatic, contextPathStatic);
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
log.info("Navigate to {}", url);
}
@@ -252,8 +244,8 @@ public class SPDFApplication {
String trimmedBase =
(backendUrl == null || backendUrl.isBlank())
? "http://localhost"
: TRAILING_SLASH_PATTERN.matcher(backendUrl.trim()).replaceAll("");
boolean hasScheme = URL_SCHEME_PATTERN.matcher(trimmedBase).matches();
: backendUrl.trim().replaceAll("/+$", "");
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
Integer parsedPort = parsePort(port);
@@ -306,7 +298,7 @@ public class SPDFApplication {
if (port == null) {
return trimmedBase;
}
if (PORT_SUFFIX_PATTERN.matcher(trimmedBase).matches()) {
if (trimmedBase.matches(".+:\\d+$")) {
return trimmedBase;
}
return trimmedBase + ":" + port;
@@ -104,7 +104,7 @@ public class EndpointInspector implements ApplicationListener<ContextRefreshedEv
String infoString = mappingInfo.toString();
if (infoString.contains("{")) {
String patternsSection =
infoString.substring(infoString.indexOf('{') + 1, infoString.indexOf('}'));
infoString.substring(infoString.indexOf("{") + 1, infoString.indexOf("}"));
for (String pattern : patternsSection.split(",")) {
pattern = pattern.trim();
@@ -51,7 +51,9 @@ public class ExternalAppDepConfig {
*/
private final Map<String, List<String>> commandToGroupMapping;
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
private final ExecutorService pool =
Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
@@ -1,79 +0,0 @@
package stirling.software.SPDF.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.util.unit.DataSize;
import jakarta.servlet.MultipartConfigElement;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.controller.web.UploadLimitService;
/**
* Configuration for Spring multipart file upload settings. Synchronizes multipart limits with
* fileUploadLimit from settings.yml or environment variables (SYSTEMFILEUPLOADLIMIT or
* SYSTEM_MAXFILESIZE).
*/
@Configuration
@Slf4j
public class MultipartConfiguration {
@Autowired private UploadLimitService uploadLimitService;
/**
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
* variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE). Depends on ApplicationProperties
* being initialized so @PostConstruct has run.
*/
@Bean
@DependsOn("applicationProperties")
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
// First check if SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE is explicitly set
String springMaxFileSize =
java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
long uploadLimitBytes = 0;
if (springMaxFileSize != null && !springMaxFileSize.trim().isEmpty()) {
// Parse the Spring property format (e.g., "2000MB")
try {
DataSize dataSize = DataSize.parse(springMaxFileSize.trim());
uploadLimitBytes = dataSize.toBytes();
log.info("Using SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize);
} catch (Exception e) {
log.warn(
"Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}",
springMaxFileSize,
e);
}
}
// If not set via Spring property, use UploadLimitService which reads from
// fileUploadLimit (set from SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE or settings.yml)
if (uploadLimitBytes == 0) {
uploadLimitBytes = uploadLimitService.getUploadLimit();
if (uploadLimitBytes > 0) {
log.info(
"Using fileUploadLimit setting: {}",
uploadLimitService.getReadableUploadLimit());
}
}
// If still no limit, use default of 2000MB
if (uploadLimitBytes == 0) {
uploadLimitBytes = 2000L * 1024 * 1024; // 2000MB default
log.info("Using default multipart file upload limit: 2000MB");
}
// Set max file size and max request size to the same value
factory.setMaxFileSize(DataSize.ofBytes(uploadLimitBytes));
factory.setMaxRequestSize(DataSize.ofBytes(uploadLimitBytes));
return factory.createMultipartConfig();
}
}
@@ -54,8 +54,8 @@ public class TauriProcessMonitor {
scheduler =
Executors.newSingleThreadScheduledExecutor(
r -> {
Thread t =
Thread.ofVirtual().name("tauri-process-monitor").unstarted(r);
Thread t = new Thread(r, "tauri-process-monitor");
t.setDaemon(true);
return t;
});
@@ -82,9 +82,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
registry.addMapping("/**")
.allowedOriginPatterns(
"http://localhost:*",
"https://localhost:*",
"tauri://*", // Add this for Tauri apps
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
@@ -157,8 +154,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
logger.debug(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
logger.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
@@ -8,12 +8,10 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.encryption.PDEncryption;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
@@ -37,9 +35,9 @@ public class AnalysisController {
@Operation(
summary = "Get PDF page count",
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getPageCount(@ModelAttribute PDFFile file) throws IOException {
public Map<String, Integer> getPageCount(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
return ResponseEntity.ok(Map.of("pageCount", document.getNumberOfPages()));
return Map.of("pageCount", document.getNumberOfPages());
}
}
@@ -48,13 +46,13 @@ public class AnalysisController {
@Operation(
summary = "Get basic PDF information",
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getBasicInfo(@ModelAttribute PDFFile file) throws IOException {
public Map<String, Object> getBasicInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> info = new HashMap<>();
info.put("pageCount", document.getNumberOfPages());
info.put("pdfVersion", document.getVersion());
info.put("fileSize", file.getFileInput().getSize());
return ResponseEntity.ok(info);
return info;
}
}
@@ -65,7 +63,7 @@ public class AnalysisController {
@Operation(
summary = "Get PDF document properties",
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getDocumentProperties(@ModelAttribute PDFFile file)
public Map<String, String> getDocumentProperties(@ModelAttribute PDFFile file)
throws IOException {
// Load the document in read-only mode to prevent modifications and ensure the integrity of
// the original file.
@@ -78,15 +76,9 @@ public class AnalysisController {
properties.put("keywords", info.getKeywords());
properties.put("creator", info.getCreator());
properties.put("producer", info.getProducer());
properties.put(
"creationDate",
info.getCreationDate() != null ? info.getCreationDate().toString() : null);
properties.put(
"modificationDate",
info.getModificationDate() != null
? info.getModificationDate().toString()
: null);
return ResponseEntity.ok(properties);
properties.put("creationDate", info.getCreationDate().toString());
properties.put("modificationDate", info.getModificationDate().toString());
return properties;
}
}
@@ -95,7 +87,8 @@ public class AnalysisController {
@Operation(
summary = "Get page dimensions for all pages",
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getPageDimensions(@ModelAttribute PDFFile file) throws IOException {
public List<Map<String, Float>> getPageDimensions(@ModelAttribute PDFFile file)
throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
List<Map<String, Float>> dimensions = new ArrayList<>();
PDPageTree pages = document.getPages();
@@ -106,7 +99,7 @@ public class AnalysisController {
pageDim.put("height", page.getBBox().getHeight());
dimensions.add(pageDim);
}
return ResponseEntity.ok(dimensions);
return dimensions;
}
}
@@ -116,7 +109,7 @@ public class AnalysisController {
summary = "Get form field information",
description =
"Returns count and details of form fields. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getFormFields(@ModelAttribute PDFFile file) throws IOException {
public Map<String, Object> getFormFields(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> formInfo = new HashMap<>();
PDAcroForm form = document.getDocumentCatalog().getAcroForm();
@@ -130,7 +123,7 @@ public class AnalysisController {
formInfo.put("hasXFA", false);
formInfo.put("isSignaturesExist", false);
}
return ResponseEntity.ok(formInfo);
return formInfo;
}
}
@@ -139,7 +132,7 @@ public class AnalysisController {
@Operation(
summary = "Get annotation information",
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException {
public Map<String, Object> getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> annotInfo = new HashMap<>();
int totalAnnotations = 0;
@@ -155,7 +148,7 @@ public class AnalysisController {
annotInfo.put("totalCount", totalAnnotations);
annotInfo.put("typeBreakdown", annotationTypes);
return ResponseEntity.ok(annotInfo);
return annotInfo;
}
}
@@ -165,23 +158,20 @@ public class AnalysisController {
summary = "Get font information",
description =
"Returns list of fonts used in the document. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getFontInfo(@ModelAttribute PDFFile file) throws IOException {
public Map<String, Object> getFontInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> fontInfo = new HashMap<>();
Set<String> fontNames = new HashSet<>();
for (PDPage page : document.getPages()) {
PDResources resources = page.getResources();
if (resources != null) {
for (COSName font : resources.getFontNames()) {
fontNames.add(font.getName());
}
for (COSName font : page.getResources().getFontNames()) {
fontNames.add(font.getName());
}
}
fontInfo.put("fontCount", fontNames.size());
fontInfo.put("fonts", fontNames);
return ResponseEntity.ok(fontInfo);
return fontInfo;
}
}
@@ -191,7 +181,7 @@ public class AnalysisController {
summary = "Get security information",
description =
"Returns encryption and permission details. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> getSecurityInfo(@ModelAttribute PDFFile file) throws IOException {
public Map<String, Object> getSecurityInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> securityInfo = new HashMap<>();
PDEncryption encryption = document.getEncryption();
@@ -218,7 +208,7 @@ public class AnalysisController {
securityInfo.put("isEncrypted", false);
}
return ResponseEntity.ok(securityInfo);
return securityInfo;
}
}
}
@@ -24,7 +24,6 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
@@ -119,12 +118,6 @@ public class CropController {
return r >= threshold && g >= threshold && b >= threshold;
}
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Crops a PDF document",
@@ -144,7 +137,7 @@ public class CropController {
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
if (request.isRemoveDataOutsideCrop()) {
return cropWithGhostscript(request);
} else {
return cropWithPDFBox(request);
@@ -15,6 +15,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter;
@@ -29,9 +32,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@GeneralApi
@Slf4j
@RequiredArgsConstructor
@@ -46,8 +46,8 @@ public class EditTableOfContentsController {
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
@@ -8,7 +8,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -52,7 +51,6 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class MergeController {
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@@ -175,7 +173,7 @@ public class MergeController {
String[] parts = inside.split(",");
String[] result = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
}
return result;
}
@@ -399,12 +397,7 @@ public class MergeController {
String mergedFileName =
GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf");
byte[] pdfBytes;
try {
pdfBytes = Files.readAllBytes(outputTempFile.getPath());
} finally {
outputTempFile.close();
}
byte[] pdfBytes = Files.readAllBytes(outputTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(pdfBytes, mergedFileName);
}
}
@@ -194,7 +194,7 @@ public class PdfOverlayController {
// Load the overlay document to check its page count
try (PDDocument overlayPdf = Loader.loadPDF(overlayFile)) {
int overlayPageCount = overlayPdf.getNumberOfPages();
for (int j = 0; j < repeatCount && currentPage <= basePageCount; j++) {
for (int j = 0; j < repeatCount; j++) {
for (int page = 0; page < overlayPageCount; page++) {
if (currentPage > basePageCount) break;
overlayGuide.put(currentPage++, overlayFile.getAbsolutePath());
@@ -1,275 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@Slf4j
@RequiredArgsConstructor
public class PosterPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(value = "/split-for-poster-print", consumes = "multipart/form-data")
@MultiFileResponse
@Operation(
summary = "Split large PDF pages into smaller printable chunks",
description =
"This endpoint splits large or oddly-sized PDF pages into smaller chunks "
+ "suitable for printing on standard paper sizes (e.g., A4, Letter). "
+ "Divides each page into a grid of smaller pages using Apache PDFBox. "
+ "Input: PDF Output: ZIP-PDF Type: SISO")
public ResponseEntity<StreamingResponseBody> posterPdf(@ModelAttribute PosterPdfRequest request)
throws Exception {
log.debug("Starting PDF poster split process with request: {}", request);
MultipartFile file = request.getFileInput();
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
log.debug("Base filename for output: {}", filename);
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf")) {
// Get target page size
PDRectangle targetPageSize = getTargetPageSize(request.getPageSize());
log.debug(
"Target page size: {} ({}x{})",
request.getPageSize(),
targetPageSize.getWidth(),
targetPageSize.getHeight());
// Create LayerUtility for importing pages as forms
LayerUtility layerUtility = new LayerUtility(outputDocument);
int totalPages = sourceDocument.getNumberOfPages();
int xFactor = request.getXFactor();
int yFactor = request.getYFactor();
boolean rightToLeft = request.isRightToLeft();
log.debug(
"Processing {} pages with grid {}x{}, RTL={}",
totalPages,
xFactor,
yFactor,
rightToLeft);
// Process each page
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
// Get both MediaBox and CropBox
PDRectangle mediaBox = sourcePage.getMediaBox();
PDRectangle cropBox = sourcePage.getCropBox();
// If no CropBox is set, use MediaBox
if (cropBox == null) {
cropBox = mediaBox;
}
// Save original boxes for restoration
PDRectangle originalMediaBox = sourcePage.getMediaBox();
PDRectangle originalCropBox = sourcePage.getCropBox();
// Normalize the page: set MediaBox to CropBox
// This ensures the form's coordinate space starts at (0, 0)
// instead of having an offset from the original MediaBox
sourcePage.setMediaBox(cropBox);
sourcePage.setCropBox(cropBox);
// Handle page rotation
int rotation = sourcePage.getRotation();
float sourceWidth = cropBox.getWidth();
float sourceHeight = cropBox.getHeight();
// Swap dimensions if rotated 90 or 270 degrees
if (rotation == 90 || rotation == 270) {
float temp = sourceWidth;
sourceWidth = sourceHeight;
sourceHeight = temp;
}
log.debug(
"Page {}: Normalized to CropBox dimensions {}x{}, rotation {}",
pageIndex,
sourceWidth,
sourceHeight,
rotation);
// Import source page as form (now with normalized coordinate space)
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
// Restore original boxes
sourcePage.setMediaBox(originalMediaBox);
sourcePage.setCropBox(originalCropBox);
// Calculate cell dimensions in source page coordinates
float cellWidth = sourceWidth / xFactor;
float cellHeight = sourceHeight / yFactor;
// Create grid cells (rows × columns)
for (int row = 0; row < yFactor; row++) {
for (int col = 0; col < xFactor; col++) {
// Apply RTL ordering for columns if enabled
int actualCol = rightToLeft ? (xFactor - 1 - col) : col;
// Calculate crop rectangle in source coordinates
// PDF coordinates start at bottom-left
float cropX = actualCol * cellWidth;
// For Y: invert so row 0 shows TOP (following
// SplitPdfBySectionsController
// pattern)
float cropY = (yFactor - 1 - row) * cellHeight;
// Create new output page with target size
PDPage outputPage = new PDPage(targetPageSize);
outputDocument.addPage(outputPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
outputDocument,
outputPage,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Calculate uniform scale to fit cell into target page
// Scale UP if cell is smaller than target, scale DOWN if larger
float scaleX = targetPageSize.getWidth() / cellWidth;
float scaleY = targetPageSize.getHeight() / cellHeight;
float scale = Math.min(scaleX, scaleY);
// Center the scaled content on the target page
float scaledCellWidth = cellWidth * scale;
float scaledCellHeight = cellHeight * scale;
float offsetX = (targetPageSize.getWidth() - scaledCellWidth) / 2;
float offsetY = (targetPageSize.getHeight() - scaledCellHeight) / 2;
// Apply transformations
contentStream.saveGraphicsState();
// Translate to center position
contentStream.transform(
Matrix.getTranslateInstance(offsetX, offsetY));
// Scale uniformly
contentStream.transform(Matrix.getScaleInstance(scale, scale));
// Translate to show only the desired grid cell
// IMPORTANT: The PDFormXObject's BBox already matches the CropBox
// (including its offset), so we only need to translate by
// cropX/cropY
// relative to the CropBox origin, NOT the MediaBox origin
contentStream.transform(
Matrix.getTranslateInstance(-cropX, -cropY));
// Draw the form
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
log.trace(
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
row,
actualCol,
pageIndex,
cropX,
cropY,
-cropX,
-cropY);
}
}
}
// Save output PDF to intermediate TempFile
outputDocument.save(pdfTempFile.getFile());
log.debug("Generated output PDF with {} pages", outputDocument.getNumberOfPages());
// Create ZIP from the PDF TempFile, streaming directly to zip TempFile
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
zipOut.putNextEntry(new ZipEntry(filename + "_poster.pdf"));
Files.copy(pdfTempFile.getPath(), zipOut);
zipOut.closeEntry();
}
// pdfTempFile auto-closed and deleted here (end of inner try-with-resources)
}
log.debug("Successfully created ZIP");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_poster.zip");
} catch (IOException e) {
ExceptionUtils.logException("PDF poster split process", e);
zipTempFile.close();
throw e;
} catch (Exception e) {
zipTempFile.close();
throw e;
}
}
/**
* Maps page size string to PDRectangle.
*
* @param pageSize the page size name (e.g., "A4", "Letter")
* @return the corresponding PDRectangle
* @throws IllegalArgumentException if page size is not supported
*/
private PDRectangle getTargetPageSize(String pageSize) {
Map<String, PDRectangle> sizeMap = new HashMap<>();
sizeMap.put("A4", PDRectangle.A4);
sizeMap.put("Letter", PDRectangle.LETTER);
sizeMap.put("A3", PDRectangle.A3);
sizeMap.put("A5", PDRectangle.A5);
sizeMap.put("Legal", PDRectangle.LEGAL);
sizeMap.put("Tabloid", new PDRectangle(792, 1224)); // 11x17 inches
PDRectangle size = sizeMap.get(pageSize);
if (size == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidPageSize",
"Invalid page size: {0}",
pageSize,
String.join(", ", sizeMap.keySet()));
}
return size;
}
}
@@ -191,14 +191,6 @@ public class RearrangePagesPDFController {
if (duplicateCount < 1) {
duplicateCount = 2; // Default to 2 if invalid input
}
int maxDuplicateCount = Math.max(100, totalPages * 3);
if (duplicateCount > maxDuplicateCount) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"duplicateCount",
"must not exceed " + maxDuplicateCount);
}
// For each page in the document
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
@@ -1,5 +1,6 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
@@ -9,11 +10,11 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -48,60 +49,83 @@ public class SplitPDFController {
+ " specified page numbers or ranges. Users can specify pages using"
+ " individual numbers, ranges, or 'all' for every page. Input:PDF"
+ " Output:PDF Type:SIMO")
public ResponseEntity<StreamingResponseBody> splitPdf(@ModelAttribute PDFWithPageNums request)
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
throws IOException {
MultipartFile file = request.getFileInput();
TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
try {
try (PDDocument document = pdfDocumentFactory.load(file)) {
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
log.debug(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
PDDocument document = pdfDocumentFactory.load(file)) {
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
int previousPageNumber = 0;
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
int splitPoint = pageNumbers.get(splitIndex);
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
splitDocument.addPage(document.getPage(i));
log.debug("Adding page {} to split document", i);
}
previousPageNumber = splitPoint + 1;
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
String fileName = baseFilename + "_" + (splitIndex + 1) + ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
splitDocument.save(zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
// Create a mutable ArrayList so we can add to it
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
log.debug(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
splitDocumentsBoas = new ArrayList<>(pageNumbers.size());
int previousPageNumber = 0;
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", i);
}
previousPageNumber = splitPoint + 1;
// Transfer metadata to split pdf
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
int splitDocumentsSize = splitDocumentsBoas.size();
for (int i = 0; i < splitDocumentsSize; i++) {
StringBuilder sb = new StringBuilder(baseFilename.length() + 10);
sb.append(baseFilename).append('_').append(i + 1).append(".pdf");
String fileName = sb.toString();
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
}
}
log.debug(
"Successfully created zip file with split documents: {}",
outputTempFile.getPath().toString());
byte[] data = Files.readAllBytes(outputTempFile.getPath());
String zipFilename =
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip");
return WebResponseUtils.zipFileToWebResponse(outputTempFile, zipFilename);
} catch (Exception e) {
outputTempFile.close();
throw e;
return WebResponseUtils.bytesToWebResponse(
data, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
}
}
@@ -1,6 +1,8 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -15,7 +17,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -35,8 +36,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -48,8 +47,6 @@ public class SplitPdfByChaptersController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
PDOutlineItem current,
@@ -125,19 +122,22 @@ public class SplitPdfByChaptersController {
@Operation(
summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.")
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfByChaptersRequest request) throws Exception {
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfByChaptersRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
PDDocument sourceDocument = null;
Path zipFile = null;
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
}
try {
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
}
sourceDocument = pdfDocumentFactory.load(file);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
@@ -157,10 +157,12 @@ public class SplitPdfByChaptersController {
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
} catch (Exception e) {
ExceptionUtils.logException("outline extraction", e);
throw e;
return ResponseEntity.internalServerError()
.body("Unable to extract outline items".getBytes());
}
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
@@ -179,10 +181,29 @@ public class SplitPdfByChaptersController {
bookmark.getStartPage(),
bookmark.getEndPage());
}
List<ByteArrayOutputStream> splitDocumentsBoas =
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
sourceDocument.close();
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
if (sourceDocument != null) {
sourceDocument.close();
}
if (zipFile != null) {
Files.deleteIfExists(zipFile);
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
}
}
@@ -211,55 +232,72 @@ public class SplitPdfByChaptersController {
return bookmarks;
}
private TempFile createZipFile(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
private Path createZipFile(
List<Bookmark> bookmarks, List<ByteArrayOutputStream> splitDocumentsBoas)
throws Exception {
PdfMetadata metadata =
includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null;
Path zipFile = Files.createTempFile("split_documents", ".zip");
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int i = 0; i < bookmarks.size(); i++) {
Bookmark bookmark = bookmarks.get(i);
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
for (int pg = bookmark.getStartPage();
pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
pg++) {
PDPage page = sourceDocument.getPage(pg);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", pg);
}
if (includeMetadata) {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
}
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, i)
+ bookmark.getTitle()
+ ".pdf";
zipOut.putNextEntry(new ZipEntry(fileName));
splitDocument.save(zipOut);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
String fileName =
String.format(Locale.ROOT, fileNumberFormatter, i)
+ bookmarks.get(i).getTitle()
+ ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.debug("Wrote split document {} to zip file", fileName);
}
log.info(
"Successfully created zip file with split documents: {}",
zipTempFile.getPath());
return zipTempFile;
} catch (Exception e) {
zipTempFile.close();
log.error("Failed writing to zip", e);
throw e;
}
log.info("Successfully created zip file with split documents: {}", zipFile);
return zipFile;
}
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
PdfMetadata metadata = null;
if (includeMetadata) {
metadata = pdfMetadataService.extractMetadataFromPdf(sourceDocument);
}
for (Bookmark bookmark : bookmarks) {
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
for (int i = bookmark.getStartPage();
i < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
i++) {
PDPage page = sourceDocument.getPage(i);
splitDocument.addPage(page);
log.debug("Adding page {} to split document", i);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (includeMetadata) {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
}
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
}
}
return splitDocumentsBoas;
}
}
@@ -23,8 +23,6 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -59,7 +57,7 @@ public class SplitPdfBySectionsController {
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> splitPdf(@Valid @ModelAttribute SplitPdfBySectionsRequest request)
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
String pageNumbers = request.getPageNumbers();
@@ -103,68 +101,66 @@ public class SplitPdfBySectionsController {
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
}
} else {
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
}
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
}
}
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(
sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error("Error processing unsplit page {}", pageNum, e);
throw e;
}
}
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error("Error processing unsplit page {}", pageNum, e);
throw e;
}
}
} catch (IOException e) {
log.error("Error creating ZIP file with split PDF sections", e);
throw e;
}
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (IOException e) {
log.error("Error creating ZIP file with split PDF sections", e);
throw e;
}
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
} catch (Exception e) {
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -12,11 +13,9 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -51,8 +50,8 @@ public class SplitPdfBySizeController {
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
+ " (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF"
+ " Output:ZIP-PDF Type:SISO")
public ResponseEntity<StreamingResponseBody> autoSplitPdf(
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request)
throws Exception {
log.debug("Starting PDF split process with request: {}", request);
MultipartFile file = request.getFileInput();
@@ -60,51 +59,63 @@ public class SplitPdfBySizeController {
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
log.debug("Base filename for output: {}", filename);
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
log.debug("Created temporary managed zip file: {}", zipTempFile.getPath());
log.debug("Creating ZIP output stream");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
log.debug(
"Successfully loaded PDF with {} pages", sourceDocument.getNumberOfPages());
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
Path managedZipPath = zipTempFile.getPath();
log.debug("Created temporary managed zip file: {}", managedZipPath);
try {
log.debug("Reading input file bytes");
byte[] pdfBytes = file.getBytes();
log.debug("Successfully read {} bytes from input file", pdfBytes.length);
int type = request.getSplitType();
String value = request.getSplitValue();
log.debug("Split type: {}, Split value: {}", type, value);
log.debug("Creating ZIP output stream");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(managedZipPath))) {
log.debug("Loading PDF document");
try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) {
log.debug(
"Successfully loaded PDF with {} pages",
sourceDocument.getNumberOfPages());
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) {
log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value);
log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} else if (type == 2) {
log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value);
log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else {
log.error("Invalid split type: {}", type);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"split type: " + type);
int type = request.getSplitType();
String value = request.getSplitValue();
log.debug("Split type: {}, Split value: {}", type, value);
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) {
log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value);
log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} else if (type == 2) {
log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value);
log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else {
log.error("Invalid split type: {}", type);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"split type: " + type);
}
log.debug("PDF splitting completed successfully");
}
}
log.debug("PDF splitting completed successfully");
}
log.debug("Returning streaming response for zip file");
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
} catch (Exception e) {
ExceptionUtils.logException("PDF splitting process", e);
zipTempFile.close();
throw e;
byte[] data = Files.readAllBytes(managedZipPath);
log.debug("Successfully read {} bytes from ZIP file", data.length);
log.debug("Returning response with {} bytes of data", data.length);
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
ExceptionUtils.logException("PDF splitting process", e);
throw e; // Re-throw to ensure proper error response
}
}
}
@@ -113,7 +124,6 @@ public class SplitPdfBySizeController {
throws IOException {
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
@Getter
class DocHolder implements AutoCloseable {
private PDDocument doc;
@@ -121,6 +131,10 @@ public class SplitPdfBySizeController {
this.doc = doc;
}
public PDDocument getDoc() {
return doc;
}
public void setDoc(PDDocument doc) {
if (this.doc != null) {
try {
@@ -16,6 +16,9 @@ import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Data;
@@ -32,9 +35,6 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@UiDataApi
public class UIDataController {
@@ -44,21 +44,18 @@ public class UIDataController {
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
private final ObjectMapper objectMapper;
public UIDataController(
ApplicationProperties applicationProperties,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig,
ObjectMapper objectMapper) {
RuntimePathConfig runtimePathConfig) {
this.applicationProperties = applicationProperties;
this.signatureService = signatureService;
this.userService = userService;
this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig;
this.objectMapper = objectMapper;
}
@GetMapping("/footer-info")
@@ -96,8 +93,9 @@ public class UIDataController {
try (InputStream is = resource.getInputStream()) {
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> licenseData =
objectMapper.readValue(json, new TypeReference<>() {});
mapper.readValue(json, new TypeReference<>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
@@ -129,8 +127,8 @@ public class UIDataController {
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent =
objectMapper.readValue(
config, new TypeReference<Map<String, Object>>() {});
new ObjectMapper()
.readValue(config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
String filename =
@@ -22,7 +22,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -52,7 +51,6 @@ import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -80,7 +78,7 @@ public class ConvertImgPDFController {
"This endpoint converts a PDF file to image(s) with the specified image format,"
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images. Input:PDF Output:Image Type:SI-Conditional")
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
public ResponseEntity<byte[]> convertToImage(@ModelAttribute ConvertToImageRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
String imageFormat = request.getImageFormat();
@@ -182,33 +180,28 @@ public class ConvertImgPDFController {
"No WebP files were created. " + resultProcess.getMessages());
}
byte[] bodyBytes = new byte[0];
if (webpFiles.size() == 1) {
// Return the single WebP file directly
Path webpFilePath = webpFiles.get(0);
byte[] webpBytes = Files.readAllBytes(webpFilePath);
Files.deleteIfExists(tempFile);
tempFile = null;
FileUtils.deleteDirectory(tempOutputDir.toFile());
tempOutputDir = null;
String docName = filename + "." + imageFormat;
MediaType mediaType = MediaType.parseMediaType(getMediaType(imageFormat));
return WebResponseUtils.bytesToWebResponse(webpBytes, docName, mediaType);
bodyBytes = Files.readAllBytes(webpFilePath);
} else {
ByteArrayOutputStream zipBAOS = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(zipBAOS)) {
// Create a ZIP file containing all WebP images
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
for (Path webpFile : webpFiles) {
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
Files.copy(webpFile, zos);
zos.closeEntry();
}
bodyBytes = zipOutputStream.toByteArray();
}
Files.deleteIfExists(tempFile);
tempFile = null;
FileUtils.deleteDirectory(tempOutputDir.toFile());
tempOutputDir = null;
String zipFilename = filename + "_convertedToImages.zip";
return WebResponseUtils.bytesToWebResponse(
zipBAOS.toByteArray(), zipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
// Clean up the temporary files
Files.deleteIfExists(tempFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
result = bodyBytes;
}
if (singleImage) {
@@ -274,8 +267,8 @@ public class ConvertImgPDFController {
description =
"This endpoint converts a CBZ (ZIP) comic book archive to a PDF file. "
+ "Input:CBZ Output:PDF Type:SISO")
public ResponseEntity<StreamingResponseBody> convertCbzToPdf(
@ModelAttribute ConvertCbzToPdfRequest request) throws IOException {
public ResponseEntity<?> convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean optimizeForEbook = request.isOptimizeForEbook();
@@ -285,13 +278,13 @@ public class ConvertImgPDFController {
optimizeForEbook = false;
}
TempFile pdfFile =
byte[] pdfBytes =
CbzUtils.convertCbzToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
@@ -300,8 +293,8 @@ public class ConvertImgPDFController {
description =
"This endpoint converts a PDF file to a CBZ (ZIP) comic book archive. "
+ "Input:PDF Output:CBZ Type:SISO")
public ResponseEntity<StreamingResponseBody> convertPdfToCbz(
@ModelAttribute ConvertPdfToCbzRequest request) throws IOException {
public ResponseEntity<?> convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
int dpi = request.getDpi();
@@ -309,12 +302,12 @@ public class ConvertImgPDFController {
dpi = 300;
}
TempFile cbzFile =
PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory, tempFileManager);
byte[] cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
return WebResponseUtils.bytesToWebResponse(
cbzBytes, filename, MediaType.APPLICATION_OCTET_STREAM);
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
@@ -44,7 +44,7 @@ public class ConvertMarkdownToPdf {
@Operation(
summary = "Convert a Markdown file to PDF",
description =
"This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to"
"This endpoint takes a Markdown file input, converts it to HTML, and then to"
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
public ResponseEntity<byte[]> markdownToPdf(@ModelAttribute GeneralFile generalFile)
throws Exception {
@@ -52,187 +52,40 @@ public class ConvertMarkdownToPdf {
if (fileInput == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "Markdown or ZIP");
"error.fileFormatRequired", "File must be in {0} format", "Markdown");
}
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null) {
if (originalFilename == null || !originalFilename.endsWith(".md")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
"error.fileFormatRequired", "File must be in {0} format", ".md");
}
boolean isZip = originalFilename.toLowerCase().endsWith(".zip");
boolean isMarkdown = originalFilename.toLowerCase().endsWith(".md");
// Convert Markdown to HTML using CommonMark
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
if (!isZip && !isMarkdown) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
}
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
byte[] pdfBytes;
String outputFilename;
if (isZip) {
// Handle ZIP file containing markdown + images
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
// Extract ZIP to temp directory
java.nio.file.Path tempDirPath = tempDir.getPath();
try (java.util.zip.ZipInputStream zipIn =
io.github.pixee.security.ZipSecurity.createHardenedInputStream(
new java.io.ByteArrayInputStream(fileInput.getBytes()))) {
java.util.zip.ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
java.nio.file.Path filePath =
tempDirPath.resolve(entry.getName()).normalize();
if (!filePath.startsWith(tempDirPath)) {
throw new java.io.IOException(
"ZIP entry is outside of target directory: "
+ entry.getName());
}
java.nio.file.Files.createDirectories(filePath.getParent());
java.nio.file.Files.copy(zipIn, filePath);
}
zipIn.closeEntry();
}
}
// Find the markdown file (look for .md files, prefer index.md or first one)
java.io.File markdownFile = findMarkdownFile(tempDirPath.toFile());
if (markdownFile == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired",
"ZIP must contain at least one {0} file",
".md");
}
// Read and convert markdown to HTML
String markdownContent = java.nio.file.Files.readString(markdownFile.toPath());
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(markdownContent);
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
// Create a new ZIP with HTML + images for WeasyPrint
byte[] htmlZipBytes = createHtmlZip(htmlContent, tempDirPath.toFile());
// Use FileToPdf which already supports ZIP files with images
pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlZipBytes,
"package.zip",
tempFileManager,
customHtmlSanitizer);
outputFilename =
GeneralUtils.generateFilename(
originalFilename.substring(0, originalFilename.lastIndexOf('.')),
".pdf");
}
} else {
// Handle plain markdown file (no images)
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
}
String htmlContent = renderer.render(document);
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
/**
* Finds a markdown file in the directory. Prefers index.md, otherwise returns the first .md
* file found.
*/
private java.io.File findMarkdownFile(java.io.File directory) throws java.io.IOException {
java.io.File indexMd = new java.io.File(directory, "index.md");
if (indexMd.exists()) {
return indexMd;
}
// Search for any .md file
try (java.util.stream.Stream<java.nio.file.Path> paths =
java.nio.file.Files.walk(directory.toPath())) {
return paths.filter(p -> p.toString().toLowerCase().endsWith(".md"))
.findFirst()
.map(java.nio.file.Path::toFile)
.orElse(null);
}
}
/**
* Creates a ZIP file containing the HTML content and all other files (images) from the
* directory.
*/
private byte[] createHtmlZip(String htmlContent, java.io.File sourceDir)
throws java.io.IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
// Add HTML file to root
java.util.zip.ZipEntry htmlEntry = new java.util.zip.ZipEntry("index.html");
zos.putNextEntry(htmlEntry);
zos.write(htmlContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
zos.closeEntry();
// Add all other files (images, etc.)
addDirectoryToZip(zos, sourceDir.toPath(), sourceDir.toPath());
}
return baos.toByteArray();
}
/** Recursively adds files from a directory to a ZIP, excluding .md files. */
private void addDirectoryToZip(
java.util.zip.ZipOutputStream zos,
java.nio.file.Path sourceDir,
java.nio.file.Path rootDir)
throws java.io.IOException {
try (java.util.stream.Stream<java.nio.file.Path> paths =
java.nio.file.Files.walk(sourceDir, 1)) {
for (java.nio.file.Path path : paths.toList()) {
if (java.nio.file.Files.isDirectory(path)) {
if (!path.equals(sourceDir)) {
addDirectoryToZip(zos, path, rootDir);
}
} else if (!path.toString().toLowerCase().endsWith(".md")) {
// Add file to ZIP, maintaining relative path structure
java.nio.file.Path relativePath = rootDir.relativize(path);
java.util.zip.ZipEntry entry =
new java.util.zip.ZipEntry(relativePath.toString());
zos.putNextEntry(entry);
java.nio.file.Files.copy(path, zos);
zos.closeEntry();
}
}
}
}
}
class TableAttributeProvider implements AttributeProvider {
@@ -91,32 +91,22 @@ public class ConvertOfficeController {
Path libreOfficeProfile = null;
try {
ProcessExecutorResult result = null;
IOException unoconvertException = null;
// Try unoconvert first if available
ProcessExecutorResult result;
// Run Unoconvert command
if (isUnoconvertAvailable()) {
try {
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--convert-to");
command.add("pdf");
command.add(inputPath.toString());
command.add(outputPath.toString());
// Unoconvert: schreibe direkt in outputPath innerhalb des workDir
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--convert-to");
command.add("pdf");
command.add(inputPath.toString());
command.add(outputPath.toString());
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
unoconvertException = e;
log.warn(
"Unoconvert command failed ({}). Falling back to soffice command.",
e.getMessage());
}
}
// Fallback to soffice if unoconvert was unavailable or failed
if (result == null) {
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} // Run soffice command
else {
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getSOfficePath());
@@ -124,21 +114,14 @@ public class ConvertOfficeController {
command.add("--headless");
command.add("--nologo");
command.add("--convert-to");
command.add("pdf");
command.add("pdf:writer_pdf_Export");
command.add("--outdir");
command.add(workDir.toString());
command.add(inputPath.toString());
try {
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
if (unoconvertException != null) {
e.addSuppressed(unoconvertException);
}
throw e;
}
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
}
// Check the result
@@ -54,12 +54,6 @@ public class ConvertPDFToEpubController {
command.add(inputPath.toString());
command.add(outputPath.toString());
// Use pdftohtml engine (poppler) for PDF input instead of calibre's Qt-based engine.
// This avoids the Qt WebEngine dependency for PDF parsing and uses the lighter
// poppler-utils pdftohtml binary which is already available in the container.
command.add("--pdf-engine");
command.add("pdftohtml");
// Golden defaults
command.add("--enable-heuristics");
command.add("--insert-blank-line");
@@ -1,124 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.RectangularTextContainer;
import technology.tabula.Table;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
@ConvertApi
@Slf4j
@RequiredArgsConstructor
public class ConvertPDFToExcelController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(value = "/pdf/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert a PDF to an Excel spreadsheet (XLSX)",
description =
"Extracts tabular data from each page of a PDF and writes it into an Excel"
+ " workbook, one sheet per table. Input:PDF Output:XLSX Type:SISO")
public ResponseEntity<byte[]> pdfToExcel(@ModelAttribute PDFWithPageNums request)
throws Exception {
String baseName =
GeneralUtils.removeExtension(request.getFileInput().getOriginalFilename());
try (PDDocument document = pdfDocumentFactory.load(request);
XSSFWorkbook workbook = new XSSFWorkbook();
ObjectExtractor extractor = new ObjectExtractor(document)) {
List<Integer> pages = request.getPageNumbersList(document, true);
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
int sheetCount = 0;
for (int pageNum : pages) {
Page page = extractor.extract(pageNum);
List<Table> tables = sea.extract(page);
for (int tableIdx = 0; tableIdx < tables.size(); tableIdx++) {
Table table = tables.get(tableIdx);
String sheetName =
tables.size() == 1
? String.format(Locale.ROOT, "Page %d", pageNum)
: String.format(
Locale.ROOT, "Page %d Table %d", pageNum, tableIdx + 1);
sheetName = getUniqueSheetName(workbook, sheetName);
Sheet sheet = workbook.createSheet(sheetName);
List<List<RectangularTextContainer>> rows = table.getRows();
for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) {
Row excelRow = sheet.createRow(rowIdx);
List<RectangularTextContainer> cells = rows.get(rowIdx);
for (int cellIdx = 0; cellIdx < cells.size(); cellIdx++) {
Cell excelCell = excelRow.createCell(cellIdx);
excelCell.setCellValue(cells.get(cellIdx).getText());
}
}
sheetCount++;
}
}
if (sheetCount == 0) {
return ResponseEntity.noContent().build();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
workbook.write(baos);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(
ContentDisposition.builder("attachment").filename(baseName + ".xlsx").build());
headers.setContentType(
MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(baos.toByteArray());
}
}
private String getUniqueSheetName(Workbook workbook, String baseName) {
String safeName = WorkbookUtil.createSafeSheetName(baseName);
String uniqueName = safeName;
int count = 1;
while (workbook.getSheet(uniqueName) != null) {
String suffix = " (" + count + ")";
if (safeName.length() + suffix.length() > 31) {
uniqueName = safeName.substring(0, 31 - suffix.length()) + suffix;
} else {
uniqueName = safeName + suffix;
}
count++;
}
return uniqueName;
}
}
@@ -71,12 +71,10 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpSerializer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
@@ -101,7 +99,6 @@ public class ConvertPDFToPDFA {
private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]");
private final RuntimePathConfig runtimePathConfig;
private final stirling.software.SPDF.service.VeraPDFService veraPDFService;
private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc";
private static final int PDFA_COMPATIBILITY_POLICY = 1;
@@ -590,8 +587,7 @@ public class ConvertPDFToPDFA {
if (isPdfX) {
return handlePdfXConversion(inputFile, outputFormat);
} else {
return handlePdfAConversion(
inputFile, outputFormat, request.getStrict() != null && request.getStrict());
return handlePdfAConversion(inputFile, outputFormat);
}
}
@@ -1797,7 +1793,7 @@ public class ConvertPDFToPDFA {
}
private ResponseEntity<byte[]> handlePdfAConversion(
MultipartFile inputFile, String outputFormat, boolean strict) throws Exception {
MultipartFile inputFile, String outputFormat) throws Exception {
PdfaProfile profile = PdfaProfile.fromRequest(outputFormat);
// Get the original filename without extension
@@ -1826,10 +1822,6 @@ public class ConvertPDFToPDFA {
validateAndWarnPdfA(converted, profile, "Ghostscript");
if (strict) {
verifyStrictCompliance(converted);
}
return WebResponseUtils.bytesToWebResponse(
converted, outputFilename, MediaType.APPLICATION_PDF);
} catch (IOException | InterruptedException e) {
@@ -1847,42 +1839,14 @@ public class ConvertPDFToPDFA {
// Validate with PDFBox preflight and warn if issues found
validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice");
if (strict) {
verifyStrictCompliance(converted);
}
return WebResponseUtils.bytesToWebResponse(
converted, outputFilename, MediaType.APPLICATION_PDF);
} finally {
deleteQuietly(workingDir);
}
}
private void verifyStrictCompliance(byte[] pdfBytes) throws IOException {
try (InputStream is = new ByteArrayInputStream(pdfBytes)) {
List<stirling.software.SPDF.model.api.security.PDFVerificationResult> results =
veraPDFService.validatePDF(is);
boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant());
if (!isCompliant) {
String details =
results.stream()
.map(r -> r.getStandard() + ": " + r.getComplianceSummary())
.collect(Collectors.joining("; "));
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: "
+ details);
}
} catch (Exception e) {
if (e instanceof ResponseStatusException) {
throw (ResponseStatusException) e;
}
log.error("Error during strict PDF/A verification", e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Error during strict PDF/A verification");
}
}
private Path sanitizePdfWithPdfBox(Path inputPdf, boolean addWhiteBackground) {
try {
Path sanitizedPath =
@@ -1,9 +1,7 @@
package stirling.software.SPDF.controller.api.converters;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -38,9 +36,6 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class ConvertPdfJsonController {
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("[\\r\\n\\t]+");
private static final Pattern NON_PRINTABLE_PATTERN = Pattern.compile("[^\\x20-\\x7E]");
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
@@ -61,13 +56,10 @@ public class ConvertPdfJsonController {
}
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
logJsonResponse("pdf/text-editor", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
@@ -90,9 +82,7 @@ public class ConvertPdfJsonController {
String originalName = jsonFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
@@ -118,17 +108,14 @@ public class ConvertPdfJsonController {
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
logJsonResponse("pdf/text-editor/metadata", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + "_metadata.json";
@@ -165,9 +152,7 @@ public class ConvertPdfJsonController {
String baseName =
(filename != null && !filename.isBlank())
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(filename))
.replaceFirst("")
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
: Optional.ofNullable(document.getMetadata())
.map(PdfJsonMetadata::getTitle)
.filter(title -> title != null && !title.isBlank())
@@ -190,33 +175,11 @@ public class ConvertPdfJsonController {
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
logJsonResponse("pdf/text-editor/page", jsonBytes);
String docName = "page_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@GetMapping(value = "/pdf/text-editor/fonts/{jobId}/{pageNumber}")
@Operation(
summary = "Extract fonts used by a single cached page for text editor",
description =
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractPageFonts(jobId, pageNumber);
logJsonResponse("pdf/text-editor/fonts/page", jsonBytes);
String docName = "page_fonts_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(
value = "/pdf/text-editor/clear-cache/{jobId}",
consumes = MediaType.ALL_VALUE)
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@Operation(
summary = "Clear cached PDF document for text editor",
description =
@@ -246,191 +209,6 @@ public class ConvertPdfJsonController {
return baseJobId;
}
private void logJsonResponse(String label, byte[] jsonBytes) {
if (jsonBytes == null) {
log.warn("Returning {} JSON response: null bytes", label);
return;
}
// Only perform expensive tail extraction if debug logging is enabled
if (log.isDebugEnabled()) {
int length = jsonBytes.length;
boolean endsWithJson =
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
String tail = "";
if (length > 0) {
int start = Math.max(0, length - 64);
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
tail =
NON_PRINTABLE_PATTERN
.matcher(WHITESPACE_PATTERN.matcher(tail).replaceAll(" "))
.replaceAll("?");
}
log.debug(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
}
if (isPdfJsonDebugDumpEnabled()) {
try {
String tmpDir = System.getProperty("java.io.tmpdir");
String customDir = System.getenv("SPDF_PDFJSON_DUMP_DIR");
java.nio.file.Path dumpDir =
customDir != null && !customDir.isBlank()
? java.nio.file.Path.of(customDir)
: java.nio.file.Path.of(tmpDir);
java.nio.file.Path dumpPath =
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
java.nio.file.Files.write(dumpPath, jsonBytes);
log.debug("PDF JSON debug dump ({}): {}", label, dumpPath);
} catch (Exception ex) {
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
}
}
if (isPdfJsonRepeatScanEnabled()) {
logRepeatedJsonStrings(label, jsonBytes);
}
}
private boolean isPdfJsonDebugDumpEnabled() {
String env = System.getenv("SPDF_PDFJSON_DUMP");
if (env != null && env.equalsIgnoreCase("true")) {
return true;
}
return Boolean.getBoolean("spdf.pdfjson.dump");
}
private boolean isPdfJsonRepeatScanEnabled() {
String env = System.getenv("SPDF_PDFJSON_REPEAT_SCAN");
if (env != null && env.equalsIgnoreCase("true")) {
return true;
}
return Boolean.getBoolean("spdf.pdfjson.repeatScan");
}
private void logRepeatedJsonStrings(String label, byte[] jsonBytes) {
final int minLen = 12;
final int maxLen = 200;
final int maxUnique = 50000;
java.util.Map<String, Integer> counts = new java.util.HashMap<>();
boolean inString = false;
boolean escape = false;
boolean tooLong = false;
StringBuilder current = new StringBuilder(64);
boolean capped = false;
for (byte b : jsonBytes) {
char ch = (char) (b & 0xFF);
if (!inString) {
if (ch == '"') {
inString = true;
escape = false;
tooLong = false;
current.setLength(0);
}
continue;
}
if (escape) {
escape = false;
if (!tooLong && current.length() < maxLen) {
current.append(ch);
}
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = false;
if (!tooLong) {
int len = current.length();
if (len >= minLen && len <= maxLen) {
String value = current.toString();
if (!looksLikeBase64(value)) {
if (!capped || counts.containsKey(value)) {
counts.merge(value, 1, Integer::sum);
if (!capped && counts.size() >= maxUnique) {
capped = true;
}
}
}
}
}
continue;
}
if (!tooLong) {
if (current.length() < maxLen) {
current.append(ch);
} else {
tooLong = true;
}
}
}
java.util.List<java.util.Map.Entry<String, Integer>> top =
counts.entrySet().stream()
.filter(e -> e.getValue() > 1)
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
.limit(20)
.toList();
if (!top.isEmpty()) {
String summary =
top.stream()
.map(
e ->
String.format(
"\"%s\"(len=%d,count=%d)",
truncateForLog(e.getKey()),
e.getKey().length(),
e.getValue()))
.collect(java.util.stream.Collectors.joining("; "));
log.debug(
"PDF JSON repeat scan ({}): top strings -> {}{}",
label,
summary,
capped ? " (capped)" : "");
} else {
log.debug(
"PDF JSON repeat scan ({}): no repeated strings found{}",
label,
capped ? " (capped)" : "");
}
}
private boolean looksLikeBase64(String value) {
if (value.length() < 32) {
return false;
}
int base64Chars = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if ((c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '+'
|| c == '/'
|| c == '=') {
base64Chars++;
}
}
return base64Chars >= value.length() * 0.9;
}
private String truncateForLog(String value) {
int max = 64;
if (value.length() <= max) {
return WHITESPACE_PATTERN.matcher(value).replaceAll(" ");
}
return WHITESPACE_PATTERN.matcher(value.substring(0, max)).replaceAll(" ") + "...";
}
/**
* Validate that the current user has access to the given job.
*
@@ -9,13 +9,13 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.exception.CacheUnavailableException;
import tools.jackson.databind.ObjectMapper;
@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class)
@Slf4j
@RequiredArgsConstructor
@@ -166,7 +166,7 @@ public class ConvertWebsiteToPDF {
private String fetchRemoteHtml(String url) throws IOException, InterruptedException {
HttpClient client =
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
@@ -180,7 +180,7 @@ public class ConvertWebsiteToPDF {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 300 || response.body() == null) {
if (response.statusCode() >= 400 || response.body() == null) {
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Failed to retrieve remote HTML. Status: {0}",
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -12,6 +11,9 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
@@ -33,7 +35,6 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
public ConfigController(
@@ -44,66 +45,15 @@ public class ConfigController {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
}
/**
* Get current license type dynamically instead of from cached bean. This ensures the frontend
* sees updated license status after admin changes the license key.
*/
private String getCurrentLicenseType() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.getLicenseTypeName();
}
// Fallback to cached bean if service not available
if (applicationContext.containsBean("license")) {
return applicationContext.getBean("license", String.class);
}
return null;
}
/** Check if running Pro or higher (SERVER or ENTERPRISE license) dynamically. */
private Boolean isRunningProOrHigher() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.isRunningProOrHigher();
}
// Fallback to cached bean
if (applicationContext.containsBean("runningProOrHigher")) {
return applicationContext.getBean("runningProOrHigher", Boolean.class);
}
return null;
}
/** Check if running Enterprise edition dynamically. */
private Boolean isRunningEE() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.isRunningEE();
}
// Fallback to cached bean
if (applicationContext.containsBean("runningEE")) {
return applicationContext.getBean("runningEE", Boolean.class);
}
return null;
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
@@ -151,32 +101,6 @@ public class ConfigController {
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// User preference defaults
configData.put(
"defaultHideUnavailableTools",
applicationProperties.getUi().isDefaultHideUnavailableTools());
configData.put(
"defaultHideUnavailableConversions",
applicationProperties.getUi().isDefaultHideUnavailableConversions());
// Hide disabled tools settings
configData.put(
"hideDisabledToolsGoogleDrive",
applicationProperties.getUi().getHideDisabledTools().isGoogleDrive());
configData.put(
"hideDisabledToolsMobileQRScanner",
applicationProperties.getUi().getHideDisabledTools().isMobileQRScanner());
// Google Drive backend settings (only if enabled)
ApplicationProperties.Premium.ProFeatures.GoogleDrive googleDrive =
applicationProperties.getPremium().getProFeatures().getGoogleDrive();
if (googleDrive.isEnabled()) {
configData.put("googleDriveEnabled", true);
configData.put("googleDriveClientId", googleDrive.getClientId());
configData.put("googleDriveApiKey", googleDrive.getApiKey());
configData.put("googleDriveAppId", googleDrive.getAppId());
}
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
@@ -261,23 +185,19 @@ public class ConfigController {
applicationProperties.getLegal().getAccessibilityStatement());
// Try to get EEAppConfig values if available
// Get these dynamically to reflect current license status (not cached at startup)
try {
Boolean runningProOrHigher = isRunningProOrHigher();
if (runningProOrHigher != null) {
configData.put("runningProOrHigher", runningProOrHigher);
if (applicationContext.containsBean("runningProOrHigher")) {
configData.put(
"runningProOrHigher",
applicationContext.getBean("runningProOrHigher", Boolean.class));
}
Boolean runningEE = isRunningEE();
if (runningEE != null) {
configData.put("runningEE", runningEE);
if (applicationContext.containsBean("runningEE")) {
configData.put(
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
}
String licenseType = getCurrentLicenseType();
if (licenseType != null) {
configData.put("license", licenseType);
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -336,13 +256,11 @@ public class ConfigController {
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints", required = false) List<String> endpoints) {
Collection<String> toCheck =
(endpoints == null || endpoints.isEmpty())
? endpointConfiguration.getAllEndpoints()
: endpoints;
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : toCheck) {
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
@@ -67,7 +67,7 @@ public class ExtractImageScansController {
MultipartFile inputFile = request.getFileInput();
String fileName = inputFile.getOriginalFilename();
String extension = fileName.substring(fileName.lastIndexOf('.') + 1);
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
List<String> images = new ArrayList<>();
@@ -5,7 +5,6 @@ import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
@@ -29,7 +28,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,8 +42,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ImageProcessingUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@MiscApi
@@ -54,7 +50,6 @@ import stirling.software.common.util.WebResponseUtils;
public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/extract-images")
@MultiFileResponse
@@ -64,115 +59,112 @@ public class ExtractImagesController {
"This endpoint extracts images from a given PDF file and returns them in a zip"
+ " file. Users can specify the output image format. Input:PDF"
+ " Output:IMAGE/ZIP Type:SIMO")
public ResponseEntity<StreamingResponseBody> extractImages(
@ModelAttribute PDFExtractImagesRequest request)
public ResponseEntity<byte[]> extractImages(@ModelAttribute PDFExtractImagesRequest request)
throws IOException, InterruptedException, ExecutionException {
MultipartFile file = request.getFileInput();
String format = request.getFormat();
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
PDDocument document = pdfDocumentFactory.load(file);
// Determine if multithreading should be used based on PDF size or number of pages
boolean useMultithreading = shouldUseMultithreading(file, document);
// Create ByteArrayOutputStream to write zip file to byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Create ZipOutputStream to create zip file
ZipOutputStream zos = new ZipOutputStream(baos);
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
String filename = GeneralUtils.removeExtension(file.getOriginalFilename());
Set<byte[]> processedImages = new HashSet<>();
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zos =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
PDDocument document = pdfDocumentFactory.load(file)) {
if (useMultithreading) {
// Executor service to handle multithreading
ExecutorService executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
Set<Future<Void>> futures = new HashSet<>();
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
// Safely iterate over each page, handling corrupt PDFs where page count might be wrong
try {
int pageCount = document.getPages().getCount();
log.debug("Document reports {} pages", pageCount);
// Determine if multithreading should be used based on PDF size or number of pages
boolean useMultithreading = shouldUseMultithreading(file, document);
int consecutiveFailures = 0;
if (useMultithreading) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Future<Void>> futures = new HashSet<>();
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
try {
PDPage page = document.getPage(pgNum);
consecutiveFailures = 0; // Reset on success
final int currentPageNum = pgNum + 1; // Convert to 1-based page numbering
Future<Void> future =
executor.submit(
() -> {
try {
// Call the image extraction method for each page
extractImagesFromPage(
page,
format,
filename,
currentPageNum,
processedImages,
zos,
allowDuplicates);
} catch (Exception e) {
// Log the error and continue processing other pages
ExceptionUtils.logException(
"image extraction from page "
+ currentPageNum,
e);
}
try {
int pageCount = document.getPages().getCount();
log.debug("Document reports {} pages", pageCount);
return null; // Callable requires a return type
});
int consecutiveFailures = 0;
// Add the Future object to the list to track completion
futures.add(future);
} catch (Exception e) {
consecutiveFailures++;
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
try {
PDPage page = document.getPage(pgNum);
consecutiveFailures = 0; // Reset on success
final int currentPageNum =
pgNum + 1; // Convert to 1-based page numbering
Future<Void> future =
executor.submit(
() -> {
try {
// Call the image extraction method for each
// page
extractImagesFromPage(
page,
format,
filename,
currentPageNum,
processedImages,
zos,
allowDuplicates);
} catch (Exception e) {
// Log the error and continue processing other
// pages
ExceptionUtils.logException(
"image extraction from page "
+ currentPageNum,
e);
}
return null; // Callable requires a return type
});
// Add the Future object to the list to track completion
futures.add(future);
} catch (Exception e) {
consecutiveFailures++;
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
if (consecutiveFailures >= 3) {
log.warn("Stopping page iteration after 3 consecutive failures");
break;
}
if (consecutiveFailures >= 3) {
log.warn("Stopping page iteration after 3 consecutive failures");
break;
}
}
} catch (Exception e) {
ExceptionUtils.logException("page count determination", e);
throw e;
}
// Wait for all tasks to complete
for (Future<Void> future : futures) {
future.get();
}
// Close executor service
executor.shutdown();
} else {
// Single-threaded extraction
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
extractImagesFromPage(
page,
format,
filename,
pgNum + 1,
processedImages,
zos,
allowDuplicates);
}
} catch (Exception e) {
ExceptionUtils.logException("page count determination", e);
throw e;
}
// Wait for all tasks to complete
for (Future<Void> future : futures) {
future.get();
}
// Close executor service
executor.shutdown();
} else {
// Single-threaded extraction
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
extractImagesFromPage(
page, format, filename, pgNum + 1, processedImages, zos, allowDuplicates);
}
// document and zos closed by try-with-resources
} catch (Exception e) {
zipTempFile.close();
throw e;
}
return WebResponseUtils.zipFileToWebResponse(
zipTempFile, filename + "_extracted-images.zip");
// Close PDDocument and ZipOutputStream
document.close();
zos.close();
// Create ByteArrayResource from byte array
byte[] zipContents = baos.toByteArray();
return WebResponseUtils.baosToWebResponse(
baos, filename + "_extracted-images.zip", MediaType.APPLICATION_OCTET_STREAM);
}
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
@@ -223,17 +215,13 @@ public class ExtractImagesController {
// Convert to standard RGB colorspace if needed
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
// Encode image outside the lock to allow parallel encoding across threads
// Write image to zip file
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
byte[] imageData = imageBaos.toByteArray();
// Write encoded bytes to zip under lock (ZipOutputStream requires
// serialization)
synchronized (zos) {
zos.putNextEntry(new ZipEntry(imageName));
zos.write(imageData);
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
zos.write(imageBaos.toByteArray());
zos.closeEntry();
}
}
@@ -94,7 +94,7 @@ public class OCRController {
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
List<String> selectedLanguages = request.getLanguages();
boolean sidecar = request.isSidecar();
Boolean sidecar = request.isSidecar();
Boolean deskew = request.isDeskew();
Boolean clean = request.isClean();
Boolean cleanFinal = request.isCleanFinal();
@@ -246,12 +246,10 @@ public class OCRController {
command.add("--clean-final");
}
if (ocrType != null && !ocrType.isEmpty()) {
if ("force-ocr".equals(ocrType)) {
command.add("--force-ocr");
} else {
// Default for 'Normal' and 'skip-text': use --skip-text
// ocrmypdf 17+ requires explicit flag when PDF already contains text
if ("skip-text".equals(ocrType)) {
command.add("--skip-text");
} else if ("force-ocr".equals(ocrType)) {
command.add("--force-ocr");
}
}
command.add("--invalidate-digital-signatures");
@@ -380,12 +378,11 @@ public class OCRController {
List<String> command = new ArrayList<>();
command.add("tesseract");
command.add(imagePath.toString());
String outputBase =
command.add(
new File(
tempOutputDir,
String.format(Locale.ROOT, "page_%d", pageNum))
.toString();
command.add(outputBase);
.toString());
command.add("-l");
command.add(String.join("+", selectedLanguages));
command.add("pdf"); // Always output PDF
@@ -403,18 +400,6 @@ public class OCRController {
result.getRc());
}
// Verify the OCR'd PDF was created
if (!pageOutputPath.exists()) {
log.warn(
"Tesseract did not create expected output file: {}. Page may be blank or unreadable.",
pageOutputPath.getAbsolutePath());
// Save original page without OCR as fallback
try (PDDocument pageDoc = new PDDocument()) {
pageDoc.addPage(page);
pageDoc.save(pageOutputPath);
}
}
// Add OCR'd PDF to merger
merger.addSource(pageOutputPath);
} else {
@@ -55,45 +55,45 @@ public class OverlayImageController {
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
PDDocument document = pdfDocumentFactory.load(pdfBytes);
if (isSvg) {
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
} else {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF page {}", i);
}
}
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
if (!everyPage && i == 0) {
break;
if (isSvg) {
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
} else {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF page {}", i);
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] result = baos.toByteArray();
log.info("PDF with overlaid image successfully created");
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_overlayed.pdf"));
if (!everyPage && i == 0) {
break;
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
byte[] result = baos.toByteArray();
log.info("PDF with overlaid image successfully created");
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
} catch (IOException e) {
log.error("Failed to add image to PDF", e);
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);

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