Compare commits

..
Author SHA1 Message Date
Anthony Stirling b4c6ea0f53 Merge branch 'main' into pdfEditorFixes 2026-02-07 00:19:04 +00:00
Anthony Stirling 4418f562c7 tests 2026-02-02 17:31:05 +00:00
Anthony Stirling 4f62b00b3f Merge branch 'main' into codex/modify-tauri-app-to-maintain-file-path 2026-02-02 10:51:30 +00:00
Anthony Stirling c8967d80c0 remove code 2026-01-31 22:18:24 +00:00
Anthony Stirling 9a282b1f74 more cleanup 2026-01-31 22:12:24 +00:00
Anthony Stirling bd65f36964 cleanups on shitty design 2026-01-31 22:01:44 +00:00
Anthony Stirling 0b991c7de4 Merge branch 'codex/modify-tauri-app-to-maintain-file-path' of
git@github.com:Stirling-Tools/Stirling-PDF.git into
codex/modify-tauri-app-to-maintain-file-path
2026-01-31 21:39:42 +00:00
Anthony Stirling edb93c54b8 Merge branch 'codex/modify-tauri-app-to-maintain-file-path' of git@github.com:Stirling-Tools/Stirling-PDF.git into codex/modify-tauri-app-to-maintain-file-path 2026-01-31 21:39:35 +00:00
Anthony Stirling 27f4e365d7 cleanups 2026-01-31 21:31:07 +00:00
Anthony Stirling 7fba215fab Merge branch 'main' into codex/modify-tauri-app-to-maintain-file-path 2026-01-31 20:05:09 +00:00
Anthony Stirling e04158eed6 Merge branch 'main' into codex/modify-tauri-app-to-maintain-file-path 2026-01-31 16:16:04 +00:00
Anthony Stirling 93af41d74b updates 2026-01-30 00:49:38 +00:00
Anthony Stirling c879e54441 update from upload and auto save 2026-01-30 00:26:09 +00:00
Anthony Stirling c987deb31b Preserve local paths for desktop saves 2026-01-23 17:48:51 +00:00
605 changed files with 290227 additions and 299970 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
+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
-2
View File
@@ -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
@@ -150,10 +150,10 @@ jobs:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
@@ -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
+11 -96
View File
@@ -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
@@ -68,20 +68,10 @@ jobs:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-${{ 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
with:
gradle-version: 8.14
cache-disabled: true
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew build -PnoSpotless
@@ -150,23 +140,13 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
cache-disabled: true
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
@@ -229,34 +209,19 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
cache-disabled: true
- name: 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 }}
@@ -288,10 +253,6 @@ jobs:
# )
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
checks: write
steps:
- name: Harden Runner
@@ -302,34 +263,20 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
cache-disabled: true
- name: 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@v3
- 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
@@ -409,26 +334,16 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
cache-disabled: true
- name: Build application
run: ./gradlew build
@@ -453,8 +368,8 @@ 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
@@ -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
@@ -330,10 +330,10 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
@@ -344,12 +344,7 @@ jobs:
- 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 }}
+8 -16
View File
@@ -21,6 +21,8 @@ on:
- windows
- macos
- linux
push:
branches: [main, V2-master]
release:
types: [created]
@@ -42,22 +44,12 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@v4
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
with:
@@ -125,10 +117,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
@@ -205,10 +197,10 @@ jobs:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
+8 -18
View File
@@ -39,22 +39,12 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@v4
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
with:
@@ -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 }}
+2 -2
View File
@@ -33,10 +33,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
+2 -5
View File
@@ -15,15 +15,13 @@ on:
- linux
pull_request:
branches: [main, V2-tauri-windows]
types: [opened, reopened, synchronize, ready_for_review]
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
@@ -329,7 +327,6 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
-2
View File
@@ -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
+69 -1
View File
@@ -1 +1,69 @@
dont emrge only for test delpoyment
<p align="center">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
</p>
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
<p align="center">
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
</a>
<a href="https://discord.gg/HYmhKj45pU">
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
</a>
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
</a>
<a href="https://github.com/Stirling-Tools/stirling-pdf">
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
</a>
</p>
![Stirling PDF - Dashboard](images/home-light.png)
## Key Capabilities
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API.
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
- **Enterprisegrade** - SSO, auditing, and flexible onprem deployments.
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
- **Global UI** - Interface available in 40+ languages.
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
## Quick Start
```bash
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
```
Then open: http://localhost:8080
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
## Resources
- [**Documentation**](https://docs.stirlingpdf.com)
- [**Homepage**](https://stirling.com)
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
## Support
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
For development setup, see the [Developer Guide](DeveloperGuide.md).
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
## License
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
+3 -3
View File
@@ -27,8 +27,8 @@ spotless {
}
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
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'
@@ -42,7 +42,7 @@ dependencies {
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
@@ -391,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");
@@ -606,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)
@@ -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,17 +29,16 @@ 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;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import jakarta.annotation.PostConstruct;
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;
@@ -102,8 +101,8 @@ public class ApplicationProperties {
}
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
* 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() {
@@ -125,18 +124,12 @@ public class ApplicationProperties {
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
if (sizeInMB > 0 && sizeInMB <= 999) {
fileUploadLimit = sizeInMB + "MB";
log.info(
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
sizeInMB);
log.info("Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB", sizeInMB);
} else {
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
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);
log.warn("SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring", systemMaxFileSize);
}
}
}
@@ -394,107 +387,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
@@ -712,8 +610,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;
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -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;
}
}
@@ -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
@@ -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,8 +121,7 @@ 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("."))
@@ -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();
@@ -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();
@@ -42,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) {
@@ -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);
}
}
}
}
}
@@ -100,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")
@@ -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;
}
/*
@@ -107,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 markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
@@ -574,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 {
@@ -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));
}
}
+4 -28
View File
@@ -46,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"
@@ -76,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.7.15'
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
implementation ('technology.tabula:tabula:1.0.5') {
@@ -91,15 +73,11 @@ dependencies {
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
exclude group: 'com.google.code.gson', module: 'gson'
}
// CVE-2022-25647: Explicit gson 2.8.9 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.8.9'
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'
// 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'
@@ -190,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'
@@ -237,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'
@@ -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;
@@ -176,14 +171,11 @@ 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() {
@@ -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;
@@ -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,7 +1,7 @@
package stirling.software.SPDF.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.servlet.MultipartConfigFactory;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
@@ -14,9 +14,9 @@ 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 for Spring multipart file upload settings.
* Synchronizes multipart limits with fileUploadLimit from settings.yml or environment variables
* (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
*/
@Configuration
@Slf4j
@@ -25,9 +25,9 @@ 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.
* 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")
@@ -35,8 +35,7 @@ public class MultipartConfiguration {
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");
String springMaxFileSize = java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
long uploadLimitBytes = 0;
if (springMaxFileSize != null && !springMaxFileSize.trim().isEmpty()) {
@@ -46,10 +45,7 @@ public class MultipartConfiguration {
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);
log.warn("Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize, e);
}
}
@@ -77,3 +73,4 @@ public class MultipartConfiguration {
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;
}
}
}
@@ -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
@@ -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,271 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
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.WebResponseUtils;
@GeneralApi
@Slf4j
@RequiredArgsConstructor
public class PosterPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(
value = "/split-for-poster-print",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@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<byte[]> 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);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream();
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream()) {
// 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
outputDocument.save(pdfOutputStream);
byte[] pdfData = pdfOutputStream.toByteArray();
log.debug(
"Generated output PDF with {} pages ({} bytes)",
outputDocument.getNumberOfPages(),
pdfData.length);
// Create ZIP file with the result
try (ZipOutputStream zipOut = new ZipOutputStream(zipOutputStream)) {
ZipEntry zipEntry = new ZipEntry(filename + "_poster.pdf");
zipOut.putNextEntry(zipEntry);
zipOut.write(pdfData);
zipOut.closeEntry();
}
byte[] zipData = zipOutputStream.toByteArray();
log.debug("Successfully created ZIP with {} bytes", zipData.length);
return WebResponseUtils.bytesToWebResponse(
zipData, filename + "_poster.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (IOException e) {
ExceptionUtils.logException("PDF poster split process", e);
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++) {
@@ -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);
@@ -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 =
@@ -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
@@ -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;
}
}
@@ -1,9 +1,8 @@
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 java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -38,7 +37,6 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class ConvertPdfJsonController {
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
@@ -63,9 +61,7 @@ public class ConvertPdfJsonController {
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);
@@ -88,9 +84,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);
@@ -116,7 +110,7 @@ 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);
@@ -124,9 +118,7 @@ public class ConvertPdfJsonController {
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";
@@ -163,9 +155,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())
@@ -193,28 +183,7 @@ public class ConvertPdfJsonController {
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 =
@@ -249,25 +218,21 @@ public class ConvertPdfJsonController {
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 = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
}
log.debug(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
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 = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
}
log.info(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
if (isPdfJsonDebugDumpEnabled()) {
try {
@@ -280,7 +245,7 @@ public class ConvertPdfJsonController {
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);
log.info("PDF JSON debug dump ({}): {}", label, dumpPath);
} catch (Exception ex) {
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
}
@@ -386,16 +351,14 @@ public class ConvertPdfJsonController {
e.getKey().length(),
e.getValue()))
.collect(java.util.stream.Collectors.joining("; "));
log.debug(
log.info(
"PDF JSON repeat scan ({}): top strings -> {}{}",
label,
summary,
capped ? " (capped)" : "");
} else {
log.debug(
"PDF JSON repeat scan ({}): no repeated strings found{}",
label,
capped ? " (capped)" : "");
log.info(
"PDF JSON repeat scan ({}): no repeated strings found{}", label, capped ? " (capped)" : "");
}
}
@@ -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,14 +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());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
@@ -243,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",
@@ -318,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,
@@ -82,8 +82,9 @@ public class ExtractImagesController {
Set<byte[]> processedImages = new HashSet<>();
if (useMultithreading) {
// Virtual thread executor — lightweight threads ideal for I/O-bound image extraction
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// Executor service to handle multithreading
ExecutorService executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
Set<Future<Void>> futures = new HashSet<>();
// Safely iterate over each page, handling corrupt PDFs where page count might be wrong
@@ -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);
@@ -57,7 +57,6 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class StampController {
private static final Pattern NEWLINE_PATTERN = Pattern.compile("\\r?\\n");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@@ -267,7 +266,7 @@ public class StampController {
.getEscapedNewlinePattern()
.matcher(processedStampText)
.replaceAll("\n");
String[] lines = NEWLINE_PATTERN.split(normalizedText);
String[] lines = normalizedText.split("\\r?\\n");
PDRectangle pageSize = page.getMediaBox();
@@ -14,6 +14,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
@@ -30,10 +34,6 @@ import stirling.software.common.service.PostHogService;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DatabindException;
import tools.jackson.databind.ObjectMapper;
@PipelineApi
@Slf4j
@RequiredArgsConstructor
@@ -54,7 +54,7 @@ public class PipelineController {
+ "Users provide files and a JSON configuration defining the sequence of operations to perform. "
+ "Input:PDF Output:PDF/ZIP Type:MIMO")
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws DatabindException, JacksonException {
throws JsonMappingException, JsonProcessingException {
MultipartFile[] files = request.getFileInput();
String jsonString = request.getJson();
if (files == null) {
@@ -21,13 +21,14 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.core.io.Resource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PipelineConfig;
@@ -38,14 +39,11 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.FileMonitor;
import tools.jackson.databind.ObjectMapper;
@Service
@Slf4j
public class PipelineDirectoryProcessor {
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
private final ObjectMapper objectMapper;
private final ApiDocService apiDocService;
@@ -435,12 +433,10 @@ public class PipelineDirectoryProcessor {
private Path determineOutputPath(PipelineConfig config, Path dir) {
String outputDir =
WATCHED_FOLDERS_PATTERN
.matcher(
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString()))
.replaceAll("");
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString())
.replaceAll("\\\\?watchedFolders", "");
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
}
@@ -47,6 +47,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
@@ -62,11 +66,6 @@ import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@SecurityApi
@Slf4j
@RequiredArgsConstructor
@@ -78,7 +77,7 @@ public class GetInfoOnPDF {
private static final String PAGE_PREFIX = "Page ";
private static final long MAX_FILE_SIZE = 100L * 1024 * 1024;
private static final ObjectMapper objectMapper = JsonMapper.builder().build();
private static final ObjectMapper objectMapper = new ObjectMapper();
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final VeraPDFService veraPDFService;
@@ -33,8 +33,6 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
@@ -73,7 +71,7 @@ public class WatermarkController {
"This endpoint adds a watermark to a given PDF file. Users can specify the"
+ " watermark type (text or image), rotation, opacity, width spacer, and"
+ " height spacer. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> addWatermark(@Valid @ModelAttribute AddWatermarkRequest request)
public ResponseEntity<byte[]> addWatermark(@ModelAttribute AddWatermarkRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
String pdfFileName = pdfFile.getOriginalFilename();
@@ -239,8 +237,8 @@ public class WatermarkController {
// Calculating the number of rows and columns.
int watermarkRows = Math.min((int) (pageHeight / newWatermarkHeight + 1), 10_000);
int watermarkCols = Math.min((int) (pageWidth / newWatermarkWidth + 1), 10_000);
int watermarkRows = (int) (pageHeight / newWatermarkHeight + 1);
int watermarkCols = (int) (pageWidth / newWatermarkWidth + 1);
// Add the text watermark
for (int i = 0; i <= watermarkRows; i++) {
@@ -292,15 +290,9 @@ public class WatermarkController {
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
int watermarkRows =
Math.min(
(int)
((pageHeight + heightSpacer)
/ (desiredPhysicalHeight + heightSpacer)),
10_000);
(int) ((pageHeight + heightSpacer) / (desiredPhysicalHeight + heightSpacer));
int watermarkCols =
Math.min(
(int) ((pageWidth + widthSpacer) / (desiredPhysicalWidth + widthSpacer)),
10_000);
(int) ((pageWidth + widthSpacer) / (desiredPhysicalWidth + widthSpacer));
for (int i = 0; i < watermarkRows; i++) {
for (int j = 0; j < watermarkCols; j++) {
@@ -1,7 +1,5 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
@@ -56,27 +54,10 @@ public class MetricsController {
}
Map<String, String> status = new HashMap<>();
status.put("status", "UP");
String version = getClass().getPackage().getImplementationVersion();
if (version == null) {
version = getVersionFromProperties();
}
status.put("version", version);
status.put("version", getClass().getPackage().getImplementationVersion());
return ResponseEntity.ok(status);
}
private String getVersionFromProperties() {
try (InputStream is = getClass().getResourceAsStream("/version.properties")) {
if (is != null) {
Properties props = new Properties();
props.load(is);
return props.getProperty("version");
}
} catch (IOException e) {
log.error("Failed to load version.properties", e);
}
return null;
}
@GetMapping("/load")
@Operation(
summary = "GET request count",
@@ -6,7 +6,6 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
@@ -29,8 +28,6 @@ public class ReactRoutingController {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -97,9 +94,9 @@ public class ReactRoutingController {
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
BASE_HREF_PATTERN
.matcher(html)
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
@@ -23,8 +23,6 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -192,31 +190,6 @@ public class GlobalExceptionHandler {
return problemDetail;
}
/**
* Checks whether the given IOException indicates that the client disconnected before the
* response could be written (broken pipe, connection reset, etc.). When this happens there is
* no point in serialising a {@link ProblemDetail} body because the socket is already closed —
* and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if
* the response Content-Type was already committed as a non-JSON type (e.g. image/png).
*/
private static boolean isClientDisconnectException(IOException ex) {
// Walk the causal chain — Jetty/Tomcat may wrap the low-level SocketException
Throwable current = ex;
while (current != null) {
String msg = current.getMessage();
if (msg != null) {
String lower = msg.toLowerCase(java.util.Locale.ROOT);
if (lower.contains("broken pipe")
|| lower.contains("connection reset")
|| lower.contains("an established connection was aborted")) {
return true;
}
}
current = current.getCause();
}
return false;
}
/**
* Helper method to create a standardized ProblemDetail response for exceptions with error
* codes.
@@ -868,24 +841,21 @@ public class GlobalExceptionHandler {
response.setContentType("application/problem+json");
response.setCharacterEncoding("UTF-8");
// Use ObjectMapper to properly escape JSON values and prevent XSS
ObjectMapper mapper = new ObjectMapper();
java.util.Map<String, Object> errorMap = new java.util.LinkedHashMap<>();
errorMap.put("type", "about:blank");
errorMap.put("title", "Not Acceptable");
errorMap.put("status", 406);
errorMap.put(
"detail",
"The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.");
errorMap.put("instance", request.getRequestURI());
errorMap.put("timestamp", Instant.now().toString());
errorMap.put(
"hints",
java.util.Arrays.asList(
"Error responses are always returned as application/json or application/problem+json",
"Set Accept header to include application/json for proper error handling"));
String errorJson =
String.format(
"""
{
"type": "about:blank",
"title": "Not Acceptable",
"status": 406,
"detail": "The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.",
"instance": "%s",
"timestamp": "%s",
"hints": ["Error responses are always returned as application/json or application/problem+json", "Set Accept header to include application/json for proper error handling"]
}
""",
request.getRequestURI(), Instant.now().toString());
String errorJson = mapper.writeValueAsString(errorMap);
response.getWriter().write(errorJson);
response.getWriter().flush();
}
@@ -1137,15 +1107,6 @@ public class GlobalExceptionHandler {
public ResponseEntity<ProblemDetail> handleIOException(
IOException ex, HttpServletRequest request) {
// Broken pipe / connection reset means the client disconnected.
// Attempting to write a ProblemDetail response will fail because the
// response Content-Type may already be committed (e.g. image/png) and
// the client is gone anyway. Log at WARN and return an empty body.
if (isClientDisconnectException(ex)) {
log.warn("Client disconnected at {}: {}", request.getRequestURI(), ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
// Check if this is a PDF-specific error and wrap it appropriately
IOException processedException =
ExceptionUtils.handlePdfException(ex, request.getRequestURI());
@@ -3,9 +3,9 @@ package stirling.software.SPDF.model;
import java.util.HashMap;
import java.util.Map;
import lombok.Getter;
import com.fasterxml.jackson.databind.JsonNode;
import tools.jackson.databind.JsonNode;
import lombok.Getter;
public class ApiEndpoint {
private final String name;
@@ -18,10 +18,10 @@ public class ApiEndpoint {
postNode.path("parameters")
.forEach(
paramNode -> {
String paramName = paramNode.path("name").asText("");
String paramName = paramNode.path("name").asText();
parameters.put(paramName, paramNode);
});
this.description = postNode.path("description").asText("");
this.description = postNode.path("description").asText();
}
public boolean areParametersValid(Map<String, Object> providedParams) {
@@ -2,9 +2,6 @@ package stirling.software.SPDF.model.api;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -36,8 +33,6 @@ public class SplitPdfBySectionsRequest extends PDFFile {
defaultValue = "0",
minimum = "0",
requiredMode = Schema.RequiredMode.REQUIRED)
@Min(value = 0, message = "Horizontal divisions must be non-negative")
@Max(value = 50, message = "Horizontal divisions must not exceed 50")
private int horizontalDivisions;
@Schema(
@@ -45,8 +40,6 @@ public class SplitPdfBySectionsRequest extends PDFFile {
defaultValue = "1",
minimum = "0",
requiredMode = Schema.RequiredMode.REQUIRED)
@Min(value = 0, message = "Vertical divisions must be non-negative")
@Max(value = 50, message = "Vertical divisions must not exceed 50")
private int verticalDivisions;
@Schema(
@@ -1,41 +0,0 @@
package stirling.software.SPDF.model.api.general;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class PosterPdfRequest extends PDFFile {
@Schema(
description = "Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
private String pageSize = "A4";
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int xFactor = 2;
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int yFactor = 2;
@Schema(
description = "Split right-to-left instead of left-to-right",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean rightToLeft = false;
}
@@ -4,9 +4,6 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -35,7 +32,6 @@ public class AddWatermarkRequest extends PDFFile {
private String alphabet;
@Schema(description = "The font size of the watermark text", defaultValue = "30")
@DecimalMin(value = "1.0", message = "Font size must be at least 1.0")
private float fontSize;
@Schema(description = "The rotation of the watermark in degrees", defaultValue = "0")
@@ -45,11 +41,9 @@ public class AddWatermarkRequest extends PDFFile {
private float opacity;
@Schema(description = "The width spacer between watermark elements", defaultValue = "50")
@Min(value = 0, message = "Width spacer must be non-negative")
private int widthSpacer;
@Schema(description = "The height spacer between watermark elements", defaultValue = "50")
@Min(value = 0, message = "Height spacer must be non-negative")
private int heightSpacer;
@Schema(description = "The color for watermark", defaultValue = "#d3d3d3")
@@ -15,6 +15,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletContext;
import lombok.extern.slf4j.Slf4j;
@@ -25,9 +28,6 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.RegexPatternUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Service
@Slf4j
public class ApiDocService {
@@ -36,15 +36,12 @@ public class ApiDocService {
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final ObjectMapper objectMapper;
Map<String, List<String>> outputToFileTypes = new HashMap<>();
JsonNode apiDocsJsonRootNode;
public ApiDocService(
ObjectMapper objectMapper,
ServletContext servletContext,
@Autowired(required = false) UserServiceInterface userService) {
this.objectMapper = objectMapper;
this.servletContext = servletContext;
this.userService = userService;
}
@@ -119,7 +116,8 @@ public class ApiDocService {
ResponseEntity<String> response =
restTemplate.exchange(getApiDocsUrl(), HttpMethod.GET, entity, String.class);
apiDocsJson = response.getBody();
apiDocsJsonRootNode = objectMapper.readTree(apiDocsJson);
ObjectMapper mapper = new ObjectMapper();
apiDocsJsonRootNode = mapper.readTree(apiDocsJson);
JsonNode paths = apiDocsJsonRootNode.path("paths");
paths.propertyStream()
.forEach(
@@ -427,8 +427,7 @@ public class CertificateValidationService {
try (InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) {
if (certStream == null) {
log.debug(
"Bundled Mozilla CA certificate file not found in resources — using Java system trust store only");
log.warn("Bundled Mozilla CA certificate file not found in resources");
return;
}
@@ -612,11 +611,9 @@ public class CertificateValidationService {
*/
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(javax.xml.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);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
@@ -840,11 +837,9 @@ public class CertificateValidationService {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// Secure processing hardening
factory.setFeature(javax.xml.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);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
@@ -38,7 +38,6 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.imageio.ImageIO;
@@ -90,6 +89,8 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
@@ -127,16 +128,11 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfJsonConversionService {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern WHITESPACE_DASH_UNDERSCORE_PATTERN = Pattern.compile("[\\s\\-_]");
private static final Pattern FONT_SUBSET_PREFIX_PATTERN = Pattern.compile("^[A-Z]{6}\\+");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final EndpointConfiguration endpointConfiguration;
@@ -239,12 +235,12 @@ public class PdfJsonConversionService {
}
cacheBudgetBytes = effective;
if (cacheBudgetBytes > 0) {
log.debug(
log.info(
"PDF JSON cache budget configured: {} bytes (source: {})",
cacheBudgetBytes,
cacheMaxBytes > 0 ? "max-bytes" : "max-percent");
} else {
log.debug("PDF JSON cache budget: unlimited");
log.info("PDF JSON cache budget: unlimited");
}
}
@@ -433,8 +429,7 @@ public class PdfJsonConversionService {
80, "annotations", "Collecting annotations and form fields"));
boolean includeAnnotationRawData = !(lightweight && isRealJobId);
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage =
collectAnnotations(
document, totalPages, progress, includeAnnotationRawData);
collectAnnotations(document, totalPages, progress, includeAnnotationRawData);
progress.accept(
PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata"));
@@ -442,21 +437,13 @@ public class PdfJsonConversionService {
pdfJson.setMetadata(extractMetadata(document));
pdfJson.setXmpMetadata(extractXmpMetadata(document));
pdfJson.setLazyImages(useLazyImages);
List<PdfJsonFont> cachedFonts = cloneFontList(fonts.values());
cachedFonts.sort(
List<PdfJsonFont> serializedFonts = cloneFontList(fonts.values());
serializedFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid,
Comparator.nullsLast(Comparator.naturalOrder())));
dedupeFontPayloads(cachedFonts);
Map<String, PdfJsonFont> cachedFontMap = new LinkedHashMap<>();
for (PdfJsonFont cachedFont : cachedFonts) {
String cacheKey = resolveFontCacheKey(cachedFont);
if (cacheKey != null) {
cachedFontMap.put(cacheKey, cachedFont);
}
}
List<PdfJsonFont> responseFonts = cloneFontList(cachedFonts);
pdfJson.setFonts(responseFonts);
dedupeFontPayloads(serializedFonts);
pdfJson.setFonts(serializedFonts);
pdfJson.setPages(
extractPages(
document,
@@ -464,18 +451,11 @@ public class PdfJsonConversionService {
imagesByPage,
annotationsByPage,
lightweight && isRealJobId));
if (lightweight && isRealJobId) {
// Lightweight async editor flow does not use form fields and this payload can
// be
// very large due nested raw dictionaries.
pdfJson.setFormFields(null);
} else {
pdfJson.setFormFields(collectFormFields(document));
}
pdfJson.setFormFields(collectFormFields(document));
// Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) {
log.debug(
log.info(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
jobId,
useLazyImages,
@@ -483,7 +463,7 @@ public class PdfJsonConversionService {
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
docMetadata.setFonts(cloneFontList(responseFonts));
docMetadata.setFonts(serializedFonts);
docMetadata.setFormFields(pdfJson.getFormFields());
docMetadata.setLazyImages(Boolean.TRUE);
@@ -513,11 +493,7 @@ public class PdfJsonConversionService {
}
CachedPdfDocument cached =
buildCachedDocument(
jobId,
cachedPdfBytes,
docMetadata,
cachedFontMap,
pageFontResources);
jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached);
log.info(
"Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
@@ -539,11 +515,10 @@ public class PdfJsonConversionService {
applyLightweightTransformations(pdfJson);
}
if (lightweight && isRealJobId) {
stripFontProgramPayloads(responseFonts);
stripFontCosStreamData(responseFonts);
stripFontCosStreamData(serializedFonts);
}
logFontPayloadStats(responseFonts, "pdf/text-editor");
logFontPayloadStats(serializedFonts, "pdf/text-editor");
analyzePdfJson(pdfJson, "pdf/text-editor");
progress.accept(
@@ -551,7 +526,7 @@ public class PdfJsonConversionService {
// Collect font issues for summary
java.util.List<String> fontsWithMissingProgram =
responseFonts.stream()
serializedFonts.stream()
.filter(
f ->
Boolean.TRUE.equals(f.getEmbedded())
@@ -570,20 +545,19 @@ public class PdfJsonConversionService {
: "Unknown";
// Clean up subset prefix (e.g., "ABCDEF+TimesNewRoman"
// -> "TimesNewRoman")
String cleanName =
FONT_SUBSET_PREFIX_PATTERN
.matcher(name)
.replaceAll("");
String cleanName = name.replaceAll("^[A-Z]{6}\\+", "");
return String.format("%s (%s)", cleanName, subtype);
})
.collect(java.util.stream.Collectors.toList());
long type3Fonts =
responseFonts.stream().filter(f -> "Type3".equals(f.getSubtype())).count();
serializedFonts.stream()
.filter(f -> "Type3".equals(f.getSubtype()))
.count();
if (!fontsWithMissingProgram.isEmpty()) {
log.warn(
"PDF->JSON conversion complete: {} fonts ({} Type3), {} pages. Missing font programs for {} embedded font(s): {}",
responseFonts.size(),
serializedFonts.size(),
type3Fonts,
pdfJson.getPages().size(),
fontsWithMissingProgram.size(),
@@ -591,7 +565,7 @@ public class PdfJsonConversionService {
} else {
log.info(
"PDF->JSON conversion complete: {} fonts ({} Type3), {} pages",
responseFonts.size(),
serializedFonts.size(),
type3Fonts,
pdfJson.getPages().size());
}
@@ -993,59 +967,40 @@ public class PdfJsonConversionService {
if (font == null) {
return null;
}
try {
byte[] bytes = objectMapper.writeValueAsBytes(font);
return objectMapper.readValue(bytes, PdfJsonFont.class);
} catch (Exception ex) {
log.debug(
"Failed deep-cloning font {} via roundtrip: {}", font.getId(), ex.getMessage());
PdfJsonCosValue cosClone = null;
try {
if (font.getCosDictionary() != null) {
byte[] cosBytes = objectMapper.writeValueAsBytes(font.getCosDictionary());
cosClone = objectMapper.readValue(cosBytes, PdfJsonCosValue.class);
}
} catch (Exception cosEx) {
log.debug(
"Failed deep-cloning font cosDictionary {}: {}",
font.getId(),
cosEx.getMessage());
}
return PdfJsonFont.builder()
.id(font.getId())
.pageNumber(font.getPageNumber())
.uid(font.getUid())
.baseName(font.getBaseName())
.subtype(font.getSubtype())
.encoding(font.getEncoding())
.cidSystemInfo(font.getCidSystemInfo())
.embedded(font.getEmbedded())
.program(font.getProgram())
.programFormat(font.getProgramFormat())
.webProgram(font.getWebProgram())
.webProgramFormat(font.getWebProgramFormat())
.pdfProgram(font.getPdfProgram())
.pdfProgramFormat(font.getPdfProgramFormat())
.type3Glyphs(
font.getType3Glyphs() == null
? null
: new ArrayList<>(font.getType3Glyphs()))
.conversionCandidates(
font.getConversionCandidates() == null
? null
: new ArrayList<>(font.getConversionCandidates()))
.toUnicode(font.getToUnicode())
.standard14Name(font.getStandard14Name())
.fontDescriptorFlags(font.getFontDescriptorFlags())
.ascent(font.getAscent())
.descent(font.getDescent())
.capHeight(font.getCapHeight())
.xHeight(font.getXHeight())
.italicAngle(font.getItalicAngle())
.unitsPerEm(font.getUnitsPerEm())
.cosDictionary(cosClone)
.build();
}
return PdfJsonFont.builder()
.id(font.getId())
.pageNumber(font.getPageNumber())
.uid(font.getUid())
.baseName(font.getBaseName())
.subtype(font.getSubtype())
.encoding(font.getEncoding())
.cidSystemInfo(font.getCidSystemInfo())
.embedded(font.getEmbedded())
.program(font.getProgram())
.programFormat(font.getProgramFormat())
.webProgram(font.getWebProgram())
.webProgramFormat(font.getWebProgramFormat())
.pdfProgram(font.getPdfProgram())
.pdfProgramFormat(font.getPdfProgramFormat())
.type3Glyphs(
font.getType3Glyphs() == null
? null
: new ArrayList<>(font.getType3Glyphs()))
.conversionCandidates(
font.getConversionCandidates() == null
? null
: new ArrayList<>(font.getConversionCandidates()))
.toUnicode(font.getToUnicode())
.standard14Name(font.getStandard14Name())
.fontDescriptorFlags(font.getFontDescriptorFlags())
.ascent(font.getAscent())
.descent(font.getDescent())
.capHeight(font.getCapHeight())
.xHeight(font.getXHeight())
.italicAngle(font.getItalicAngle())
.unitsPerEm(font.getUnitsPerEm())
.cosDictionary(font.getCosDictionary())
.build();
}
private void applyLightweightTransformations(PdfJsonDocument document) {
@@ -1099,9 +1054,6 @@ public class PdfJsonConversionService {
}
private void logFontPayloadStats(List<PdfJsonFont> fonts, String label) {
if (!log.isDebugEnabled()) {
return;
}
if (fonts == null || fonts.isEmpty()) {
return;
}
@@ -1143,7 +1095,7 @@ public class PdfJsonConversionService {
}
}
log.debug(
log.info(
"Font payload stats ({}): fonts={}, programBytes={}, webProgramBytes={}, pdfProgramBytes={}, toUnicodeBytes={}, maxFontPayloadBytes={} (fontId={})",
label,
fonts.size(),
@@ -1224,7 +1176,7 @@ public class PdfJsonConversionService {
logDuplicateSummary("resources", label, resourceStats);
logDuplicateSummary("fontCosDictionary", label, fontDictStats);
logDuplicateSummary("annotationRawData", label, annotationStats);
log.debug(
log.info(
"PDF JSON analysis ({}): images={} imageDataBytes={} textElements={} textChars={}",
label,
imageCount,
@@ -1237,7 +1189,7 @@ public class PdfJsonConversionService {
long metadataBytes = sizeOfObject(pdfJson.getMetadata());
long xmpBytes = sizeOfObject(pdfJson.getXmpMetadata());
long formFieldsBytes = sizeOfObject(pdfJson.getFormFields());
log.debug(
log.info(
"PDF JSON analysis ({}): sectionSizes fonts={} pages={} metadata={} xmp={} formFields={}",
label,
fontsBytes,
@@ -1267,10 +1219,9 @@ public class PdfJsonConversionService {
.map(
s ->
String.format(
"page=%d size=%d",
s.pageNumber, s.sizeBytes))
"page=%d size=%d", s.pageNumber, s.sizeBytes))
.collect(java.util.stream.Collectors.joining("; "));
log.debug("PDF JSON analysis ({}): topPageSizes -> {}", label, top);
log.info("PDF JSON analysis ({}): topPageSizes -> {}", label, top);
topPages.stream()
.limit(3)
@@ -1282,7 +1233,7 @@ public class PdfJsonConversionService {
long annotations = sizeOfObject(page.getAnnotations());
long textElements = sizeOfObject(page.getTextElements());
long imageElements = sizeOfObject(page.getImageElements());
log.debug(
log.info(
"PDF JSON analysis ({}): pageBreakdown page={} total={} resources={} contentStreams={} annotations={} textElements={} imageElements={}",
label,
s.pageNumber,
@@ -1308,10 +1259,8 @@ public class PdfJsonConversionService {
if (bytes.length == 0) {
return;
}
String hash =
Base64.getEncoder()
.encodeToString(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
String hash = Base64.getEncoder().encodeToString(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
DuplicateStats entry = stats.computeIfAbsent(hash, k -> new DuplicateStats());
entry.count++;
if (entry.sizeBytes == 0) {
@@ -1327,7 +1276,10 @@ public class PdfJsonConversionService {
List<DuplicateStats> duplicates =
stats.values().stream()
.filter(s -> s.count > 1)
.sorted((a, b) -> Long.compare(b.totalBytesSaved(), a.totalBytesSaved()))
.sorted(
(a, b) ->
Long.compare(
b.totalBytesSaved(), a.totalBytesSaved()))
.limit(5)
.toList();
@@ -1343,7 +1295,11 @@ public class PdfJsonConversionService {
"count=%d size=%d potentialSavings=%d",
s.count, s.sizeBytes, s.totalBytesSaved()))
.collect(java.util.stream.Collectors.joining("; "));
log.debug("PDF JSON analysis ({}): top duplicates for {} -> {}", label, category, summary);
log.info(
"PDF JSON analysis ({}): top duplicates for {} -> {}",
label,
category,
summary);
}
private boolean isPdfJsonDebugAnalyzeEnabled() {
@@ -1418,28 +1374,12 @@ public class PdfJsonConversionService {
}
}
private void stripFontProgramPayloads(List<PdfJsonFont> fonts) {
if (fonts == null || fonts.isEmpty()) {
return;
}
for (PdfJsonFont font : fonts) {
if (font == null) {
continue;
}
font.setProgram(null);
font.setProgramFormat(null);
font.setWebProgram(null);
font.setWebProgramFormat(null);
font.setPdfProgram(null);
font.setPdfProgramFormat(null);
}
}
private void stripFontCosStreamData(List<PdfJsonFont> fonts) {
if (fonts == null || fonts.isEmpty()) {
return;
}
Set<PdfJsonCosValue> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Set<PdfJsonCosValue> visited =
Collections.newSetFromMap(new IdentityHashMap<>());
for (PdfJsonFont font : fonts) {
if (font == null) {
continue;
@@ -1886,9 +1826,9 @@ public class PdfJsonConversionService {
if (!fallbackFontService.canEncodeFully(font, text)) {
String fontName =
fontModel != null && fontModel.getBaseName() != null
? FONT_SUBSET_PREFIX_PATTERN
.matcher(fontModel.getBaseName())
.replaceAll("") // Remove subset prefix
? fontModel
.getBaseName()
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
: (font != null ? font.getName() : "unknown");
String fontKey = fontName + ":" + element.getFontId() + ":" + pageNumber;
if (!warnedFonts.contains(fontKey)) {
@@ -1971,12 +1911,12 @@ public class PdfJsonConversionService {
"[FALLBACK-DEBUG] Reusing cached fallback font {} (key: {})", effectiveId, key);
return font;
}
log.debug(
log.info(
"[FALLBACK-DEBUG] Loading fallback font {} (key: {}) via fallbackFontService",
effectiveId,
key);
PDFont loaded = fallbackFontService.loadFallbackPdfFont(document, effectiveId);
log.debug(
log.info(
"[FALLBACK-DEBUG] Loaded fallback font {} - PDFont class: {}, name: {}",
effectiveId,
loaded.getClass().getSimpleName(),
@@ -2171,7 +2111,7 @@ public class PdfJsonConversionService {
PDStream fontFile3 = descriptor.getFontFile3();
if (fontFile3 != null) {
String subtype = fontFile3.getCOSObject().getNameAsString(COSName.SUBTYPE);
log.debug(
log.info(
"[FONT-DEBUG] Font {}: Found FontFile3 with subtype {}",
font.getName(),
subtype);
@@ -2346,10 +2286,7 @@ public class PdfJsonConversionService {
if (plusIndex >= 0 && plusIndex < normalized.length() - 1) {
normalized = normalized.substring(plusIndex + 1);
}
normalized =
WHITESPACE_DASH_UNDERSCORE_PATTERN
.matcher(normalized.toLowerCase(Locale.ROOT))
.replaceAll("");
normalized = normalized.toLowerCase(Locale.ROOT).replaceAll("[\\s\\-_]", "");
// Exact match after normalization
try {
@@ -2446,15 +2383,14 @@ public class PdfJsonConversionService {
// imageElements
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase);
if (omitResourceStreamData) {
// In lightweight editor mode, omit heavy resource/content stream payloads entirely.
// Partial export preserves originals from cached PDF when these fields are missing.
pageModel.setResources(null);
pageModel.setContentStreams(null);
} else {
pageModel.setResources(cosMapper.serializeCosValue(filteredResources));
pageModel.setContentStreams(extractContentStreams(page, false));
}
PdfJsonCosValue resourcesModel =
omitResourceStreamData
? cosMapper.serializeCosValue(
filteredResources,
PdfJsonCosMapper.SerializationContext.RESOURCES_LIGHTWEIGHT)
: cosMapper.serializeCosValue(filteredResources);
pageModel.setResources(resourcesModel);
pageModel.setContentStreams(extractContentStreams(page, true));
pages.add(pageModel);
pageIndex++;
}
@@ -3338,8 +3274,8 @@ public class PdfJsonConversionService {
baseFontModel.getUid(), Collections.emptySet())
: Collections.emptySet();
boolean hasNormalizedType3 = baseIsType3 && normalizedType3Font != null;
if (hasNormalizedType3 && log.isDebugEnabled()) {
log.debug(
if (hasNormalizedType3 && log.isInfoEnabled()) {
log.info(
"[TYPE3-RUNTIME] Using normalized library font {} for Type3 resource {} on page {}",
normalizedType3Font.getName(),
baseFontModel != null ? baseFontModel.getId() : baseFontId,
@@ -3469,7 +3405,7 @@ public class PdfJsonConversionService {
}
if (rawType3CodesUsed) {
log.debug(
log.info(
"[TYPE3-RUNTIME] Reused original Type3 charCodes for font {} on page {} ({} glyphs)",
baseFontModel != null ? baseFontModel.getId() : baseFontId,
pageNumber,
@@ -3733,7 +3669,7 @@ public class PdfJsonConversionService {
if (value == null) {
return "";
}
String trimmed = WHITESPACE_PATTERN.matcher(value).replaceAll(" ").trim();
String trimmed = value.replaceAll("\s+", " ").trim();
if (trimmed.length() <= 32) {
return trimmed;
}
@@ -4109,22 +4045,22 @@ public class PdfJsonConversionService {
// NOTE: Do NOT sanitize encoded bytes for normalized Type3 fonts
// Multi-byte encodings (UTF-16BE, CID fonts) have null bytes that are essential
// Removing them corrupts the byte boundaries and produces garbled text
log.debug(
log.info(
"[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes",
text.length() > 20 ? text.substring(0, 20) + "..." : text,
fontModel.getId(),
encoded != null ? encoded.length : 0);
if (encoded != null && encoded.length > 0) {
log.debug(
log.info(
"[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding",
fontModel.getId());
return encoded;
}
log.debug(
log.info(
"[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping",
fontModel.getId());
} catch (IOException | IllegalArgumentException ex) {
log.debug(
log.info(
"[TYPE3] Standard encoding failed for normalized Type3 font {}: {}",
fontModel.getId(),
ex.getMessage());
@@ -4650,7 +4586,7 @@ public class PdfJsonConversionService {
// Last resort: Fuzzy match baseName against Standard14 fonts
Standard14Fonts.FontName fuzzyMatch = fuzzyMatchStandard14(fontModel.getBaseName());
if (fuzzyMatch != null) {
log.debug(
log.info(
"Fuzzy-matched font {} (baseName: {}) to Standard14 font {}",
fontModel.getId(),
fontModel.getBaseName(),
@@ -4684,7 +4620,7 @@ public class PdfJsonConversionService {
document, fontModel, source, originalFormat, true, true, true);
if (font != null) {
type3NormalizedFontCache.put(cacheKey, font);
log.debug(
log.info(
"Cached normalized font {} for Type3 {} (key: {})",
source.originLabel(),
fontModel.getId(),
@@ -4731,7 +4667,7 @@ public class PdfJsonConversionService {
String originLabel = source.originLabel();
try {
if (!skipMetadataLog) {
log.debug(
log.info(
"[FONT-DEBUG] Attempting to load font {} using payload {} (format={}, size={} bytes)",
fontModel.getId(),
originLabel,
@@ -4758,7 +4694,7 @@ public class PdfJsonConversionService {
// so all glyphs are available for editing
boolean willBeSubset = !originLabel.contains("type3-library");
if (!willBeSubset) {
log.debug(
log.info(
"[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}",
fontModel.getId(),
originLabel);
@@ -4810,7 +4746,7 @@ public class PdfJsonConversionService {
try {
restored = cosMapper.deserializeCosValue(fontModel.getCosDictionary(), document);
} catch (Exception ex) {
log.debug(
log.warn(
"[FONT-RESTORE] Font {} cosDictionary deserialization failed: {}",
fontModel.getId(),
ex.getMessage());
@@ -4818,7 +4754,7 @@ public class PdfJsonConversionService {
}
if (!(restored instanceof COSDictionary cosDictionary)) {
log.debug(
log.warn(
"[FONT-RESTORE] Font {} cosDictionary deserialized to {} instead of COSDictionary",
fontModel.getId(),
restored != null ? restored.getClass().getSimpleName() : "null");
@@ -4828,7 +4764,7 @@ public class PdfJsonConversionService {
// Validate that dictionary contains required font keys
if (!cosDictionary.containsKey(org.apache.pdfbox.cos.COSName.TYPE)
|| !cosDictionary.containsKey(org.apache.pdfbox.cos.COSName.SUBTYPE)) {
log.debug(
log.warn(
"[FONT-RESTORE] Font {} cosDictionary missing required Type or Subtype keys",
fontModel.getId());
return null;
@@ -4837,14 +4773,14 @@ public class PdfJsonConversionService {
try {
PDFont font = PDFontFactory.createFont(cosDictionary);
if (font == null) {
log.debug(
log.warn(
"[FONT-RESTORE] Font {} PDFontFactory returned null for valid dictionary",
fontModel.getId());
return null;
}
if (!font.isEmbedded()) {
log.debug(
log.warn(
"[FONT-RESTORE] Font {} restored from dictionary but is not embedded; rejecting to avoid system font substitution",
fontModel.getId());
return null;
@@ -4858,7 +4794,7 @@ public class PdfJsonConversionService {
return font;
} catch (IOException ex) {
log.debug(
log.warn(
"[FONT-RESTORE] Failed to restore font {} from dictionary ({}): {}",
fontModel.getId(),
fontModel.getSubtype(),
@@ -6124,12 +6060,11 @@ public class PdfJsonConversionService {
docMetadata.setMetadata(extractMetadata(document));
docMetadata.setXmpMetadata(extractXmpMetadata(document));
List<PdfJsonFont> serializedFonts = cloneFontList(fonts.values());
List<PdfJsonFont> serializedFonts = new ArrayList<>(fonts.values());
serializedFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
dedupeFontPayloads(serializedFonts);
stripFontProgramPayloads(serializedFonts);
stripFontCosStreamData(serializedFonts);
docMetadata.setFonts(serializedFonts);
@@ -6147,9 +6082,7 @@ public class PdfJsonConversionService {
pageIndex++;
}
docMetadata.setPageDimensions(pageDimensions);
// Metadata endpoint is used for lazy editor bootstrapping; omit form fields to avoid
// shipping large duplicate raw dictionaries before any edit occurs.
docMetadata.setFormFields(null);
docMetadata.setFormFields(collectFormFields(document));
docMetadata.setLazyImages(Boolean.TRUE);
// Cache PDF bytes, metadata, and fonts for lazy page loading
@@ -6321,8 +6254,11 @@ public class PdfJsonConversionService {
// Extract resources and content streams
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase);
pageModel.setResources(null);
pageModel.setContentStreams(null);
pageModel.setResources(
cosMapper.serializeCosValue(
filteredResources,
PdfJsonCosMapper.SerializationContext.RESOURCES_LIGHTWEIGHT));
pageModel.setContentStreams(extractContentStreams(page, true));
log.debug(
"Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}",
@@ -6336,59 +6272,11 @@ public class PdfJsonConversionService {
}
}
public byte[] extractPageFonts(String jobId, int pageNumber) throws IOException {
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document found for jobId: " + jobId);
}
int totalPages = cached.getMetadata().getPageDimensions().size();
if (pageNumber < 1 || pageNumber > totalPages) {
throw new IllegalArgumentException(
String.format("pageNumber must be between 1 and %d", totalPages));
}
Map<Integer, Map<PDFont, String>> pageFontResources = cached.getPageFontResources();
Map<PDFont, String> pageMap =
pageFontResources != null ? pageFontResources.get(pageNumber) : null;
if (pageMap == null || pageMap.isEmpty()) {
return objectMapper.writeValueAsBytes(Collections.emptyList());
}
Map<String, PdfJsonFont> cachedFonts = cached.getFonts();
List<PdfJsonFont> pageFonts = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String fontId : pageMap.values()) {
if (fontId == null || fontId.isBlank()) {
continue;
}
String key = buildFontKey(jobId, pageNumber, fontId);
if (!seen.add(key)) {
continue;
}
PdfJsonFont font = cachedFonts.get(key);
if (font == null) {
// Fallback to unscoped key for resilience with legacy cached entries.
font = cachedFonts.get(buildFontKey(null, pageNumber, fontId));
}
if (font == null) {
continue;
}
PdfJsonFont clone = cloneFont(font);
pageFonts.add(clone != null ? clone : font);
}
pageFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
return objectMapper.writeValueAsBytes(pageFonts);
}
public byte[] exportUpdatedPages(String jobId, PdfJsonDocument updates) throws IOException {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export");
}
log.debug("Looking up cache for jobId: {}", jobId);
log.info("Looking up cache for jobId: {}", jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
log.error(
@@ -6398,7 +6286,7 @@ public class PdfJsonConversionService {
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document available for jobId: " + jobId);
}
log.debug(
log.info(
"Found cached document for jobId: {} (size={}, diskBacked={})",
jobId,
cached.getPdfSize(),
@@ -6579,8 +6467,7 @@ public class PdfJsonConversionService {
shouldPreserveExistingAnnotations(pageModel.getAnnotations());
boolean preserveExistingContentStreams =
shouldPreserveExistingContentStreams(pageModel.getContentStreams());
boolean preserveExistingResources =
shouldPreserveExistingResources(pageModel.getResources());
boolean preserveExistingResources = shouldPreserveExistingResources(pageModel.getResources());
PDRectangle currentBox = page.getMediaBox();
float fallbackWidth = currentBox != null ? currentBox.getWidth() : 612f;
@@ -6740,12 +6627,9 @@ public class PdfJsonConversionService {
}
private boolean shouldPreserveExistingAnnotations(List<PdfJsonAnnotation> annotations) {
if (annotations == null) {
if (annotations == null || annotations.isEmpty()) {
return true;
}
if (annotations.isEmpty()) {
return false;
}
for (PdfJsonAnnotation annotation : annotations) {
if (annotation == null || annotation.getRawData() == null) {
return true;
@@ -6758,10 +6642,7 @@ public class PdfJsonConversionService {
}
private boolean shouldPreserveExistingContentStreams(List<PdfJsonStream> streams) {
if (streams == null) {
return true;
}
if (streams.isEmpty()) {
if (streams == null || streams.isEmpty()) {
return false;
}
for (PdfJsonStream stream : streams) {
@@ -6773,9 +6654,6 @@ public class PdfJsonConversionService {
}
private boolean shouldPreserveExistingResources(PdfJsonCosValue resources) {
if (resources == null) {
return true;
}
return hasMissingStreamData(resources);
}
@@ -6795,16 +6673,9 @@ public class PdfJsonConversionService {
}
private boolean hasMissingStreamData(PdfJsonCosValue value) {
return hasMissingStreamData(value, Collections.newSetFromMap(new IdentityHashMap<>()));
}
private boolean hasMissingStreamData(PdfJsonCosValue value, Set<PdfJsonCosValue> visited) {
if (value == null || value.getType() == null) {
return false;
}
if (!visited.add(value)) {
return false;
}
switch (value.getType()) {
case STREAM:
PdfJsonStream stream = value.getStream();
@@ -6812,7 +6683,7 @@ public class PdfJsonConversionService {
case ARRAY:
if (value.getItems() != null) {
for (PdfJsonCosValue item : value.getItems()) {
if (hasMissingStreamData(item, visited)) {
if (hasMissingStreamData(item)) {
return true;
}
}
@@ -6821,7 +6692,7 @@ public class PdfJsonConversionService {
case DICTIONARY:
if (value.getEntries() != null) {
for (PdfJsonCosValue entry : value.getEntries().values()) {
if (hasMissingStreamData(entry, visited)) {
if (hasMissingStreamData(entry)) {
return true;
}
}
@@ -6834,8 +6705,7 @@ public class PdfJsonConversionService {
/** Schedules automatic cleanup of cached documents after 30 minutes. */
private void scheduleDocumentCleanup(String jobId) {
Thread.ofVirtual()
.start(
new Thread(
() -> {
try {
Thread.sleep(TimeUnit.MINUTES.toMillis(30));
@@ -6844,6 +6714,7 @@ public class PdfJsonConversionService {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
})
.start();
}
}
@@ -45,7 +45,7 @@ public class PdfJsonCosMapper {
RESOURCES_LIGHTWEIGHT;
public boolean omitStreamData() {
return this == CONTENT_STREAMS_LIGHTWEIGHT || this == RESOURCES_LIGHTWEIGHT;
return this != DEFAULT;
}
}
@@ -74,7 +74,8 @@ public class PdfJsonCosMapper {
if (cosStream == null) {
return null;
}
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
SerializationContext effective =
context != null ? context : SerializationContext.DEFAULT;
return serializeStream(
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
@@ -96,7 +97,8 @@ public class PdfJsonCosMapper {
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
throws IOException {
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
SerializationContext effective =
context != null ? context : SerializationContext.DEFAULT;
return serializeCosValue(
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
@@ -272,16 +274,15 @@ public class PdfJsonCosMapper {
return builder.build();
}
if (base instanceof COSStream stream) {
builder.type(PdfJsonCosValue.Type.STREAM).stream(
serializeStream(stream, visited, context));
builder.type(PdfJsonCosValue.Type.STREAM)
.stream(serializeStream(stream, visited, context));
return builder.build();
}
if (base instanceof COSDictionary dictionary) {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
for (COSName key : dictionary.keySet()) {
PdfJsonCosValue serialized =
serializeCosValue(
dictionary.getDictionaryObject(key), visited, context);
serializeCosValue(dictionary.getDictionaryObject(key), visited, context);
entries.put(key.getName(), serialized);
}
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
@@ -7,7 +7,6 @@ import java.io.InputStream;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
@@ -311,11 +310,6 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
"DejaVuSansMono-BoldOblique",
"ttf")));
private static final Pattern BOLD_FONT_WEIGHT_PATTERN =
Pattern.compile(".*[_-]?[6-9]00(wght)?.*");
private static final Pattern FONT_NAME_DELIMITER_PATTERN = Pattern.compile("[-_,+]");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern PATTERN = Pattern.compile("^[A-Z]{6}\\+");
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@@ -424,18 +418,16 @@ public class PdfJsonFallbackFontService {
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
// remove spaces
String normalized =
WHITESPACE_PATTERN
.matcher(
PATTERN.matcher(originalFontName)
.replaceAll("") // Remove subset prefix
.toLowerCase())
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
originalFontName
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
.toLowerCase()
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
// "timesnewroman")
// Extract base name without weight/style suffixes
// Split on common delimiters: hyphen, underscore, comma, plus
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
String baseName = FONT_NAME_DELIMITER_PATTERN.split(normalized)[0];
String baseName = normalized.split("[-_,+]")[0];
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
if (aliasedFontId != null) {
@@ -478,7 +470,7 @@ public class PdfJsonFallbackFontService {
// Check for numeric weight indicators (600-900 = bold)
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
if (BOLD_FONT_WEIGHT_PATTERN.matcher(normalizedFontName).matches()) {
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
return true;
}
@@ -522,7 +514,7 @@ public class PdfJsonFallbackFontService {
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
boolean isSupported =
baseFontId.startsWith("fallback-liberation-")
|| "fallback-noto-sans".equals(baseFontId)
|| baseFontId.equals("fallback-noto-sans")
|| baseFontId.startsWith("fallback-dejavu-");
if (!isSupported) {
@@ -531,8 +523,8 @@ public class PdfJsonFallbackFontService {
// DejaVu Sans and Mono use "oblique" instead of "italic"
boolean useOblique =
"fallback-dejavu-sans".equals(baseFontId)
|| "fallback-dejavu-mono".equals(baseFontId);
baseFontId.equals("fallback-dejavu-sans")
|| baseFontId.equals("fallback-dejavu-mono");
if (isBold && isItalic) {
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
@@ -9,12 +9,13 @@ import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
@@ -22,20 +23,17 @@ import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig;
import tools.jackson.databind.ObjectMapper;
@Service
@Slf4j
public class SharedSignatureService {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService(ObjectMapper objectMapper) {
public SharedSignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = objectMapper;
this.objectMapper = new ObjectMapper();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
@@ -107,7 +105,7 @@ public class SharedSignatureService {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
@@ -115,7 +113,7 @@ public class SharedSignatureService {
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
@@ -18,6 +18,8 @@ import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,8 +38,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExceptionUtils;
import tools.jackson.databind.ObjectMapper;
/**
* Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce
* memory usage for large PDFs.
@@ -255,8 +255,7 @@ public class PdfLazyLoadingService {
/** Schedules automatic cleanup of cached documents after 30 minutes. */
private void scheduleDocumentCleanup(String jobId) {
Thread.ofVirtual()
.start(
new Thread(
() -> {
try {
Thread.sleep(TimeUnit.MINUTES.toMillis(30));
@@ -265,7 +264,8 @@ public class PdfLazyLoadingService {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
})
.start();
}
/**
@@ -18,14 +18,14 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@Component
@RequiredArgsConstructor
@@ -202,34 +202,20 @@ public class Type3FontLibrary {
if (payload == null) {
return null;
}
String base64;
byte[] data = null;
if (payload.base64 != null && !payload.base64.isBlank()) {
// Validate the base64 string without wasteful full decode
// Only decode a small prefix to verify encoding is valid
try {
byte[] probe =
Base64.getDecoder()
.decode(
payload.base64.substring(
0, Math.min(4, payload.base64.length())));
if (probe.length == 0 && payload.base64.length() <= 4) {
return null;
}
data = Base64.getDecoder().decode(payload.base64);
} catch (IllegalArgumentException ex) {
log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage());
return null;
}
// Keep the original base64 string directly — avoids 3x memory pressure
base64 = payload.base64;
} else if (payload.resource != null && !payload.resource.isBlank()) {
byte[] data = loadResourceBytes(payload.resource);
if (data == null || data.length == 0) {
return null;
}
base64 = Base64.getEncoder().encodeToString(data);
} else {
data = loadResourceBytes(payload.resource);
}
if (data == null || data.length == 0) {
return null;
}
String base64 = Base64.getEncoder().encodeToString(data);
return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format));
}
@@ -26,15 +26,14 @@ import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectWriter;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally
* emits JSON describing the glyph coverage. This allows Type3 library entries to be added without
@@ -49,7 +48,7 @@ import tools.jackson.databind.json.JsonMapper;
public final class Type3SignatureTool {
private static final ObjectMapper OBJECT_MAPPER =
JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private Type3SignatureTool() {}
@@ -97,7 +97,7 @@ public class SvgToPdf {
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
throws IOException {
GVTBuilder builder = new GVTBuilder();
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<GraphicsNode> buildTask = () -> builder.build(ctx, svgDoc);
Future<GraphicsNode> future = executor.submit(buildTask);
@@ -13,18 +13,6 @@ logging.level.stirling.software.common.service.JobExecutorService=INFO
logging.level.stirling.software.common.service.TaskManager=INFO
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE
# Enable HTTP/2 for improved performance (multiplexed streams, header compression)
server.http2.enabled=true
# Enable virtual threads (Java 21+, pinning fix in Java 25)
spring.threads.virtual.enabled=true
# Response compression
server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
server.error.path=/error
server.error.whitelabel.enabled=false
server.error.include-stacktrace=always
@@ -50,7 +38,6 @@ spring.devtools.livereload.enabled=true
spring.devtools.restart.exclude=stirling.software.proprietary.security/**
spring.web.resources.mime-mappings.webmanifest=application/manifest+json
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
server.jetty.max-http-request-header-size=32768
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
@@ -64,10 +64,7 @@ security:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
@@ -233,12 +230,10 @@ ui:
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
endpoints:
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice', 'DeveloperTools', 'DeveloperDocs', 'Automation'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
metrics:
enabled: true # 'true' to enable Info APIs (`/api/*`) endpoints, 'false' to disable
@@ -214,13 +214,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.github.virtuald:curvesapi",
"moduleUrl": "https://github.com/virtuald/curvesapi",
"moduleVersion": "1.08",
"moduleLicense": "BSD License",
"moduleLicenseUrl": "http://opensource.org/licenses/BSD-3-Clause"
},
{
"moduleName": "com.google.code.findbugs:jsr305",
"moduleUrl": "http://findbugs.sourceforge.net/",
@@ -1015,13 +1008,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-compress",
"moduleUrl": "https://commons.apache.org/proper/commons-compress/",
"moduleVersion": "1.28.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-csv",
"moduleUrl": "https://commons.apache.org/proper/commons-csv/",
@@ -1145,27 +1131,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.poi:poi",
"moduleUrl": "https://poi.apache.org/",
"moduleVersion": "5.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.poi:poi-ooxml",
"moduleUrl": "https://poi.apache.org/",
"moduleVersion": "5.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.poi:poi-ooxml-lite",
"moduleUrl": "https://poi.apache.org/",
"moduleVersion": "5.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.poi:poi-scratchpad",
"moduleUrl": "https://poi.apache.org/",
@@ -1194,13 +1159,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.xmlbeans:xmlbeans",
"moduleUrl": "https://xmlbeans.apache.org/",
"moduleVersion": "5.3.0",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.xmlgraphics:batik-all",
"moduleVersion": "1.19",
@@ -0,0 +1,364 @@
const PDFJS_DEFAULT_OPTIONS = {
cMapUrl: pdfjsPath + 'cmaps/',
cMapPacked: true,
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
};
function formatProblemDetailsJson(input) {
try {
const obj = typeof input === 'string' ? JSON.parse(input) : input;
const preferredOrder = [
'errorCode',
'title',
'status',
'type',
'detail',
'instance',
'path',
'timestamp',
'hints',
'actionRequired'
];
const ordered = {};
preferredOrder.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
ordered[key] = obj[key];
}
});
Object.keys(obj).forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(ordered, key)) {
ordered[key] = obj[key];
}
});
return JSON.stringify(ordered, null, 2);
} catch (err) {
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch (jsonErr) {
return String(input);
}
}
}
function formatUserFriendlyError(json) {
if (!json || typeof json !== 'object') {
return typeof json === 'string' ? json : '';
}
const lines = [];
const title = json.title || json.error || '';
const detail = json.detail || json.message || '';
const primaryLine = title && detail ? `${title}: ${detail}` : title || detail;
if (primaryLine) {
lines.push(primaryLine);
}
if (json.errorCode) {
lines.push('');
lines.push(`Error Code: ${json.errorCode}`);
}
const detailAlreadyIncluded = detail && primaryLine && primaryLine.includes(detail);
if (detail && !detailAlreadyIncluded) {
lines.push('');
lines.push(detail);
}
if (json.hints && Array.isArray(json.hints) && json.hints.length > 0) {
lines.push('');
lines.push('How to fix:');
json.hints.forEach((hint, index) => {
lines.push(` ${index + 1}. ${hint}`);
});
}
if (json.actionRequired) {
lines.push('');
lines.push(json.actionRequired);
}
if (json.supportId) {
lines.push('');
lines.push(`Support ID: ${json.supportId}`);
}
return lines
.filter((line, index, arr) => {
if (line !== '') return true;
if (index === 0 || index === arr.length - 1) return false;
return arr[index - 1] !== '';
})
.join('\n');
}
function buildPdfPasswordProblemDetail(fileName) {
const stirling = window.stirlingPDF || {};
const detailTemplate = stirling.pdfPasswordDetail || 'The PDF Document is passworded and either the password was not provided or was incorrect';
const title = stirling.pdfPasswordTitle || 'PDF Password Required';
const hints = [
stirling.pdfPasswordHint1,
stirling.pdfPasswordHint2,
stirling.pdfPasswordHint3,
stirling.pdfPasswordHint4,
stirling.pdfPasswordHint5,
stirling.pdfPasswordHint6
].filter((hint) => typeof hint === 'string' && hint.trim().length > 0);
const actionRequired = stirling.pdfPasswordAction || 'Provide the owner/permissions password, not just the document open password.';
return {
errorCode: 'E004',
title,
detail: detailTemplate.replace('{0}', fileName),
type: '/errors/pdf-password',
path: '/api/v1/security/remove-password',
hints,
actionRequired
};
}
function buildCorruptedPdfProblemDetail(fileName) {
const stirling = window.stirlingPDF || {};
const detailTemplate = stirling.pdfCorruptedMessage || 'The PDF file "{0}" appears to be corrupted or has an invalid structure.';
const hints = [
stirling.pdfCorruptedHint1,
stirling.pdfCorruptedHint2,
stirling.pdfCorruptedHint3
].filter((hint) => typeof hint === 'string' && hint.trim().length > 0);
const actionRequired = stirling.pdfCorruptedAction || stirling.tryRepairMessage || '';
return {
errorCode: 'E001',
title: stirling.pdfCorruptedTitle || 'PDF File Corrupted',
detail: detailTemplate.replace('{0}', fileName),
type: '/errors/pdf-corrupted',
hints,
actionRequired
};
}
export class DecryptFile {
constructor(){
this.decryptWorker = null
}
async decryptFile(file, requiresPassword) {
try {
async function getCsrfToken() {
const cookieValue = document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
if (cookieValue) {
return cookieValue;
}
const csrfElement = document.querySelector('input[name="_csrf"]');
return csrfElement ? csrfElement.value : null;
}
const csrfToken = await getCsrfToken();
const formData = new FormData();
formData.append('fileInput', file);
if (requiresPassword) {
const password = prompt(`${window.decrypt.passwordPrompt}`);
if (password === null) {
// User cancelled
console.error(`Password prompt cancelled for PDF: ${file.name}`);
return null; // No file to return
}
if (!password) {
// No password provided
console.error(`No password provided for encrypted PDF: ${file.name}`);
const problemDetail = buildPdfPasswordProblemDetail(file.name);
this.showProblemDetail(problemDetail);
return null; // No file to return
}
formData.append('password', password);
}
// Send decryption request
const response = await fetchWithCsrf('/api/v1/security/remove-password', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json') || contentType.includes('application/problem+json')) {
const errorJson = await response.json();
this.showProblemDetail(errorJson);
} else {
const errorText = await response.text();
console.error(`${window.decrypt.invalidPassword} ${errorText}`);
const fallbackProblem = buildPdfPasswordProblemDetail(file.name);
if (errorText && errorText.trim().length > 0) {
fallbackProblem.detail = errorText.trim();
}
this.showProblemDetail(fallbackProblem);
}
return null; // No file to return
}
this.removeErrorBanner();
const decryptedBlob = await response.blob();
return new File([decryptedBlob], file.name, {
type: 'application/pdf',
});
} catch (error) {
// Handle network or unexpected errors
console.error(`Failed to decrypt PDF: ${file.name}`, error);
const fallbackDetail =
(error && error.message) ||
window.decrypt.unexpectedError ||
'There was an error processing the file. Please try again.';
const unexpectedProblem = {
title: (window.stirlingPDF && window.stirlingPDF.errorUnexpectedTitle) || 'Unexpected Error',
detail: fallbackDetail,
};
if (window.decrypt.serverError) {
unexpectedProblem.hints = [
window.decrypt.serverError.replace('{0}', file.name),
];
}
this.showProblemDetail(unexpectedProblem);
return null; // No file to return
}
}
async checkFileEncrypted(file) {
try {
if (file.type !== 'application/pdf') {
return {isEncrypted: false, requiresPassword: false};
}
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
const arrayBuffer = await file.arrayBuffer();
const arrayBufferForPdfLib = arrayBuffer.slice(0);
var loadingTask;
if(this.decryptWorker == null){
loadingTask = pdfjsLib.getDocument({
...PDFJS_DEFAULT_OPTIONS,
data: arrayBuffer,
});
this.decryptWorker = loadingTask._worker
}else {
loadingTask = pdfjsLib.getDocument({
...PDFJS_DEFAULT_OPTIONS,
data: arrayBuffer,
worker: this.decryptWorker
});
}
await loadingTask.promise;
try {
//Uses PDFLib.PDFDocument to check if unpassworded but encrypted
const pdfDoc = await PDFLib.PDFDocument.load(arrayBufferForPdfLib);
return {isEncrypted: false, requiresPassword: false};
} catch (error) {
if (error.message.includes('Input document to `PDFDocument.load` is encrypted')) {
return {isEncrypted: true, requiresPassword: false};
}
console.error('Error checking encryption:', error);
throw new Error('Failed to determine if the file is encrypted.');
}
} catch (error) {
if (error.name === 'PasswordException') {
if (error.code === pdfjsLib.PasswordResponses.NEED_PASSWORD) {
return {isEncrypted: true, requiresPassword: true};
} else if (error.code === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD) {
return {isEncrypted: true, requiresPassword: false};
}
} else if (error.name === 'InvalidPDFException' ||
(error.message && error.message.includes('Invalid PDF structure'))) {
// Handle corrupted PDF files
console.error('Corrupted PDF detected:', error);
if (window.stirlingPDF.currentPage !== 'repair') {
const corruptedProblem = buildCorruptedPdfProblemDetail(file.name);
this.showProblemDetail(corruptedProblem);
} else {
console.log('Suppressing corrupted PDF warning banner on repair page');
}
throw new Error('PDF file is corrupted.');
}
console.error('Error checking encryption:', error);
throw new Error('Failed to determine if the file is encrypted.');
}
}
showProblemDetail(problemDetail) {
const errorContainer = document.getElementById('errorContainer');
if (!errorContainer) {
console.error('Error container not found');
return;
}
errorContainer.style.display = 'block';
const heading = errorContainer.querySelector('.alert-heading');
const messageEl = errorContainer.querySelector('p');
const traceEl = document.querySelector('#traceContent');
const fallbackHeading = (window.stirlingPDF && window.stirlingPDF.error) || 'Error';
if (heading) {
heading.textContent =
(problemDetail && typeof problemDetail === 'object' && problemDetail.title) ||
fallbackHeading;
}
if (messageEl) {
messageEl.style.whiteSpace = 'pre-wrap';
messageEl.textContent =
typeof problemDetail === 'object'
? formatUserFriendlyError(problemDetail)
: String(problemDetail || '');
}
if (traceEl) {
traceEl.textContent =
typeof problemDetail === 'object' ? formatProblemDetailsJson(problemDetail) : '';
}
}
removeErrorBanner() {
const errorContainer = document.getElementById('errorContainer');
if (!errorContainer) {
return;
}
errorContainer.style.display = 'none';
const heading = errorContainer.querySelector('.alert-heading');
if (heading) {
heading.textContent = (window.stirlingPDF && window.stirlingPDF.error) || 'Error';
}
const messageEl = errorContainer.querySelector('p');
if (messageEl) {
messageEl.textContent = '';
}
const traceEl = document.querySelector('#traceContent');
if (traceEl) {
traceEl.textContent = '';
}
}
}
@@ -0,0 +1,84 @@
document.addEventListener("DOMContentLoaded", function() {
var cacheInputs = localStorage.getItem("cacheInputs") || "disabled";
if (cacheInputs !== "enabled") {
return; // Stop execution if caching is not enabled
}
// Function to generate a key based on the form's action attribute
function generateStorageKey(form) {
const action = form.getAttribute('action');
if (!action || action.length < 3) {
return null; // Not a valid action, return null to skip processing
}
return 'formData_' + encodeURIComponent(action);
}
// Function to save form data to localStorage
function saveFormData(form) {
const formKey = generateStorageKey(form);
if (!formKey) return; // Skip if no valid key
const formData = {};
const elements = form.elements;
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
// Skip elements without names, passwords, files, hidden fields, and submit/reset buttons
if (!element.name ||
element.type === 'password' ||
element.type === 'file' ||
//element.type === 'hidden' ||
element.type === 'submit' ||
element.type === 'reset') {
continue;
}
// Handle checkboxes: store only if checked
if (element.type === 'checkbox') {
if (element.checked) {
formData[element.name] = element.value;
} else {
continue; // Skip unchecked boxes
}
} else {
// Skip saving empty values
if (element.value === "" || element.value == null) {
continue;
}
formData[element.name] = element.value;
}
}
if (Object.keys(formData).length > 0) {
localStorage.setItem(formKey, JSON.stringify(formData));
}
}
// Function to load form data from localStorage
function loadFormData(form) {
const formKey = generateStorageKey(form);
if (!formKey) return; // Skip if no valid key
const savedData = localStorage.getItem(formKey);
if (savedData) {
const formData = JSON.parse(savedData);
for (const key in formData) {
if (formData.hasOwnProperty(key) && form.elements[key]) {
const element = form.elements[key];
if (element.type === 'checkbox') {
element.checked = true;
} else {
element.value = formData[key];
}
}
}
}
}
// Attach event listeners and load data for all forms
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(event) {
saveFormData(form);
});
loadFormData(form);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
importScripts('./diff.js');
let complexMessage = 'One or both of the provided documents are large files, accuracy of comparison may be reduced';
let largeFilesMessage = 'One or Both of the provided documents are too large to process';
// Early: Listener for SET messages (before onmessage)
self.addEventListener('message', (event) => {
if (event.data.type === 'SET_COMPLEX_MESSAGE') {
complexMessage = event.data.message;
} else if (event.data.type === 'SET_TOO_LARGE_MESSAGE') {
largeFilesMessage = event.data.message;
}
});
self.onmessage = async function (e) {
const data = e.data;
if (data.type !== 'COMPARE') {
console.log('Worker ignored non-COMPARE message');
return;
}
const { text1, text2, color1, color2 } = data;
console.log('Received text for comparison:', { lengths: { text1: text1.length, text2: text2.length } }); // Safe Log
const startTime = performance.now();
// Safe Trim
if (!text1 || !text2 || text1.trim() === "" || text2.trim() === "") {
self.postMessage({ status: 'error', message: 'One or both of the texts are empty.' });
return;
}
// Robust Word-Split (handles spaces/punctuation better)
const words1 = text1.trim().split(/\s+/).filter(w => w.length > 0);
const words2 = text2.trim().split(/\s+/).filter(w => w.length > 0);
const MAX_WORD_COUNT = 150000;
const COMPLEX_WORD_COUNT = 50000;
const BATCH_SIZE = 5000; // Define a suitable batch size for processing
const OVERLAP_SIZE = 200; // Number of words to overlap - bigger increases accuracy but affects performance
const isComplex = words1.length > COMPLEX_WORD_COUNT || words2.length > COMPLEX_WORD_COUNT;
const isTooLarge = words1.length > MAX_WORD_COUNT || words2.length > MAX_WORD_COUNT;
if (isTooLarge) {
self.postMessage({ status: 'error', message: largeFilesMessage });
return;
}
if (isComplex) {
self.postMessage({ status: 'warning', message: complexMessage });
}
// Diff based on size
let differences;
if (isComplex) {
differences = await staggeredBatchDiff(words1, words2, color1 || '#ff0000', color2 || '#008000', BATCH_SIZE, OVERLAP_SIZE);
} else {
differences = diff(words1, words2, color1 || '#ff0000', color2 || '#008000');
}
console.log(`Diff took ${performance.now() - startTime} ms for ${words1.length + words2.length} words`);
self.postMessage({ status: 'success', differences });
};
// Splits text into smaller batches to run through diff checking algorithms. overlaps the batches to help ensure
async function staggeredBatchDiff(words1, words2, color1, color2, batchSize, overlapSize) {
const differences = [];
const totalWords1 = words1.length;
const totalWords2 = words2.length;
let previousEnd1 = 0; // Track where the last batch ended in words1
let previousEnd2 = 0; // Track where the last batch ended in words2
// Track processed indices to dedupe overlaps
const processed1 = new Set();
const processed2 = new Set();
while (previousEnd1 < totalWords1 || previousEnd2 < totalWords2) {
// Define the next chunk boundaries
const start1 = previousEnd1;
const end1 = Math.min(start1 + batchSize, totalWords1);
const start2 = previousEnd2;
const end2 = Math.min(start2 + batchSize, totalWords2);
// Adaptive: If many diffs, smaller batch (max 3x downscale)
const recentDiffs = differences.slice(-100).filter(([c]) => c !== 'black').length;
// If difference is too high decrease batch size for more granular check
const dynamicBatchSize = Math.max(batchSize / Math.min(8, 1 + recentDiffs / 50), batchSize / 8);
const extendedEnd1 = Math.min(end1 + dynamicBatchSize, totalWords1);
const extendedEnd2 = Math.min(end2 + dynamicBatchSize, totalWords2);
const batchWords1 = words1.slice(start1, extendedEnd1);
const batchWords2 = words2.slice(start2, extendedEnd2);
// Include overlap from the previous chunk
const overlapStart1 = Math.max(0, previousEnd1 - overlapSize);
const overlapStart2 = Math.max(0, previousEnd2 - overlapSize);
const overlapWords1 = previousEnd1 > 0 ? words1.slice(overlapStart1, previousEnd1) : [];
const overlapWords2 = previousEnd2 > 0 ? words2.slice(overlapStart2, previousEnd2) : [];
// Combine overlaps and current batches for comparison
const combinedWords1 = [...overlapWords1, ...batchWords1];
const combinedWords2 = [...overlapWords2, ...batchWords2];
// Perform the diff on the combined words
const batchDifferences = diff(combinedWords1, combinedWords2, color1, color2);
const combinedIndices1 = [];
for (let i = overlapStart1; i < previousEnd1; i++) {
combinedIndices1.push(i);
}
for (let i = start1; i < extendedEnd1; i++) {
combinedIndices1.push(i);
}
const combinedIndices2 = [];
for (let i = overlapStart2; i < previousEnd2; i++) {
combinedIndices2.push(i);
}
for (let i = start2; i < extendedEnd2; i++) {
combinedIndices2.push(i);
}
let pointer1 = 0;
let pointer2 = 0;
const filteredBatch = [];
batchDifferences.forEach(([color, word]) => {
if (color === color1) {
const globalIndex1 = combinedIndices1[pointer1];
if (globalIndex1 === undefined || !processed1.has(globalIndex1)) {
filteredBatch.push([color, word]);
}
if (globalIndex1 !== undefined) {
processed1.add(globalIndex1);
}
pointer1++;
} else if (color === color2) {
const globalIndex2 = combinedIndices2[pointer2];
if (globalIndex2 === undefined || !processed2.has(globalIndex2)) {
filteredBatch.push([color, word]);
}
if (globalIndex2 !== undefined) {
processed2.add(globalIndex2);
}
pointer2++;
} else {
const globalIndex1 = combinedIndices1[pointer1];
const globalIndex2 = combinedIndices2[pointer2];
const alreadyProcessed = (globalIndex1 !== undefined && processed1.has(globalIndex1)) && (globalIndex2 !== undefined && processed2.has(globalIndex2));
if (!alreadyProcessed) {
filteredBatch.push([color, word]);
}
if (globalIndex1 !== undefined) {
processed1.add(globalIndex1);
}
if (globalIndex2 !== undefined) {
processed2.add(globalIndex2);
}
pointer1++;
pointer2++;
}
});
differences.push(...filteredBatch);
// Mark as processed
for (let k = start1; k < end1; k++) processed1.add(k);
for (let k = start2; k < end2; k++) processed2.add(k);
previousEnd1 = end1;
previousEnd2 = end2;
// Yield for async (avoids blocking)
await new Promise(resolve => setTimeout(resolve, 0));
}
return differences;
}
// Standard diff function for small text comparisons
function diff(words1, words2, color1, color2) {
console.log(`Diff: ${words1.length} vs ${words2.length} words`);
const oldStr = words1.join(' '); // As string for diff.js
const newStr = words2.join(' ');
// Static method: No 'new' needed, avoids constructor error
const changes = Diff.diffWords(oldStr, newStr, { ignoreWhitespace: true });
// Map changes to [color, word] format (change.value and added/removed)
const differences = [];
changes.forEach(change => {
const value = change.value;
const op = change.added ? 1 : change.removed ? -1 : 0;
// Split value into words and process
const words = value.split(/\s+/).filter(w => w.length > 0);
words.forEach(word => {
if (op === 0) { // Equal
differences.push(['black', word]);
} else if (op === 1) { // Insert
differences.push([color2, word]);
} else if (op === -1) { // Delete
differences.push([color1, word]);
}
});
});
return differences;
}
@@ -0,0 +1,37 @@
document.addEventListener('DOMContentLoaded', function() {
// Get CSRF token from cookie
const getCsrfToken = () => {
return document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
};
// Function to decode the URI-encoded cookie value
const decodeCsrfToken = (token) => {
if (token) {
return decodeURIComponent(token);
}
return null;
};
// Find all forms and add CSRF token
const forms = document.querySelectorAll('form');
const csrfToken = decodeCsrfToken(getCsrfToken());
// Only proceed if we have a cookie-based token
if (csrfToken) {
forms.forEach(form => {
// Only now remove existing CSRF input fields since we have a new token
const existingCsrfInputs = form.querySelectorAll('input[name="_csrf"]');
existingCsrfInputs.forEach(input => input.remove());
// Create and add new CSRF input field
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = '_csrf';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
});
}
});
@@ -0,0 +1,105 @@
var toggleCount = 0;
var lastToggleTime = Date.now();
var elements = {
lightModeStyles: null,
darkModeStyles: null,
rainbowModeStyles: null,
darkModeIcon: null,
searchBar: null,
formControls: null,
navIcons: null,
navDropdownMenus: null,
};
function getElements() {
elements.lightModeStyles = document.getElementById("light-mode-styles");
elements.darkModeStyles = document.getElementById("dark-mode-styles");
elements.rainbowModeStyles = document.getElementById("rainbow-mode-styles");
elements.darkModeIcon = document.getElementById("dark-mode-icon");
elements.searchBar = document.getElementById("searchBar");
elements.formControls = document.querySelectorAll(".form-control");
elements.navDropdownMenus = document.querySelectorAll(".dropdown-menu");
}
function setMode(mode) {
var event = new CustomEvent("modeChanged", { detail: mode });
document.dispatchEvent(event);
if (elements && elements.lightModeStyles) {
elements.lightModeStyles.disabled = mode !== "off";
}
if (elements && elements.darkModeStyles) {
elements.darkModeStyles.disabled = mode !== "on";
}
if (elements && elements.rainbowModeStyles) {
elements.rainbowModeStyles.disabled = mode !== "rainbow";
}
var jumbotron = document.getElementById("jumbotron");
if (mode === "on") {
if (elements && elements.darkModeIcon) {
elements.darkModeIcon.textContent = "dark_mode";
}
var tables = document.querySelectorAll(".table");
tables.forEach((table) => {
table.classList.add("table-dark");
});
} else if (mode === "off") {
if (elements && elements.darkModeIcon) {
elements.darkModeIcon.textContent = "light_mode";
}
var tables = document.querySelectorAll(".table-dark");
tables.forEach((table) => {
table.classList.remove("table-dark");
});
} else if (mode === "rainbow") {
if (elements && elements.darkModeIcon) {
elements.darkModeIcon.textContent = "looks";
}
}
}
function toggleDarkMode() {
var currentTime = Date.now();
if (currentTime - lastToggleTime < 1000) {
toggleCount++;
} else {
toggleCount = 1;
}
lastToggleTime = currentTime;
document.body.classList.add("transition-theme");
if (toggleCount >= 18) {
localStorage.setItem("dark-mode", "rainbow");
setMode("rainbow");
} else if (localStorage.getItem("dark-mode") == "on") {
localStorage.setItem("dark-mode", "off");
setMode("off");
} else {
localStorage.setItem("dark-mode", "on");
setMode("on");
}
}
document.addEventListener("DOMContentLoaded", function () {
getElements();
var currentMode = localStorage.getItem("dark-mode");
if (currentMode === "on" || currentMode === "off" || currentMode === "rainbow") {
setMode(currentMode);
} else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
setMode("on");
} else {
setMode("off");
}
var darkModeToggle = document.getElementById("dark-mode-toggle");
if (darkModeToggle !== null) {
darkModeToggle.addEventListener("click", function (event) {
event.preventDefault();
toggleDarkMode();
});
}
});
@@ -0,0 +1,27 @@
document.getElementById('download-pdf').addEventListener('click', async () => {
const modifiedPdf = await DraggableUtils.getOverlayedPdfDocument();
let decryptedFile = modifiedPdf;
let isEncrypted = false;
let requiresPassword = false;
await this.decryptFile
.checkFileEncrypted(decryptedFile)
.then((result) => {
isEncrypted = result.isEncrypted;
requiresPassword = result.requiresPassword;
})
.catch((error) => {
console.error(error);
});
if (decryptedFile.type === 'application/pdf' && isEncrypted) {
decryptedFile = await this.decryptFile.decryptFile(decryptedFile, requiresPassword);
if (!decryptedFile) {
throw new Error('File decryption failed.');
}
}
const modifiedPdfBytes = await modifiedPdf.save();
const blob = new Blob([modifiedPdfBytes], {type: 'application/pdf'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = originalFileName + '_signed.pdf';
link.click();
});
@@ -0,0 +1,791 @@
(function () {
if (window.isDownloadScriptInitialized) return; // Prevent re-execution
window.isDownloadScriptInitialized = true;
const PDFJS_DEFAULT_OPTIONS = {
cMapUrl: pdfjsPath + 'cmaps/',
cMapPacked: true,
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
};
// Global PDF processing count tracking for survey system
window.incrementPdfProcessingCount = function() {
let pdfProcessingCount = parseInt(localStorage.getItem('pdfProcessingCount') || '0');
pdfProcessingCount++;
localStorage.setItem('pdfProcessingCount', pdfProcessingCount.toString());
};
const {
pdfPasswordPrompt,
multipleInputsForSingleRequest,
disableMultipleFiles,
remoteCall,
sessionExpired,
refreshPage,
error,
} = window.stirlingPDF;
// Format Problem Details JSON with consistent key order and pretty-printing
function formatProblemDetailsJson(input) {
try {
const obj = typeof input === 'string' ? JSON.parse(input) : input;
const preferredOrder = [
'errorCode',
'title',
'status',
'type',
'detail',
'instance',
'path',
'timestamp',
'hints',
'actionRequired'
];
const out = {};
// Place preferred keys first if present
preferredOrder.forEach((k) => {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
out[k] = obj[k];
}
});
// Append remaining keys preserving their original order
Object.keys(obj).forEach((k) => {
if (!Object.prototype.hasOwnProperty.call(out, k)) {
out[k] = obj[k];
}
});
return JSON.stringify(out, null, 2);
} catch (e) {
// Fallback: if it's already a string, return as-is; otherwise pretty-print best effort
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
}
function showErrorBanner(message, stackTrace) {
const errorContainer = document.getElementById('errorContainer');
if (!errorContainer) {
console.error('Error container not found');
return;
}
errorContainer.style.display = 'block'; // Display the banner
const heading = errorContainer.querySelector('.alert-heading');
const messageEl = errorContainer.querySelector('p');
const traceEl = document.querySelector('#traceContent');
if (heading) heading.textContent = error;
if (messageEl) {
messageEl.style.whiteSpace = 'pre-wrap';
messageEl.textContent = message;
}
// Format stack trace: if it looks like JSON, pretty-print with consistent key order; otherwise clean it up
if (traceEl) {
if (stackTrace) {
// Check if stackTrace is already JSON formatted
if (stackTrace.trim().startsWith('{') || stackTrace.trim().startsWith('[')) {
traceEl.textContent = formatProblemDetailsJson(stackTrace);
} else {
// Filter out unhelpful stack traces (internal browser/library paths)
// Only show if it contains meaningful error info
const lines = stackTrace.split('\n');
const meaningfulLines = lines.filter(line =>
!line.includes('pdfjs-legacy') &&
!line.includes('pdf.worker') &&
!line.includes('pdf.mjs') &&
line.trim().length > 0
);
traceEl.textContent = meaningfulLines.length > 0 ? meaningfulLines.join('\n') : 'No additional trace information available';
}
} else {
traceEl.textContent = '';
}
}
}
function showSessionExpiredPrompt() {
const errorContainer = document.getElementById('errorContainer');
errorContainer.style.display = 'block';
errorContainer.querySelector('.alert-heading').textContent = sessionExpired;
errorContainer.querySelector('p').textContent = sessionExpired;
document.querySelector('#traceContent').textContent = '';
// Optional: Add a refresh button
const refreshButton = document.createElement('button');
refreshButton.textContent = refreshPage;
refreshButton.className = 'btn btn-primary mt-3';
refreshButton.onclick = () => location.reload();
errorContainer.appendChild(refreshButton);
}
let firstErrorOccurred = false;
$(document).ready(function () {
$('form').submit(async function (event) {
event.preventDefault();
firstErrorOccurred = false;
const url = this.action;
let files = $('#fileInput-input')[0].files;
const uploadLimit = window.stirlingPDF?.uploadLimit ?? 0;
if (uploadLimit > 0) {
const oversizedFiles = Array.from(files).filter(f => f.size > uploadLimit);
if (oversizedFiles.length > 0) {
const names = oversizedFiles.map(f => `"${f.name}"`).join(', ');
if (names.length === 1) {
alert(`${names} ${window.stirlingPDF.uploadLimitExceededSingular} ${window.stirlingPDF.uploadLimitReadable}.`);
} else {
alert(`${names} ${window.stirlingPDF.uploadLimitExceededPlural} ${window.stirlingPDF.uploadLimitReadable}.`);
}
files = Array.from(files).filter(f => f.size <= uploadLimit);
if (files.length === 0) return;
}
}
const formData = new FormData(this);
const submitButton = document.getElementById('submitBtn');
const showGameBtn = document.getElementById('show-game-btn');
const originalButtonText = submitButton.textContent;
var boredWaiting = localStorage.getItem('boredWaiting') || 'disabled';
if (showGameBtn) {
showGameBtn.style.display = 'none';
}
// Log fileOrder for debugging
const fileOrderValue = formData.get('fileOrder');
if (fileOrderValue) {
console.log('FormData fileOrder:', fileOrderValue);
}
// Remove empty file entries
for (let [key, value] of formData.entries()) {
if (value instanceof File && !value.name) {
formData.delete(key);
}
}
const override = $('#override').val() || '';
console.log(override);
// Set a timeout to show the game button if operation takes more than 5 seconds
const timeoutId = setTimeout(() => {
if (boredWaiting === 'enabled' && showGameBtn) {
showGameBtn.style.display = 'block';
showGameBtn.parentNode.insertBefore(document.createElement('br'), showGameBtn.nextSibling);
}
}, 5000);
try {
if (!url.includes('remove-password')) {
// Check if any PDF files are encrypted and handle decryption if necessary
const decryptedFiles = await checkAndDecryptFiles(url, files);
files = decryptedFiles;
}
submitButton.textContent = 'Processing...';
submitButton.disabled = true;
if (remoteCall === true) {
if (override === 'multi' || (!multipleInputsForSingleRequest && files.length > 1 && override !== 'single')) {
await submitMultiPdfForm(url, files, this);
} else {
await handleSingleDownload(url, formData);
}
}
//clearFileInput();
clearTimeout(timeoutId);
if (showGameBtn) {
showGameBtn.style.display = 'none';
showGameBtn.style.marginTop = '';
}
submitButton.textContent = originalButtonText;
submitButton.disabled = false;
// After process finishes, check for boredWaiting and gameDialog open status
const gameDialog = document.getElementById('game-container-wrapper');
if (boredWaiting === 'enabled' && gameDialog && gameDialog.open) {
// Display a green banner at the bottom of the screen saying "Download complete"
let downloadCompleteText = 'Download Complete';
if (window.downloadCompleteText) {
downloadCompleteText = window.downloadCompleteText;
}
$('body').append(
'<div id="download-complete-banner" style="position:fixed;bottom:0;left:0;width:100%;background-color:green;color:white;text-align:center;padding:10px;font-size:16px;z-index:1000;">' +
downloadCompleteText +
'</div>'
);
setTimeout(function () {
$('#download-complete-banner').fadeOut('slow', function () {
$(this).remove(); // Remove the banner after fading out
});
}, 5000); // Banner will fade out after 5 seconds
}
} catch (error) {
clearTimeout(timeoutId);
if(showGameBtn){
showGameBtn.style.display = 'none';
}
submitButton.textContent = originalButtonText;
submitButton.disabled = false;
handleDownloadError(error);
console.error(error);
}
});
});
async function getPDFPageCount(file) {
try {
const arrayBuffer = await file.arrayBuffer();
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
const pdf = await pdfjsLib
.getDocument({
...PDFJS_DEFAULT_OPTIONS,
data: arrayBuffer,
})
.promise;
return pdf.numPages;
} catch (error) {
console.error('Error getting PDF page count:', error);
return null;
}
}
async function checkAndDecryptFiles(url, files) {
const decryptedFiles = [];
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
// Extract the base URL
const baseUrl = new URL(url);
let removePasswordUrl = `${baseUrl.origin}`;
// Check if there's a path before /api/
const apiIndex = baseUrl.pathname.indexOf('/api/');
if (apiIndex > 0) {
removePasswordUrl += baseUrl.pathname.substring(0, apiIndex);
}
// Append the new endpoint
removePasswordUrl += '/api/v1/security/remove-password';
console.log(`Remove password URL: ${removePasswordUrl}`);
for (const file of files) {
console.log(`Processing file: ${file.name}`);
if (file.type !== 'application/pdf') {
console.log(`Skipping non-PDF file: ${file.name}`);
decryptedFiles.push(file);
continue;
}
try {
const arrayBuffer = await file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({
...PDFJS_DEFAULT_OPTIONS,
data: arrayBuffer,
});
console.log(`Attempting to load PDF: ${file.name}`);
const pdf = await loadingTask.promise;
console.log(`File is not encrypted: ${file.name}`);
decryptedFiles.push(file); // If no error, file is not encrypted
} catch (error) {
if (error.name === 'PasswordException' && error.code === 1) {
console.log(`PDF requires password: ${file.name}`, error);
console.log(`Attempting to remove password from PDF: ${file.name} with password.`);
const password = prompt(`${window.decrypt.passwordPrompt}`);
if (!password) {
console.error(`No password provided for encrypted PDF: ${file.name}`);
// Create a Problem Detail object matching the server's E004 response using localized strings
const passwordDetailTemplate =
window.stirlingPDF?.pdfPasswordDetail ||
`The PDF file "${file.name}" requires a password to proceed.`;
const hints = [
window.stirlingPDF?.pdfPasswordHint1,
window.stirlingPDF?.pdfPasswordHint2,
window.stirlingPDF?.pdfPasswordHint3,
window.stirlingPDF?.pdfPasswordHint4,
window.stirlingPDF?.pdfPasswordHint5,
window.stirlingPDF?.pdfPasswordHint6
].filter(Boolean);
const noProblemDetail = {
errorCode: 'E004',
title: window.stirlingPDF?.pdfPasswordTitle || 'PDF Password Required',
detail: passwordDetailTemplate.includes('{0}')
? passwordDetailTemplate.replace('{0}', file.name)
: passwordDetailTemplate,
hints,
actionRequired:
window.stirlingPDF?.pdfPasswordAction ||
'Provide the owner/permissions password, not just the document open password.'
};
const bannerMessage = formatUserFriendlyError(noProblemDetail);
const debugInfo = formatProblemDetailsJson(noProblemDetail);
showErrorBanner(bannerMessage, debugInfo);
const err = new Error(noProblemDetail.detail);
err.alreadyHandled = true;
throw err;
}
try {
// Prepare FormData for the decryption request
const formData = new FormData();
formData.append('fileInput', file);
formData.append('password', password);
// Use handleSingleDownload to send the request
const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData});
// Check if we got an error response (RFC 7807 Problem Details)
if (!decryptionResult.ok) {
const contentType = decryptionResult.headers.get('content-type');
if (contentType && (contentType.includes('application/json') || contentType.includes('application/problem+json'))) {
// Parse the RFC 7807 error response
const errorJson = await decryptionResult.json();
const formattedError = formatUserFriendlyError(errorJson);
const debugInfo = formatProblemDetailsJson(errorJson);
const title = errorJson.title || 'Decryption Failed';
const detail = errorJson.detail || 'Failed to decrypt PDF';
const bannerMessage = formattedError || `${title}: ${detail}`;
showErrorBanner(bannerMessage, debugInfo);
const err = new Error(detail);
err.alreadyHandled = true; // Mark error as already handled
throw err;
} else {
throw new Error('Decryption failed: Invalid server response');
}
}
if (decryptionResult && decryptionResult.blob) {
const decryptedBlob = await decryptionResult.blob();
const decryptedFile = new File([decryptedBlob], file.name, {type: 'application/pdf'});
decryptedFiles.push(decryptedFile);
console.log(`Successfully decrypted PDF: ${file.name}`);
} else {
throw new Error('Decryption failed: No valid response from server');
}
} catch (decryptError) {
console.error(`Failed to decrypt PDF: ${file.name}`, decryptError);
// Error banner already shown above with formatted hints/actions
throw decryptError;
}
} else if (error.name === 'InvalidPDFException' ||
(error.message && error.message.includes('Invalid PDF structure'))) {
// Handle corrupted PDF files
console.log(`Corrupted PDF detected: ${file.name}`, error);
if (window.stirlingPDF.currentPage !== 'repair') {
// Create a formatted error message using properties from language files
const errorMessage = window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name);
const hints = [
window.stirlingPDF.pdfCorruptedHint1,
window.stirlingPDF.pdfCorruptedHint2,
window.stirlingPDF.pdfCorruptedHint3
].filter((hint) => typeof hint === 'string' && hint.trim().length > 0);
const action = window.stirlingPDF.pdfCorruptedAction || window.stirlingPDF.tryRepairMessage;
const problemDetails = {
title: window.stirlingPDF.pdfCorruptedTitle || window.stirlingPDF.error || 'Error',
detail: errorMessage
};
if (hints.length > 0) {
problemDetails.hints = hints;
}
if (action) {
problemDetails.actionRequired = action;
}
const bannerMessage = formatUserFriendlyError(problemDetails);
const debugInfo = formatProblemDetailsJson(problemDetails);
showErrorBanner(bannerMessage, debugInfo);
// Mark error as already handled to prevent double display
error.alreadyHandled = true;
} else {
// On repair page, suppress banner; user already knows and is repairing
console.log('Suppressing corrupted PDF banner on repair page');
}
throw error;
} else {
console.log(`Error loading PDF: ${file.name}`, error);
throw error;
}
}
}
return decryptedFiles;
}
async function handleSingleDownload(url, formData, isMulti = false, isZip = false) {
const startTime = performance.now();
const file = formData.get('fileInput');
let success = false;
let errorMessage = null;
try {
const response = await window.fetchWithCsrf(url, {method: 'POST', body: formData});
const contentType = response.headers.get('content-type');
if (!response.ok) {
errorMessage = response.status;
// Check for JSON error responses first (including RFC 7807 Problem Details)
if (contentType && (contentType.includes('application/json') || contentType.includes('application/problem+json'))) {
console.error('Throwing error banner, response was not okay');
await handleJsonResponse(response);
// Return early - error banner already shown by handleJsonResponse
// Don't throw to avoid double error display
return null;
}
// Only show session expired for 401 without JSON body (actual auth failure)
if (response.status === 401) {
showSessionExpiredPrompt();
return;
}
// For non-JSON errors, try to extract error message from response body
try {
const errorText = await response.text();
if (errorText && errorText.trim().length > 0) {
showErrorBanner(`HTTP ${response.status}`, errorText);
// Return early - error already shown
return null;
}
} catch (textError) {
// If we can't read the response body, show generic error
const errorMsg = `HTTP ${response.status} - ${response.statusText || 'Request failed'}`;
showErrorBanner('Error', errorMsg);
return null;
}
}
const contentDisposition = response.headers.get('Content-Disposition');
let filename = getFilenameFromContentDisposition(contentDisposition);
const blob = await response.blob();
success = true;
if (contentType.includes('application/pdf') || contentType.includes('image/')) {
//clearFileInput();
return handleResponse(blob, filename, !isMulti, isZip);
} else {
//clearFileInput();
return handleResponse(blob, filename, false, isZip);
}
} catch (error) {
success = false;
errorMessage = error.message;
console.error('Error in handleSingleDownload:', error);
throw error;
} finally {
const processingTime = performance.now() - startTime;
// Capture analytics
const pageCount = file && file.type === 'application/pdf' ? await getPDFPageCount(file) : null;
if (analyticsEnabled) {
posthog.capture('file_processing', {
success: success,
file_type: file ? file.type || 'unknown' : 'unknown',
file_size: file ? file.size : 0,
processing_time: processingTime,
error_message: errorMessage,
pdf_pages: pageCount,
});
}
// Increment PDF processing count for survey tracking
if (success && typeof window.incrementPdfProcessingCount === 'function') {
window.incrementPdfProcessingCount();
}
}
}
function getFilenameFromContentDisposition(contentDisposition) {
let filename;
if (contentDisposition && contentDisposition.indexOf('attachment') !== -1) {
filename = decodeURIComponent(contentDisposition.split('filename=')[1].replace(/"/g, '')).trim();
} else {
// If the Content-Disposition header is not present or does not contain the filename, use a default filename
filename = 'download';
}
return filename;
}
/**
* Format error details in a user-friendly way
* Extracts key information and presents hints/actions prominently
*/
function formatUserFriendlyError(json) {
if (!json || typeof json !== 'object') {
return typeof json === 'string' ? json : '';
}
const lines = [];
const title = json.title || json.error || '';
const detail = json.detail || json.message || '';
const primaryLine = title && detail
? `${title}: ${detail}`
: title || detail;
if (primaryLine) {
lines.push(primaryLine);
}
if (json.errorCode) {
lines.push('');
lines.push(`Error Code: ${json.errorCode}`);
}
const detailAlreadyIncluded = detail && primaryLine && primaryLine.includes(detail);
if (detail && !detailAlreadyIncluded) {
lines.push('');
lines.push(detail);
}
if (json.hints && Array.isArray(json.hints) && json.hints.length > 0) {
lines.push('');
lines.push('How to fix:');
json.hints.forEach((hint, index) => {
lines.push(` ${index + 1}. ${hint}`);
});
}
if (json.actionRequired) {
lines.push('');
lines.push(json.actionRequired);
}
if (json.supportId) {
lines.push('');
lines.push(`Support ID: ${json.supportId}`);
}
return lines
.filter((line, index, arr) => {
if (line !== '') return true;
if (index === 0 || index === arr.length - 1) return false;
return arr[index - 1] !== '';
})
.join('\n');
}
async function handleJsonResponse(response) {
const json = await response.json();
// Format the full JSON response for display in stack trace with errorCode first
const formattedJson = formatProblemDetailsJson(json);
// Check for PDF password errors using RFC 7807 fields
const isPdfPasswordError =
json.type === '/errors/pdf-password' ||
json.errorCode === 'E004' ||
(json.detail && (
json.detail.toLowerCase().includes('pdf document is passworded') ||
json.detail.toLowerCase().includes('password is incorrect') ||
json.detail.toLowerCase().includes('password was not provided') ||
json.detail.toLowerCase().includes('pdf contains an encryption dictionary')
));
const fallbackTitle = json.title || json.error || 'Error';
const fallbackDetail = json.detail || json.message || '';
const fallbackMessage = fallbackDetail ? `${fallbackTitle}: ${fallbackDetail}` : fallbackTitle;
const bannerMessage = formatUserFriendlyError(json) || fallbackMessage;
if (isPdfPasswordError) {
showErrorBanner(bannerMessage, formattedJson);
// Show alert only once for user attention
if (!firstErrorOccurred) {
firstErrorOccurred = true;
const detail = json.detail || 'The PDF document requires a password to open.';
alert(pdfPasswordPrompt + '\n\n' + detail);
}
} else {
// Show user-friendly error, fallback to full JSON for debugging
showErrorBanner(bannerMessage, formattedJson);
}
}
async function handleResponse(blob, filename, considerViewOptions = false, isZip = false) {
if (!blob) return;
const downloadOption = localStorage.getItem('downloadOption');
if (considerViewOptions) {
if (downloadOption === 'sameWindow') {
const url = URL.createObjectURL(blob);
window.location.href = url;
return;
} else if (downloadOption === 'newWindow') {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
return;
}
}
if (!isZip) {
downloadFile(blob, filename);
}
return {filename, blob};
}
function handleDownloadError(error) {
// Skip if error was already handled and displayed
if (error.alreadyHandled) {
return;
}
const errorMessage = error.message;
showErrorBanner(errorMessage);
}
let urls = []; // An array to hold all the URLs
function downloadFile(blob, filename) {
if (!(blob instanceof Blob)) {
console.error('Invalid blob passed to downloadFile function');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
urls.push(url); // Store the URL so it doesn't get garbage collected too soon
return {filename, blob};
}
async function submitMultiPdfForm(url, files, form) {
const zipThreshold = parseInt(localStorage.getItem('zipThreshold'), 10) || 4;
const zipFiles = files.length > zipThreshold;
let jszip = null;
// Add Space below Progress Bar before Showing
$('.progressBarContainer').after($('<br>'));
$('.progressBarContainer').show();
// Initialize the progress bar
let progressBar = $('.progressBar');
progressBar.css('width', '0%');
progressBar.attr('aria-valuenow', 0);
progressBar.attr('aria-valuemax', files.length);
if (zipFiles) {
jszip = new JSZip();
}
// Get the form with the method attribute set to POST
let postForm = document.querySelector('form[method="POST"]');
// Get existing form data
let formData;
if (form) {
formData = new FormData(form);
} else if (postForm) {
formData = new FormData($(postForm)[0]); // Convert the form to a jQuery object and get the raw DOM element
} else {
console.log('No form with POST method found.');
}
//Remove file to reuse parameters for other runs
formData.delete('fileInput');
// Remove empty file entries
for (let [key, value] of formData.entries()) {
if (value instanceof File && !value.name) {
formData.delete(key);
}
}
const CONCURRENCY_LIMIT = 8;
const chunks = [];
for (let i = 0; i < Array.from(files).length; i += CONCURRENCY_LIMIT) {
chunks.push(Array.from(files).slice(i, i + CONCURRENCY_LIMIT));
}
for (const chunk of chunks) {
const promises = chunk.map(async (file) => {
let fileFormData = new FormData();
fileFormData.append('fileInput', file);
for (let [key, value] of fileFormData.entries()) {
console.log(key, value);
} // Add other form data
for (let pair of formData.entries()) {
fileFormData.append(pair[0], pair[1]);
console.log(pair[0] + ', ' + pair[1]);
}
try {
const downloadDetails = await handleSingleDownload(url, fileFormData, true, zipFiles);
console.log(downloadDetails);
// If downloadDetails is null, error was already shown, skip processing
if (downloadDetails) {
if (zipFiles) {
jszip.file(downloadDetails.filename, downloadDetails.blob);
} else {
//downloadFile(downloadDetails.blob, downloadDetails.filename);
}
updateProgressBar(progressBar, Array.from(files).length);
}
} catch (error) {
handleDownloadError(error);
console.error(error);
}
});
await Promise.all(promises);
}
if (zipFiles) {
try {
const content = await jszip.generateAsync({type: 'blob'});
downloadFile(content, 'files.zip');
} catch (error) {
console.error('Error generating ZIP file: ' + error);
}
}
progressBar.css('width', '100%');
progressBar.attr('aria-valuenow', Array.from(files).length);
setTimeout(() => {
progressBar.closest('.progressBarContainer').hide();
progressBar.css('width', '0%');
progressBar.attr('aria-valuenow', 0);
}, 1000);
}
function updateProgressBar(progressBar, files) {
let progress = (progressBar.attr('aria-valuenow') / files.length) * 100 + 100 / files.length;
progressBar.css('width', progress + '%');
progressBar.attr('aria-valuenow', parseInt(progressBar.attr('aria-valuenow')) + 1);
}
window.addEventListener('unload', () => {
for (const url of urls) {
URL.revokeObjectURL(url);
}
});
// Clear file input after job
function clearFileInput() {
let pathname = document.location.pathname;
if (pathname != '/merge-pdfs') {
let formElement = document.querySelector('#fileInput-input');
formElement.value = '';
let editSectionElement = document.querySelector('#editSection');
if (editSectionElement) {
editSectionElement.style.display = 'none';
}
let cropPdfCanvas = document.querySelector('#cropPdfCanvas');
let overlayCanvas = document.querySelector('#overlayCanvas');
if (cropPdfCanvas && overlayCanvas) {
cropPdfCanvas.width = 0;
cropPdfCanvas.height = 0;
overlayCanvas.width = 0;
overlayCanvas.height = 0;
}
} else {
console.log("Disabled for 'Merge'");
}
}
})();
@@ -0,0 +1,631 @@
const DraggableUtils = {
boxDragContainer: document.getElementById('box-drag-container'),
pdfCanvas: document.getElementById('pdf-canvas'),
nextId: 0,
pdfDoc: null,
pageIndex: 0,
elementAllPages: [],
documentsMap: new Map(),
lastInteracted: null,
padding: 15,
maintainRatioEnabled: true,
init() {
interact('.draggable-canvas')
.draggable({
listeners: {
start(event) {
const target = event.target;
x = parseFloat(target.getAttribute('data-bs-x'));
y = parseFloat(target.getAttribute('data-bs-y'));
},
move: (event) => {
const target = event.target;
// Retrieve position attributes
let x = parseFloat(target.getAttribute('data-bs-x')) || 0;
let y = parseFloat(target.getAttribute('data-bs-y')) || 0;
const angle = parseFloat(target.getAttribute('data-angle')) || 0;
// Update position based on drag movement
x += event.dx;
y += event.dy;
// Apply translation to the parent container (bounding box)
target.style.transform = `translate(${x}px, ${y}px)`;
// Preserve rotation on the inner canvas
const canvas = target.querySelector('.display-canvas');
const canvasWidth = parseFloat(canvas.style.width);
const canvasHeight = parseFloat(canvas.style.height);
const cosAngle = Math.abs(Math.cos(angle));
const sinAngle = Math.abs(Math.sin(angle));
const rotatedWidth = canvasWidth * cosAngle + canvasHeight * sinAngle;
const rotatedHeight = canvasWidth * sinAngle + canvasHeight * cosAngle;
const offsetX = (rotatedWidth - canvasWidth) / 2;
const offsetY = (rotatedHeight - canvasHeight) / 2;
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) rotate(${angle}rad)`;
// Update attributes for persistence
target.setAttribute('data-bs-x', x);
target.setAttribute('data-bs-y', y);
// Set the last interacted element
this.lastInteracted = target;
},
},
})
.resizable({
edges: { left: true, right: true, bottom: true, top: true },
listeners: {
start: (event) => {
const target = event.target;
x = parseFloat(target.getAttribute('data-bs-x')) || 0;
y = parseFloat(target.getAttribute('data-bs-y')) || 0;
},
move: (event) => {
const target = event.target;
const MAX_CHANGE = 60;
let width = event.rect.width - 2 * this.padding;
let height = event.rect.height - 2 * this.padding;
const canvas = target.querySelector('.display-canvas');
if (canvas) {
const originalWidth = parseFloat(canvas.style.width) || canvas.width;
const originalHeight = parseFloat(canvas.style.height) || canvas.height;
const angle = parseFloat(target.getAttribute('data-angle')) || 0;
const aspectRatio = originalWidth / originalHeight;
if (!event.ctrlKey && this.maintainRatioEnabled) {
if (Math.abs(event.deltaRect.width) >= Math.abs(event.deltaRect.height)) {
height = width / aspectRatio;
} else {
width = height * aspectRatio;
}
}
const widthChange = width - originalWidth;
const heightChange = height - originalHeight;
if (Math.abs(widthChange) > MAX_CHANGE || Math.abs(heightChange) > MAX_CHANGE) {
const scale = MAX_CHANGE / Math.max(Math.abs(widthChange), Math.abs(heightChange));
width = originalWidth + widthChange * scale;
height = originalHeight + heightChange * scale;
}
const cosAngle = Math.abs(Math.cos(angle));
const sinAngle = Math.abs(Math.sin(angle));
const boundingWidth = width * cosAngle + height * sinAngle;
const boundingHeight = width * sinAngle + height * cosAngle;
if (event.edges.left) {
const dx = event.deltaRect.left;
x += dx;
}
if (event.edges.top) {
const dy = event.deltaRect.top;
y += dy;
}
target.style.transform = `translate(${x}px, ${y}px)`;
target.style.width = `${boundingWidth + 2 * this.padding}px`;
target.style.height = `${boundingHeight + 2 * this.padding}px`;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.style.transform = `translate(${(boundingWidth - width) / 2}px, ${(boundingHeight - height) / 2
}px) rotate(${angle}rad)`;
target.setAttribute('data-bs-x', x);
target.setAttribute('data-bs-y', y);
this.lastInteracted = target;
}
},
},
modifiers: [
interact.modifiers.restrictSize({
min: { width: 50, height: 50 },
}),
],
inertia: true,
});
//Arrow key Support for Add-Image and Sign pages
if (window.location.pathname.endsWith('sign') || window.location.pathname.endsWith('add-image')) {
window.addEventListener('keydown', (event) => {
//Check for last interacted element
if (!this.lastInteracted) {
return;
}
// Get the currently selected element
const target = this.lastInteracted;
// Step size relatively to the elements size
const stepX = target.offsetWidth * 0.05;
const stepY = target.offsetHeight * 0.05;
// Get the current x and y coordinates
let x = parseFloat(target.getAttribute('data-bs-x')) || 0;
let y = parseFloat(target.getAttribute('data-bs-y')) || 0;
// Check which key was pressed and update the coordinates accordingly
switch (event.key) {
case 'ArrowUp':
y -= stepY;
event.preventDefault(); // Prevent the default action
break;
case 'ArrowDown':
y += stepY;
event.preventDefault();
break;
case 'ArrowLeft':
x -= stepX;
event.preventDefault();
break;
case 'ArrowRight':
x += stepX;
event.preventDefault();
break;
default:
return; // Listen only to arrow keys
}
// Update position
const angle = parseFloat(target.getAttribute('data-angle')) || 0;
target.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
target.setAttribute('data-bs-x', x);
target.setAttribute('data-bs-y', y);
DraggableUtils.onInteraction(target);
});
}
},
onInteraction(target) {
this.lastInteracted = target;
// this.boxDragContainer.appendChild(target);
// target.appendChild(target.querySelector(".display-canvas"));
},
createDraggableCanvasFromUrl(dataUrl) {
return new Promise((resolve) => {
const canvasContainer = document.createElement('div');
const createdCanvas = document.createElement('canvas'); // Inner canvas
const padding = this.padding;
canvasContainer.id = `draggable-canvas-${this.nextId++}`;
canvasContainer.classList.add('draggable-canvas');
createdCanvas.classList.add('display-canvas');
canvasContainer.style.position = 'absolute';
canvasContainer.style.padding = `${padding}px`;
canvasContainer.style.overflow = 'hidden';
let x = 0,
y = 30,
angle = 0;
canvasContainer.style.transform = `translate(${x}px, ${y}px)`;
canvasContainer.setAttribute('data-bs-x', x);
canvasContainer.setAttribute('data-bs-y', y);
canvasContainer.setAttribute('data-angle', angle);
canvasContainer.addEventListener('click', () => {
this.lastInteracted = canvasContainer;
this.showRotationControls(canvasContainer);
});
canvasContainer.appendChild(createdCanvas);
this.boxDragContainer.appendChild(canvasContainer);
const myImage = new Image();
myImage.src = dataUrl;
myImage.onload = () => {
const context = createdCanvas.getContext('2d');
createdCanvas.width = myImage.width;
createdCanvas.height = myImage.height;
const imgAspect = myImage.width / myImage.height;
const containerWidth = this.boxDragContainer.offsetWidth;
const containerHeight = this.boxDragContainer.offsetHeight;
let scaleMultiplier = Math.min(containerWidth / myImage.width, containerHeight / myImage.height);
const scaleFactor = 0.5;
const newWidth = myImage.width * scaleMultiplier * scaleFactor;
const newHeight = myImage.height * scaleMultiplier * scaleFactor;
// Calculate initial bounding box size
const cosAngle = Math.abs(Math.cos(angle));
const sinAngle = Math.abs(Math.sin(angle));
const boundingWidth = newWidth * cosAngle + newHeight * sinAngle;
const boundingHeight = newWidth * sinAngle + newHeight * cosAngle;
createdCanvas.style.width = `${newWidth}px`;
createdCanvas.style.height = `${newHeight}px`;
canvasContainer.style.width = `${boundingWidth + 2 * padding}px`;
canvasContainer.style.height = `${boundingHeight + 2 * padding}px`;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(myImage, 0, 0, myImage.width, myImage.height);
this.showRotationControls(canvasContainer);
this.lastInteracted = canvasContainer;
resolve(canvasContainer);
};
myImage.onerror = () => {
console.error('Failed to load the image.');
resolve(null);
};
});
},
toggleMaintainRatio() {
this.maintainRatioEnabled = !this.maintainRatioEnabled;
const button = document.getElementById('ratioToggleBtn');
if (this.maintainRatioEnabled) {
button.classList.remove('btn-danger');
button.classList.add('btn-outline-secondary');
} else {
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-danger');
}
},
deleteAllDraggableCanvases() {
this.boxDragContainer.querySelectorAll('.draggable-canvas').forEach((el) => el.remove());
},
async addAllPagesDraggableCanvas(element) {
if (element) {
let currentPage = this.pageIndex;
if (!this.elementAllPages.includes(element)) {
this.elementAllPages.push(element);
element.style.filter = 'sepia(1) hue-rotate(90deg) brightness(1.2)';
let newElement = {
element: element,
offsetWidth: element.width,
offsetHeight: element.height,
};
let pagesMap = this.documentsMap.get(this.pdfDoc);
if (!pagesMap) {
pagesMap = {};
this.documentsMap.set(this.pdfDoc, pagesMap);
}
let page = this.pageIndex;
for (let pageIndex = 0; pageIndex < this.pdfDoc.numPages; pageIndex++) {
if (pagesMap[`${pageIndex}-offsetWidth`]) {
if (!pagesMap[pageIndex].includes(newElement)) {
pagesMap[pageIndex].push(newElement);
}
} else {
pagesMap[pageIndex] = [];
pagesMap[pageIndex].push(newElement);
pagesMap[`${pageIndex}-offsetWidth`] = pagesMap[`${page}-offsetWidth`];
pagesMap[`${pageIndex}-offsetHeight`] = pagesMap[`${page}-offsetHeight`];
}
await this.goToPage(pageIndex);
}
} else {
const index = this.elementAllPages.indexOf(element);
if (index !== -1) {
this.elementAllPages.splice(index, 1);
}
element.style.filter = '';
let pagesMap = this.documentsMap.get(this.pdfDoc);
if (!pagesMap) {
pagesMap = {};
this.documentsMap.set(this.pdfDoc, pagesMap);
}
for (let pageIndex = 0; pageIndex < this.pdfDoc.numPages; pageIndex++) {
if (pagesMap[`${pageIndex}-offsetWidth`] && pageIndex != currentPage) {
const pageElements = pagesMap[pageIndex];
pageElements.forEach((elementPage) => {
const elementIndex = pageElements.findIndex((elementPage) => elementPage['element'].id === element.id);
if (elementIndex !== -1) {
pageElements.splice(elementIndex, 1);
}
});
}
await this.goToPage(pageIndex);
}
}
await this.goToPage(currentPage);
}
},
deleteDraggableCanvas(element) {
if (element) {
//Check if deleted element is the last interacted
if (this.lastInteracted === element) {
// If it is, set lastInteracted to null
this.lastInteracted = null;
}
element.remove();
}
},
getLastInteracted() {
return this.lastInteracted;
},
showRotationControls(element) {
const rotationControls = document.getElementById('rotation-controls');
const rotationInput = document.getElementById('rotation-input');
rotationControls.style.display = 'flex';
rotationInput.value = Math.round((parseFloat(element.getAttribute('data-angle')) * 180) / Math.PI);
rotationInput.addEventListener('input', this.handleRotationInputChange);
},
hideRotationControls() {
const rotationControls = document.getElementById('rotation-controls');
const rotationInput = document.getElementById('rotation-input');
rotationControls.style.display = 'none';
rotationInput.addEventListener('input', this.handleRotationInputChange);
},
applyRotationToElement(element, degrees) {
const radians = degrees * (Math.PI / 180); // Convert degrees to radians
// Get current position
const x = parseFloat(element.getAttribute('data-bs-x')) || 0;
const y = parseFloat(element.getAttribute('data-bs-y')) || 0;
// Get the inner canvas (image)
const canvas = element.querySelector('.display-canvas');
if (canvas) {
const originalWidth = parseFloat(canvas.style.width);
const originalHeight = parseFloat(canvas.style.height);
const padding = this.padding; // Access the padding value
// Calculate rotated bounding box dimensions
const cosAngle = Math.abs(Math.cos(radians));
const sinAngle = Math.abs(Math.sin(radians));
const boundingWidth = originalWidth * cosAngle + originalHeight * sinAngle + 2 * padding;
const boundingHeight = originalWidth * sinAngle + originalHeight * cosAngle + 2 * padding;
// Update parent container to fit the rotated bounding box
element.style.width = `${boundingWidth}px`;
element.style.height = `${boundingHeight}px`;
// Center the canvas within the bounding box, accounting for padding
const offsetX = (boundingWidth - originalWidth) / 2 - padding;
const offsetY = (boundingHeight - originalHeight) / 2 - padding;
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) rotate(${radians}rad)`;
}
// Keep the bounding box positioned properly
element.style.transform = `translate(${x}px, ${y}px)`;
element.setAttribute('data-angle', radians);
},
handleRotationInputChange() {
const rotationInput = document.getElementById('rotation-input');
const degrees = parseFloat(rotationInput.value) || 0;
DraggableUtils.applyRotationToElement(DraggableUtils.lastInteracted, degrees);
},
storePageContents() {
var pagesMap = this.documentsMap.get(this.pdfDoc);
if (!pagesMap) {
pagesMap = {};
}
const elements = [...this.boxDragContainer.querySelectorAll('.draggable-canvas')];
const draggablesData = elements.map((el) => {
return {
element: el,
offsetWidth: el.offsetWidth,
offsetHeight: el.offsetHeight,
};
});
elements.forEach((el) => this.boxDragContainer.removeChild(el));
pagesMap[this.pageIndex] = draggablesData;
pagesMap[this.pageIndex + '-offsetWidth'] = this.pdfCanvas.offsetWidth;
pagesMap[this.pageIndex + '-offsetHeight'] = this.pdfCanvas.offsetHeight;
this.documentsMap.set(this.pdfDoc, pagesMap);
},
loadPageContents() {
var pagesMap = this.documentsMap.get(this.pdfDoc);
this.deleteAllDraggableCanvases();
if (!pagesMap) {
return;
}
const draggablesData = pagesMap[this.pageIndex];
if (draggablesData && Array.isArray(draggablesData)) {
draggablesData.forEach((draggableData) => this.boxDragContainer.appendChild(draggableData.element));
}
this.documentsMap.set(this.pdfDoc, pagesMap);
},
async renderPage(pdfDocument, pageIdx) {
this.pdfDoc = pdfDocument ? pdfDocument : this.pdfDoc;
this.pageIndex = pageIdx;
// persist
const page = await this.pdfDoc.getPage(this.pageIndex + 1);
// set the canvas size to the size of the page
if (page.rotate == 90 || page.rotate == 270) {
this.pdfCanvas.width = page.view[3];
this.pdfCanvas.height = page.view[2];
} else {
this.pdfCanvas.width = page.view[2];
this.pdfCanvas.height = page.view[3];
}
// render the page onto the canvas
var renderContext = {
canvasContext: this.pdfCanvas.getContext('2d'),
viewport: page.getViewport({ scale: 1 }),
};
await page.render(renderContext).promise;
//return pdfCanvas.toDataURL();
},
async goToPage(pageIndex) {
this.storePageContents();
await this.renderPage(this.pdfDoc, pageIndex);
this.loadPageContents();
},
async incrementPage() {
if (this.pageIndex < this.pdfDoc.numPages - 1) {
this.storePageContents();
await this.renderPage(this.pdfDoc, this.pageIndex + 1);
this.loadPageContents();
}
},
async decrementPage() {
if (this.pageIndex > 0) {
this.storePageContents();
await this.renderPage(this.pdfDoc, this.pageIndex - 1);
this.loadPageContents();
}
},
async getOverlayedPdfDocument() {
const pdfBytes = await this.pdfDoc.getData();
const pdfDocModified = await PDFLib.PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
});
this.storePageContents();
const pagesMap = this.documentsMap.get(this.pdfDoc);
for (let pageIdx in pagesMap) {
if (pageIdx.includes('offset')) {
continue;
}
const page = pdfDocModified.getPage(parseInt(pageIdx));
let draggablesData = pagesMap[pageIdx];
const offsetWidth = pagesMap[pageIdx + '-offsetWidth'];
const offsetHeight = pagesMap[pageIdx + '-offsetHeight'];
for (const draggableData of draggablesData) {
// Embed the draggable canvas
const draggableElement = draggableData.element.querySelector('.display-canvas');
const response = await fetch(draggableElement.toDataURL());
const draggableImgBytes = await response.arrayBuffer();
const pdfImageObject = await pdfDocModified.embedPng(draggableImgBytes);
// Extract transformation data
const transform = draggableData.element.style.transform || '';
const translateRegex = /translate\((-?\d+(?:\.\d+)?)px,\s*(-?\d+(?:\.\d+)?)px\)/;
const translateMatch = transform.match(translateRegex);
const translateX = translateMatch ? parseFloat(translateMatch[1]) : 0;
const translateY = translateMatch ? parseFloat(translateMatch[2]) : 0;
const childTransform = draggableElement.style.transform || '';
const childTranslateMatch = childTransform.match(translateRegex);
const childOffsetX = childTranslateMatch ? parseFloat(childTranslateMatch[1]) : 0;
const childOffsetY = childTranslateMatch ? parseFloat(childTranslateMatch[2]) : 0;
const rotateAngle = parseFloat(draggableData.element.getAttribute('data-angle')) || 0;
const draggablePositionPixels = {
x: translateX + childOffsetX + this.padding + 2,
y: translateY + childOffsetY + this.padding + 2,
width: parseFloat(draggableElement.style.width),
height: parseFloat(draggableElement.style.height),
angle: rotateAngle, // Store rotation
};
const pageRotation = page.getRotation();
// Normalize page rotation angle
let normalizedAngle = pageRotation.angle % 360;
if (normalizedAngle < 0) {
normalizedAngle += 360;
}
// Determine the viewed page dimensions based on the normalized rotation angle
let viewedPageWidth = (normalizedAngle === 90 || normalizedAngle === 270) ? page.getHeight() : page.getWidth();
let viewedPageHeight = (normalizedAngle === 90 || normalizedAngle === 270) ? page.getWidth() : page.getHeight();
const draggablePositionRelative = {
x: draggablePositionPixels.x / offsetWidth,
y: draggablePositionPixels.y / offsetHeight,
width: draggablePositionPixels.width / offsetWidth,
height: draggablePositionPixels.height / offsetHeight,
angle: draggablePositionPixels.angle,
};
const draggablePositionPdf = {
x: draggablePositionRelative.x * viewedPageWidth,
y: draggablePositionRelative.y * viewedPageHeight,
width: draggablePositionRelative.width * viewedPageWidth,
height: draggablePositionRelative.height * viewedPageHeight,
};
// Calculate position based on normalized page rotation
let x = draggablePositionPdf.x;
let y = viewedPageHeight - draggablePositionPdf.y - draggablePositionPdf.height;
if (normalizedAngle === 90) {
x = draggablePositionPdf.y;
y = draggablePositionPdf.x;
} else if (normalizedAngle === 180) {
x = viewedPageWidth - draggablePositionPdf.x - draggablePositionPdf.width;
y = draggablePositionPdf.y;
} else if (normalizedAngle === 270) {
x = viewedPageHeight - draggablePositionPdf.y - draggablePositionPdf.height;
y = viewedPageWidth - draggablePositionPdf.x - draggablePositionPdf.width;
}
// Convert rotation angle to radians
let pageRotationInRadians = PDFLib.degreesToRadians(normalizedAngle);
const rotationInRadians = pageRotationInRadians - draggablePositionPixels.angle;
// Calculate the center of the image
const imageCenterX = x + draggablePositionPdf.width / 2;
const imageCenterY = y + draggablePositionPdf.height / 2;
// Apply transformations to rotate the image about its center
page.pushOperators(
PDFLib.pushGraphicsState(),
PDFLib.concatTransformationMatrix(1, 0, 0, 1, imageCenterX, imageCenterY), // Translate to center
PDFLib.concatTransformationMatrix(
Math.cos(rotationInRadians),
Math.sin(rotationInRadians),
-Math.sin(rotationInRadians),
Math.cos(rotationInRadians),
0,
0
), // Rotate
PDFLib.concatTransformationMatrix(1, 0, 0, 1, -imageCenterX, -imageCenterY) // Translate back
);
page.drawImage(pdfImageObject, {
x: x,
y: y,
width: draggablePositionPdf.width,
height: draggablePositionPdf.height,
});
// Restore the graphics state
page.pushOperators(PDFLib.popGraphicsState());
}
}
this.loadPageContents();
return pdfDocModified;
},
};
document.addEventListener('DOMContentLoaded', () => {
DraggableUtils.init();
});

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