mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c6ea0f53 | ||
|
|
4418f562c7 | ||
|
|
4f62b00b3f | ||
|
|
c8967d80c0 | ||
|
|
9a282b1f74 | ||
|
|
bd65f36964 | ||
|
|
0b991c7de4 | ||
|
|
edb93c54b8 | ||
|
|
27f4e365d7 | ||
|
|
7fba215fab | ||
|
|
e04158eed6 | ||
|
|
93af41d74b | ||
|
|
c879e54441 | ||
|
|
c987deb31b |
+65
-42
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
- **Enterprise‑grade** - SSO, auditing, and flexible on‑prem 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
+8
-112
@@ -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;
|
||||
}
|
||||
}
|
||||
+372
-566
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
-29
@@ -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
|
||||
*  and converts to 
|
||||
*/
|
||||
private String updateImageReferences(String markdown) {
|
||||
// Match markdown image syntax: 
|
||||
// Only update if the path doesn't already start with images/
|
||||
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+6
-10
@@ -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);
|
||||
|
||||
+3
-4
@@ -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 {
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-14
@@ -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
@@ -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")
|
||||
|
||||
+21
-31
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
-271
@@ -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;
|
||||
}
|
||||
}
|
||||
-8
@@ -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++) {
|
||||
|
||||
+55
-59
@@ -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 =
|
||||
|
||||
+23
-170
@@ -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 {
|
||||
|
||||
+18
-35
@@ -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
|
||||
|
||||
-124
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
-62
@@ -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)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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}",
|
||||
|
||||
+16
-80
@@ -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,
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+5
-20
@@ -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 {
|
||||
|
||||
+32
-32
@@ -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);
|
||||
|
||||
+1
-2
@@ -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();
|
||||
|
||||
|
||||
+5
-5
@@ -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) {
|
||||
|
||||
+6
-10
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -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;
|
||||
|
||||
+5
-13
@@ -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
-20
@@ -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",
|
||||
|
||||
+3
-6
@@ -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 =
|
||||
|
||||
+14
-53
@@ -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;
|
||||
}
|
||||
-6
@@ -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(
|
||||
|
||||
+1
-6
@@ -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;
|
||||
|
||||
+125
-254
@@ -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);
|
||||
|
||||
+9
-17
@@ -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);
|
||||
|
||||
+5
-5
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-23
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -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
Reference in New Issue
Block a user