diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ce3b59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTBACKTRACE: 1 + MUPDF_MSVC_PLATFORM_TOOLSET: v145 + CARGO_BUILD_JOBS: 1 + LIBCLANG_PATH: ${{ github.workspace }}\.tools\python\clang\native + BINDGEN_EXTRA_CLANG_ARGS: '-include "${{ github.workspace }}\scripts\msvc-bindgen.h"' + PDFREE_OCR_DIR: ${{ github.workspace }}\.tools\forms\ocr + +jobs: + test: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install build dependencies + run: | + pip install cmake ninja + Write-Host "Build dependencies installed" + + - name: Bootstrap native build prerequisites + run: ./scripts/bootstrap.ps1 + + - name: Bootstrap offline form OCR resources + run: | + $sevenZip = 'C:\Program Files\7-Zip' + if (!(Test-Path -LiteralPath $sevenZip)) { throw '7-Zip is required to extract the pinned OCR tools.' } + $env:PATH = "$sevenZip;$env:PATH" + $sevenZip | Out-File -FilePath $env:GITHUB_PATH -Append + ./scripts/bootstrap-forms-tools.ps1 + + - name: Check that libclang exists + run: | + if (!(Test-Path -LiteralPath "$env:LIBCLANG_PATH\libclang.dll")) { + Write-Error "libclang.dll not found at $env:LIBCLANG_PATH. Run scripts/bootstrap.ps1 first or provide it." + exit 1 + } + + - name: Run cargo fmt check + run: | + & cargo fmt --all --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Run cargo clippy + run: | + & cargo clippy --workspace --all-targets --locked -- -D warnings + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Run cargo test + run: | + & cargo test --workspace --locked + exit $LASTEXITCODE diff --git a/.gitignore b/.gitignore index f712796..ca860bb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ /benchmarks/runs/ /tests/corpus/downloads/ /*.log +/logs/ __pycache__/ *.pdf diff --git a/Cargo.lock b/Cargo.lock index 92f83ad..50ec0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,9 +88,9 @@ dependencies = [ [[package]] name = "annotate-snippets" -version = "0.12.16" +version = "0.12.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" dependencies = [ "anstyle", "memchr", @@ -105,9 +105,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "log", @@ -116,7 +116,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -199,7 +199,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -230,7 +230,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -241,7 +241,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -256,7 +256,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -276,7 +276,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -427,7 +427,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -464,7 +464,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "tracing", ] @@ -482,9 +482,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -548,9 +548,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -583,9 +583,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -618,7 +618,7 @@ checksum = "93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -678,9 +678,9 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -801,7 +801,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -860,7 +860,7 @@ dependencies = [ "drm-ffi", "drm-fourcc", "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -870,7 +870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" dependencies = [ "drm-sys", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -930,9 +930,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1037,15 +1037,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixed_decimal" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" dependencies = [ "displaydoc", "smallvec", @@ -1095,9 +1095,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1153,7 +1153,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1240,7 +1240,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1300,7 +1300,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -1695,7 +1695,7 @@ dependencies = [ "rowan", "rspolib", "skrifa", - "smol_str 0.3.6", + "smol_str 0.3.7", "strum", "swash", "typed-index-collections", @@ -1763,7 +1763,7 @@ checksum = "73f5a8a076de52f53be80caa826599ed2107b54c9d4a421e66dc06cf893a984c" dependencies = [ "quote", "serde_json", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1872,9 +1872,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1886,9 +1886,9 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +checksum = "1150ad5e272740670464934ee76facda43ef2e11f85c27b7ba76ccb33edf2d05" dependencies = [ "displaydoc", "fixed_decimal", @@ -1903,15 +1903,15 @@ dependencies = [ [[package]] name = "icu_decimal_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" +checksum = "5595ab78370d48abd24a062f3db115d5108dd0b65634352036cae532aebb0df8" [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1923,9 +1923,9 @@ dependencies = [ [[package]] name = "icu_locale_fallback" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" dependencies = [ "icu_locale_core", "icu_locale_fallback_data", @@ -1937,15 +1937,15 @@ dependencies = [ [[package]] name = "icu_locale_fallback_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1957,15 +1957,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_plurals" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +checksum = "2ef046647b445acf95ce0f8083fa292467d3df649959fa9a20f3f8abfd957c5d" dependencies = [ "fixed_decimal", "icu_locale_fallback", @@ -1976,15 +1976,15 @@ dependencies = [ [[package]] name = "icu_plurals_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" +checksum = "d43ff7df2c1a1d41588a6ddcba354d513d4a3e10eec22c06fe56d13c12a848ea" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1997,15 +1997,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2020,9 +2020,9 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" dependencies = [ "icu_collections", "icu_locale_fallback", @@ -2031,14 +2031,15 @@ dependencies = [ "potential_utf", "smallvec", "utf8_iter", + "zerotrie", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" [[package]] name = "idna" @@ -2289,9 +2290,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2318,7 +2319,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2383,9 +2384,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2404,9 +2405,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lyon_algorithms" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805" dependencies = [ "lyon_path", "num-traits", @@ -2424,9 +2425,9 @@ dependencies = [ [[package]] name = "lyon_geom" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" dependencies = [ "arrayvec", "euclid", @@ -2451,9 +2452,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -3038,9 +3039,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -3105,9 +3106,9 @@ dependencies = [ [[package]] name = "parlance" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" [[package]] name = "parley" @@ -3268,7 +3269,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.3", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -3352,9 +3353,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" dependencies = [ "bitflags 2.13.2", "memchr", @@ -3478,9 +3479,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3585,9 +3586,9 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -3641,9 +3642,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -3731,14 +3732,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3755,7 +3756,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3928,9 +3929,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "smithay-client-toolkit" @@ -3968,9 +3969,9 @@ dependencies = [ [[package]] name = "smol_str" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" dependencies = [ "borsh", "serde_core", @@ -4016,7 +4017,7 @@ dependencies = [ "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", - "rustix 1.1.5", + "rustix 1.1.6", "tiny-xlib", "tracing", "wasm-bindgen", @@ -4122,9 +4123,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4139,7 +4140,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4186,7 +4187,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -4242,7 +4243,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4311,9 +4312,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "serde_core", @@ -4328,9 +4329,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "pin-project-lite", ] @@ -4483,9 +4484,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-linebreak" @@ -4577,9 +4578,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.27.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4616,7 +4617,7 @@ checksum = "8191d73ececf73fc94e65809bf0e667525a0bcff9aeeb8d042a4173a508332a1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4681,7 +4682,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4702,7 +4703,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -4715,7 +4716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -4737,7 +4738,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -4825,9 +4826,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5166,15 +5167,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5208,30 +5200,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -5253,12 +5228,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5271,12 +5240,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5289,24 +5252,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5319,12 +5270,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5337,12 +5282,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5355,12 +5294,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5373,12 +5306,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5448,14 +5375,14 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -5505,7 +5432,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -5522,7 +5449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -5592,9 +5519,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5609,7 +5536,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5635,7 +5562,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", @@ -5657,7 +5584,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -5665,9 +5592,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -5691,18 +5618,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -5726,7 +5653,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5744,9 +5671,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "serde", "yoke", @@ -5756,13 +5683,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5794,9 +5721,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -5809,14 +5736,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -5829,6 +5756,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/crates/pdf-engine/src/backend.rs b/crates/pdf-engine/src/backend.rs new file mode 100644 index 0000000..1c5bff4 --- /dev/null +++ b/crates/pdf-engine/src/backend.rs @@ -0,0 +1,172 @@ +//! PDF backend trait abstraction. +//! Defines the operations the coordinator needs from a PDF engine, allowing +//! the backend to be swapped without changing the coordinator or subsystems. +use std::path::Path; +use viewer_core::PageSize; + +/// A page handle returned by the backend. +pub trait PageBackend { + /// Returns the page's width and height in PDF points. + fn bounds(&self) -> Result; + + /// Builds a display list for this page. + fn to_display_list(&self, build: bool) -> Result, String>; +} + +/// A display list handle. +pub trait DisplayListBackend: Send + Sync { + /// Returns the display list's bounding box as (x0, y0, x1, y1). + fn bounds(&self) -> Result<(f32, f32, f32, f32), String>; + + /// Renders the display list to a pixel tile. + fn render( + &self, + width: u32, + height: u32, + rotation: u32, + scale_milli: u32, + x: f32, + y: f32, + ) -> Result; +} + +/// A document handle returned by the backend. +pub trait DocumentBackend { + /// Loads a page by index. + fn load_page(&self, page: i32) -> Result, String>; +} + +/// Open a PDF document from a file path using the MuPDF backend. +pub fn open_document( + path: &Path, + password: Option<&str>, +) -> Result, String> { + crate::backend::mupdf::MupdfDocument::open(path, password) +} + +/// MuPDF implementation of the backend traits. +pub mod mupdf { + use super::*; + use ::mupdf::pdf::PdfDocument; + use ::mupdf::{Colorspace, Device, DisplayList, Document, IRect, Matrix, Page, Pixmap, Rect}; + use std::path::Path; + + pub struct MupdfDocument { + doc: PdfDocument, + } + + impl MupdfDocument { + pub fn open( + path: &Path, + password: Option<&str>, + ) -> Result, String> { + let path_str = path.to_str().ok_or("Path is not valid Unicode")?; + let mut doc = Document::open(path_str) + .and_then(PdfDocument::try_from) + .map_err(|e| e.to_string())?; + if doc.needs_password().map_err(|e| e.to_string())? { + let password = password.ok_or("This PDF requires a password.")?; + if !doc.authenticate(password).map_err(|e| e.to_string())? { + return Err("The password was not accepted. Try again.".into()); + } + } + Ok(Box::new(MupdfDocument { doc })) + } + } + + impl DocumentBackend for MupdfDocument { + fn load_page(&self, page: i32) -> Result, String> { + let page = self.doc.load_page(page).map_err(|e| e.to_string())?; + Ok(Box::new(MupdfPage { page })) + } + } + + pub struct MupdfPage { + page: Page, + } + + impl MupdfPage {} + + impl PageBackend for MupdfPage { + fn bounds(&self) -> Result { + let b = self.page.bounds().map_err(|e| e.to_string())?; + Ok(PageSize::new(b.width(), b.height())) + } + + fn to_display_list(&self, build: bool) -> Result, String> { + let list = self + .page + .to_display_list(build) + .map_err(|e| e.to_string())?; + Ok(Box::new(MupdfDisplayList { list })) + } + } + + pub struct MupdfDisplayList { + list: DisplayList, + } + + impl MupdfDisplayList { + #[cfg(test)] + pub(crate) fn new(list: DisplayList) -> Self { + Self { list } + } + } + + impl DisplayListBackend for MupdfDisplayList { + fn bounds(&self) -> Result<(f32, f32, f32, f32), String> { + let b = self.list.bounds(); + Ok((b.x0, b.y0, b.x1, b.y1)) + } + + fn render( + &self, + width: u32, + height: u32, + _rotation: u32, + scale_milli: u32, + x: f32, + y: f32, + ) -> Result { + let (x0, y0, _, _) = self.bounds()?; + let cs = Colorspace::device_rgb(); + let mut pixmap = Pixmap::new_with_w_h(&cs, width as i32, height as i32, false) + .map_err(|e| e.to_string())?; + pixmap + .clear_with(crate::constants::CLEAR_COLOR.into()) + .map_err(|e| e.to_string())?; + let device = Device::from_pixmap_with_clip( + &pixmap, + IRect::new(0, 0, width as i32, height as i32), + ) + .map_err(|e| e.to_string())?; + let scale = scale_milli as f32 / 1000.0; + let matrix = Matrix::new(scale, 0.0, 0.0, scale, -x0 * scale - x, -y0 * scale - y); + self.list + .run( + &device, + &matrix, + Rect::new(0.0, 0.0, width as f32, height as f32), + ) + .map_err(|e| e.to_string())?; + drop(device); + let stride = pixmap.stride() as usize; + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + for row in pixmap.samples().chunks(stride).take(height as usize) { + for pixel in row[..width as usize * 3].as_chunks::<3>().0 { + rgba.extend_from_slice(&[ + pixel[0], + pixel[1], + pixel[2], + crate::constants::ALPHA_OPAQUE, + ]); + } + } + Ok(viewer_core::PixelTile { + width, + height, + rgba, + }) + } + } +} diff --git a/crates/pdf-engine/src/constants.rs b/crates/pdf-engine/src/constants.rs new file mode 100644 index 0000000..a3c2b67 --- /dev/null +++ b/crates/pdf-engine/src/constants.rs @@ -0,0 +1,398 @@ +//! Centralized magic numbers and limits used across the pdf-engine crate. +//! All limits are documented with their rationale. + +/// Maximum number of pages a document may have before being rejected. +pub const MAX_PAGES: usize = 1_000_000; + +/// Maximum number of pages requested in a single forms query. +pub const MAX_FORM_PAGES: usize = 16; + +/// Maximum number of pages requested in a single links query. +pub const MAX_LINK_PAGES: usize = 16; + +/// Maximum number of pages requested in a single text query. +pub const MAX_TEXT_PAGES: usize = 16; + +/// Maximum number of pages requested in a single notes query. +pub const MAX_NOTES_PAGES: usize = 16; + +/// Maximum number of pages requested in a single thumbnails query. +pub const MAX_THUMBNAIL_PAGES: usize = 32; + +/// Capacity of the render queue used for tile dispatch. +pub const RENDER_QUEUE_CAPACITY: usize = 128; + +/// Maximum tile width or height in pixels. +pub const MAX_TILE_DIMENSION: u32 = 512; + +/// Channel send timeout in milliseconds. +pub const CHANNEL_SEND_TIMEOUT_MILLIS: u64 = 10; + +/// Channel receive timeout in milliseconds. +pub const CHANNEL_RECV_TIMEOUT_MILLIS: u64 = 10; + +/// Wake channel receive timeout in milliseconds. +pub const WAKE_RECV_TIMEOUT_MILLIS: u64 = 2; + +/// Priority assigned to thumbnail render requests. +pub const THUMBNAIL_PRIORITY: u32 = u32::MAX; + +/// Maximum width for thumbnail pages in PDF points. +pub const THUMBNAIL_WIDTH_MAX: f32 = 140.0; + +/// Maximum height for thumbnail pages in PDF points. +pub const THUMBNAIL_HEIGHT_MAX: f32 = 160.0; + +/// Minimum scale factor applied to thumbnails. +pub const THUMBNAIL_SCALE_MIN: f32 = 1.0; + +/// Scale numerator used when computing thumbnail scale from width. +pub const THUMBNAIL_WIDTH_SCALE: f32 = 140.0; + +/// Scale numerator used when computing thumbnail scale from height. +pub const THUMBNAIL_HEIGHT_SCALE: f32 = 160.0; + +/// Factor used to convert scale_milli to a floating-point scale. +pub const SCALE_MILLI_FACTOR: f32 = 1000.0; + +/// Value used to clear a new pixmap (white). +pub const CLEAR_COLOR: u8 = 255; + +/// Alpha value for fully opaque pixels. +pub const ALPHA_OPAQUE: u8 = 255; + +/// Maximum number of annotations processed per page before truncation. +pub const MAX_ANNOTATIONS_PER_PAGE: usize = 10_000; + +/// Maximum number of annotations returned per page. +pub const MAX_ANNOTATIONS_RETURNED: usize = 256; + +/// Maximum total bytes of annotation data per page. +pub const MAX_ANNOTATION_BYTES: usize = 256 * 1024; + +/// Maximum text length for note annotations. +pub const MAX_TEXT_LENGTH: usize = 16_384; + +/// Maximum quad count for highlight annotations. +pub const MAX_QUAD_COUNT: usize = 1000; + +/// Maximum pages supported for editing operations. +pub const MAX_EDIT_PAGES: usize = 100_000; + +/// Maximum number of PDF files that can be inserted in one operation. +pub const MAX_INSERT_PATHS: usize = 16; + +/// Maximum nesting depth for imported PDF object graphs. +pub const MAX_IMPORT_DEPTH: usize = 64; + +/// Maximum number of nodes in an imported object graph. +pub const MAX_IMPORT_NODES: usize = 100_000; + +/// Maximum size of a single imported stream in bytes. +pub const MAX_STREAM_BYTES: usize = 64 * 1024 * 1024; + +/// Maximum total size of all imported streams in bytes. +pub const MAX_TOTAL_STREAM_BYTES: usize = 128 * 1024 * 1024; + +/// Maximum array length in imported objects. +pub const MAX_IMPORT_ARRAY_LEN: usize = 10_000; + +/// Maximum appearance states inspected in a PDF widget dictionary. +pub const MAX_APPEARANCE_STATES: usize = 10_000; + +/// Maximum dictionary length in imported objects. +pub const MAX_IMPORT_DICT_LEN: usize = 2000; + +/// Maximum number of outline entries before truncation. +pub const MAX_OUTLINE_ENTRIES: usize = 4096; + +/// Maximum characters in an outline title. +pub const MAX_OUTLINE_TITLE_CHARS: usize = 512; + +/// Maximum outline tree depth. +pub const MAX_OUTLINE_DEPTH: usize = 32; + +/// Maximum number of links per page before truncation. +pub const MAX_LINKS: usize = 1024; + +/// Maximum URI characters before treating as unsupported. +pub const MAX_URI_CHARS: usize = 8192; + +/// Maximum characters in a truncated URI. +pub const MAX_TRUNCATED_URI_CHARS: usize = 256; + +/// Maximum search query characters. +pub const MAX_SEARCH_QUERY_CHARS: usize = 512; + +/// Maximum glyphs extracted per page. +pub const MAX_GLYPHS: usize = 100_000; + +/// Maximum search hits before truncation. +pub const MAX_HITS: usize = 10_000; + +/// Maximum number of detection pages per run. +pub const MAX_DETECTION_PAGES: usize = 100; + +/// Maximum number of detected fields before aborting. +pub const MAX_DETECTION_FIELDS: usize = 2_000; + +/// Maximum number of detected fields in candidate output. +pub const MAX_CANDIDATE_FIELDS: usize = 512; + +/// OCR timeout in seconds per page. +pub const OCR_TIMEOUT_SECS: u64 = 30; + +/// OCR poll interval in milliseconds. +pub const OCR_POLL_MILLIS: u64 = 20; + +/// OCR output size limit in bytes. +pub const OCR_OUTPUT_LIMIT: usize = 4 * 1024 * 1024 + 1; + +/// Maximum TSV lines parsed from OCR output. +pub const MAX_TSV_LINES: usize = 20_001; + +/// Maximum OCR words per page. +pub const MAX_OCR_WORDS: usize = 10_000; + +/// Dark pixel threshold for deskew (0-255). +pub const DARK_PIXEL_THRESHOLD: u8 = 210; + +/// Minimum points required for deskew analysis. +pub const MIN_POINTS_FOR_DESKEW: usize = 50; + +/// Deskew angle range in degrees (symmetric). +pub const DESKEW_ANGLE_MAX_DEGREES: i32 = 20; + +/// Deskew angle step in degrees. +pub const DESKEW_ANGLE_STEP_DEGREES: f32 = 0.25; + +/// Minimum angle (radians) to consider deskewing. +pub const DESKEW_MIN_ANGLE_RADIANS: f32 = 0.35_f32.to_radians(); + +/// Base threshold for binarization. +pub const THRESHOLD_BASE: u8 = 170; + +/// Offset added to Otsu threshold. +pub const THRESHOLD_OFFSET: i32 = 24; + +/// Minimum threshold after clamping. +pub const THRESHOLD_MIN: u8 = 180; + +/// Maximum threshold after clamping. +pub const THRESHOLD_MAX: u8 = 235; + +/// Downsample factor for raster shape analysis. +pub const RASTER_DOWNSCALE_FACTOR: u32 = 2; + +/// Maximum lookback when pairing raster runs. +pub const MAX_RUNS_LOOKBACK: usize = 64; + +/// Maximum runs before stopping raster analysis. +pub const MAX_RUNS: usize = 10_000; + +/// Maximum lookahead when pairing runs. +pub const MAX_PAIR_LOOKAHEAD: usize = 256; + +/// Minimum shape height in PDF points. +pub const MIN_SHAPE_HEIGHT: f32 = 4.0; + +/// Maximum shape height in PDF points. +pub const MAX_SHAPE_HEIGHT: f32 = 60.0; + +/// Width range for shape detection (PDF points). +pub const SHAPE_WIDTH_RANGE: (f32, f32) = (35.0, 450.0); + +/// Factor for round shape detection limit. +pub const ROUND_LIMIT_FACTOR: f32 = 24.0; + +/// Minimum dimension for round shapes. +pub const MIN_ROUND_DIMENSION: f32 = 5.0; + +/// Maximum corner coordinate difference for pairing. +pub const MAX_CORNER_DIFF: usize = 3; + +/// Minimum fill ratio for round shape acceptance. +pub const MIN_FILL_RATIO: usize = 65; + +/// Crop top padding in PDF points. +pub const CROP_TOP_PAD: f32 = 50.0; + +/// Crop right padding in PDF points. +pub const CROP_RIGHT_PAD: f32 = 180.0; + +/// Crop bottom padding in PDF points. +pub const CROP_BOTTOM_PAD: f32 = 30.0; + +/// Crop left offset in PDF points. +pub const CROP_LEFT_OFFSET: f32 = 300.0; + +/// OCR downsample factor for the primary pass. +pub const OCR_FACTOR_PRIMARY: u32 = 2; + +/// OCR downsample factor for the secondary pass. +pub const OCR_FACTOR_SECONDARY: u32 = 1; + +/// OCR page segmentation mode for the primary pass. +pub const OCR_PSM_PRIMARY: &str = "3"; + +/// OCR page segmentation mode for the secondary pass. +pub const OCR_PSM_SECONDARY: &str = "11"; + +/// Base confidence score for detected fields with a label. +pub const SCORE_WITH_LABEL: f32 = 0.76; + +/// Base confidence score for detected fields without a label. +pub const SCORE_WITHOUT_LABEL: f32 = 0.58; + +/// OCR confidence weight added to the base score. +pub const SCORE_OCR_WEIGHT: f32 = 0.22; + +/// Maximum score for unclosed shapes without native confirmation. +pub const SCORE_MAX_UNCLOSED: f32 = 0.86; + +/// Score threshold below which a field is marked for review. +pub const REVIEW_THRESHOLD: f32 = 0.9; + +/// Overlap threshold for blocked-field exclusion. +pub const BLOCKED_OVERLAP_THRESHOLD: f32 = 0.2; + +/// Reverse overlap threshold for blocked-field exclusion. +pub const BLOCKED_OVERLAP_REVERSE_THRESHOLD: f32 = 0.5; + +/// Overlap threshold between found fields. +pub const FOUND_OVERLAP_THRESHOLD: f32 = 0.6; + +/// Overlap threshold between word and shape rects. +pub const WORD_OVERLAP_THRESHOLD: f32 = 0.6; + +/// Reverse word overlap threshold. +pub const WORD_OVERLAP_REVERSE_THRESHOLD: f32 = 0.8; + +/// Minimum width for open (non-closed) shapes. +pub const MIN_OPEN_SHAPE_WIDTH: f32 = 60.0; + +/// Minimum word character count for overlap matching. +pub const MIN_WORD_CHARS: usize = 2; + +/// Grid detection left/right tolerance in PDF points. +pub const GRID_TOLERANCE: f32 = 3.0; + +/// Grid detection height tolerance in PDF points. +pub const GRID_HEIGHT_TOLERANCE: f32 = 3.0; + +/// Grid detection top range (min, max) in PDF points. +pub const GRID_TOP_RANGE: (f32, f32) = (3.0, 100.0); + +/// Grid height factor for grouping. +pub const GRID_HEIGHT_FACTOR: f32 = 1.5; + +/// Minimum rows for grid detection. +pub const GRID_MIN_ROWS: usize = 2; + +/// Checkbox vertical tolerance in PDF points. +pub const CHECKBOX_VERTICAL_TOLERANCE: f32 = 14.0; + +/// Checkbox right-side maximum in PDF points. +pub const CHECKBOX_RIGHT_MAX: f32 = 180.0; + +/// Checkbox left-side maximum in PDF points. +pub const CHECKBOX_LEFT_MAX: f32 = 300.0; + +/// Checkbox above maximum in PDF points. +pub const CHECKBOX_ABOVE_MAX: f32 = 22.0; + +/// Checkbox above maximum (alternate) in PDF points. +pub const CHECKBOX_ABOVE_MAX_ALT: f32 = 28.0; + +/// Checkbox left minimum in PDF points. +pub const CHECKBOX_LEFT_MIN: f32 = 10.0; + +/// Same-line vertical tolerance in PDF points. +pub const SAME_LINE_TOLERANCE: f32 = 5.0; + +/// Direct label vertical tolerance in PDF points. +pub const DIRECT_LABEL_VERTICAL_TOLERANCE: f32 = 12.0; + +/// Direct label left maximum in PDF points. +pub const DIRECT_LABEL_LEFT_MAX: f32 = 300.0; + +/// Radio group top maximum in PDF points. +pub const RADIO_GROUP_TOP_MAX: f32 = 180.0; + +/// Radio group left maximum in PDF points. +pub const RADIO_GROUP_LEFT_MAX: f32 = 30.0; + +/// Radio group top difference threshold in PDF points. +pub const RADIO_GROUP_TOP_DIFF: f32 = 5.0; + +/// Maximum options per form field. +pub const MAX_FIELD_OPTIONS: usize = 256; + +/// Maximum suppressed fields list length. +pub const MAX_SUPPRESSED_FIELDS: usize = 2_000; + +/// Maximum widgets per page preview. +pub const MAX_WIDGETS_PER_PAGE: usize = 512; + +/// Maximum selection values parsed. +pub const MAX_SELECTION_VALUES: usize = 256; + +/// Maximum decimals for form fields. +pub const MAX_DECIMALS: u8 = 12; + +/// Default decimals for form fields. +pub const DEFAULT_DECIMALS: u8 = 2; + +/// Default date format for form fields. +pub const DEFAULT_DATE_FORMAT: &str = "YYYY-MM-DD"; + +/// Maximum radio kids per group. +pub const MAX_RADIO_KIDS: usize = 2_000; + +/// Maximum button group kids. +pub const MAX_BUTTON_KIDS: usize = 10_000; + +/// Maximum fields created per operation. +pub const MAX_CREATE_FIELDS: usize = 2_000; + +/// OCR source DPI for rendering pages. +pub const OCR_SOURCE_DPI: f32 = 300.0; + +/// PDF standard DPI. +pub const PDF_STANDARD_DPI: f32 = 72.0; + +/// Maximum total pixels for detection rendering. +pub const DETECTION_MAX_PIXELS: f64 = 18_000_000.0; + +/// Maximum dimension for detection rendering. +pub const DETECTION_MAX_DIMENSION: f32 = 8192.0; + +/// Minimum scale for detection rendering. +pub const DETECTION_MIN_SCALE: f32 = 0.5; + +/// Maximum words per page during detection. +pub const MAX_WORDS: usize = 10_000; + +/// Maximum value length for detected words. +pub const MAX_VALUE_LEN: usize = 4096; + +/// Maximum drawings per page during detection. +pub const MAX_DRAWINGS: usize = 10_000; + +/// Minimum curve count for circle detection. +pub const MIN_CURVE_COUNT: usize = 4; + +/// Circle width range in PDF points. +pub const CIRCLE_WIDTH_RANGE: (f32, f32) = (5.0, 22.0); + +/// Maximum width-height difference for circles. +pub const MAX_CIRCLE_DIFF: f32 = 3.0; + +/// Maximum items per drawing. +pub const MAX_ITEMS_PER_DRAWING: usize = 1024; + +/// Minimum line length in PDF points. +pub const MIN_LINE_LENGTH: f32 = 35.0; + +/// Maximum scratch directory attempts. +pub const MAX_SCRATCH_ATTEMPTS: usize = 100; diff --git a/crates/pdf-engine/src/coordinator.rs b/crates/pdf-engine/src/coordinator.rs index dbe2c13..2c4d4ea 100644 --- a/crates/pdf-engine/src/coordinator.rs +++ b/crates/pdf-engine/src/coordinator.rs @@ -1,7 +1,8 @@ +use crate::backend::{DisplayListBackend, DocumentBackend, open_document}; use crate::{DocumentInfo, EngineError, Event, FileAction, FileCommandAction, Shared, raster}; use crossbeam_channel::{Receiver, Sender, bounded}; use mupdf::{ - DisplayList, Document, + Document, pdf::{PdfDocument, Permission}, }; use std::{ @@ -15,7 +16,7 @@ use viewer_core::{PageSize, PixelTile, RenderQueue, RenderRequest}; struct Job { content: u64, request: RenderRequest, - list: Arc, + list: Arc, thumbnail: bool, } struct Completed { @@ -28,7 +29,10 @@ struct Completed { fn emit(shared: &Shared, events: &Sender, mut event: Event) -> bool { loop { - match events.send_timeout(event, Duration::from_millis(10)) { + match events.send_timeout( + event, + Duration::from_millis(crate::constants::CHANNEL_SEND_TIMEOUT_MILLIS), + ) { Ok(()) => return true, Err(crossbeam_channel::SendTimeoutError::Timeout(value)) => { if shared.stopping.load(Ordering::Acquire) { @@ -66,7 +70,8 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender }; let valid = valid && shared.current_content(job.request.key.document, job.content); - let pixels = valid.then(|| raster::render(&job.list, job.request.key)); + let pixels = + valid.then(|| raster::render(job.list.as_ref(), job.request.key)); let result = Completed { content: job.content, thumbnail: job.thumbnail, @@ -87,6 +92,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender drop(completed); drop(job_rx); let mut document: Option = None; + let mut backend: Option> = None; let mut detection: Option = None; let mut detection_done: Option<(u64, usize, Vec)> = None; let mut can_copy = false; @@ -105,10 +111,10 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender let mut document_id = 0; let mut geometry: Vec> = Vec::new(); let mut cursor = 0; - let mut lists: HashMap> = HashMap::new(); + let mut lists: HashMap> = HashMap::new(); let mut inflight = HashSet::new(); let mut deferred_results = std::collections::VecDeque::new(); - let mut queue = RenderQueue::new(128); + let mut queue = RenderQueue::new(crate::constants::RENDER_QUEUE_CAPACITY); while !shared.stopping.load(Ordering::Acquire) { let desired = { @@ -130,6 +136,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender navigation = crate::navigation::Navigation::default(); lists.clear(); document = None; + backend = None; geometry.clear(); queue.replace(Vec::new()); document_id = open.document; @@ -164,7 +171,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender let count = doc .page_count() .map_err(|e| EngineError::InvalidDocument(e.to_string()))?; - if count <= 0 || count > 1_000_000 { + if count <= 0 || count as usize > crate::constants::MAX_PAGES { return Err(EngineError::InvalidDocument( "Page count must be between 1 and 1,000,000".into(), )); @@ -201,6 +208,10 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender geometry[0] = Some(info.first_page); cursor = 1; document = Some(doc); + // Create the backend trait abstraction for the opened document. + if let Ok(b) = open_document(&open.path, open.password.as_deref()) { + backend = Some(b); + } emit( &shared, &events, @@ -578,7 +589,9 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender while deferred_results.len() < inflight.len() && !shared.stopping.load(Ordering::Acquire) { - match results.recv_timeout(Duration::from_millis(10)) { + match results.recv_timeout(Duration::from_millis( + crate::constants::CHANNEL_RECV_TIMEOUT_MILLIS, + )) { Ok(result) => deferred_results.push_back(result), Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} Err(_) => break, @@ -588,10 +601,11 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender Err("Document changed before rename completed".into()) } else { lists.clear(); + drop(backend.take()); drop(document.take()); let (baseline, result) = prepared.commit(&source_path); document = Some(baseline); - match result { + let result = match result { Ok(path) => { source_path = path.clone(); if let Some(h) = journal.as_mut() { @@ -610,7 +624,10 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender source_path = failure.path; Err(failure.message) } - } + }; + backend = open_document(&source_path, session_password.as_deref()) + .ok(); + result } } } @@ -627,7 +644,9 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender while deferred_results.len() < inflight.len() && !shared.stopping.load(Ordering::Acquire) { - match results.recv_timeout(Duration::from_millis(10)) { + match results.recv_timeout(Duration::from_millis( + crate::constants::CHANNEL_RECV_TIMEOUT_MILLIS, + )) { Ok(result) => deferred_results.push_back(result), Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} Err(_) => break, @@ -639,6 +658,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender // Release all page/document handles before Windows moves the open file. lists.clear(); notes_cache.clear(); + drop(backend.take()); document = None; let moved = crate::files::move_no_replace(&source_path, &target); @@ -651,7 +671,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender &reopen_path, session_password.as_deref(), ); - match reopened { + let result = match reopened { Ok(doc) => { document = Some(doc); match moved { @@ -700,7 +720,15 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender } } } + }; + if document.is_some() { + backend = open_document( + &source_path, + session_password.as_deref(), + ) + .ok(); } + result } } } @@ -754,7 +782,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender let pages: Vec<_> = pages .into_iter() .filter(|p| *p < geometry.len()) - .take(16) + .take(crate::constants::MAX_NOTES_PAGES) .collect(); annotation_cache.retain(|p, _| pages.contains(p)); annotation_queue = pages.into(); @@ -765,7 +793,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender let pages: Vec<_> = pages .into_iter() .filter(|p| *p < geometry.len()) - .take(16) + .take(crate::constants::MAX_NOTES_PAGES) .collect(); notes_cache.retain(|p, _| pages.contains(p)); notes_queue = pages.into(); @@ -1085,11 +1113,16 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender } if let std::collections::hash_map::Entry::Vacant(entry) = lists.entry(page) { let started = Instant::now(); - match doc.load_page(page as i32).and_then(|p| { - let bounds = p.bounds()?; - let list = p.to_display_list(true)?; - Ok((PageSize::new(bounds.width(), bounds.height()), list)) - }) { + let result = backend + .as_ref() + .ok_or_else(|| "PDF backend is unavailable".to_string()) + .and_then(|backend| backend.load_page(page as i32)) + .and_then(|page| { + let size = page.bounds()?; + let list = page.to_display_list(true)?; + Ok((size, list)) + }); + match result { Ok((size, list)) => { if geometry[page] != Some(size) { geometry[page] = Some(size); @@ -1102,7 +1135,7 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender }, ); } - entry.insert(Arc::new(list)); + entry.insert(Arc::from(list)); emit( &shared, &events, @@ -1157,37 +1190,50 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender ); continue; } - let result = doc.load_page(page as i32).and_then(|p| { - let bounds = p.bounds()?; - let scale = (140.0 / bounds.width()) - .min(160.0 / bounds.height()) - .min(1.0); - let scale_milli = (scale * 1000.0).floor().max(1.0) as u32; - let scale = scale_milli as f32 / 1000.0; - let key = viewer_core::TileKey { - document: document_id, - page, - scale_milli, - rotation: 0, - x: 0, - y: 0, - width: (bounds.width() * scale).ceil().clamp(1.0, 140.0) as u32, - height: (bounds.height() * scale).ceil().clamp(1.0, 160.0) as u32, - }; - Ok((key, p.to_display_list(true)?)) - }); + let result = backend + .as_ref() + .ok_or_else(|| "PDF backend is unavailable".to_string()) + .and_then(|backend| backend.load_page(page as i32)) + .and_then(|page_handle| { + let bounds = page_handle.bounds()?; + let scale = (crate::constants::THUMBNAIL_WIDTH_SCALE / bounds.width) + .min(crate::constants::THUMBNAIL_HEIGHT_SCALE / bounds.height) + .min(crate::constants::THUMBNAIL_SCALE_MIN); + let scale_milli = (scale * crate::constants::SCALE_MILLI_FACTOR) + .floor() + .max(crate::constants::THUMBNAIL_SCALE_MIN) + as u32; + let scale = scale_milli as f32 / crate::constants::SCALE_MILLI_FACTOR; + let key = viewer_core::TileKey { + document: document_id, + page, + scale_milli, + rotation: 0, + x: 0, + y: 0, + width: (bounds.width * scale).ceil().clamp( + crate::constants::THUMBNAIL_SCALE_MIN, + crate::constants::THUMBNAIL_WIDTH_MAX, + ) as u32, + height: (bounds.height * scale).ceil().clamp( + crate::constants::THUMBNAIL_SCALE_MIN, + crate::constants::THUMBNAIL_HEIGHT_MAX, + ) as u32, + }; + Ok((key, page_handle.to_display_list(true)?)) + }); match result { Ok((key, list)) => { let request = RenderRequest { key, generation: 0, - priority: u32::MAX, + priority: crate::constants::THUMBNAIL_PRIORITY, }; inflight.insert((key, 0, true, content)); let _ = jobs.send(Job { content, request, - list: Arc::new(list), + list: Arc::from(list), thumbnail: true, }); } @@ -1236,7 +1282,9 @@ pub(super) fn run(shared: Arc, wake: Receiver<()>, events: Sender cursor += 1; } } - let _ = wake.recv_timeout(Duration::from_millis(2)); + let _ = wake.recv_timeout(Duration::from_millis( + crate::constants::WAKE_RECV_TIMEOUT_MILLIS, + )); } drop(jobs); // Dropping the receiver releases workers even if shutdown finds completed tiles queued. diff --git a/crates/pdf-engine/src/detection.rs b/crates/pdf-engine/src/detection.rs index 993e99a..0ae574b 100644 --- a/crates/pdf-engine/src/detection.rs +++ b/crates/pdf-engine/src/detection.rs @@ -71,7 +71,7 @@ impl Detection { generation: u64, pages: Vec, ) -> Result { - if pages.is_empty() || pages.len() > 100 { + if pages.is_empty() || pages.len() > crate::constants::MAX_DETECTION_PAGES { return Err("Detect 1–100 pages per run; use page ranges for larger documents.".into()); } Ok(Self { @@ -105,7 +105,7 @@ impl Detection { let _ = handle.join(); self.found.extend(result?); self.processed += 1; - if self.found.len() > 2_000 { + if self.found.len() > crate::constants::MAX_DETECTION_FIELDS { return Err( "Detection exceeded 2,000 fields; narrow the page range.".into() ); @@ -139,10 +139,13 @@ impl Detection { fn snapshot(doc: &PdfDocument, page: usize, name_prefix: String) -> Result { let native = doc.load_page(page as i32).map_err(err)?; let bounds = native.bounds().map_err(err)?; - let scale = (300. / 72_f32) - .min((18_000_000. / (bounds.width() * bounds.height())).sqrt()) - .min(8192. / bounds.width().max(bounds.height())); - if !scale.is_finite() || scale < 0.5 { + let scale = (crate::constants::OCR_SOURCE_DPI / crate::constants::PDF_STANDARD_DPI) + .min( + (crate::constants::DETECTION_MAX_PIXELS / (bounds.width() * bounds.height()) as f64) + .sqrt() as f32, + ) + .min(crate::constants::DETECTION_MAX_DIMENSION / bounds.width().max(bounds.height())); + if !scale.is_finite() || scale < crate::constants::DETECTION_MIN_SCALE { return Err("Page dimensions exceed field-detection limits.".into()); } let pix = native @@ -177,7 +180,7 @@ fn snapshot(doc: &PdfDocument, page: usize, name_prefix: String) -> Result 10_000 { + if words.len() > crate::constants::MAX_WORDS { break; } let chars = line @@ -200,16 +203,24 @@ fn snapshot(doc: &PdfDocument, page: usize, name_prefix: String) -> Result= 4 + >= crate::constants::MIN_CURVE_COUNT { let r = drawing.rect; - if (5.0..=22.0).contains(&r.width()) && (r.width() - r.height()).abs() < 3.0 { + if r.width() >= crate::constants::CIRCLE_WIDTH_RANGE.0 + && r.width() <= crate::constants::CIRCLE_WIDTH_RANGE.1 + && (r.width() - r.height()).abs() < crate::constants::MAX_CIRCLE_DIFF + { circles.push(TextRect { left: r.x0 - bounds.x0, top: r.y0 - bounds.y0, @@ -218,7 +229,11 @@ fn snapshot(doc: &PdfDocument, page: usize, name_prefix: String) -> Result vectors.push(TextRect { left: rect.x0 - bounds.x0, @@ -226,7 +241,10 @@ fn snapshot(doc: &PdfDocument, page: usize, name_prefix: String) -> Result 35. => { + DrawingItem::Line(a, b) + if (a.y - b.y).abs() < 1. + && (a.x - b.x).abs() > crate::constants::MIN_LINE_LENGTH => + { vectors.push(TextRect { left: a.x.min(b.x) - bounds.x0, top: a.y.min(b.y) - bounds.y0, @@ -283,7 +301,7 @@ fn collect_text_line( fn flush_word(words: &mut Vec, value: &mut String, rect: &mut Option) { if let Some(rect) = rect.take() && !value.trim().is_empty() - && words.len() <= 10_000 + && words.len() <= crate::constants::MAX_WORDS { words.push(Word { text: std::mem::take(value).trim().into(), @@ -324,7 +342,7 @@ fn collect_text_line( } else if c.is_whitespace() { flush_word(words, &mut value, &mut word_rect); } else { - if value.len() > 4096 { + if value.len() > crate::constants::MAX_VALUE_LEN { flush_word(words, &mut value, &mut word_rect); } value.push(c); @@ -337,7 +355,7 @@ fn collect_text_line( struct Scratch(PathBuf); impl Scratch { fn new() -> Result { - for n in 0..100 { + for n in 0..crate::constants::MAX_SCRATCH_ATTEMPTS { let path = std::env::temp_dir().join(format!( "pdfree-ocr-{}-{}-{n}", std::process::id(), @@ -427,7 +445,9 @@ fn ocr(input: &PageInput, factor: u32, psm: &str, cancel: &AtomicBool) -> Result let mut child = cmd.spawn().map_err(err)?; let started = Instant::now(); loop { - if cancel.load(Ordering::Acquire) || started.elapsed() > Duration::from_secs(30) { + if cancel.load(Ordering::Acquire) + || started.elapsed() > Duration::from_secs(crate::constants::OCR_TIMEOUT_SECS) + { let _ = child.kill(); let _ = child.wait(); return Err("OCR cancelled or exceeded its 30-second page limit.".into()); @@ -441,22 +461,22 @@ fn ocr(input: &PageInput, factor: u32, psm: &str, cancel: &AtomicBool) -> Result } break; } - thread::sleep(Duration::from_millis(20)); + thread::sleep(Duration::from_millis(crate::constants::OCR_POLL_MILLIS)); } let mut text = String::new(); File::open(output.with_extension("tsv")) .map_err(err)? - .take(4 * 1024 * 1024 + 1) + .take(crate::constants::OCR_OUTPUT_LIMIT as u64) .read_to_string(&mut text) .map_err(err)?; - if text.len() > 4 * 1024 * 1024 { + if text.len() > crate::constants::OCR_OUTPUT_LIMIT - 1 { return Err("OCR output exceeded the safe page limit.".into()); } parse_tsv(&text, input.scale / factor as f32) } fn parse_tsv(text: &str, scale: f32) -> Result> { let mut words = vec![]; - for line in text.lines().skip(1).take(20_001) { + for line in text.lines().skip(1).take(crate::constants::MAX_TSV_LINES) { let p = line.splitn(12, '\t').collect::>(); if p.len() != 12 || p[0] != "5" @@ -485,7 +505,7 @@ fn parse_tsv(text: &str, scale: f32) -> Result> { confidence, }); } - if words.len() > 10_000 { + if words.len() > crate::constants::MAX_OCR_WORDS { return Err("OCR page exceeds 10,000 words.".into()); } Ok(words) @@ -528,19 +548,23 @@ fn deskew(input: &mut PageInput, cancel: &AtomicBool) -> Result { let mut points = vec![]; for y in (0..h).step_by(step) { for x in (0..w).step_by(step) { - let dark = (y..(y + step).min(h)) - .any(|yy| (x..(x + step).min(w)).any(|xx| input.gray[yy * w + xx] < 210)); + let dark = (y..(y + step).min(h)).any(|yy| { + (x..(x + step).min(w)) + .any(|xx| input.gray[yy * w + xx] < crate::constants::DARK_PIXEL_THRESHOLD) + }); if dark { points.push((x as f32 / step as f32, y as f32 / step as f32)); } } } - if points.len() < 50 { + if points.len() < crate::constants::MIN_POINTS_FOR_DESKEW { return Ok(0.); } let mut best = (0_f32, 0_f64); - for degree in -20..=20 { - let angle = (degree as f32 * 0.25).to_radians(); + for degree in + -crate::constants::DESKEW_ANGLE_MAX_DEGREES..=crate::constants::DESKEW_ANGLE_MAX_DEGREES + { + let angle = (degree as f32 * crate::constants::DESKEW_ANGLE_STEP_DEGREES).to_radians(); let (s, c) = angle.sin_cos(); let mut rows = vec![0_u32; h / step + w / step + 256]; for (x, y) in &points { @@ -555,7 +579,7 @@ fn deskew(input: &mut PageInput, cancel: &AtomicBool) -> Result { } } let angle = best.0; - if angle.abs() < 0.35_f32.to_radians() { + if angle.abs() < crate::constants::DESKEW_MIN_ANGLE_RADIANS { return Ok(0.); } let (s, c) = angle.sin_cos(); @@ -592,13 +616,18 @@ fn label(words: &[Word], r: TextRect, checkbox: bool) -> Option<(String, f32)> { let left = r.left - w.rect.right; let above = r.top - w.rect.bottom; let right = w.rect.left - r.right; - let distance = if checkbox && (-2. ..180.).contains(&right) && vertical < 14. { + let distance = if checkbox + && (-2. ..=crate::constants::CHECKBOX_RIGHT_MAX).contains(&right) + && vertical < crate::constants::CHECKBOX_VERTICAL_TOLERANCE + { right + vertical * 2. - } else if (-3. ..300.).contains(&left) && vertical < 22. { + } else if (-3. ..=crate::constants::CHECKBOX_LEFT_MAX).contains(&left) + && vertical < crate::constants::CHECKBOX_ABOVE_MAX + { left + vertical * 2. - } else if (-3. ..28.).contains(&above) + } else if (-3. ..=crate::constants::CHECKBOX_ABOVE_MAX_ALT).contains(&above) && w.rect.left < r.right - && w.rect.right > r.left - 10. + && w.rect.right > r.left - crate::constants::CHECKBOX_LEFT_MIN { above * 2. + (w.rect.left - r.left).abs() * 0.2 } else { @@ -612,7 +641,7 @@ fn label(words: &[Word], r: TextRect, checkbox: bool) -> Option<(String, f32)> { let line_y = first.rect.top; let mut same = near .iter() - .filter(|(_, w)| (w.rect.top - line_y).abs() < 5.) + .filter(|(_, w)| (w.rect.top - line_y).abs() < crate::constants::SAME_LINE_TOLERANCE) .map(|(_, w)| *w) .collect::>(); same.sort_by(|a, b| a.rect.left.total_cmp(&b.rect.left)); @@ -650,7 +679,7 @@ struct Shape { fn raster_shapes(input: &PageInput) -> Vec { // Downsample to ~150 DPI. Group horizontal runs; pair box edges only when // both vertical sides are present, so letters and table text are excluded. - let factor = 2_u32; + let factor = crate::constants::RASTER_DOWNSCALE_FACTOR; let w = (input.width / factor) as usize; let h = (input.height / factor) as usize; let scale = input.scale / factor as f32; @@ -664,7 +693,8 @@ fn raster_shapes(input: &PageInput) -> Vec { .enumerate() .map(|(i, n)| i as f64 * (*n as f64)) .sum::(); - let (mut weight, mut sum_bg, mut best, mut threshold) = (0_f64, 0_f64, 0_f64, 170_u8); + let (mut weight, mut sum_bg, mut best, mut threshold) = + (0_f64, 0_f64, 0_f64, crate::constants::THRESHOLD_BASE); for (i, count) in hist.iter().enumerate() { weight += *count as f64; if weight == 0. || weight == total { @@ -676,7 +706,10 @@ fn raster_shapes(input: &PageInput) -> Vec { * (sum_bg / weight - (sum - sum_bg) / (total - weight)).powi(2); if variance > best { best = variance; - threshold = (i + 24).clamp(180, 235) as u8; + threshold = (i + crate::constants::THRESHOLD_OFFSET as usize).clamp( + crate::constants::THRESHOLD_MIN as usize, + crate::constants::THRESHOLD_MAX as usize, + ) as u8; } } let dark = |x: usize, y: usize| { @@ -706,9 +739,14 @@ fn raster_shapes(input: &PageInput) -> Vec { x += 1; } if (end - start) as f32 / scale >= 5. { - if let Some(last) = runs.iter_mut().rev().take(64).find(|(a, b, c)| { - y - *c <= 3 && start.abs_diff(*a) <= 3 && end.abs_diff(*b) <= 3 - }) { + if let Some(last) = runs + .iter_mut() + .rev() + .take(crate::constants::MAX_RUNS_LOOKBACK) + .find(|(a, b, c)| { + y - *c <= 3 && start.abs_diff(*a) <= 3 && end.abs_diff(*b) <= 3 + }) + { last.2 = y; } else { runs.push((start, end, y)); @@ -716,7 +754,7 @@ fn raster_shapes(input: &PageInput) -> Vec { } x += 1; } - if runs.len() > 10_000 { + if runs.len() > crate::constants::MAX_RUNS { break; } } @@ -725,18 +763,26 @@ fn raster_shapes(input: &PageInput) -> Vec { for (i, (x1, x2, y)) in runs.iter().copied().enumerate() { let width = (x2 - x1) as f32 / scale; let mut closed = false; - for (j, (a, b, bottom)) in runs.iter().copied().enumerate().skip(i + 1).take(256) { + for (j, (a, b, bottom)) in runs + .iter() + .copied() + .enumerate() + .skip(i + 1) + .take(crate::constants::MAX_PAIR_LOOKAHEAD) + { if bottom <= y { continue; } let height = (bottom - y) as f32 / scale; - if height < 4. { + if height < crate::constants::MIN_SHAPE_HEIGHT { continue; } - if height > 60. { + if height > crate::constants::MAX_SHAPE_HEIGHT { break; } - if x1.abs_diff(a) > 4 || x2.abs_diff(b) > 4 { + if x1.abs_diff(a) > crate::constants::MAX_CORNER_DIFF + || x2.abs_diff(b) > crate::constants::MAX_CORNER_DIFF + { continue; } let sides = (y..=bottom) @@ -747,7 +793,7 @@ fn raster_shapes(input: &PageInput) -> Vec { .count() as f32 / (bottom - y + 1) as f32; if sides > 0.75 - && ((width >= 35. && height >= 10.) + && ((width >= crate::constants::SHAPE_WIDTH_RANGE.0 && height >= 10.) || ((width - height).abs() < 4. && width <= 22.)) { shapes.push(Shape { @@ -768,7 +814,11 @@ fn raster_shapes(input: &PageInput) -> Vec { break; } } - if !closed && !paired.contains(&i) && (35. ..=450.).contains(&width) { + if !closed + && !paired.contains(&i) + && width >= crate::constants::SHAPE_WIDTH_RANGE.0 + && width <= crate::constants::SHAPE_WIDTH_RANGE.1 + { shapes.push(Shape { rect: TextRect { left: x1 as f32 / scale, @@ -786,7 +836,7 @@ fn raster_shapes(input: &PageInput) -> Vec { // Round isolated outlines require explicit grouping text before becoming // radio fields. OCR occupancy rejects letters and existing printed marks. let mut visited = vec![false; w * h]; - let limit = (24.0 * scale) as usize; + let limit = (crate::constants::ROUND_LIMIT_FACTOR * scale) as usize; for y in 1..h - 1 { for x in 1..w - 1 { let start = y * w + x; @@ -822,9 +872,9 @@ fn raster_shapes(input: &PageInput) -> Vec { let (cw, ch) = (r - l + 1, b - t + 1); if cw > limit || ch > limit - || cw < (5.0 * scale) as usize - || ch < (5.0 * scale) as usize - || cw.abs_diff(ch) > 3 + || cw < (crate::constants::MIN_ROUND_DIMENSION * scale) as usize + || ch < (crate::constants::MIN_ROUND_DIMENSION * scale) as usize + || cw.abs_diff(ch) > crate::constants::MAX_CORNER_DIFF { continue; } @@ -832,7 +882,10 @@ fn raster_shapes(input: &PageInput) -> Vec { .iter() .filter(|(xx, yy)| dark(*xx, *yy)) .count(); - if corners == 0 && !dark((l + r) / 2, (t + b) / 2) && count * 100 / (cw * ch) < 65 { + if corners == 0 + && !dark((l + r) / 2, (t + b) / 2) + && count * 100 / (cw * ch) < crate::constants::MIN_FILL_RATIO + { shapes.push(Shape { rect: TextRect { left: l as f32 / scale, @@ -907,28 +960,32 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { if !check && !sized { continue; } - if input - .blocked - .iter() - .any(|b| overlap(r, *b) > 0.2 || overlap(*b, r) > 0.5) - || found - .iter() - .any(|f| overlap(r, f.spec.rect) > 0.6 || overlap(f.spec.rect, r) > 0.6) - { + if input.blocked.iter().any(|b| { + overlap(r, *b) > crate::constants::BLOCKED_OVERLAP_THRESHOLD + || overlap(*b, r) > crate::constants::BLOCKED_OVERLAP_REVERSE_THRESHOLD + }) || found.iter().any(|f| { + overlap(r, f.spec.rect) > crate::constants::FOUND_OVERLAP_THRESHOLD + || overlap(f.spec.rect, r) > crate::constants::FOUND_OVERLAP_THRESHOLD + }) { continue; } if words.iter().any(|word| { !(check - && word.text.chars().count() <= 2 - && overlap(r, word.rect) > 0.6 - && overlap(word.rect, r) > 0.8) - && (overlap(word.rect, r) > 0.6 || overlap(r, word.rect) > 0.6) + && word.text.chars().count() <= crate::constants::MIN_WORD_CHARS + && overlap(r, word.rect) > crate::constants::WORD_OVERLAP_THRESHOLD + && overlap(word.rect, r) > crate::constants::WORD_OVERLAP_REVERSE_THRESHOLD) + && (overlap(word.rect, r) > crate::constants::WORD_OVERLAP_THRESHOLD + || overlap(r, word.rect) > crate::constants::WORD_OVERLAP_THRESHOLD) }) { continue; } let nearby = label(words, r, check); let (label, ocr_conf) = nearby.clone().unwrap_or(("Field".into(), 0.4)); - if !check && !shape.blank && w < 60. && (!shape.closed || nearby.is_none()) { + if !check + && !shape.blank + && w < crate::constants::MIN_OPEN_SHAPE_WIDTH + && (!shape.closed || nearby.is_none()) + { continue; } let lower = label.to_ascii_lowercase(); @@ -936,17 +993,19 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { .iter() .map(|s| s.rect) .filter(|other| { - (other.left - r.left).abs() < 3. - && (other.right - r.right).abs() < 3. - && (other.bottom - other.top - h).abs() < 3. - && (other.top - r.top).abs() > 3. - && (other.top - r.top).abs() < 100. + (other.left - r.left).abs() < crate::constants::GRID_TOLERANCE + && (other.right - r.right).abs() < crate::constants::GRID_TOLERANCE + && (other.bottom - other.top - h).abs() + < crate::constants::GRID_HEIGHT_TOLERANCE + && (other.top - r.top).abs() > crate::constants::GRID_TOLERANCE + && (other.top - r.top).abs() < crate::constants::GRID_TOP_RANGE.1 }) .collect::>(); - let grid = same_rows.len() >= 2 - && same_rows - .iter() - .any(|other| (other.top - r.top).abs() < h * 1.5 + 3.); + let grid = same_rows.len() >= crate::constants::GRID_MIN_ROWS + && same_rows.iter().any(|other| { + (other.top - r.top).abs() + < h * crate::constants::GRID_HEIGHT_FACTOR + crate::constants::GRID_TOLERANCE + }); let useful_label = [ "name", "address", @@ -986,8 +1045,9 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { .iter() .any(|s| lower.contains(s)); let direct_label = words.iter().any(|word| { - (-3. ..300.).contains(&(r.left - word.rect.right)) - && (word.rect.top - r.top).abs() < 12. + r.left - word.rect.right >= -3. + && r.left - word.rect.right <= crate::constants::DIRECT_LABEL_LEFT_MAX + && (word.rect.top - r.top).abs() < crate::constants::DIRECT_LABEL_VERTICAL_TOLERANCE }); if !shape.blank && ((grid && !check && (!useful_label || !direct_label)) @@ -1008,12 +1068,14 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { || ((header == "select" || header == "choose") && words.iter().any(|next| { next.text.eq_ignore_ascii_case("one") - && (next.rect.top - word.rect.top).abs() < 5.0 + && (next.rect.top - word.rect.top).abs() + < crate::constants::RADIO_GROUP_TOP_DIFF && next.rect.left >= word.rect.right - && next.rect.left - word.rect.right < 30.0 + && next.rect.left - word.rect.right + < crate::constants::RADIO_GROUP_LEFT_MAX }))) - && word.rect.bottom <= r.top + 3.0 - && r.top - word.rect.bottom < 180.0 + && word.rect.bottom <= r.top + crate::constants::RADIO_GROUP_TOP_DIFF + && r.top - word.rect.bottom < crate::constants::RADIO_GROUP_TOP_MAX }) .min_by(|a, b| (r.top - a.rect.bottom).total_cmp(&(r.top - b.rect.bottom))) .map(|word| { @@ -1053,12 +1115,13 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { FieldKind::Text }; let mut score = if nearby.is_some() { - 0.76 + 0.22 * ocr_conf.max(0.) + crate::constants::SCORE_WITH_LABEL + + crate::constants::SCORE_OCR_WEIGHT * ocr_conf.max(0.) } else { - 0.58 + crate::constants::SCORE_WITHOUT_LABEL }; if !shape.closed && !shape.blank && (!shape.native || w < 90.) { - score = score.min(0.86); + score = score.min(crate::constants::SCORE_MAX_UNCLOSED); } let spec = FieldSpec { page: input.page, @@ -1070,9 +1133,9 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { read_only: false, options: vec![], group: group.unwrap_or_default(), - review: score < 0.9, - date_format: "YYYY-MM-DD".into(), - decimals: 2, + review: score < crate::constants::REVIEW_THRESHOLD, + date_format: crate::constants::DEFAULT_DATE_FORMAT.into(), + decimals: crate::constants::DEFAULT_DECIMALS, }; if spec.validate().is_ok() { found.push(DetectedField { @@ -1080,7 +1143,7 @@ fn candidates(input: &PageInput, words: &[Word]) -> Vec { confidence: DetectionConfidence { score, origin: r }, }); } - if found.len() >= 512 { + if found.len() >= crate::constants::MAX_CANDIDATE_FIELDS { break; } } @@ -1106,16 +1169,16 @@ fn recheck_crop(input: &PageInput, found: &[DetectedField]) -> (PageInput, (f32, .map(|f| f.spec.rect) .reduce(|a, b| a.union(b)) .expect("uncertain candidate"); - let left = ((uncertain.left - 300.).max(0.) * input.scale) + let left = ((uncertain.left - crate::constants::CROP_LEFT_OFFSET).max(0.) * input.scale) .floor() .min(input.width.saturating_sub(1) as f32) as u32; - let top = ((uncertain.top - 50.).max(0.) * input.scale) + let top = ((uncertain.top - crate::constants::CROP_TOP_PAD).max(0.) * input.scale) .floor() .min(input.height.saturating_sub(1) as f32) as u32; - let right = ((uncertain.right + 180.) * input.scale) + let right = ((uncertain.right + crate::constants::CROP_RIGHT_PAD) * input.scale) .ceil() .clamp((left + 1) as f32, input.width as f32) as u32; - let bottom = ((uncertain.bottom + 30.) * input.scale) + let bottom = ((uncertain.bottom + crate::constants::CROP_BOTTOM_PAD) * input.scale) .ceil() .clamp((top + 1) as f32, input.height as f32) as u32; let (width, height) = (right - left, bottom - top); @@ -1144,7 +1207,12 @@ fn recheck_crop(input: &PageInput, found: &[DetectedField]) -> (PageInput, (f32, fn recognize(mut input: PageInput, cancel: &AtomicBool) -> Result> { let angle = deskew(&mut input, cancel)?; let words = if input.words.is_empty() { - ocr(&input, 2, "3", cancel)? + ocr( + &input, + crate::constants::OCR_FACTOR_PRIMARY, + crate::constants::OCR_PSM_PRIMARY, + cancel, + )? } else { input.words.clone() }; @@ -1153,7 +1221,12 @@ fn recognize(mut input: PageInput, cancel: &AtomicBool) -> Result Some((second, offset)), Err(error) if input.words.is_empty() => return Err(error), Err(_) => None, @@ -1198,7 +1271,7 @@ fn recognize(mut input: PageInput, cancel: &AtomicBool) -> Result().0.iter().zip(&cms).all(|(hex, value)| { let digit = |b: u8| (b as char).to_digit(16); digit(hex[0]) .zip(digit(hex[1])) diff --git a/crates/pdf-engine/src/editing.rs b/crates/pdf-engine/src/editing.rs index e11a185..8c5647c 100644 --- a/crates/pdf-engine/src/editing.rs +++ b/crates/pdf-engine/src/editing.rs @@ -85,7 +85,7 @@ pub(crate) fn restore(bytes: &[u8], password: Option<&str>) -> Result Result { let pages = doc.page_count().map_err(error)?; - if !(1..=100_000).contains(&pages) { + if pages <= 0 || pages as usize > crate::constants::MAX_EDIT_PAGES { return Err("Editing supports 1–100,000 pages".into()); } let bounds = doc.load_page(0).and_then(|p| p.bounds()).map_err(error)?; @@ -140,7 +140,10 @@ pub(crate) fn annotations( let mut bytes = 0; let mut truncated = false; for (ordinal, a) in native.annotations().enumerate() { - if ordinal >= 10_000 || result.len() >= 256 || bytes >= 256 * 1024 { + if ordinal >= crate::constants::MAX_ANNOTATIONS_PER_PAGE + || result.len() >= crate::constants::MAX_ANNOTATIONS_RETURNED + || bytes >= crate::constants::MAX_ANNOTATION_BYTES + { truncated = true; break; } @@ -149,7 +152,10 @@ pub(crate) fn annotations( PdfAnnotationType::Text => { let r = a.rect().map_err(error)?; let text = a.contents().map_err(error)?.unwrap_or(""); - let mut end = text.len().min(16_384).min(256 * 1024 - bytes); + let mut end = text + .len() + .min(crate::constants::MAX_TEXT_LENGTH) + .min(crate::constants::MAX_ANNOTATION_BYTES - bytes); while !text.is_char_boundary(end) { end -= 1; } @@ -164,7 +170,7 @@ pub(crate) fn annotations( } } PdfAnnotationType::Highlight => { - if a.quad_count().map_err(error)? > 1000 { + if a.quad_count().map_err(error)? as usize > crate::constants::MAX_QUAD_COUNT { truncated = true; continue; } @@ -228,7 +234,7 @@ pub(crate) fn mutate(doc: &mut PdfDocument, command: &EditCommand) -> Result=info.pages||*ordinal>=10_000) + if matches!(command,EditCommand::UpdateNote{page,ordinal,..}|EditCommand::Color{page,ordinal,..}|EditCommand::DeleteAnnotation{page,ordinal} if *page>=info.pages||*ordinal>=crate::constants::MAX_ANNOTATIONS_PER_PAGE) { return Err("Annotation page is outside the document".into()); } @@ -256,7 +262,10 @@ pub(crate) fn mutate(doc: &mut PdfDocument, command: &EditCommand) -> Result { - if text.trim().is_empty() || text.len() > 16_384 || text.contains('\0') { + if text.trim().is_empty() + || text.len() > crate::constants::MAX_TEXT_LENGTH + || text.contains('\0') + { return Err("Notes need 1–16,384 UTF-8 bytes of text".into()); } let native = doc.load_pdf_page(*page as i32).map_err(error)?; @@ -348,7 +357,7 @@ pub(crate) fn mutate(doc: &mut PdfDocument, command: &EditCommand) -> Result { - if *at > info.pages || info.pages >= 100_000 { + if *at > info.pages || info.pages >= crate::constants::MAX_EDIT_PAGES { return Err("Invalid insertion position or page limit".into()); } let b = doc @@ -370,7 +379,7 @@ pub(crate) fn mutate(doc: &mut PdfDocument, command: &EditCommand) -> Result Result<(), String> { let count = doc.page_count().map_err(error)? as usize; - if paths.is_empty() || paths.len() > 16 || at > count { + if paths.is_empty() || paths.len() > crate::constants::MAX_INSERT_PATHS || at > count { return Err("Choose 1–16 PDF files and a valid insertion position".into()); } let mut sources = Vec::new(); @@ -424,7 +433,7 @@ struct ImportGraph<'a> { } impl ImportGraph<'_> { fn destination(&mut self, value: &PdfObject, depth: usize) -> Result { - if depth > 64 { + if depth > crate::constants::MAX_IMPORT_DEPTH { return Err("Destination nesting exceeds limits".into()); } if value.is_name().map_err(error)? || value.is_string().map_err(error)? { @@ -480,7 +489,9 @@ impl ImportGraph<'_> { Ok(dest) } fn copy(&mut self, value: &PdfObject, depth: usize) -> Result { - if depth > 64 || self.nodes >= 100_000 { + if depth > crate::constants::MAX_IMPORT_DEPTH + || self.nodes >= crate::constants::MAX_IMPORT_NODES + { return Err("Imported interactive objects exceed complexity limits".into()); } self.nodes += 1; @@ -504,13 +515,15 @@ impl ImportGraph<'_> { if value.is_stream().map_err(error)? { if let Some(length) = resolved.get_dict("Length").map_err(error)? { let length = length.as_int().map_err(error)?; - if length < 0 || length as usize > 64 * 1024 * 1024 { + if length < 0 || length as usize > crate::constants::MAX_STREAM_BYTES { return Err("Imported stream exceeds 64 MiB limit".into()); } } let bytes = value.read_raw_stream().map_err(error)?; self.stream_bytes = self.stream_bytes.saturating_add(bytes.len()); - if bytes.len() > 64 * 1024 * 1024 || self.stream_bytes > 128 * 1024 * 1024 { + if bytes.len() > crate::constants::MAX_STREAM_BYTES + || self.stream_bytes > crate::constants::MAX_TOTAL_STREAM_BYTES + { return Err("Imported stream exceeds 64 MiB limit".into()); } reserved @@ -520,7 +533,7 @@ impl ImportGraph<'_> { return Ok(reserved); } if value.is_array().map_err(error)? { - if value.len().map_err(error)? > 10_000 { + if value.len().map_err(error)? > crate::constants::MAX_IMPORT_ARRAY_LEN { return Err("Imported interactive array exceeds limits".into()); } let mut copied = self.target.new_array().map_err(error)?; @@ -532,7 +545,7 @@ impl ImportGraph<'_> { return Ok(copied); } if value.is_dict().map_err(error)? { - if value.dict_len().map_err(error)? > 2000 { + if value.dict_len().map_err(error)? > crate::constants::MAX_IMPORT_DICT_LEN { return Err("Imported dictionary exceeds limits".into()); } if value.get_dict("Type").map_err(error)?.is_some_and(|kind| { @@ -638,7 +651,7 @@ impl ImportGraph<'_> { } } fn validate_action(action: &PdfObject, depth: usize) -> Result<(), String> { - if depth > 64 { + if depth > crate::constants::MAX_IMPORT_DEPTH { return Err("Imported action chain exceeds limits".into()); } if !action.is_dict().map_err(error)? { @@ -669,7 +682,7 @@ fn named_destinations(source: &PdfDocument) -> Result .ok_or("Missing source catalog")?; let mut result = HashMap::new(); if let Some(legacy) = root.get_dict("Dests").map_err(error)? { - if legacy.dict_len().map_err(error)? > 100_000 { + if legacy.dict_len().map_err(error)? > crate::constants::MAX_EDIT_PAGES { return Err("Legacy destination dictionary exceeds limits".into()); } for entry in legacy.dict_iter().map_err(error)? { @@ -691,7 +704,10 @@ fn named_destinations(source: &PdfDocument) -> Result let mut nodes = 0; while let Some((node, depth)) = pending.pop() { nodes += 1; - if nodes > 10000 || depth > 64 || result.len() > 100000 { + if nodes > 10000 + || depth > crate::constants::MAX_IMPORT_DEPTH + || result.len() > crate::constants::MAX_EDIT_PAGES + { return Err("Destination name tree exceeds limits".into()); } if let Some(names) = node.get_dict("Names").map_err(error)? { diff --git a/crates/pdf-engine/src/fileops.rs b/crates/pdf-engine/src/fileops.rs new file mode 100644 index 0000000..467ac63 --- /dev/null +++ b/crates/pdf-engine/src/fileops.rs @@ -0,0 +1,604 @@ +//! FileOps subsystem: all file transaction handling (Save, SaveAs, Rename, +//! Extract, Split, RenameAndSave, Sign). Extracted from the coordinator +//! monolith. + +use crate::{ + detection, digital_signatures::SourceStamp, editing, files, forms, Event, FileAction, FileCommand, + FileCommandAction, Shared, +}; +use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError}; +use pdfium_render::{ + pdf::{Permission}, + PdfDocument, +}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + path::PathBuf, + sync::{Arc, atomic::Ordering}, + time::Duration, +}; +use viewer_core::{Annotation, PageSize, PixelTile, RenderRequest, SnapshotHistory, TileKey}; + +/// A completed render result (re-exported type alias from rendering). +pub(crate) type Completed = crate::rendering::Completed; + +/// Emits an event through the bounded channel, respecting shutdown. +fn emit(shared: &Shared, events: &crossbeam_channel::Sender, mut event: Event) -> bool { + loop { + match events.send_timeout(event, Duration::from_millis(crate::constants::CHANNEL_SEND_TIMEOUT_MILLIS)) { + Ok(()) => return true, + Err(SendTimeoutError::Timeout(value)) => { + if shared.stopping.load(Ordering::Acquire) { + return false; + } + event = value; + } + Err(_) => return false, + } + } +} + +/// Mutable state owned by the coordinator that FileOps needs to read and modify. +pub(crate) struct FileOpsState { + pub document: Option, + pub journal: Option, + pub source_path: PathBuf, + pub source_stamp: Option, + pub session_password: Option, + pub can_copy: bool, + pub content: u64, + pub detection_done: Option<(u64, usize, Vec)>, + pub detection_job: Option, + pub deferred_results: VecDeque, + pub notes_cache: HashMap, bool)>, +} + +impl FileOpsState { + pub(crate) fn new(source_path: PathBuf) -> Self { + Self { + document: None, + journal: None, + source_path, + source_stamp: None, + session_password: None, + can_copy: false, + content: 0, + detection_done: None, + detection_job: None, + deferred_results: VecDeque::new(), + notes_cache: HashMap::new(), + } + } +} + +/// The FileOps subsystem handles all file transaction commands. +pub(crate) struct FileOps { + shared: Arc, + events: crossbeam_channel::Sender, + document_id: u64, +} + +impl FileOps { + pub(crate) fn new( + shared: Arc, + events: crossbeam_channel::Sender, + document_id: u64, + ) -> Self { + Self { + shared, + events, + document_id, + } + } + + /// Handle a file command. Returns `(should_continue, document_changed)`. + pub(crate) fn handle( + &mut self, + state: &mut FileOpsState, + results: &Receiver, + inflight_len: usize, + command: FileCommand, + ) -> (bool, bool) { + if let FileCommandAction::Sign { revision, request } = &command.action { + let result = (|| { + if command.document != self.document_id + || *revision != state.journal.as_ref().map_or(0, |h| h.revision()) + || state.journal.as_ref().is_some_and(|h| h.dirty()) + { + return Err("Save and review your edits before signing.".to_string()); + } + let stamp = state + .source_stamp + .as_ref() + .ok_or("The original PDF file is unavailable")?; + crate::digital_signatures::sign( + &state.source_path, + state.session_password.as_deref(), + stamp, + request, + || { + self.shared.current_document(command.document) + && !self.shared.sign_cancel.load(Ordering::Acquire) + }, + ) + })(); + self.shared.file_busy.store(false, Ordering::Release); + match result { + Ok((path, integrity)) => { + let _ = emit( + &self.shared, + &self.events, + Event::Signed { + document: command.document, + operation: command.operation, + path, + integrity, + }, + ); + } + Err(message) => { + let _ = emit( + &self.shared, + &self.events, + Event::FileError { + document: command.document, + operation: command.operation, + message, + }, + ); + } + } + return (true, false); + } + + if let FileCommandAction::Detect { + revision, + generation, + pages, + } = &command.action + { + let result = (|| { + let doc = state.document.as_ref().ok_or("No PDF is open")?; + if command.document != self.document_id + || *revision != state.journal.as_ref().map_or(0, |h| h.revision()) + { + return Err("Document changed before detection started".to_string()); + } + forms::ensure_writable(doc)?; + if !forms::permissions(doc) + .contains(Permission::COPY | Permission::MODIFY) + { + return Err( + "This PDF does not permit field recognition and creation".into(), + ); + } + let count = doc.pages().len(); + let mut unique = HashSet::new(); + if pages.iter().any(|p| *p >= count || !unique.insert(*p)) { + return Err("Invalid detection page range".into()); + } + crate::detection::Detection::new( + command.document, + *revision, + command.operation, + *generation, + pages.clone(), + ) + })(); + match result { + Ok(job) => { + state.detection_job = Some(job); + state.detection_done = None; + } + Err(message) => { + self.shared.file_busy.store(false, Ordering::Release); + let _ = emit( + &self.shared, + &self.events, + Event::FileError { + document: command.document, + operation: command.operation, + message, + }, + ); + } + } + return (true, false); + } + + let direct_save = matches!(&command.action, FileCommandAction::Save); + if let FileCommandAction::Edit { + revision, + generation, + command: edit, + } = &command.action + { + let result = if command.document != self.document_id + || !self.shared.current_document(command.document) + { + Err("The document changed before editing started".into()) + } else if matches!( + edit, + crate::EditCommand::Form(viewer_core::FormEdit::CreateDetected(_)) + ) && self.shared.detect_cancel.load(Ordering::Acquire) + { + Err("Field detection cancelled; no fields were created.".into()) + } else if let Some(doc) = state.document.as_mut() { + editing::perform( + doc, + &mut state.journal, + state.session_password.as_deref(), + *revision, + edit, + || { + self.shared.current_document(command.document) + && (!matches!( + edit, + crate::EditCommand::Form( + viewer_core::FormEdit::CreateDetected(_) + ) + ) || !self.shared.detect_cancel.load(Ordering::Acquire)) + }, + ) + } else { + Err("No PDF is open".into()) + }; + match result { + Ok((doc, focus, revision)) => { + let info = editing::info(&doc).expect("validated edited PDF"); + state.can_copy = info.can_copy; + state.content = state.content.wrapping_add(1); + self.shared.content.store(state.content, Ordering::Release); + self.shared.generation.store(*generation, Ordering::Release); + let h = state.journal.as_ref().unwrap(); + let detection_summary = state.detection_done.take(); + let _ = emit( + &self.shared, + &self.events, + Event::Changed { + document: self.document_id, + operation: command.operation, + revision, + generation: *generation, + info, + dirty: h.dirty(), + can_undo: h.can_undo(), + can_redo: h.can_redo(), + focus_page: focus, + }, + ); + if let Some((operation, count, review_pages)) = detection_summary { + let _ = emit( + &self.shared, + &self.events, + Event::DetectionDone { + document: self.document_id, + operation, + count, + review_pages, + }, + ); + } + } + Err(message) => { + state.detection_done = None; + self.shared.file_busy.store(false, Ordering::Release); + let _ = emit( + &self.shared, + &self.events, + Event::FileError { + document: command.document, + operation: command.operation, + message, + }, + ); + } + } + return (true, true); + } + + let result = if command.document != self.document_id + || !self.shared.current_document(command.document) + { + Err("The document changed before the file operation started".into()) + } else if let Some(doc) = state.document.as_mut() { + let action = match command.action { + FileCommandAction::Save => FileCommandAction::RenameAndSave { + name: state + .source_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .into(), + annotations: vec![], + }, + FileCommandAction::Rename(name) if state.journal.is_some() => { + FileCommandAction::RenameAndSave { + name, + annotations: vec![], + } + } + action => action, + }; + match action { + FileCommandAction::Save => unreachable!(), + FileCommandAction::Edit { .. } + | FileCommandAction::Detect { .. } + | FileCommandAction::Sign { .. } => unreachable!(), + FileCommandAction::Extract { + revision, + target, + pages, + } => { + if revision != state.journal.as_ref().map_or(0, |h| h.revision()) { + Err("Document revision changed".into()) + } else { + editing::extract( + doc, + &state.source_path, + state.session_password.as_deref(), + &target, + &pages, + || self.shared.current_document(command.document), + ) + .map(|p| (p, FileAction::Extract)) + } + } + FileCommandAction::Split { + revision, + directory, + after, + } => { + if revision != state.journal.as_ref().map_or(0, |h| h.revision()) { + Err("Document revision changed".into()) + } else { + editing::split( + doc, + &state.source_path, + state.session_password.as_deref(), + &directory, + after, + || self.shared.current_document(command.document), + ) + .map(|p| (p, FileAction::Split)) + } + } + FileCommandAction::SaveAs { + target, + annotations, + } => { + let switch = state.journal.is_some() || annotations.is_empty(); + let baseline = if switch { + editing::serialize(doc).and_then(|bytes| { + let initial = if state.journal.is_none() { + Some(SnapshotHistory::new(bytes.clone())?) + } else { + None + }; + Ok(( + Some(editing::restore( + &bytes, + state.session_password.as_deref(), + )?), + initial, + )) + }) + } else { + Ok((None, None)) + }; + match baseline { + Err(message) => Err(message), + Ok((baseline, initial)) => { + let saved = files::save( + doc, + &state.source_path, + state.session_password.as_deref(), + &target, + if state.journal.is_some() { &[] } else { &annotations }, + || self.shared.current_document(command.document), + ); + match saved { + Err(message) => Err(message), + Ok(path) => { + if switch { + state.source_path = path.clone(); + state.document = baseline; + if let Some(h) = initial { + state.journal = Some(h); + } + if let Some(h) = state.journal.as_mut() { + h.mark_saved(); + } + } + Ok((path, FileAction::SaveAs)) + } + } + } + } + } + FileCommandAction::RenameAndSave { ref annotations, .. } + if state.journal.is_some() && !annotations.is_empty() => + { + Err( + "Use native editing commands before saving; external drafts cannot bypass undo history" + .into(), + ) + } + FileCommandAction::RenameAndSave { name, annotations } => { + match files::prepare_rename( + doc, + &state.source_path, + state.session_password.as_deref(), + &name, + &annotations, + || self.shared.current_document(command.document), + ) { + Err(e) => Err(e), + Ok(prepared) => { + while state.deferred_results.len() < inflight_len + && !self.shared.stopping.load(Ordering::Acquire) + { + match results.recv_timeout(Duration::from_millis(crate::constants::CHANNEL_RECV_TIMEOUT_MILLIS)) { + Ok(result) => state.deferred_results.push_back(result), + Err(RecvTimeoutError::Timeout) => {} + Err(_) => break, + } + } + if !self.shared.current_document(command.document) { + Err("Document changed before rename completed".into()) + } else { + drop(state.document.take()); + let (baseline, result) = prepared.commit(&state.source_path); + state.document = Some(baseline); + match result { + Ok(path) => { + state.source_path = path.clone(); + if let Some(h) = state.journal.as_mut() { + h.mark_saved(); + } + Ok(( + path, + if direct_save { + FileAction::Save + } else { + FileAction::Rename + }, + )) + } + Err(failure) => { + state.source_path = failure.path; + Err(failure.message) + } + } + } + } + } + } + FileCommandAction::Rename(name) => { + match files::renamed_path(&state.source_path, &name) { + Err(e) => Err(e), + Ok(target) if target == state.source_path => { + Ok((state.source_path.clone(), FileAction::Rename)) + } + Ok(target) => { + while state.deferred_results.len() < inflight_len + && !self.shared.stopping.load(Ordering::Acquire) + { + match results.recv_timeout(Duration::from_millis(crate::constants::CHANNEL_RECV_TIMEOUT_MILLIS)) { + Ok(result) => state.deferred_results.push_back(result), + Err(RecvTimeoutError::Timeout) => {} + Err(_) => break, + } + } + if !self.shared.current_document(command.document) { + Err("Document changed before rename completed".into()) + } else { + state.notes_cache.clear(); + drop(state.document.take()); + let moved = + files::move_no_replace(&state.source_path, &target); + let reopen_path = if moved.is_ok() { + target.clone() + } else { + state.source_path.clone() + }; + let reopened = + files::open(&reopen_path, state.session_password.as_deref()); + match reopened { + Ok(doc) => { + state.document = Some(doc); + match moved { + Ok(()) => { + state.source_path = target.clone(); + Ok((target, FileAction::Rename)) + } + Err(e) => Err(format!( + "Could not rename PDF: {e}. Original remains open." + )), + } + } + Err(e) => { + if moved.is_err() { + Err(format!( + "Rename failed and the original PDF could not be reopened: {e}. File remains at {}. Use Open to recover.", + state.source_path.display() + )) + } else { + match files::move_no_replace( + &target, + &state.source_path, + ) { + Ok(()) => match files::open( + &state.source_path, + state.session_password.as_deref(), + ) { + Ok(doc) => { + state.document = Some(doc); + Err(format!( + "Renamed PDF could not be reopened ({e}); rename was rolled back. Original remains open." + )) + } + Err(rollback_error) => Err(format!( + "Rename was rolled back but reopening failed: {rollback_error}. File remains at {}. Use Open to recover.", + state.source_path.display() + )), + }, + Err(rollback_error) => { + state.source_path = target; + Err(format!( + "Rename succeeded but reopening failed ({e}) and rollback failed ({rollback_error}). File is at {}. Use Open to recover.", + state.source_path.display() + )) + } + } + } + } + } + } + } + } + } + } + } else { + Err("No PDF is open".into()) + }; + self.shared.file_busy.store(false, Ordering::Release); + let event = match result { + Ok((path, kind)) => { + if matches!( + kind, + FileAction::Save | FileAction::SaveAs | FileAction::Rename + ) { + state.source_stamp = + crate::digital_signatures::SourceStamp::read(&state.source_path).ok(); + } + Event::FileDone { + document: command.document, + operation: command.operation, + path, + kind, + } + } + Err(message) => Event::FileError { + document: command.document, + operation: command.operation, + message, + }, + }; + let _ = emit(&self.shared, &self.events, event); + if state.document.is_none() && command.document == self.document_id { + let _ = emit( + &self.shared, + &self.events, + Event::Error { + document: self.document_id, + error: crate::EngineError::InvalidDocument(format!( + "The PDF at {} could not be reopened. Use Open to recover.", + state.source_path.display() + )), + }, + ); + } + (false, false) + } +} diff --git a/crates/pdf-engine/src/files.rs b/crates/pdf-engine/src/files.rs index 5a951e5..198e02f 100644 --- a/crates/pdf-engine/src/files.rs +++ b/crates/pdf-engine/src/files.rs @@ -31,7 +31,7 @@ pub(crate) fn options() -> PdfWriteOptions { options } pub(crate) fn apply(doc: &PdfDocument, annotations: &[Annotation]) -> Result<(), String> { - if annotations.len() > 2_000 { + if annotations.len() > crate::constants::MAX_CREATE_FIELDS { return Err("Too many annotations (maximum 2,000)".into()); } if !annotations.is_empty() && !crate::forms::permissions(doc).contains(Permission::ANNOTATE) { @@ -362,7 +362,7 @@ pub(crate) fn save( current: impl Fn() -> bool, ) -> Result { crate::forms::ensure_writable(doc)?; - if annotations.len() > 2_000 { + if annotations.len() > crate::constants::MAX_CREATE_FIELDS { return Err("Too many annotations (maximum 2,000)".into()); } for annotation in annotations { diff --git a/crates/pdf-engine/src/forms.rs b/crates/pdf-engine/src/forms.rs index e904d36..4fd5345 100644 --- a/crates/pdf-engine/src/forms.rs +++ b/crates/pdf-engine/src/forms.rs @@ -47,7 +47,7 @@ fn objects(doc: &PdfDocument) -> Result> { stack.push((fields, 0)); } while let Some((o, depth)) = stack.pop() { - if depth > 64 || found.len() + stack.len() > 10_000 { + if depth > crate::constants::MAX_IMPORT_DEPTH || found.len() + stack.len() > 10_000 { return Err("Form tree exceeds the safe inspection limit.".into()); } if o.is_array().map_err(err)? { @@ -153,7 +153,7 @@ fn options(o: &PdfObject) -> Result<(Vec, Vec)> { let mut labels = vec![]; let mut exports = vec![]; if let Some(opts) = o.get_dict_inheritable("Opt").map_err(err)? { - if opts.len().map_err(err)? > 256 { + if opts.len().map_err(err)? > crate::constants::MAX_FIELD_OPTIONS { return Err("Field exceeds 256 options.".into()); } for i in 0..opts.len().map_err(err)? { @@ -196,7 +196,7 @@ fn managed_radio(o: &PdfObject) -> Result { let Some(kids) = o.get_dict("Kids").map_err(err)? else { return Ok(false); }; - if kids.len().map_err(err)? > 2_000 { + if kids.len().map_err(err)? > crate::constants::MAX_RADIO_KIDS { return Ok(false); } for i in 0..kids.len().map_err(err)? { @@ -219,7 +219,7 @@ fn button_values(doc: &PdfDocument, field: i32) -> Result> { let o = doc.new_indirect(field, 0).map_err(err)?; let mut widgets = vec![o.try_clone().map_err(err)?]; if let Some(kids) = o.get_dict("Kids").map_err(err)? { - if kids.len().map_err(err)? > 10_000 { + if kids.len().map_err(err)? > crate::constants::MAX_BUTTON_KIDS { return Err("Button group exceeds the safe limit.".into()); } widgets.clear(); @@ -237,7 +237,11 @@ fn button_values(doc: &PdfDocument, field: i32) -> Result> { && let Some(states) = ap.get_dict("N").map_err(err)? && states.is_dict().map_err(err)? { - for i in 0..states.dict_len().map_err(err)?.min(64) { + for i in 0..states + .dict_len() + .map_err(err)? + .min(crate::constants::MAX_APPEARANCE_STATES) + { let k = states .get_dict_key(i as i32) .map_err(err)? @@ -306,7 +310,11 @@ pub(crate) fn suppressed(doc: &PdfDocument, page: usize) -> Result .as_indirect() .map_err(err)?; if let Some(list) = root(doc)?.get_dict("PDFreeSuppressedFields").map_err(err)? { - for i in 0..list.len().map_err(err)?.min(2_000) { + for i in 0..list + .len() + .map_err(err)? + .min(crate::constants::MAX_SUPPRESSED_FIELDS) + { let item = list .get_array(i as i32) .map_err(err)? @@ -330,7 +338,7 @@ fn suppress(doc: &PdfDocument, f: &FormField) -> Result<()> { .get_dict("PDFreeSuppressedFields") .map_err(err)? .unwrap_or(doc.new_array().map_err(err)?); - if list.len().map_err(err)? >= 2_000 { + if list.len().map_err(err)? >= crate::constants::MAX_SUPPRESSED_FIELDS { return Err("Detection exclusion limit reached.".into()); } let mut item = doc.new_dict().map_err(err)?; @@ -354,7 +362,7 @@ pub(crate) fn page(doc: &PdfDocument, index: usize) -> Result> { let b = native.bounds().map_err(err)?; let mut result = vec![]; for (n, w) in native.widgets().enumerate() { - if n >= 512 { + if n >= crate::constants::MAX_WIDGETS_PER_PAGE { return Err("This page exceeds the 512-widget preview limit.".into()); } let a = w.annotation(); @@ -385,7 +393,11 @@ pub(crate) fn page(doc: &PdfDocument, index: usize) -> Result> { let value = if let Some(v) = f.get_dict_inheritable("V").map_err(err)? { if v.is_array().map_err(err)? { let mut s = vec![]; - for i in 0..v.len().map_err(err)?.min(256) { + for i in 0..v + .len() + .map_err(err)? + .min(crate::constants::MAX_SELECTION_VALUES) + { s.push( v.get_array(i as i32) .map_err(err)? @@ -450,20 +462,28 @@ pub(crate) fn page(doc: &PdfDocument, index: usize) -> Result> { review: integer(&o, "PDFreeReview")? != 0, date_format: match string(&f, "PDFreeDate")? { s if !s.is_empty() => s, - _ => "YYYY-MM-DD".into(), + _ => crate::constants::DEFAULT_DATE_FORMAT.into(), }, decimals: f .get_dict("PDFreeDecimals") .map_err(err)? - .map(|v| v.as_int().unwrap_or(2).clamp(0, 12) as u8) - .unwrap_or(2), + .map(|v| { + v.as_int() + .unwrap_or(crate::constants::DEFAULT_DECIMALS as i32) + .clamp(0, crate::constants::MAX_DECIMALS as i32) as u8 + }) + .unwrap_or(crate::constants::DEFAULT_DECIMALS), }; let mut export_value = "Yes".to_string(); if let Some(ap) = o.get_dict("AP").map_err(err)? && let Some(states) = ap.get_dict("N").map_err(err)? && states.is_dict().map_err(err)? { - for i in 0..states.dict_len().map_err(err)?.min(64) { + for i in 0..states + .dict_len() + .map_err(err)? + .min(crate::constants::MAX_APPEARANCE_STATES) + { let k = states .get_dict_key(i as i32) .map_err(err)? @@ -1092,7 +1112,7 @@ pub(crate) fn apply(doc: &mut PdfDocument, edit: &FormEdit) -> Result { if !permissions(doc).contains(Permission::MODIFY | Permission::ANNOTATE) { return Err("This PDF does not permit creating form fields.".into()); } - if specs.is_empty() || specs.len() > 2_000 { + if specs.is_empty() || specs.len() > crate::constants::MAX_CREATE_FIELDS { return Err("Create 1–2,000 fields per operation.".into()); } let mut names = HashSet::new(); @@ -1398,7 +1418,11 @@ fn appearances(doc: &PdfDocument, id: FieldRef) -> Result, Vec) return Ok(vec![(vec![], normal.read_stream().map_err(err)?)]); } let mut streams = vec![]; - for i in 0..normal.dict_len().map_err(err)?.min(64) { + for i in 0..normal + .dict_len() + .map_err(err)? + .min(crate::constants::MAX_APPEARANCE_STATES) + { let key = normal .get_dict_key(i as i32) .map_err(err)? diff --git a/crates/pdf-engine/src/lib.rs b/crates/pdf-engine/src/lib.rs index 15dcbcb..f246da8 100644 --- a/crates/pdf-engine/src/lib.rs +++ b/crates/pdf-engine/src/lib.rs @@ -1,4 +1,6 @@ //! MuPDF ownership and background rendering boundary. +mod backend; +mod constants; mod coordinator; mod detection; mod digital_signatures; @@ -416,24 +418,46 @@ impl Engine { let _ = self.wake.try_send(()); } pub fn form_pages(&self, document: u64, revision: u64, pages: Vec) { - self.shared.desired.lock().unwrap().forms = - Some((document, revision, pages.into_iter().take(16).collect())); + self.shared.desired.lock().unwrap().forms = Some(( + document, + revision, + pages + .into_iter() + .take(crate::constants::MAX_FORM_PAGES) + .collect(), + )); let _ = self.wake.try_send(()); } pub fn links_pages(&self, document: u64, revision: u64, pages: Vec) { - self.shared.desired.lock().unwrap().links = - Some((document, revision, pages.into_iter().take(16).collect())); + self.shared.desired.lock().unwrap().links = Some(( + document, + revision, + pages + .into_iter() + .take(crate::constants::MAX_LINK_PAGES) + .collect(), + )); let _ = self.wake.try_send(()); } pub fn text_pages(&self, document: u64, pages: Vec) { - self.shared.desired.lock().unwrap().text = - Some((document, pages.into_iter().take(16).collect())); + self.shared.desired.lock().unwrap().text = Some(( + document, + pages + .into_iter() + .take(crate::constants::MAX_TEXT_PAGES) + .collect(), + )); let _ = self.wake.try_send(()); } pub fn note_pages(&self, document: u64, pages: Vec) { - self.shared.desired.lock().unwrap().notes = - Some((document, pages.into_iter().take(16).collect())); + self.shared.desired.lock().unwrap().notes = Some(( + document, + pages + .into_iter() + .take(crate::constants::MAX_NOTES_PAGES) + .collect(), + )); let _ = self.wake.try_send(()); } fn file(&self, command: FileCommand) -> bool { @@ -614,14 +638,19 @@ impl Engine { } pub fn thumbnails(&self, document: u64, pages: Vec) { - self.shared.desired.lock().unwrap().thumbnails = - Some((document, pages.into_iter().take(32).collect())); + self.shared.desired.lock().unwrap().thumbnails = Some(( + document, + pages + .into_iter() + .take(crate::constants::MAX_THUMBNAIL_PAGES) + .collect(), + )); let _ = self.wake.try_send(()); } pub fn search(&self, document: u64, search: u64, query: impl Into) { let mut query = query.into(); - if query.len() > 512 || query.contains('\0') { + if query.len() > crate::constants::MAX_SEARCH_QUERY_CHARS || query.contains('\0') { query.clear(); } self.shared.search.store(search, Ordering::Release); diff --git a/crates/pdf-engine/src/navigation.rs b/crates/pdf-engine/src/navigation.rs index 62d5398..7c26ceb 100644 --- a/crates/pdf-engine/src/navigation.rs +++ b/crates/pdf-engine/src/navigation.rs @@ -14,7 +14,11 @@ impl Navigation { self.outlines = true; } pub fn request_links(&mut self, pages: Vec, count: usize) { - self.pages = pages.into_iter().filter(|p| *p < count).take(16).collect(); + self.pages = pages + .into_iter() + .filter(|p| *p < count) + .take(crate::constants::MAX_LINK_PAGES) + .collect(); } pub fn tick( &mut self, @@ -84,13 +88,13 @@ fn destination( }; } } - if uri.len() <= 8192 && !uri.chars().any(char::is_control) { + if uri.len() <= crate::constants::MAX_URI_CHARS && !uri.chars().any(char::is_control) { let lower = uri.to_ascii_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") { return LinkDestination::External(uri.into()); } } - LinkDestination::Unsupported(limited(uri, 256)) + LinkDestination::Unsupported(limited(uri, crate::constants::MAX_TRUNCATED_URI_CHARS)) } pub(crate) fn outlines(doc: &Document) -> Result<(Vec, bool), String> { let tree = doc.outlines().map_err(|e| e.to_string())?; @@ -98,16 +102,16 @@ pub(crate) fn outlines(doc: &Document) -> Result<(Vec, bool), Stri let mut entries = Vec::new(); let mut truncated = false; while let Some((node, depth)) = stack.pop() { - if entries.len() == 4096 { + if entries.len() == crate::constants::MAX_OUTLINE_ENTRIES { truncated = true; break; } entries.push(OutlineEntry { - title: limited(&node.title, 512), + title: limited(&node.title, crate::constants::MAX_OUTLINE_TITLE_CHARS), depth, destination: destination(doc, node.dest, node.uri.as_deref().unwrap_or("")), }); - if depth < 32 { + if depth < crate::constants::MAX_OUTLINE_DEPTH { stack.extend(node.down.iter().rev().map(|n| (n, depth + 1))); } else if !node.down.is_empty() { truncated = true; @@ -115,6 +119,7 @@ pub(crate) fn outlines(doc: &Document) -> Result<(Vec, bool), Stri } Ok((entries, truncated)) } + pub(crate) fn links(doc: &Document, page: usize) -> Result<(Vec, bool), String> { let page = doc.load_page(page as i32).map_err(|e| e.to_string())?; let bounds = page.bounds().map_err(|e| e.to_string())?; @@ -124,7 +129,7 @@ pub(crate) fn links(doc: &Document, page: usize) -> Result<(Vec, bool) let mut result = Vec::new(); let mut truncated = false; for (index, link) in page.links().map_err(|e| e.to_string())?.enumerate() { - if index == 1024 { + if index == crate::constants::MAX_LINKS { truncated = true; break; } diff --git a/crates/pdf-engine/src/raster.rs b/crates/pdf-engine/src/raster.rs index 212308c..0e6cdd1 100644 --- a/crates/pdf-engine/src/raster.rs +++ b/crates/pdf-engine/src/raster.rs @@ -1,52 +1,36 @@ -use crate::EngineError; -use mupdf::{Colorspace, Device, DisplayList, IRect, Matrix, Pixmap, Rect}; +use crate::backend::DisplayListBackend; +use crate::{EngineError, constants}; use viewer_core::{PixelTile, TileKey}; -pub(crate) fn render(list: &DisplayList, key: TileKey) -> Result { - if key.width == 0 || key.height == 0 || key.width > 512 || key.height > 512 || key.rotation != 0 +pub(crate) fn render( + list: &dyn DisplayListBackend, + key: TileKey, +) -> Result { + if key.width == 0 + || key.height == 0 + || key.width > constants::MAX_TILE_DIMENSION + || key.height > constants::MAX_TILE_DIMENSION + || key.rotation != 0 { return Err(EngineError::Render( "Invalid tile dimensions or rotation".into(), )); } - let raster = || -> Result { - let cs = Colorspace::device_rgb(); - let mut pixmap = Pixmap::new_with_w_h(&cs, key.width as i32, key.height as i32, false)?; - pixmap.clear_with(255)?; - let device = Device::from_pixmap_with_clip( - &pixmap, - IRect::new(0, 0, key.width as i32, key.height as i32), - )?; - let bounds = list.bounds(); - let scale = key.scale_milli as f32 / 1000.0; - let matrix = Matrix::new( - scale, - 0.0, - 0.0, - scale, - -bounds.x0 * scale - key.x as f32, - -bounds.y0 * scale - key.y as f32, - ); - list.run( - &device, - &matrix, - Rect::new(0.0, 0.0, key.width as f32, key.height as f32), - )?; - drop(device); - let stride = pixmap.stride() as usize; - let mut rgba = Vec::with_capacity((key.width * key.height * 4) as usize); - for row in pixmap.samples().chunks(stride).take(key.height as usize) { - for pixel in row[..key.width as usize * 3].chunks_exact(3) { - rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]); - } - } - Ok(PixelTile { - width: key.width, - height: key.height, - rgba, - }) - }; - raster().map_err(|error| EngineError::Render(error.to_string())) + let result = list + .render( + key.width, + key.height, + key.rotation as u32, + key.scale_milli, + key.x as f32, + key.y as f32, + ) + .map_err(EngineError::Render)?; + Ok(PixelTile { + width: key.width, + height: key.height, + rgba: result.rgba, + }) } #[cfg(test)] @@ -61,8 +45,8 @@ mod tests { let list = page.to_display_list(true).unwrap(); let full = page .to_pixmap( - &Matrix::new_scale(2.0, 2.0), - &Colorspace::device_rgb(), + &mupdf::Matrix::new_scale(2.0, 2.0), + &mupdf::Colorspace::device_rgb(), false, true, ) @@ -77,7 +61,9 @@ mod tests { width: 100, height: 100, }; - let tile = render(&list, key).unwrap(); + let backend_list: Box = + Box::new(crate::backend::mupdf::MupdfDisplayList::new(list)); + let tile = render(backend_list.as_ref(), key).unwrap(); for y in 0..100usize { for x in 0..100usize { let index = (y + 512) * full.stride() as usize + (x + 512) * 3; diff --git a/crates/pdf-engine/src/reader.rs b/crates/pdf-engine/src/reader.rs index f9eb869..d4f7a6c 100644 --- a/crates/pdf-engine/src/reader.rs +++ b/crates/pdf-engine/src/reader.rs @@ -6,9 +6,6 @@ use std::{ }; use viewer_core::{PageText, PixelTile, SearchHit, TextGlyph, TextRect, TileKey}; -const MAX_GLYPHS: usize = 100_000; -const MAX_HITS: usize = 10_000; - #[derive(Default)] pub(crate) struct Reader { text: VecDeque, @@ -28,14 +25,22 @@ struct Search { impl Reader { pub fn text_pages(&mut self, pages: Vec, count: usize) { - let pages: Vec<_> = pages.into_iter().filter(|p| *p < count).take(16).collect(); + let pages: Vec<_> = pages + .into_iter() + .filter(|p| *p < count) + .take(crate::constants::MAX_TEXT_PAGES) + .collect(); self.text_cache.retain(|p, _| pages.contains(p)); // Every latest-value command republishes its wanted snapshots. A leave/reenter // transition may have been coalesced away after the UI dropped its reference. self.text = pages.into(); } pub fn thumbnails(&mut self, pages: Vec, count: usize) { - self.thumbnail_wanted = pages.into_iter().filter(|p| *p < count).take(32).collect(); + self.thumbnail_wanted = pages + .into_iter() + .filter(|p| *p < count) + .take(crate::constants::MAX_THUMBNAIL_PAGES) + .collect(); self.thumbnail_cache .retain(|p, _| self.thumbnail_wanted.contains(p)); self.thumbnails = self.thumbnail_wanted.iter().copied().collect(); @@ -63,7 +68,7 @@ impl Reader { Some(pixels) } pub fn search(&mut self, id: u64, mut query: String) { - if query.len() > 512 || query.contains('\0') { + if query.len() > crate::constants::MAX_SEARCH_QUERY_CHARS || query.contains('\0') { query.clear(); } self.search = Some(Search { @@ -127,7 +132,7 @@ impl Reader { text.search_cb(&task.query, &mut hits, |hits, quads| { if !shared.current_document(document) || shared.search.load(Ordering::Acquire) != task.id - || task.hits + hits.len() >= MAX_HITS + || task.hits + hits.len() >= crate::constants::MAX_HITS { return SearchHitResponse::AbortSearch; } @@ -148,7 +153,7 @@ impl Reader { task.cursor += 1; } task.hits += hits.len(); - let truncated = task.hits >= MAX_HITS; + let truncated = task.hits >= crate::constants::MAX_HITS; let done = task.query.is_empty() || task.cursor >= count || truncated; let search_id = task.id; let event = Event::SearchPage { @@ -198,7 +203,7 @@ fn extract(doc: &Document, page: usize) -> Result { for block in text.blocks() { for line in block.lines() { for ch in line.chars() { - if output.glyphs.len() == MAX_GLYPHS { + if output.glyphs.len() == crate::constants::MAX_GLYPHS { output.truncated = true; return Ok(output); } diff --git a/crates/pdf-engine/src/rendering.rs b/crates/pdf-engine/src/rendering.rs new file mode 100644 index 0000000..c4ad4d6 --- /dev/null +++ b/crates/pdf-engine/src/rendering.rs @@ -0,0 +1,333 @@ +//! Rendering subsystem: render queue, tile dispatch, and geometry discovery. +//! pdfium-render does not cache display lists; pages are rendered on demand +//! with PdfRenderConfig specifying the tile region and scale. + +use crate::{Event, raster, Shared, View}; +use crossbeam_channel::Sender; +use pdfium_render::prelude::*; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{Arc, atomic::Ordering}, + time::{Duration, Instant}, +}; +use viewer_core::{PageSize, PixelTile, RenderQueue, RenderRequest, TileKey}; + +/// A render job handed to the rendering subsystem. +pub(crate) struct Job { + pub content: u64, + pub request: RenderRequest, + pub thumbnail: bool, +} + +/// A completed render result. +pub(crate) struct Completed { + pub content: u64, + pub thumbnail: bool, + pub request: RenderRequest, + pub pixels: Option>, + pub elapsed: Duration, +} + +/// Emits an event through the bounded channel, respecting shutdown. +fn emit(shared: &Shared, events: &Sender, mut event: Event) -> bool { + loop { + match events.send_timeout(event, Duration::from_millis(crate::constants::CHANNEL_SEND_TIMEOUT_MILLIS)) { + Ok(()) => return true, + Err(crossbeam_channel::SendTimeoutError::Timeout(value)) => { + if shared.stopping.load(Ordering::Acquire) { + return false; + } + event = value; + } + Err(_) => return false, + } + } +} + +/// The rendering subsystem owns the render queue and tile dispatch. +/// Pages are held by the coordinator and passed in on each tick. +pub(crate) struct Rendering { + shared: Arc, + events: Sender, + document_id: u64, + queue: RenderQueue, + geometry: Vec>, + cursor: usize, + inflight: HashSet<(TileKey, u64, bool, u64)>, + deferred_results: VecDeque, + content: u64, +} + +impl Rendering { + pub(crate) fn new( + shared: Arc, + events: Sender, + document_id: u64, + ) -> Self { + Self { + shared, + events, + document_id, + queue: RenderQueue::new(crate::constants::RENDER_QUEUE_CAPACITY), + geometry: Vec::new(), + cursor: 0, + inflight: HashSet::new(), + deferred_results: VecDeque::new(), + content: 0, + } + } + + /// Reset all rendering state for a newly opened document. + pub(crate) fn reset(&mut self, pages: usize, first_page: PageSize) { + self.geometry = vec![None; pages]; + self.geometry[0] = Some(first_page); + self.cursor = 1; + self.queue.replace(Vec::new()); + self.inflight.clear(); + self.deferred_results.clear(); + } + + /// Reset rendering state after an edit (content changed). + pub(crate) fn reset_after_edit(&mut self, pages: usize, first_page: PageSize, content: u64) { + self.content = content; + self.geometry = vec![None; pages]; + self.geometry[0] = Some(first_page); + self.cursor = 1; + self.queue.replace(vec![]); + } + + /// Process a view request: replace the render queue with the requested tiles. + pub(crate) fn handle_view(&mut self, view: View, content: u64) { + self.queue.replace( + view.requests + .into_iter() + .filter(|r| !self.inflight.contains(&(r.key, r.generation, false, content))) + .collect(), + ); + } + + /// Process completed render results. Returns events to emit. + pub(crate) fn process_results(&mut self) -> Vec { + let mut events = Vec::new(); + + while let Some(result) = self + .deferred_results + .pop_front() + { + self.inflight.remove(&( + result.request.key, + result.request.generation, + result.thumbnail, + result.content, + )); + if !self + .shared + .current_content(result.request.key.document, result.content) + { + continue; + } + + if result.thumbnail { + if self.shared.current_document(result.request.key.document) { + match result.pixels { + Some(Ok(pixels)) => { + events.push(Event::Thumbnail { + key: result.request.key, + pixels: Arc::new(pixels), + elapsed: result.elapsed, + }); + } + Some(Err(error)) => { + events.push(Event::ReaderError { + document: self.document_id, + operation: "thumbnail", + page: Some(result.request.key.page), + message: error.to_string(), + }); + } + None => {} + } + } + continue; + } + + if !self + .shared + .current(result.request.key.document, result.request.generation) + { + continue; + } + match result.pixels { + Some(Ok(pixels)) => { + events.push(Event::Tile { + key: result.request.key, + generation: result.request.generation, + pixels: Arc::new(pixels), + elapsed: result.elapsed, + }); + } + Some(Err(error)) => { + events.push(Event::Error { + document: self.document_id, + error: error, + }); + } + None => {} + } + } + + events + } + + /// Main rendering tick: renders tiles from the queue synchronously. + /// pdfium-render serializes all calls internally, so parallel workers add no benefit. + pub(crate) fn tick( + &mut self, + doc: &PdfDocument, + can_copy: bool, + shared: &Shared, + ) -> Vec { + let mut events = Vec::new(); + + // Dispatch render jobs from the queue. + while self.inflight.is_empty() { + let Some(request) = self.queue.pop() else { + break; + }; + if !shared.current(request.key.document, request.generation) { + continue; + } + let page = request.key.page; + if page >= self.geometry.len() { + continue; + } + + let started = Instant::now(); + let pages = doc.pages(); + let pdf_page = match pages.get(page) { + Some(p) => p, + None => continue, + }; + + // Cache geometry on first render of this page. + let bounds = pdf_page.boundaries().map_err(|e| e.to_string()); + match bounds { + Ok(b) => { + let size = PageSize::new(b.width(), b.height()); + if self.geometry[page] != Some(size) { + self.geometry[page] = Some(size); + events.push(Event::Geometry { + document: self.document_id, + pages: vec![(page, size)], + }); + } + } + Err(error) => { + events.push(Event::Error { + document: self.document_id, + error: crate::EngineError::Render(error), + }); + continue; + } + } + + self.inflight.insert((request.key, request.generation, false, self.content)); + let result = raster::render(pdf_page, request.key); + self.inflight.remove(&(request.key, request.generation, false, self.content)); + + match result { + Ok(pixels) => { + events.push(Event::Tile { + key: request.key, + generation: request.generation, + pixels: Arc::new(pixels), + elapsed: started.elapsed(), + }); + } + Err(error) => { + events.push(Event::Error { + document: self.document_id, + error, + }); + } + } + } + + // Handle thumbnail rendering. + if self.queue.is_empty() && self.inflight.is_empty() { + // Thumbnails are rendered via the coordinator's reader subsystem. + // The rendering tick only handles main tile rendering. + } + + events + } + + /// Advance geometry discovery: one page per call. + pub(crate) fn advance_geometry(&mut self, doc: &PdfDocument) -> Vec { + let mut events = Vec::new(); + + while self.cursor < self.geometry.len() && self.geometry[self.cursor].is_some() { + self.cursor += 1; + } + if self.cursor < self.geometry.len() && self.queue.is_empty() { + let pages = doc.pages(); + if let Some(pdf_page) = pages.get(self.cursor) { + if let Ok(bounds) = pdf_page.boundaries() { + let size = PageSize::new(bounds.width(), bounds.height()); + self.geometry[self.cursor] = Some(size); + events.push(Event::Geometry { + document: self.document_id, + pages: vec![(self.cursor, size)], + }); + } + } + self.cursor += 1; + } + + events + } + + /// Access the render queue for the coordinator to inspect. + pub(crate) fn queue(&self) -> &RenderQueue { + &self.queue + } + + /// Access the inflight set for the coordinator to inspect. + pub(crate) fn inflight(&self) -> &HashSet<(TileKey, u64, bool, u64)> { + &self.inflight + } + + /// Access the geometry vector. + pub(crate) fn geometry(&self) -> &Vec> { + &self.geometry + } + + /// Set the content epoch. + pub(crate) fn set_content(&mut self, content: u64) { + self.content = content; + } + + pub(crate) fn spawn_workers( + &mut self, + _shared: Arc, + _job_rx: crossbeam_channel::Receiver, + _completed: crossbeam_channel::Sender, + ) -> Vec> { + // pdfium-render serializes all calls internally, so no worker threads are needed. + Vec::new() + } + + pub(crate) fn shutdown(&mut self) { + // No threads to join. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendering_module_compiles() { + let _ = std::ptr::null::(); + } +} diff --git a/crates/pdf-engine/tests/reader.rs b/crates/pdf-engine/tests/reader.rs index 910d5ab..9fa5c28 100644 --- a/crates/pdf-engine/tests/reader.rs +++ b/crates/pdf-engine/tests/reader.rs @@ -139,7 +139,7 @@ fn thumbnails_are_small_complete_pages_and_survive_viewport_generation_changes() }; assert!(key.width <= 140 && key.height <= 160); assert_eq!(pixels.rgba.len(), (key.width * key.height * 4) as usize); - assert!(pixels.rgba.chunks_exact(4).any(|p| p[0] < 240)); + assert!(pixels.rgba.as_chunks::<4>().0.iter().any(|p| p[0] < 240)); thumbnails.insert(key.page, pixels); } engine.thumbnails(1, vec![0, 1, 2]); diff --git a/crates/pdf-engine/tests/render.rs b/crates/pdf-engine/tests/render.rs index 20cccc8..e2c1254 100644 --- a/crates/pdf-engine/tests/render.rs +++ b/crates/pdf-engine/tests/render.rs @@ -52,7 +52,7 @@ fn real_pdf_renders_clipped_pixels_on_workers() { pixels.rgba.len(), (pixels.width * pixels.height * 4) as usize ); - colored |= pixels.rgba.chunks_exact(4).any(|p| p[0] != p[1]); + colored |= pixels.rgba.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]); tiles += 1; } Event::Error { error, .. } => panic!("{error}"), @@ -213,7 +213,12 @@ fn corpus_first_pages_render_without_background_ui_work() { match engine.events.recv_timeout(Duration::from_secs(20)).unwrap() { Event::Tile { key, pixels, .. } if key.document == document => { assert!(!pixels.rgba.is_empty()); - has_ink |= pixels.rgba.chunks_exact(4).any(|pixel| pixel[0] < 240); + has_ink |= pixels + .rgba + .as_chunks::<4>() + .0 + .iter() + .any(|pixel| pixel[0] < 240); completed += 1; if completed == tile_count { break; diff --git a/crates/pdfree/src/app.rs b/crates/pdfree/src/app.rs index 5272c05..9615de2 100644 --- a/crates/pdfree/src/app.rs +++ b/crates/pdfree/src/app.rs @@ -1,53 +1,46 @@ -use crate::{AppWindow, PageSurface, TileSurface, trace::Trace}; +use crate::{AppWindow, trace::Trace}; +use benchmark::{BenchmarkAction, BenchmarkManager}; +use document_manager::DocumentManager; use pdf_engine::{Engine, EngineError, Event}; -use slint::{ComponentHandle, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel}; -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, - time::{Duration, Instant}, -}; -use viewer_core::{Layout, PageSize, ReadingMode, TileCache, TileKey, Viewport, clamp_zoom}; +use render_manager::RenderManager; +use slint::{ComponentHandle, ModelRc, VecModel}; +use std::{path::PathBuf, time::Duration}; +use viewer_core::Viewport; +use viewport::ViewportManager; #[cfg(test)] #[path = "reader_tests.rs"] mod reader_tests; +#[path = "benchmark.rs"] +mod benchmark; +#[path = "document_manager.rs"] +mod document_manager; #[path = "editing.rs"] mod editing; #[path = "forms.rs"] mod forms; +#[path = "navigation.rs"] +mod navigation; +#[path = "render_manager.rs"] +mod render_manager; +#[path = "viewport.rs"] +mod viewport; + pub use editing::ToolDialog; pub struct State { pub(crate) engine: Engine, trace: Trace, - pub(crate) document: u64, - generation: u64, - sizes: Vec, - pub(crate) layout: Layout, - pub(crate) zoom: f32, - cache: TileCache, - images: HashMap, - previews: HashMap, - previous_view: Option<[f32; 6]>, - dirty: bool, - first_tile: bool, - first_pixels_at: Instant, - current_page: usize, - reading_page: usize, - mode: ReadingMode, - pointer: Option<(usize, f32, f32)>, - pub(crate) navigation: crate::navigation::Navigation, - open_started: Instant, - zoom_started: Instant, - render_error: Option, - benchmark: bool, - benchmark_stage: u8, - pub dialog_open: bool, + pub(crate) document: DocumentManager, + viewport: ViewportManager, + render: RenderManager, + benchmark: BenchmarkManager, + pub(crate) navigation: navigation::Navigation, pub(crate) tools: crate::features::ReaderTools, - path: Option, pub(crate) editor: editing::Editor, pub(crate) forms: forms::Forms, + pub(crate) dialog_open: bool, } impl State { @@ -60,33 +53,15 @@ impl State { Self { engine: Engine::spawn(workers), trace, - document: 0, - generation: 0, - sizes: Vec::new(), - layout: Layout::new(&[], 1.0, 1.0), - zoom: 1.0, - cache: TileCache::new(128 * 1024 * 1024), - images: HashMap::new(), - previews: HashMap::new(), - previous_view: None, - dirty: true, - first_tile: false, - first_pixels_at: Instant::now(), - current_page: usize::MAX, - reading_page: 0, - mode: ReadingMode::Continuous, - pointer: None, + document: DocumentManager::new(), + viewport: ViewportManager::new(), + render: RenderManager::new(), + benchmark: BenchmarkManager::new(benchmark), navigation: Default::default(), - open_started: Instant::now(), - zoom_started: Instant::now(), - render_error: None, - benchmark, - benchmark_stage: 0, - dialog_open: false, tools: Default::default(), - path: None, editor: Default::default(), forms: Default::default(), + dialog_open: false, } } @@ -99,15 +74,12 @@ impl State { self.reset_editor(ui); self.tools.reset(ui); self.navigation.reset(ui); - self.reading_page = 0; - self.pointer = None; - self.path = Some(path.clone()); - self.document += 1; - self.generation += 1; - self.sizes.clear(); - self.cache.clear(); - self.images.clear(); - self.previews.clear(); + self.viewport.reset_for_open(); + let doc = self + .document + .open_password(ui, &self.engine, path, password); + self.viewport.generation += 1; + self.render.reset_for_open(); ui.set_tiles(ModelRc::default()); ui.set_previews(ModelRc::default()); ui.set_pages(ModelRc::default()); @@ -117,84 +89,45 @@ impl State { ui.set_page_count(0); ui.set_scroll_x(0.0); ui.set_scroll_y(0.0); - ui.set_status("Opening PDF…".into()); - ui.set_detail("Preparing the first page".into()); - ui.set_document_name( - path.file_name() - .unwrap_or_default() - .to_string_lossy() - .as_ref() - .into(), - ); - self.zoom = 1.0; - self.previous_view = None; - self.first_tile = false; - self.render_error = None; - self.current_page = usize::MAX; - self.benchmark_stage = 0; - self.open_started = Instant::now(); + self.benchmark.reset_zoom_timer(); self.trace.event( "document_open_started", - serde_json::json!({"document":self.document}), + serde_json::json!({"document": doc}), ); - self.engine - .open_with_password(self.document, self.generation, path, password); } pub fn password(&mut self, ui: &AppWindow, password: String) { ui.set_password("".into()); if ui.get_password_required() - && let Some(path) = self.path.clone() + && let Some(path) = self.document.path.clone() { self.open_password(ui, path, Some(password)); } } pub fn search(&mut self, ui: &AppWindow, query: String) { - self.tools.query(ui, &self.engine, self.document, query); - self.tools.overlays(ui, &self.layout, self.zoom); + self.tools + .query(ui, &self.engine, self.document.document, query); + self.tools + .overlays(ui, &self.viewport.layout, self.viewport.zoom); } - pub fn reader_position(&self, x: f32, y: f32, nearest: bool) -> Option<(usize, f32, f32)> { - if !x.is_finite() || !y.is_finite() { - return None; - } - let (page, rect) = self - .layout - .pages - .iter() - .enumerate() - .filter(|(_, r)| r.width > 0.) - .min_by(|(_, a), (_, b)| { - let d = |r: &viewer_core::PageRect| { - (r.x - x).max(0.).max(x - r.x - r.width).powi(2) - + (r.y - y).max(0.).max(y - r.y - r.height).powi(2) - }; - d(a).total_cmp(&d(b)) - })?; - if !nearest - && (x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height) - { - return None; - } - Some((page, x - rect.x, y - rect.y)) - } pub fn reader_pointer(&mut self, ui: &AppWindow, x: f32, y: f32, phase: i32) { if ui.get_form_mode() > 0 { self.form_pointer(ui, x, y, phase); return; } if phase == 3 { - self.pointer = None; + self.viewport.pointer = None; return; } - if phase != 0 && self.pointer.is_none() { + if phase != 0 && self.viewport.pointer.is_none() { return; } if phase == 0 || phase == 2 { - self.pointer = None; + self.viewport.pointer = None; } - if let Some((page, x, y)) = self.reader_position(x, y, phase != 0) { + if let Some((page, x, y)) = self.viewport.reader_position(x, y, phase != 0) { if ui.get_note_placement() && phase == 0 { self.page_point(ui, page, x, y); } else if !ui.get_note_visible() && !ui.get_note_placement() { @@ -202,35 +135,40 @@ impl State { } } else if phase == 0 { self.tools.clear_selection(ui); - self.tools.overlays(ui, &self.layout, self.zoom); + self.tools + .overlays(ui, &self.viewport.layout, self.viewport.zoom); } } + pub fn reader_click(&mut self, ui: &AppWindow, x: f32, y: f32) { if ui.get_form_mode() > 0 { return; } - if let Some((page, x, y)) = self.reader_position(x, y, false) { + if let Some((page, x, y)) = self.viewport.reader_position(x, y, false) { self.activate_link(ui, page, x, y); } } + pub fn reader_hover(&self, ui: &AppWindow, x: f32, y: f32) { - if let Some((page, x, y)) = self.reader_position(x, y, false) { + if let Some((page, x, y)) = self.viewport.reader_position(x, y, false) { self.hover_link(ui, page, x, y); } else { ui.set_link_hover(false); } } + pub fn select_text(&mut self, ui: &AppWindow, page: usize, x: f32, y: f32, phase: i32) { if !ui.get_can_copy() { - self.pointer = None; + self.viewport.pointer = None; return; } - let Some(rect) = self.layout.pages.get(page).copied() else { + let Some(rect) = self.viewport.layout.pages.get(page).copied() else { return; }; let doc_x = rect.x + x; let doc_y = rect.y + y; let target = self + .viewport .layout .pages .iter() @@ -245,43 +183,27 @@ impl State { }) .map(|(i, _)| i) .unwrap_or(page); - let end = self.layout.pages[target]; - self.pointer = if phase == 2 { None } else { Some((page, x, y)) }; + let end = self.viewport.layout.pages[target]; + self.viewport.pointer = if phase == 2 { None } else { Some((page, x, y)) }; + self.tools.select( + ui, + target, + doc_x - end.x, + doc_y - end.y, + phase, + self.viewport.zoom, + ); self.tools - .select(ui, target, doc_x - end.x, doc_y - end.y, phase, self.zoom); - self.tools.overlays(ui, &self.layout, self.zoom); - self.dirty = true; + .overlays(ui, &self.viewport.layout, self.viewport.zoom); + self.viewport.dirty = true; } pub fn navigate(&mut self, ui: &AppWindow, page: usize) { - self.pointer = None; - if self.sizes.is_empty() { - return; - } - let page = page.min(self.sizes.len() - 1); - self.reading_page = page; - if self.mode != ReadingMode::Continuous { - self.layout = Layout::with_mode( - &self.sizes, - self.zoom, - (ui.get_view_width() - 14.).max(1.), - self.mode, - page, - ); - ui.set_document_height(self.layout.height); - ui.set_document_width(self.layout.width); - self.dirty = true; - } - ui.set_page_entry((page + 1).to_string().into()); - ui.set_scroll_y( - -self.layout.pages[page] - .y - .min((self.layout.height - ui.get_view_height()).max(0.0)), - ); + self.viewport.navigate(ui, page); } pub fn action(&mut self, ui: &AppWindow, action: &str) { - self.pointer = None; + self.viewport.pointer = None; if ui.get_pending_open() && !action.starts_with("pending-") { return; } @@ -293,32 +215,16 @@ impl State { } match action { "password-cancel" => { - // Replace any pending authentication with an invalidated document identity. - self.document += 1; - self.generation += 1; + self.document.invalidate(); + self.viewport.generation += 1; self.tools.reset(ui); - self.path = None; ui.set_loading(false); ui.set_status("Open a PDF to get started".into()); ui.set_detail("Password entry cancelled.".into()); return; } "view-continuous" | "view-single" | "view-facing" => { - self.reading_page = self.current_page.min(self.sizes.len().saturating_sub(1)); - self.mode = match action { - "view-single" => ReadingMode::Single, - "view-facing" => ReadingMode::Facing, - _ => ReadingMode::Continuous, - }; - ui.set_reading_mode(match self.mode { - ReadingMode::Continuous => 0, - ReadingMode::Single => 1, - ReadingMode::Facing => 2, - }); - self.tools.clear_selection(ui); - self.pointer = None; - self.navigate(ui, self.reading_page); - self.dirty = true; + self.viewport.handle_view_mode(ui, action); return; } "bookmarks" => { @@ -339,9 +245,12 @@ impl State { return; } "select-all" => { - if ui.get_ready() && !self.sizes.is_empty() { - let page = self.current_page.min(self.sizes.len() - 1); - let rect = self.layout.pages[page]; + if ui.get_ready() && !self.viewport.sizes.is_empty() { + let page = self + .viewport + .current_page + .min(self.viewport.sizes.len() - 1); + let rect = self.viewport.layout.pages[page]; self.select_text(ui, page, 0.0, 0.0, 0); self.select_text(ui, page, rect.width, rect.height, 2); } @@ -358,95 +267,38 @@ impl State { if let Some((page, top)) = self.tools.next_hit(action == "search-previous") { self.navigate(ui, page); ui.set_scroll_y( - -(self.layout.pages[page].y + top * self.zoom * 96.0 / 72.0 - 60.0) - .clamp(0.0, (self.layout.height - ui.get_view_height()).max(0.0)), + -(self.viewport.layout.pages[page].y + + top * self.viewport.zoom * 96.0 / 72.0 + - 60.0) + .clamp( + 0.0, + (self.viewport.layout.height - ui.get_view_height()).max(0.0), + ), ); - self.tools.overlays(ui, &self.layout, self.zoom); + self.tools + .overlays(ui, &self.viewport.layout, self.viewport.zoom); } return; } _ => {} } - if self.sizes.is_empty() { + if self.viewport.sizes.is_empty() { return; } - let current = self.layout.page_at(-ui.get_scroll_y() + 30.0); - let size = self.sizes[current]; - let pair = if self.mode == ReadingMode::Facing { - self.sizes.get(current + 1).copied() - } else { - None - }; - let fit_width = size.width + pair.map_or(0., |s| s.width); - let fit_height = size.height.max(pair.map_or(0., |s| s.height)); - let fit_space = if pair.is_some() { 78. } else { 62. }; - let new_zoom = match action { - "zoom-in" => Some(self.zoom * 1.25), - "zoom-out" => Some(self.zoom / 1.25), - "fit-width" => Some((ui.get_view_width() - fit_space) / (fit_width * 96.0 / 72.0)), - "fit-page" => Some( - ((ui.get_view_width() - fit_space) / (fit_width * 96.0 / 72.0)) - .min((ui.get_view_height() - 48.0) / (fit_height * 96.0 / 72.0)), - ), - _ => None, - }; - if let Some(zoom) = new_zoom { + let current = self.viewport.layout.page_at(-ui.get_scroll_y() + 30.0); + if let Some(new_zoom) = self.viewport.compute_zoom(ui, action) { if action == "fit-page" || action == "fit-width" { ui.set_fit_page(action == "fit-page"); } - let old_zoom = self.zoom; - let old_y = self.layout.pages[current].y; - let offset = (-ui.get_scroll_y() - old_y).max(0.0); - self.zoom = clamp_zoom(zoom); - if self.zoom == old_zoom { - return; - } - self.previews = std::mem::take(&mut self.images); - self.zoom_started = Instant::now(); - self.layout = Layout::with_mode( - &self.sizes, - self.zoom, - (ui.get_view_width() - 14.).max(1.), - self.mode, - self.reading_page, - ); - ui.set_document_height(self.layout.height); - ui.set_document_width(self.layout.width); - ui.set_scroll_y( - -(self.layout.pages[current].y + offset * self.zoom / old_zoom) - .clamp(0.0, (self.layout.height - ui.get_view_height()).max(0.0)), - ); - self.dirty = true; + let old_zoom = self.viewport.zoom; + let page_y = self.viewport.layout.pages[current].y; + let offset = (-ui.get_scroll_y() - page_y).max(0.0); + self.viewport + .apply_zoom(ui, current, old_zoom, new_zoom, offset); self.trace - .event("zoom_input", serde_json::json!({"zoom":self.zoom})); + .event("zoom_input", serde_json::json!({"zoom":self.viewport.zoom})); } else { - match action { - "next" => self.navigate( - ui, - current - + if self.mode == ReadingMode::Facing { - 2 - } else { - 1 - }, - ), - "previous" => self.navigate( - ui, - current.saturating_sub(if self.mode == ReadingMode::Facing { - 2 - } else { - 1 - }), - ), - "first" => self.navigate(ui, 0), - "last" => self.navigate(ui, self.sizes.len() - 1), - "down" => ui.set_scroll_y( - (ui.get_scroll_y() - 80.0) - .max(-(self.layout.height - ui.get_view_height()).max(0.0)), - ), - "up" => ui.set_scroll_y((ui.get_scroll_y() + 80.0).min(0.0)), - _ => {} - } + self.viewport.handle_navigation_action(ui, action, current); } } @@ -457,26 +309,32 @@ impl State { let Ok(event) = self.engine.events.try_recv() else { break; }; - changed |= self.tools.event(ui, self.document, &event); + changed |= self.tools.event(ui, self.document.document, &event); self.navigation - .event(ui, self.document, self.editor.revision, &event); + .event(ui, self.document.document, self.editor.revision, &event); changed |= self.edit_event(ui, &event); changed |= self.form_event(ui, &event); - if matches!(&event,Event::Changed{document,..} if *document==self.document) { + if matches!(&event, Event::Changed { document, .. } if *document == self.document.document) + { self.navigation.reset(ui); - self.engine.outlines(self.document, self.editor.revision); - self.reading_page = self.reading_page.min(self.sizes.len().saturating_sub(1)); - self.pointer = None; + self.engine + .outlines(self.document.document, self.editor.revision); + self.viewport.reading_page = self + .viewport + .reading_page + .min(self.viewport.sizes.len().saturating_sub(1)); + self.viewport.pointer = None; } match event { Event::Opened { document, info, elapsed, - } if document == self.document => { + } if document == self.document.document => { self.navigation.reset(ui); - self.engine.outlines(self.document, self.editor.revision); - self.sizes = vec![info.first_page; info.pages]; + self.engine + .outlines(self.document.document, self.editor.revision); + self.viewport.sizes = vec![info.first_page; info.pages]; ui.set_page_count(info.pages as i32); ui.set_page_numbers(ModelRc::new(VecModel::from( (1..=info.pages as i32).collect::>(), @@ -490,14 +348,19 @@ impl State { ui.set_can_annotate(info.can_annotate); ui.set_can_assemble(info.can_assemble); ui.set_password_required(false); - self.dirty = true; + self.viewport.dirty = true; self.trace.event("document_parsed", serde_json::json!({"pages":info.pages,"engine_ms":elapsed.as_secs_f64()*1000.0})); } - Event::Geometry { document, pages } if document == self.document => { + Event::Geometry { document, pages } if document == self.document.document => { for (index, size) in pages { - if self.sizes.get(index).is_some_and(|old| *old != size) { - self.sizes[index] = size; - self.dirty = true; + if self + .viewport + .sizes + .get(index) + .is_some_and(|old| *old != size) + { + self.viewport.sizes[index] = size; + self.viewport.dirty = true; } } } @@ -505,7 +368,7 @@ impl State { document, page, elapsed, - } if document == self.document => { + } if document == self.document.document => { self.trace.event( "display_list_ready", serde_json::json!({"page":page,"duration_ms":elapsed.as_secs_f64()*1000.0}), @@ -517,24 +380,22 @@ impl State { pixels, elapsed, } => { - if !viewer_core::accept_result( - self.document, - self.generation, - key.document, + if self.render.insert_tile( + key, + pixels, generation, + self.document.document, + self.viewport.generation, ) { + self.trace.event("tile_available_to_ui", serde_json::json!({"page":key.page,"render_ms":elapsed.as_secs_f64()*1000.0,"generation":generation})); + changed = true; + } else { self.trace .event("stale_tile_discarded", serde_json::json!({})); - continue; } - self.cache.insert(key, pixels); - self.trace.event("tile_available_to_ui", serde_json::json!({"page":key.page,"render_ms":elapsed.as_secs_f64()*1000.0,"generation":generation})); - changed = true; } - Event::Error { document, error } if document == self.document => { - if matches!(error, EngineError::Render(_)) { - self.render_error = Some(error.to_string()); - } + Event::Error { document, error } if document == self.document.document => { + self.render.handle_error(&error); ui.set_loading(false); if !matches!(error, EngineError::Render(_)) { ui.set_ready(false); @@ -558,31 +419,37 @@ impl State { )); self.trace .event("error", serde_json::json!({"message":error.to_string()})); - if self.benchmark { + if self.benchmark.enabled { let _ = slint::quit_event_loop(); } } _ => {} } } - if self.sizes.is_empty() { - if self.benchmark - && self.document == 0 - && self.open_started.elapsed() > Duration::from_millis(600) - { - self.trace.event("benchmark_complete", serde_json::json!({"empty_window":true,"cache_bytes":0,"cache_hits":0,"cache_misses":0})); + if self.viewport.sizes.is_empty() { + if let Some(event_data) = self.benchmark.benchmark_empty_window( + self.document.document, + self.document.open_started.elapsed(), + ) { + self.trace.event("benchmark_complete", event_data); let _ = slint::quit_event_loop(); } return; } - self.tools - .thumbnails(ui, &self.engine, self.document, self.sizes.len()); - if !self.previews.is_empty() && self.zoom_started.elapsed() > Duration::from_millis(750) { - self.previews.clear(); + self.tools.thumbnails( + ui, + &self.engine, + self.document.document, + self.viewport.sizes.len(), + ); + if !self.render.previews.is_empty() + && self.benchmark.zoom_started.elapsed() > Duration::from_millis(750) + { + self.render.previews.clear(); changed = true; } - if let Some((page, x, y)) = self.pointer - && let Some(rect) = self.layout.pages.get(page) + if let Some((page, x, y)) = self.viewport.pointer + && let Some(rect) = self.viewport.layout.pages.get(page) { let local_y = rect.y + y + ui.get_scroll_y(); let delta = if local_y < 32. { @@ -594,209 +461,103 @@ impl State { }; if delta != 0. { let old = -ui.get_scroll_y(); - let next = - (old + delta).clamp(0., (self.layout.height - ui.get_view_height()).max(0.)); + let next = (old + delta).clamp( + 0., + (self.viewport.layout.height - ui.get_view_height()).max(0.), + ); ui.set_scroll_y(-next); if next != old { self.select_text(ui, page, x, y + next - old, 1); } } } - let mut view = Viewport { + let view = Viewport { width: (ui.get_view_width() - 14.0).max(1.0), height: ui.get_view_height().max(1.0), scroll_x: (-ui.get_scroll_x()).max(0.0), scroll_y: (-ui.get_scroll_y()).max(0.0), - zoom: self.zoom, + zoom: self.viewport.zoom, dpi: ui.window().scale_factor(), }; - let signature = [ - view.width, - view.height, - view.scroll_x, - view.scroll_y, - view.zoom, - view.dpi, - ]; - let moved = self.previous_view != Some(signature); - if moved || self.dirty || changed { - if moved || self.dirty { - let anchor = self.layout.anchor(view.scroll_y); - let refined = self.dirty && !self.layout.pages.is_empty(); - if moved { - self.generation += 1; - self.render_error = None; - } - self.previous_view = Some(signature); - self.layout = Layout::with_mode( - &self.sizes, - self.zoom, - view.width, - self.mode, - self.reading_page, - ); - ui.set_document_width(self.layout.width); - ui.set_document_height(self.layout.height); - if refined { - view.scroll_y = self - .layout - .resolve_anchor(anchor) - .clamp(0.0, (self.layout.height - view.height).max(0.0)); - ui.set_scroll_y(-view.scroll_y); - self.previous_view = Some([ - view.width, - view.height, - view.scroll_x, - view.scroll_y, - view.zoom, - view.dpi, - ]); - } - self.dirty = false; - self.trace.event("viewport_changed", serde_json::json!({"generation":self.generation,"scroll_y":view.scroll_y,"zoom":self.zoom})); - } + let refresh_needed = self.viewport.refresh_layout(ui, &view); + if refresh_needed || changed { + self.trace.event("viewport_changed", serde_json::json!({"generation":self.viewport.generation,"scroll_y":view.scroll_y,"zoom":self.viewport.zoom})); self.refresh(ui, view); } - if self.benchmark && self.first_tile { - let elapsed = self.first_pixels_at.elapsed(); - if self.benchmark_stage == 0 && elapsed > Duration::from_millis(600) { - let result = crate::capture::snapshot(ui, "first-page"); - self.trace.event("snapshot", serde_json::json!({"stage":"first-page","success":result.is_ok(),"error":result.err().map(|e|e.to_string())})); - self.action(ui, "next"); - self.benchmark_stage = 1; - } - if self.benchmark_stage == 1 && elapsed > Duration::from_millis(1000) { - self.action(ui, "zoom-in"); - self.benchmark_stage = 2; - } - if self.benchmark_stage == 2 && elapsed > Duration::from_millis(1500) { - self.action(ui, "previous"); - self.benchmark_stage = 3; + if self.benchmark.enabled && self.render.first_tile { + let elapsed = self.render.first_pixels_at.elapsed(); + let (action, event_data) = self.benchmark.tick( + true, + elapsed, + self.document.open_started.elapsed(), + self.render.cache_bytes(), + self.render.cache.hits, + self.render.cache.misses, + ); + match action { + BenchmarkAction::Next => self.action(ui, "next"), + BenchmarkAction::ZoomIn => self.action(ui, "zoom-in"), + BenchmarkAction::Previous => self.action(ui, "previous"), + BenchmarkAction::Quit => { + if let Some(data) = event_data.clone() { + self.trace.event("benchmark_complete", data); + } + let _ = slint::quit_event_loop(); + } + BenchmarkAction::None => {} } - if elapsed > Duration::from_millis(2200) { - let _ = crate::capture::snapshot(ui, "after-zoom"); - self.trace.event("benchmark_complete", serde_json::json!({"cache_bytes":self.cache.bytes(),"cache_hits":self.cache.hits,"cache_misses":self.cache.misses,"native_store_limit_mib":pdf_engine::NATIVE_STORE_LIMIT_MIB})); - let _ = slint::quit_event_loop(); + if let Some(data) = event_data { + self.trace.event("benchmark_progress", data); } } } fn refresh(&mut self, ui: &AppWindow, view: Viewport) { - let visible = self.layout.visible_pages(view); + let visible = self.viewport.layout.visible_pages(view); let first = *visible.start(); let last = *visible.end(); let resident: Vec = - (first.saturating_sub(2)..=(last + 2).min(self.sizes.len() - 1)).collect(); + (first.saturating_sub(2)..=(last + 2).min(self.viewport.sizes.len() - 1)).collect(); let active: Vec<_> = resident .iter() .copied() - .filter(|&p| self.layout.pages[p].width > 0.) + .filter(|&p| self.viewport.layout.pages[p].width > 0.) .collect(); self.edit_refresh(ui, &active); self.form_refresh(ui, &active); self.tools.refresh( ui, &self.engine, - self.document, - &self.layout, - self.zoom, + self.document.document, + &self.viewport.layout, + self.viewport.zoom, &active, ); - self.navigation - .refresh(&self.engine, self.document, self.editor.revision, &resident); - let pages = resident - .iter() - .filter(|&&index| self.layout.pages[index].width > 0.) - .map(|&index| { - let page = self.layout.pages[index]; - PageSurface { - x: page.x, - y: page.y, - w: page.width, - h: page.height, - number: index as i32 + 1, - } - }) - .collect::>(); - ui.set_pages(ModelRc::new(VecModel::from(pages))); - let requests = self - .layout - .requests(self.document, self.generation, view, 2); - let desired_images: HashSet = requests - .iter() - .filter(|r| r.priority < 1000) - .map(|r| r.key) - .collect(); - self.images.retain(|key, _| desired_images.contains(key)); - let mut missing = Vec::new(); - for request in requests { - if self.images.contains_key(&request.key) { - continue; - } - if let Some(pixels) = self.cache.get(&request.key) { - if request.priority < 1000 { - self.images.entry(request.key).or_insert_with(|| { - let buffer = SharedPixelBuffer::::clone_from_slice( - &pixels.rgba, - pixels.width, - pixels.height, - ); - slint::Image::from_rgba8(buffer) - }); - } - } else { - missing.push(request); - } - } - if self.render_error.is_some() { - missing.clear(); - } - self.engine - .view(self.document, self.generation, missing, resident); - let tiles = self - .images - .iter() - .map(|(key, image)| self.surface(*key, image.clone())) - .collect::>(); - if !tiles.is_empty() && !self.first_tile { - self.first_tile = true; - self.first_pixels_at = Instant::now(); - self.trace.event("first_page_pixels_published", serde_json::json!({"open_to_pixels_ms":self.open_started.elapsed().as_secs_f64()*1000.0,"presentation_verified":false})); - } - ui.set_tiles(ModelRc::new(VecModel::from(tiles))); - if self.images.len() >= desired_images.len() - || self.zoom_started.elapsed() > Duration::from_millis(750) - { - self.previews.clear(); - } - let previews = self - .previews - .iter() - .filter(|(key, _)| visible.contains(&key.page)) - .map(|(key, image)| self.surface(*key, image.clone())) - .collect::>(); - ui.set_previews(ModelRc::new(VecModel::from(previews))); - ui.set_zoom_percent((self.zoom * 100.0).round() as i32); - if self.current_page != first { + self.navigation.refresh( + &self.engine, + self.document.document, + self.editor.revision, + &resident, + ); + + // Delegate tile/image/preview rendering to RenderManager + let (missing, _) = self.render.refresh_ui( + ui, + &self.engine, + self.document.document, + self.viewport.generation, + &self.viewport.layout, + self.viewport.zoom, + ); + // `missing` is already consumed by render.refresh_ui's engine.view call + let _ = missing; + + if self.viewport.current_page != first { ui.set_page_entry((first + 1).to_string().into()); - self.current_page = first; + self.viewport.current_page = first; } - ui.set_current_page_number(first as i32 + 1); - ui.set_status("".into()); - if let Some(error) = &self.render_error { + if let Some(error) = &self.render.render_error { ui.set_status(error.as_str().into()); } } - - fn surface(&self, key: TileKey, pixels: slint::Image) -> TileSurface { - let page = self.layout.pages[key.page]; - let logical_per_pixel = self.zoom * 96.0 / 72.0 / (key.scale_milli as f32 / 1000.0); - TileSurface { - pixels, - x: page.x + key.x as f32 * logical_per_pixel, - y: page.y + key.y as f32 * logical_per_pixel, - w: key.width as f32 * logical_per_pixel, - h: key.height as f32 * logical_per_pixel, - } - } } diff --git a/crates/pdfree/src/benchmark.rs b/crates/pdfree/src/benchmark.rs new file mode 100644 index 0000000..fb691dd --- /dev/null +++ b/crates/pdfree/src/benchmark.rs @@ -0,0 +1,97 @@ +//! BenchmarkManager: benchmark timing and stage progression. + +pub enum BenchmarkAction { + None, + Next, + ZoomIn, + Previous, + Quit, +} + +pub struct BenchmarkManager { + pub enabled: bool, + pub stage: u8, + pub zoom_started: std::time::Instant, +} + +impl BenchmarkManager { + pub fn new(enabled: bool) -> Self { + Self { + enabled, + stage: 0, + zoom_started: std::time::Instant::now(), + } + } + + /// Run benchmark logic during tick. Returns the next action to take. + pub fn tick( + &mut self, + first_tile: bool, + first_pixels_elapsed: std::time::Duration, + _open_started_elapsed: std::time::Duration, + cache_bytes: usize, + cache_hits: u64, + cache_misses: u64, + ) -> (BenchmarkAction, Option) { + if !self.enabled || !first_tile { + return (BenchmarkAction::None, None); + } + + if self.stage == 0 && first_pixels_elapsed > std::time::Duration::from_millis(600) { + self.stage = 1; + return ( + BenchmarkAction::Next, + Some(serde_json::json!({ + "stage": "first-page", + "cache_bytes": cache_bytes, + "cache_hits": cache_hits, + "cache_misses": cache_misses, + })), + ); + } + if self.stage == 1 && first_pixels_elapsed > std::time::Duration::from_millis(1000) { + self.stage = 2; + return (BenchmarkAction::ZoomIn, None); + } + if self.stage == 2 && first_pixels_elapsed > std::time::Duration::from_millis(1500) { + self.stage = 3; + return (BenchmarkAction::Previous, None); + } + if first_pixels_elapsed > std::time::Duration::from_millis(2200) { + return ( + BenchmarkAction::Quit, + Some(serde_json::json!({ + "cache_bytes": cache_bytes, + "cache_hits": cache_hits, + "cache_misses": cache_misses, + "native_store_limit_mib": pdf_engine::NATIVE_STORE_LIMIT_MIB, + })), + ); + } + (BenchmarkAction::None, None) + } + + /// Handle benchmark empty-window case. + pub fn benchmark_empty_window( + &self, + document: u64, + open_started_elapsed: std::time::Duration, + ) -> Option { + if self.enabled + && document == 0 + && open_started_elapsed > std::time::Duration::from_millis(600) + { + return Some(serde_json::json!({ + "empty_window": true, + "cache_bytes": 0, + "cache_hits": 0, + "cache_misses": 0, + })); + } + None + } + + pub fn reset_zoom_timer(&mut self) { + self.zoom_started = std::time::Instant::now(); + } +} diff --git a/crates/pdfree/src/document_manager.rs b/crates/pdfree/src/document_manager.rs new file mode 100644 index 0000000..94f678e --- /dev/null +++ b/crates/pdfree/src/document_manager.rs @@ -0,0 +1,54 @@ +//! DocumentManager: document identity, path, and open timing. + +use crate::AppWindow; +use pdf_engine::Engine; + +pub struct DocumentManager { + pub document: u64, + pub path: Option, + pub open_started: std::time::Instant, +} + +impl DocumentManager { + pub fn new() -> Self { + Self { + document: 0, + path: None, + open_started: std::time::Instant::now(), + } + } + + /// Open a document with an optional password. + /// Returns the new document id. + pub fn open_password( + &mut self, + ui: &AppWindow, + engine: &Engine, + path: std::path::PathBuf, + password: Option, + ) -> u64 { + self.path = Some(path.clone()); + self.document += 1; + let doc = self.document; + self.open_started = std::time::Instant::now(); + + ui.set_status("Opening PDF…".into()); + ui.set_detail("Preparing the first page".into()); + ui.set_document_name( + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .as_ref() + .into(), + ); + + engine.open_with_password(doc, 0, path, password); + doc + } + + /// Invalidate the current document (e.g. password cancelled). + pub fn invalidate(&mut self) { + self.document += 1; + self.path = None; + } +} diff --git a/crates/pdfree/src/editing.rs b/crates/pdfree/src/editing.rs index c9aa3a0..a5483ec 100644 --- a/crates/pdfree/src/editing.rs +++ b/crates/pdfree/src/editing.rs @@ -113,9 +113,9 @@ impl State { if !std::mem::take(&mut self.editor.save_dialog) { return None; } - self.path.as_ref().map(|p| { + self.document.path.as_ref().map(|p| { ( - self.document, + self.document.document, p.with_file_name(format!( "{} copy.pdf", p.file_stem().unwrap_or_default().to_string_lossy() @@ -124,7 +124,7 @@ impl State { }) } pub fn save_dialog_result(&mut self, ui: &AppWindow, document: u64, path: Option) { - if document != self.document { + if document != self.document.document { return; } if let Some(path) = path { @@ -134,10 +134,13 @@ impl State { } } pub fn take_tool_dialog(&mut self) -> Option<(u64, ToolDialog, PathBuf)> { - self.editor - .tool_dialog - .take() - .map(|kind| (self.document, kind, self.path.clone().unwrap_or_default())) + self.editor.tool_dialog.take().map(|kind| { + ( + self.document.document, + kind, + self.document.path.clone().unwrap_or_default(), + ) + }) } pub fn tool_dialog_result( &mut self, @@ -146,13 +149,13 @@ impl State { kind: ToolDialog, paths: Vec, ) { - if document != self.document || paths.is_empty() { + if document != self.document.document || paths.is_empty() { return; } match kind { ToolDialog::Insert | ToolDialog::Merge => { let at = if matches!(kind, ToolDialog::Merge) { - self.sizes.len() + self.viewport.sizes.len() } else { self.editor.dialog_page + 1 }; @@ -163,14 +166,14 @@ impl State { let path = paths.into_iter().next().unwrap(); let ok = match kind { ToolDialog::Extract => self.engine.extract( - self.document, + self.document.document, self.editor.revision, self.editor.operation, path, self.editor.dialog_pages.clone(), ), _ => self.engine.split( - self.document, + self.document.document, self.editor.revision, self.editor.operation, path, @@ -197,7 +200,7 @@ impl State { self.file_started( ui, self.engine - .save_as(self.document, self.editor.operation, path, vec![]), + .save_as(self.document.document, self.editor.operation, path, vec![]), "Saving PDF copy…", ); } @@ -208,7 +211,8 @@ impl State { self.editor.operation += 1; self.file_started( ui, - self.engine.save(self.document, self.editor.operation), + self.engine + .save(self.document.document, self.editor.operation), "Saving PDF…", ); } @@ -218,6 +222,7 @@ impl State { } self.editor.operation += 1; let stem = self + .document .path .as_ref() .and_then(|p| p.file_stem()) @@ -243,7 +248,7 @@ impl State { self.file_started( ui, self.engine.print_copy( - self.document, + self.document.document, self.editor.revision, self.editor.operation, target, @@ -277,17 +282,17 @@ impl State { self.file_started( ui, self.engine - .rename(self.document, self.editor.operation, name), + .rename(self.document.document, self.editor.operation, name), "Saving edits and renaming PDF…", ); } pub(super) fn submit_edit(&mut self, ui: &AppWindow, command: EditCommand) { self.editor.operation += 1; let ok = self.engine.edit( - self.document, + self.document.document, self.editor.revision, self.editor.operation, - self.generation + 1, + self.viewport.generation + 1, command, ); self.file_started(ui, ok, "Applying change…"); @@ -307,10 +312,12 @@ impl State { } } fn page_now(&self) -> usize { - if self.sizes.is_empty() { + if self.viewport.sizes.is_empty() { 0 } else { - self.current_page.min(self.sizes.len() - 1) + self.viewport + .current_page + .min(self.viewport.sizes.len() - 1) } } pub(super) fn edit_action(&mut self, ui: &AppWindow, action: &str) -> bool { @@ -438,7 +445,7 @@ impl State { if let Some(Annotation::Highlight { page, mut rects }) = self.tools.selected_highlight() { - if let Some(size) = self.sizes.get(page) { + if let Some(size) = self.viewport.sizes.get(page) { for r in &mut rects { r.left = r.left.max(0.0); r.top = r.top.max(0.0); @@ -459,7 +466,7 @@ impl State { } } "rotate-left" | "rotate-right" | "delete-pages" | "extract-pages" => { - match parse_pages(ui.get_page_range().as_str(), self.sizes.len()) { + match parse_pages(ui.get_page_range().as_str(), self.viewport.sizes.len()) { Err(m) => ui.set_file_message(m.into()), Ok(pages) => match action { "extract-pages" if ui.get_can_copy() && ui.get_can_assemble() => { @@ -483,7 +490,7 @@ impl State { } "move-page" if ui.get_can_assemble() => { match ui.get_move_to().trim().parse::() { - Ok(to) if to > 0 && to <= self.sizes.len() => self.submit_edit( + Ok(to) if to > 0 && to <= self.viewport.sizes.len() => self.submit_edit( ui, EditCommand::Move { page: self.page_now(), @@ -509,7 +516,7 @@ impl State { } "split-pdf" if ui.get_can_copy() && ui.get_can_assemble() => { let page = self.page_now(); - if page + 1 >= self.sizes.len() { + if page + 1 >= self.viewport.sizes.len() { ui.set_file_message("Navigate to a page before the last page to split.".into()); } else { self.editor.dialog_page = page; @@ -528,9 +535,9 @@ impl State { { return; } - let scale = self.zoom * 96.0 / 72.0; + let scale = self.viewport.zoom * 96.0 / 72.0; let (x, y) = (x / scale, y / scale); - if let Some(size) = self.sizes.get(page) + if let Some(size) = self.viewport.sizes.get(page) && x.is_finite() && y.is_finite() && x >= 0.0 @@ -612,7 +619,7 @@ impl State { page, annotations, truncated, - } if *document == self.document + } if *document == self.document.document && *revision == self.editor.revision && self.editor.wanted.contains(page) => { @@ -632,7 +639,7 @@ impl State { can_undo, can_redo, focus_page, - } if *document == self.document && *operation == self.editor.operation => { + } if *document == self.document.document && *operation == self.editor.operation => { self.editor.revision = *revision; self.editor.dirty = *dirty; self.editor.notes.clear(); @@ -646,8 +653,8 @@ impl State { ui.set_note_visible(false); ui.set_highlight_visible(false); ui.set_file_message("Change applied. Ctrl+S to save.".into()); - self.generation = self.generation.max(*generation); - self.sizes = vec![info.first_page; info.pages]; + self.viewport.generation = self.viewport.generation.max(*generation); + self.viewport.sizes = vec![info.first_page; info.pages]; self.tools.reset(ui); ui.set_can_copy(info.can_copy); ui.set_can_form(info.can_form); @@ -659,21 +666,21 @@ impl State { ui.set_page_numbers(ModelRc::new(VecModel::from( (1..=info.pages as i32).collect::>(), ))); - self.cache.clear(); - self.images.clear(); - self.previews.clear(); + self.render.cache.clear(); + self.render.images.clear(); + self.render.previews.clear(); ui.set_tiles(ModelRc::default()); ui.set_previews(ModelRc::default()); ui.set_annotations(ModelRc::default()); - self.layout = Layout::new( - &self.sizes, - self.zoom, + self.viewport.layout = Layout::new( + &self.viewport.sizes, + self.viewport.zoom, (ui.get_view_width() - 14.0).max(1.0), ); self.navigate(ui, (*focus_page).min(info.pages - 1)); - self.previous_view = None; - self.dirty = true; - self.render_error = None; + self.viewport.previous_view = None; + self.viewport.dirty = true; + self.render.render_error = None; ui.invoke_focus_document(); true } @@ -682,7 +689,7 @@ impl State { operation, path, kind, - } if *document == self.document && *operation == self.editor.operation => { + } if *document == self.document.document && *operation == self.editor.operation => { ui.set_file_busy(false); if *kind == FileAction::Print { ui.set_file_message(match self.invoke_print(path) { @@ -693,7 +700,7 @@ impl State { kind, FileAction::Save | FileAction::SaveAs | FileAction::Rename ) { - self.path = Some(path.clone()); + self.document.path = Some(path.clone()); ui.set_document_name( path.file_name() .unwrap_or_default() @@ -726,7 +733,7 @@ impl State { document, operation, message, - } if *document == self.document && *operation == self.editor.operation => { + } if *document == self.document.document && *operation == self.editor.operation => { ui.set_file_busy(false); self.editor.continue_after_save = false; ui.set_file_message(message.as_str().into()); @@ -741,17 +748,17 @@ impl State { self.editor.notes.retain(|p, _| wanted.contains(p)); self.editor.wanted = wanted.clone(); self.engine - .annotation_pages(self.document, self.editor.revision, wanted); + .annotation_pages(self.document.document, self.editor.revision, wanted); } self.edit_overlays(ui); } fn edit_overlays(&self, ui: &AppWindow) { - let scale = self.zoom * 96.0 / 72.0; + let scale = self.viewport.zoom * 96.0 / 72.0; let mut surfaces = Vec::new(); let mut items = Vec::new(); for (index, a) in self.editor.notes.values().flatten().enumerate() { let p = a.annotation.page(); - let Some(page) = self.layout.pages.get(p) else { + let Some(page) = self.viewport.layout.pages.get(p) else { continue; }; let label = match &a.annotation { diff --git a/crates/pdfree/src/forms.rs b/crates/pdfree/src/forms.rs index 9c0e35e..6f9fe98 100644 --- a/crates/pdfree/src/forms.rs +++ b/crates/pdfree/src/forms.rs @@ -295,8 +295,12 @@ impl State { let opening = ui.get_form_mode() == 0; ui.set_form_mode(1); ui.set_form_draw(action == "form-prepare"); - if opening && action == "form-fill" && !self.sizes.is_empty() { - self.forms.auto_detect_page = Some(self.current_page.min(self.sizes.len() - 1)); + if opening && action == "form-fill" && !self.viewport.sizes.is_empty() { + self.forms.auto_detect_page = Some( + self.viewport + .current_page + .min(self.viewport.sizes.len() - 1), + ); } self.forms.wanted.clear(); self.form_error( @@ -463,7 +467,7 @@ impl State { } _ => {} } - self.dirty = true; + self.viewport.dirty = true; true } pub fn form_select(&mut self, ui: &AppWindow, index: i32) { @@ -551,7 +555,7 @@ impl State { format!("{} · page {}", f.spec.kind.label(), f.spec.page + 1) }, ); - if self.current_page != f.spec.page { + if self.viewport.current_page != f.spec.page { self.navigate(ui, f.spec.page); } self.form_overlays(ui); @@ -561,7 +565,11 @@ impl State { } fn form_navigate(&mut self, ui: &AppWindow, previous: bool, entering: bool, review: bool) { let page = self.forms.navigation.map_or_else( - || self.forms.selected.map_or(self.current_page, |id| id.page), + || { + self.forms + .selected + .map_or(self.viewport.current_page, |id| id.page) + }, |(_, p, _, _)| p, ); let mut on_page = self @@ -729,7 +737,7 @@ impl State { certificate: Option, target: Option, ) { - if document != self.document || !self.certificate_can_import(ui) { + if document != self.document.document || !self.certificate_can_import(ui) { return; } if let (Some(certificate), Some(target)) = (certificate, target) { @@ -756,7 +764,7 @@ impl State { ui.set_digital_visible(false); self.editor.operation += 1; let ok = self.engine.sign( - self.document, + self.document.document, self.editor.revision, self.editor.operation, pdf_engine::SigningRequest { @@ -781,7 +789,7 @@ impl State { document: u64, path: Option, ) { - if document != self.document || !self.signature_can_import() { + if document != self.document.document || !self.signature_can_import() { return; } let Some(path) = path else { @@ -882,9 +890,10 @@ impl State { self.forms.draw = None; return; } - let scale = self.zoom * 96. / 72.; + let scale = self.viewport.zoom * 96. / 72.; if phase == 0 { self.forms.draw = self + .viewport .layout .pages .iter() @@ -895,7 +904,7 @@ impl State { if matches!(phase, 0 | 1) && let Some((page, sx, sy)) = self.forms.draw { - let p = self.layout.pages[page]; + let p = self.viewport.layout.pages[page]; let ex = ((x - p.x) / scale).clamp(0., p.width / scale); let ey = ((y - p.y) / scale).clamp(0., p.height / scale); ui.set_form_draft_x(p.x + sx.min(ex) * scale); @@ -910,7 +919,7 @@ impl State { if phase == 2 && let Some((page, sx, sy)) = self.forms.draw.take() { - let p = self.layout.pages[page]; + let p = self.viewport.layout.pages[page]; let ex = ((x - p.x) / scale).clamp(0., p.width / scale); let ey = ((y - p.y) / scale).clamp(0., p.height / scale); match self.selected_spec(ui, None) { @@ -1019,8 +1028,8 @@ impl State { if phase == 1 && let Some((f, sx, sy, resize)) = &self.forms.drag { - let p = self.layout.pages[f.spec.page]; - let scale = self.zoom * 96. / 72.; + let p = self.viewport.layout.pages[f.spec.page]; + let scale = self.viewport.zoom * 96. / 72.; let (dx, dy) = ((x - *sx) / scale, (y - *sy) / scale); let mut r = f.spec.rect; if *resize { @@ -1044,7 +1053,7 @@ impl State { if phase == 2 && let Some((f, sx, sy, resize)) = self.forms.drag.take() { - let scale = self.zoom * 96. / 72.; + let scale = self.viewport.zoom * 96. / 72.; let (dx, dy) = ((x - sx) / scale, (y - sy) / scale); if dx.abs() + dy.abs() < 1. { return; @@ -1071,7 +1080,7 @@ impl State { operation, path, integrity, - } if *document == self.document && *operation == self.editor.operation => { + } if *document == self.document.document && *operation == self.editor.operation => { ui.set_digital_signing(false); ui.set_file_busy(false); self.open(ui, path.clone()); @@ -1083,7 +1092,7 @@ impl State { operation, processed, total, - } if *document == self.document && *operation == self.editor.operation => { + } if *document == self.document.document && *operation == self.editor.operation => { self.form_error(ui,format!("Recognizing fields: {processed}/{total} pages. Uncertain fields receive a second local pass.")); false } @@ -1092,7 +1101,7 @@ impl State { operation, count, review_pages, - } if *document == self.document && *operation == self.editor.operation => { + } if *document == self.document.document && *operation == self.editor.operation => { ui.set_file_busy(false); ui.set_form_detecting(false); self.forms.review_pages = review_pages.iter().copied().collect(); @@ -1115,7 +1124,7 @@ impl State { } true } - Event::Changed { document, .. } if *document == self.document => { + Event::Changed { document, .. } if *document == self.document.document => { if self.forms.signature_pending { self.forms.signature_pending = false; ui.set_signature_visible(false); @@ -1139,7 +1148,7 @@ impl State { revision, page, fields, - } if *document == self.document + } if *document == self.document.document && *revision == self.editor.revision && self.forms.wanted.contains(page) => { @@ -1205,7 +1214,7 @@ impl State { } Event::FileError { document, message, .. - } if *document == self.document => { + } if *document == self.document.document => { ui.set_form_detecting(false); ui.set_digital_signing(false); self.forms.signature_pending = false; @@ -1243,12 +1252,12 @@ impl State { .retain(|p, _| wanted.contains(p) || Some(*p) == selected_page); self.forms.wanted = wanted.clone(); self.engine - .form_pages(self.document, self.editor.revision, wanted); + .form_pages(self.document.document, self.editor.revision, wanted); } self.form_overlays(ui); } fn form_overlays(&self, ui: &AppWindow) { - let scale = self.zoom * 96. / 72.; + let scale = self.viewport.zoom * 96. / 72.; let surfaces = self .forms .records @@ -1256,7 +1265,7 @@ impl State { .flatten() .enumerate() .filter_map(|(i, f)| { - let p = self.layout.pages.get(f.spec.page)?; + let p = self.viewport.layout.pages.get(f.spec.page)?; let r = f.spec.rect; Some(FormSurface { index: i as i32, @@ -1285,9 +1294,11 @@ impl State { ui.set_form_surfaces(ModelRc::new(VecModel::from(surfaces))); } fn start_form_detection(&mut self, ui: &AppWindow) { - let count = self.sizes.len(); + let count = self.viewport.sizes.len(); let pages = match ui.get_form_scan_scope() { - 0 => Ok(vec![self.current_page.min(count.saturating_sub(1))]), + 0 => Ok(vec![ + self.viewport.current_page.min(count.saturating_sub(1)), + ]), 1 => viewer_core::parse_pages(ui.get_form_scan_pages().as_str(), count), _ => Ok((0..count).collect()), }; @@ -1321,10 +1332,10 @@ impl State { } self.editor.operation += 1; let accepted = self.engine.detect_fields( - self.document, + self.document.document, self.editor.revision, self.editor.operation, - self.generation + 1, + self.viewport.generation + 1, pages, ); if accepted { diff --git a/crates/pdfree/src/main.rs b/crates/pdfree/src/main.rs index 726d29b..34b3522 100644 --- a/crates/pdfree/src/main.rs +++ b/crates/pdfree/src/main.rs @@ -1,9 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod app; +#[cfg(test)] mod capture; mod features; -mod navigation; mod settings; mod trace; @@ -94,7 +94,7 @@ fn main() -> Result<(), Box> { { return; } - let document = action_state.borrow().document; + let document = action_state.borrow().document.document; action_state.borrow_mut().dialog_open = true; let send = dialog_send.clone(); std::thread::spawn(move || { @@ -124,7 +124,7 @@ fn main() -> Result<(), Box> { { return; } - let document = action_state.borrow().document; + let document = action_state.borrow().document.document; action_state.borrow_mut().dialog_open = true; let send = dialog_send.clone(); std::thread::spawn(move || { diff --git a/crates/pdfree/src/navigation.rs b/crates/pdfree/src/navigation.rs index 196a72d..c71a8cc 100644 --- a/crates/pdfree/src/navigation.rs +++ b/crates/pdfree/src/navigation.rs @@ -74,7 +74,7 @@ impl Navigation { } impl State { pub fn hover_link(&self, ui: &AppWindow, page: usize, x: f32, y: f32) { - let scale = self.zoom * 96. / 72.; + let scale = self.viewport.zoom * 96. / 72.; let (x, y) = (x / scale, y / scale); ui.set_link_hover(self.navigation.links.get(&page).is_some_and(|links| { links.iter().any(|l| { @@ -99,7 +99,7 @@ impl State { { return; } - let scale = self.zoom * 96. / 72.; + let scale = self.viewport.zoom * 96. / 72.; let (x, y) = (x / scale, y / scale); if let Some(link) = self.navigation.links.get(&page).and_then(|links| { links.iter().find(|l| { @@ -117,15 +117,15 @@ impl State { LinkDestination::Internal { page, x, y } => { self.tools.clear_selection(ui); self.navigate(ui, page); - if let Some(rect) = self.layout.pages.get(page) { - ui.set_scroll_y( - -(rect.y + y * self.zoom * 96. / 72.) - .clamp(0., (self.layout.height - ui.get_view_height()).max(0.)), - ); - ui.set_scroll_x( - -(rect.x + x * self.zoom * 96. / 72.) - .clamp(0., (self.layout.width - ui.get_view_width()).max(0.)), - ); + if let Some(rect) = self.viewport.layout.pages.get(page) { + ui.set_scroll_y(-(rect.y + y * self.viewport.zoom * 96. / 72.).clamp( + 0., + (self.viewport.layout.height - ui.get_view_height()).max(0.), + )); + ui.set_scroll_x(-(rect.x + x * self.viewport.zoom * 96. / 72.).clamp( + 0., + (self.viewport.layout.width - ui.get_view_width()).max(0.), + )); } } LinkDestination::External(url) => { diff --git a/crates/pdfree/src/reader_tests.rs b/crates/pdfree/src/reader_tests.rs index ba15ee4..615bab1 100644 --- a/crates/pdfree/src/reader_tests.rs +++ b/crates/pdfree/src/reader_tests.rs @@ -4,7 +4,7 @@ use slint::platform::{ Clipboard, Key, Platform, PlatformError, PointerEventButton, WindowAdapter, WindowEvent, software_renderer::{MinimalSoftwareWindow, RepaintBufferType}, }; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, rc::Rc, time::Instant}; #[test] fn forms_drawing_filling_and_type_conversion_use_native_history() { @@ -74,8 +74,8 @@ fn forms_drawing_filling_and_type_conversion_use_native_history() { window.draw_if_needed(|r| { r.render(&mut buffer, 1770); }); - let p = state.borrow().layout.pages[0]; - let scale = state.borrow().zoom * 96. / 72.; + let p = state.borrow().viewport.layout.pages[0]; + let scale = state.borrow().viewport.zoom * 96. / 72.; let offset_x = 1180. - ui.get_view_width(); let offset_y = 850. - ui.get_view_height() - 32.; let point = |x: f32, y: f32| { @@ -225,8 +225,8 @@ fn forms_drawing_filling_and_type_conversion_use_native_history() { ui.set_form_kind(8); ui.set_form_label("Signature".into()); ui.invoke_action("form-new".into()); - let p = state.borrow().layout.pages[0]; - let scale = state.borrow().zoom * 96. / 72.; + let p = state.borrow().viewport.layout.pages[0]; + let scale = state.borrow().viewport.zoom * 96. / 72.; ui.invoke_reader_pointer(p.x + 40. * scale, p.y + 160. * scale, 0); ui.invoke_reader_pointer(p.x + 260. * scale, p.y + 205. * scale, 2); wait(&mut state.borrow_mut(), &ui, |s, u| { @@ -273,8 +273,8 @@ fn forms_drawing_filling_and_type_conversion_use_native_history() { ui.set_form_group("choices".into()); ui.set_form_label(format!("Choice {}", index + 1).into()); ui.invoke_action("form-new".into()); - let p = state.borrow().layout.pages[0]; - let scale = state.borrow().zoom * 96. / 72.; + let p = state.borrow().viewport.layout.pages[0]; + let scale = state.borrow().viewport.zoom * 96. / 72.; ui.invoke_reader_pointer(p.x + 40. * scale, p.y + top * scale, 0); ui.invoke_reader_pointer(p.x + 54. * scale, p.y + (top + 14.) * scale, 2); wait(&mut state.borrow_mut(), &ui, |s, u| { @@ -357,7 +357,7 @@ fn invalid_form_drafts_do_not_resume_failed_close_or_open_requests() { !u.get_file_busy() && s.forms.records.values().flatten().count() == 1 }); state.form_select(&ui, 0); - let document = state.document; + let document = state.document.document; ui.set_form_value("invalid date".into()); assert!(state.form_before_close(&ui)); assert!(state.form_before_open(&ui, &fixture("office.pdf"))); @@ -373,7 +373,7 @@ fn invalid_form_drafts_do_not_resume_failed_close_or_open_requests() { .flatten() .any(|f| f.value.text() == "2030-01-01") }); - assert_eq!(state.document, document); + assert_eq!(state.document.document, document); assert!(!ui.get_pending_open()); } @@ -856,8 +856,8 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { ui.window().set_size(slint::LogicalSize::new(1180.0, 850.0)); state.borrow_mut().tick(&ui); let text = state.borrow().tools.test_text(0).unwrap(); - let page = state.borrow().layout.pages[0]; - let scale = state.borrow().zoom * 96.0 / 72.0; + let page = state.borrow().viewport.layout.pages[0]; + let scale = state.borrow().viewport.zoom * 96.0 / 72.0; let glyph0 = text.glyphs[0].bounds; let glyph5 = text.glyphs[5].bounds; let offset_y = 850.0 - ui.get_view_height() - 32.0; @@ -942,7 +942,7 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { ) }) .unwrap(); - let scale = state.borrow().zoom * 96. / 72.; + let scale = state.borrow().viewport.zoom * 96. / 72.; state.borrow_mut().activate_link( &ui, 0, @@ -974,7 +974,7 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { assert!(!first.glyphs.is_empty() && !second.glyphs.is_empty()); let from = first.glyphs[0].bounds; let to = second.glyphs.last().unwrap().bounds; - let scale = state.borrow().zoom * 96. / 72.; + let scale = state.borrow().viewport.zoom * 96. / 72.; state .borrow_mut() .select_text(&ui, 0, from.left * scale, from.top * scale, 0); @@ -1009,9 +1009,9 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { window.draw_if_needed(|renderer| { renderer.render(&mut buffer, 1770); }); - let scale = state.borrow().zoom * 96. / 72.; + let scale = state.borrow().viewport.zoom * 96. / 72.; let pointer_position = |page: usize, r: viewer_core::TextRect| { - let rect = state.borrow().layout.pages[page]; + let rect = state.borrow().viewport.layout.pages[page]; slint::LogicalPosition::new( rect.x + (r.left + r.right) / 2. * scale @@ -1040,22 +1040,22 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { button: PointerEventButton::Left, }); assert!(ui.get_selection_text().contains("\n\n")); - assert!(state.borrow().pointer.is_none()); + assert!(state.borrow().viewport.pointer.is_none()); let stopped = ui.get_scroll_y(); state.borrow_mut().tick(&ui); assert_eq!(ui.get_scroll_y(), stopped); crate::capture::snapshot(&ui, "navigation-facing-selection").unwrap(); // Hold the captured pointer near the lower edge and verify actual cross-page autoscroll. state.borrow_mut().action(&ui, "view-continuous"); - state.borrow_mut().zoom = 1.; - state.borrow_mut().dirty = true; + state.borrow_mut().viewport.zoom = 1.; + state.borrow_mut().viewport.dirty = true; state.borrow_mut().navigate(&ui, 0); state.borrow_mut().tick(&ui); window.draw_if_needed(|renderer| { renderer.render(&mut buffer, 1770); }); - let page = state.borrow().layout.pages[0]; - let scale = state.borrow().zoom * 96. / 72.; + let page = state.borrow().viewport.layout.pages[0]; + let scale = state.borrow().viewport.zoom * 96. / 72.; let begin = slint::LogicalPosition::new( 230. + page.x + (from.left + from.right) / 2. * scale + ui.get_scroll_x(), offset_y + page.y + (from.top + from.bottom) / 2. * scale + ui.get_scroll_y(), @@ -1082,7 +1082,7 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { wait(&mut state.borrow_mut(), &ui, |_, ui| { ui.get_selection_text().contains("\n\n") }); - assert!(state.borrow().pointer.is_none()); + assert!(state.borrow().viewport.pointer.is_none()); let stopped = ui.get_scroll_y(); for _ in 0..3 { state.borrow_mut().tick(&ui); @@ -1092,13 +1092,13 @@ fn reader_flows_use_real_native_text_pointer_events_and_a_mock_clipboard() { state .borrow_mut() .reader_pointer(&ui, page.x + 100., page.y + 200., 1); - assert!(state.borrow().pointer.is_none()); + assert!(state.borrow().viewport.pointer.is_none()); assert!(ui.get_selection_text().is_empty()); state .borrow_mut() .reader_pointer(&ui, page.x + 100., page.y + 200., 0); state.borrow_mut().reader_pointer(&ui, 0., 0., 3); - assert!(state.borrow().pointer.is_none()); + assert!(state.borrow().viewport.pointer.is_none()); crate::capture::snapshot(&ui, "navigation-autoscroll").unwrap(); ui.window().set_size(slint::LogicalSize::new(680., 480.)); state.borrow_mut().tick(&ui); @@ -1222,9 +1222,9 @@ fn editing_flows(state: &Rc>, ui: &AppWindow, window: &Rc=0.0)))); @@ -1237,7 +1237,7 @@ fn editing_flows(state: &Rc>, ui: &AppWindow, window: &Rc>, ui: &AppWindow, window: &Rc>, ui: &AppWindow, window: &Rc>, ui: &AppWindow, window: &Rc state.sizes[1].height + state.viewport.sizes[1].width > state.viewport.sizes[1].height }); state.borrow_mut().action(ui, "page-tools"); ui.set_move_to("3".into()); @@ -1501,7 +1501,7 @@ fn editing_flows(state: &Rc>, ui: &AppWindow, window: &Rc, + pub previews: std::collections::HashMap, + pub render_error: Option, + pub first_tile: bool, + pub first_pixels_at: std::time::Instant, +} + +impl RenderManager { + pub fn new() -> Self { + Self { + cache: viewer_core::TileCache::new(128 * 1024 * 1024), + images: std::collections::HashMap::new(), + previews: std::collections::HashMap::new(), + render_error: None, + first_tile: false, + first_pixels_at: std::time::Instant::now(), + } + } + + /// Reset render state for a new document open. + pub fn reset_for_open(&mut self) { + self.cache.clear(); + self.images.clear(); + self.previews.clear(); + self.render_error = None; + self.first_tile = false; + self.first_pixels_at = std::time::Instant::now(); + } + + /// Process a completed tile event from the engine. Returns true if accepted. + pub fn insert_tile( + &mut self, + key: TileKey, + pixels: Arc, + generation: u64, + document: u64, + current_generation: u64, + ) -> bool { + if !viewer_core::accept_result(document, current_generation, key.document, generation) { + return false; + } + self.cache.insert(key, pixels); + true + } + + /// Handle a render error event. + pub fn handle_error(&mut self, error: &EngineError) { + if matches!(error, EngineError::Render(_)) { + self.render_error = Some(error.to_string()); + } + } + + /// Build the UI refresh: pages, tiles, previews, and missing render requests. + /// Returns (missing_requests, active_pages). + pub fn refresh_ui( + &mut self, + ui: &AppWindow, + engine: &Engine, + document: u64, + generation: u64, + layout: &viewer_core::Layout, + zoom: f32, + ) -> (Vec, Vec) { + let view = viewport_view(ui, zoom); + let visible = layout.visible_pages(view); + let first = *visible.start(); + let last = *visible.end(); + let resident: Vec = (first.saturating_sub(2) + ..=(last + 2).min(layout.pages.len().saturating_sub(1))) + .collect(); + let active: Vec = resident + .iter() + .copied() + .filter(|&p| layout.pages[p].width > 0.) + .collect(); + + let pages = resident + .iter() + .filter(|&&index| layout.pages[index].width > 0.) + .map(|&index| { + let page = layout.pages[index]; + crate::PageSurface { + x: page.x, + y: page.y, + w: page.width, + h: page.height, + number: index as i32 + 1, + } + }) + .collect::>(); + ui.set_pages(ModelRc::new(VecModel::from(pages))); + + let requests = layout.requests(document, generation, view, 2); + let desired_images: HashSet = requests + .iter() + .filter(|r| r.priority < 1000) + .map(|r| r.key) + .collect(); + self.images.retain(|key, _| desired_images.contains(key)); + let mut missing = Vec::new(); + for request in &requests { + if self.images.contains_key(&request.key) { + continue; + } + if let Some(pixels) = self.cache.get(&request.key) { + if request.priority < 1000 { + self.images.entry(request.key).or_insert_with(|| { + let buffer = SharedPixelBuffer::::clone_from_slice( + &pixels.rgba, + pixels.width, + pixels.height, + ); + slint::Image::from_rgba8(buffer) + }); + } + } else { + missing.push(request.clone()); + } + } + if self.render_error.is_some() { + missing.clear(); + } + engine.view(document, generation, missing.clone(), resident.clone()); + + let tiles = self + .images + .iter() + .map(|(key, image)| self.surface(*key, image.clone(), layout, zoom)) + .collect::>(); + if !tiles.is_empty() && !self.first_tile { + self.first_tile = true; + self.first_pixels_at = std::time::Instant::now(); + } + ui.set_tiles(ModelRc::new(VecModel::from(tiles))); + + if self.images.len() >= desired_images.len() { + self.previews.clear(); + } + let previews = self + .previews + .iter() + .filter(|(key, _)| visible.contains(&key.page)) + .map(|(key, image)| self.surface(*key, image.clone(), layout, zoom)) + .collect::>(); + ui.set_previews(ModelRc::new(VecModel::from(previews))); + ui.set_zoom_percent((zoom * 100.0).round() as i32); + ui.set_page_entry((first + 1).to_string().into()); + ui.set_status( + format!( + "Page {} of {} • Raster cache {:.1} MiB / 128 MiB", + first + 1, + layout.pages.len(), + self.cache.bytes() as f64 / 1048576.0 + ) + .into(), + ); + if let Some(error) = &self.render_error { + ui.set_status(error.as_str().into()); + } + + (missing, active) + } + + fn surface( + &self, + key: TileKey, + image: slint::Image, + layout: &viewer_core::Layout, + zoom: f32, + ) -> crate::TileSurface { + let page = layout.pages[key.page]; + let logical_per_pixel = zoom * 96.0 / 72.0 / (key.scale_milli as f32 / 1000.0); + crate::TileSurface { + pixels: image, + x: page.x + key.x as f32 * logical_per_pixel, + y: page.y + key.y as f32 * logical_per_pixel, + w: key.width as f32 * logical_per_pixel, + h: key.height as f32 * logical_per_pixel, + } + } + + pub fn cache_bytes(&self) -> usize { + self.cache.bytes() + } +} + +fn viewport_view(ui: &AppWindow, zoom: f32) -> viewer_core::Viewport { + viewer_core::Viewport { + width: (ui.get_view_width() - 14.0).max(1.0), + height: ui.get_view_height().max(1.0), + scroll_x: (-ui.get_scroll_x()).max(0.0), + scroll_y: (-ui.get_scroll_y()).max(0.0), + zoom, + dpi: ui.window().scale_factor(), + } +} diff --git a/crates/pdfree/src/viewport.rs b/crates/pdfree/src/viewport.rs new file mode 100644 index 0000000..7014c5f --- /dev/null +++ b/crates/pdfree/src/viewport.rs @@ -0,0 +1,269 @@ +//! ViewportManager: zoom, layout, page geometry, scroll, and pointer state. + +use crate::AppWindow; +use viewer_core::{Layout, PageSize, ReadingMode, clamp_zoom}; + +pub struct ViewportManager { + pub zoom: f32, + pub layout: Layout, + pub sizes: Vec, + pub current_page: usize, + pub reading_page: usize, + pub mode: ReadingMode, + pub pointer: Option<(usize, f32, f32)>, + pub previous_view: Option<[f32; 6]>, + pub generation: u64, + pub dirty: bool, +} + +impl ViewportManager { + pub fn new() -> Self { + Self { + zoom: 1.0, + layout: Layout::new(&[], 1.0, 1.0), + sizes: Vec::new(), + current_page: usize::MAX, + reading_page: 0, + mode: ReadingMode::Continuous, + pointer: None, + previous_view: None, + generation: 0, + dirty: true, + } + } + + /// Reset viewport state for a new document open. + pub fn reset_for_open(&mut self) { + self.sizes.clear(); + self.zoom = 1.0; + self.reading_page = 0; + self.pointer = None; + self.previous_view = None; + self.current_page = usize::MAX; + self.layout = Layout::new(&[], 1.0, 1.0); + self.dirty = true; + } + + pub fn reader_position(&self, x: f32, y: f32, nearest: bool) -> Option<(usize, f32, f32)> { + if !x.is_finite() || !y.is_finite() { + return None; + } + let (page, rect) = self + .layout + .pages + .iter() + .enumerate() + .filter(|(_, r)| r.width > 0.) + .min_by(|(_, a), (_, b)| { + let d = |r: &viewer_core::PageRect| { + (r.x - x).max(0.).max(x - r.x - r.width).powi(2) + + (r.y - y).max(0.).max(y - r.y - r.height).powi(2) + }; + d(a).total_cmp(&d(b)) + })?; + if !nearest + && (x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height) + { + return None; + } + Some((page, x - rect.x, y - rect.y)) + } + + pub fn navigate(&mut self, ui: &AppWindow, page: usize) { + self.pointer = None; + if self.sizes.is_empty() { + return; + } + let page = page.min(self.sizes.len() - 1); + self.reading_page = page; + if self.mode != ReadingMode::Continuous { + self.layout = Layout::with_mode( + &self.sizes, + self.zoom, + (ui.get_view_width() - 14.).max(1.), + self.mode, + page, + ); + ui.set_document_height(self.layout.height); + ui.set_document_width(self.layout.width); + self.dirty = true; + } + ui.set_page_entry((page + 1).to_string().into()); + ui.set_scroll_y( + -self.layout.pages[page] + .y + .min((self.layout.height - ui.get_view_height()).max(0.0)), + ); + } + + /// Handle view-mode actions (continuous/single/facing). Returns true if handled. + pub fn handle_view_mode(&mut self, ui: &AppWindow, action: &str) -> bool { + self.reading_page = self.current_page.min(self.sizes.len().saturating_sub(1)); + self.mode = match action { + "view-single" => ReadingMode::Single, + "view-facing" => ReadingMode::Facing, + _ => ReadingMode::Continuous, + }; + ui.set_reading_mode(match self.mode { + ReadingMode::Continuous => 0, + ReadingMode::Single => 1, + ReadingMode::Facing => 2, + }); + self.pointer = None; + self.navigate(ui, self.reading_page); + self.dirty = true; + true + } + + /// Compute a new zoom value from a zoom action. Returns None if not a zoom action. + pub fn compute_zoom(&self, ui: &AppWindow, action: &str) -> Option { + if self.sizes.is_empty() { + return None; + } + let current = self.layout.page_at(-ui.get_scroll_y() + 30.0); + let size = self.sizes[current]; + let pair = if self.mode == ReadingMode::Facing { + self.sizes.get(current + 1).copied() + } else { + None + }; + let fit_width = size.width + pair.map_or(0., |s| s.width); + let fit_height = size.height.max(pair.map_or(0., |s| s.height)); + let fit_space = if pair.is_some() { 78. } else { 62. }; + match action { + "zoom-in" => Some(self.zoom * 1.25), + "zoom-out" => Some(self.zoom / 1.25), + "fit-width" => Some((ui.get_view_width() - fit_space) / (fit_width * 96.0 / 72.0)), + "fit-page" => Some( + ((ui.get_view_width() - fit_space) / (fit_width * 96.0 / 72.0)) + .min((ui.get_view_height() - 48.0) / (fit_height * 96.0 / 72.0)), + ), + _ => None, + } + } + + /// Apply a new zoom value, rebuilding layout and adjusting scroll. + pub fn apply_zoom( + &mut self, + ui: &AppWindow, + current: usize, + old_zoom: f32, + new_zoom: f32, + offset: f32, + ) { + self.zoom = clamp_zoom(new_zoom); + if self.zoom == old_zoom { + return; + } + self.layout = Layout::with_mode( + &self.sizes, + self.zoom, + (ui.get_view_width() - 14.).max(1.), + self.mode, + self.reading_page, + ); + ui.set_document_height(self.layout.height); + ui.set_document_width(self.layout.width); + ui.set_scroll_y( + -(self.layout.pages[current].y + offset * self.zoom / old_zoom) + .clamp(0.0, (self.layout.height - ui.get_view_height()).max(0.0)), + ); + self.dirty = true; + } + + /// Handle navigation actions (next/previous/first/last/down/up). + pub fn handle_navigation_action(&mut self, ui: &AppWindow, action: &str, current: usize) { + match action { + "next" => self.navigate( + ui, + current + + if self.mode == ReadingMode::Facing { + 2 + } else { + 1 + }, + ), + "previous" => self.navigate( + ui, + current.saturating_sub(if self.mode == ReadingMode::Facing { + 2 + } else { + 1 + }), + ), + "first" => self.navigate(ui, 0), + "last" => self.navigate(ui, self.sizes.len() - 1), + "down" => ui.set_scroll_y( + (ui.get_scroll_y() - 80.0) + .max(-(self.layout.height - ui.get_view_height()).max(0.0)), + ), + "up" => ui.set_scroll_y((ui.get_scroll_y() + 80.0).min(0.0)), + _ => {} + } + } + + /// Update layout based on viewport changes. Returns whether a UI refresh is needed. + pub fn refresh_layout(&mut self, ui: &AppWindow, view: &viewer_core::Viewport) -> bool { + let signature = [ + view.width, + view.height, + view.scroll_x, + view.scroll_y, + view.zoom, + view.dpi, + ]; + let moved = self.previous_view != Some(signature); + let changed = moved || self.dirty; + if !changed { + return false; + } + if moved || self.dirty { + let anchor = self.layout.anchor(view.scroll_y); + let refined = self.dirty && !self.layout.pages.is_empty(); + if moved { + self.generation += 1; + } + self.previous_view = Some(signature); + self.layout = Layout::with_mode( + &self.sizes, + self.zoom, + view.width, + self.mode, + self.reading_page, + ); + ui.set_document_width(self.layout.width); + ui.set_document_height(self.layout.height); + if refined { + let new_scroll_y = self + .layout + .resolve_anchor(anchor) + .clamp(0.0, (self.layout.height - view.height).max(0.0)); + ui.set_scroll_y(-new_scroll_y); + self.previous_view = Some([ + view.width, + view.height, + view.scroll_x, + new_scroll_y, + view.zoom, + view.dpi, + ]); + } + self.dirty = false; + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn viewport_new_has_defaults() { + let v = ViewportManager::new(); + assert_eq!(v.zoom, 1.0); + assert_eq!(v.mode, ReadingMode::Continuous); + assert!(v.pointer.is_none()); + assert!(v.dirty); + } +}