diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5d68ef..f1cf7eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: cargo fmt --all -- --check - name: Run Clippy Linter (cargo clippy) - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --locked --all-targets -- -D warnings - name: Run Automated Tests (cargo test) - run: cargo test --all --verbose + run: cargo test --all --locked --verbose diff --git a/.gitignore b/.gitignore index 7f039cb..82b852a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Generated by Cargo /target/ **/*.rs.bk -Cargo.lock # Environment & Secrets (Never commit secrets!) .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f38b8f2..0b95f49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,9 @@ cargo clippy --all-targets -- -D warnings # 3. Suite completa de pruebas pasando cargo test --all + +# 4. (Opcional) Test con un PDF real, ignorado por defecto +DOCUGRAPH_TEST_PDF=/ruta/a/libro.pdf cargo test --test pdf_ingestion_test -- --ignored ``` --- diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..251b1e3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1681 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "docugraph" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "hex", + "lopdf", + "miniz_oxide 0.8.9", + "rmcp", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lopdf" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3" +dependencies = [ + "aes", + "bitflags", + "brotli-decompressor", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom", + "indexmap", + "itoa", + "log", + "md-5", + "nom", + "rand", + "rangemap", + "rayon", + "serde", + "sha2 0.11.0", + "stringprep", + "thiserror", + "weezl", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmcp" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88db56b8ae316560e9e868b6b978ea940f27cb883323fc90e07435e44158f5c" +dependencies = [ + "base64", + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873b730df6f0a9b74b13eb514e0dca4c2db0d8b68b74af98a2e9bf3f9d436585" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.5", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/document/attachments.rs b/src/document/attachments.rs index 8abc976..2dfc97b 100644 --- a/src/document/attachments.rs +++ b/src/document/attachments.rs @@ -158,9 +158,8 @@ fn traverse_name_tree( }; if let Some(arr) = names_arr { - for chunk in arr.chunks_exact(2) { - let name_hint = object_to_string(&chunk[0]); - let filespec_obj = &chunk[1]; + for [name_obj, filespec_obj] in arr.as_chunks::<2>().0 { + let name_hint = object_to_string(name_obj); if let Some(att) = parse_filespec(doc, filespec_obj, name_hint, None, seen_stream_ids) { diff --git a/src/document/layout.rs b/src/document/layout.rs index dde61b6..69d155e 100644 --- a/src/document/layout.rs +++ b/src/document/layout.rs @@ -351,15 +351,11 @@ pub fn extract_positioned_fragments( let part = decode_bytes_with_encoding(bytes, enc); combined_text.push_str(&part); } - Object::Integer(i) if *i < -100 => { - if !combined_text.ends_with(' ') { - combined_text.push(' '); - } + Object::Integer(i) if *i < -100 && !combined_text.ends_with(' ') => { + combined_text.push(' '); } - Object::Real(r) if *r < -100.0 => { - if !combined_text.ends_with(' ') { - combined_text.push(' '); - } + Object::Real(r) if *r < -100.0 && !combined_text.ends_with(' ') => { + combined_text.push(' '); } _ => {} } diff --git a/src/document/links.rs b/src/document/links.rs index f1e0294..9060ffc 100644 --- a/src/document/links.rs +++ b/src/document/links.rs @@ -1,6 +1,6 @@ //! Extraction and resolution of PDF page annotations and hyperlinks (/Annots, /URI, /GoTo). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tracing::debug; use super::model::DocumentLink; @@ -135,127 +135,218 @@ pub(crate) fn object_to_f32(obj: &lopdf::Object) -> Option { } } -/// Resolve a destination object (Array, Reference, Name, or String) to a 1-based page number. +/// Maximum nesting followed through destination references, name trees and number trees. +/// +/// Guards against reference cycles and pathologically deep trees in malformed PDFs. +pub(crate) const MAX_TREE_DEPTH: usize = 32; + +/// Resolve a destination object (Array, Reference, Dictionary, Name, or String) to a 1-based page number. +/// +/// Named destinations are looked up in the catalog `/Dests` dictionary and in the `/Names /Dests` +/// name tree. A name that is not found resolves to `None`: a destination name is never a page +/// index, even when it looks numeric (LaTeX/hyperref writes names such as `(0)`, `(15)` or `(1a)`). pub fn resolve_dest( doc: &lopdf::Document, dest_obj: &lopdf::Object, page_map: &HashMap<(u32, u16), u32>, ) -> Option { + resolve_dest_at_depth(doc, dest_obj, page_map, 0) +} + +fn resolve_dest_at_depth( + doc: &lopdf::Document, + dest_obj: &lopdf::Object, + page_map: &HashMap<(u32, u16), u32>, + depth: usize, +) -> Option { + if depth > MAX_TREE_DEPTH { + return None; + } match dest_obj { // 1. Direct array: [page_ref, /XYZ, left, top, zoom] or [page_ref, /Fit] lopdf::Object::Array(arr) => { - if let Some(first) = arr.first() { - if let Ok(target_ref) = first.as_reference() { - if let Some(p) = page_map.get(&target_ref) { - return Some(*p); - } - } else if let Ok(idx) = first.as_i64() { - // 0-based page index fallback - return Some((idx as u32) + 1); - } - } - None - } - // 2. Reference to an array or destination object - lopdf::Object::Reference(ref_id) => { - if let Ok(resolved_obj) = doc.get_object(*ref_id) { - resolve_dest(doc, resolved_obj, page_map) + let first = arr.first()?; + if let Ok(target_ref) = first.as_reference() { + page_map.get(&target_ref).copied() + } else if let Ok(idx) = first.as_i64() { + // 0-based page index fallback + u32::try_from(idx).ok().and_then(|i| i.checked_add(1)) } else { None } } - // 3. Named destination as Name - lopdf::Object::Name(name_bytes) => resolve_named_destination(doc, name_bytes, page_map), - // 4. Named destination as String - lopdf::Object::String(str_bytes, _) => { - if let Some(num) = resolve_named_destination(doc, str_bytes, page_map) { - return Some(num); - } - // Fallback: if string is a numeric page index like "0", "1", "15" - if let Some(idx) = std::str::from_utf8(str_bytes) - .ok() - .and_then(|s| s.parse::().ok()) - { - return Some(idx + 1); - } - None + // 2. Reference to an array or destination object + lopdf::Object::Reference(ref_id) => doc + .get_object(*ref_id) + .ok() + .and_then(|obj| resolve_dest_at_depth(doc, obj, page_map, depth + 1)), + // 3. Destination dictionary, as stored in name trees: << /D [page_ref /XYZ ...] >> + lopdf::Object::Dictionary(dict) => dict + .get(b"D") + .ok() + .and_then(|d| resolve_dest_at_depth(doc, d, page_map, depth + 1)), + // 4. Named destination as Name or String + lopdf::Object::Name(name) | lopdf::Object::String(name, _) => { + resolve_named_destination_at_depth(doc, name, page_map, depth + 1) } _ => None, } } -/// Look up a destination name in the PDF Catalog `/Names /Dests` or Catalog `/Dests`. +/// Look up a destination name in the PDF Catalog `/Dests` dictionary or the `/Names /Dests` name tree. pub fn resolve_named_destination( doc: &lopdf::Document, dest_name: &[u8], page_map: &HashMap<(u32, u16), u32>, ) -> Option { - let trailer_root = doc.trailer.get(b"Root").ok()?.as_reference().ok()?; - let catalog = doc.get_dictionary(trailer_root).ok()?; + resolve_named_destination_at_depth(doc, dest_name, page_map, 0) +} + +fn resolve_named_destination_at_depth( + doc: &lopdf::Document, + dest_name: &[u8], + page_map: &HashMap<(u32, u16), u32>, + depth: usize, +) -> Option { + if depth > MAX_TREE_DEPTH { + return None; + } + let catalog = catalog_dictionary(doc)?; // Catalog /Dests dictionary (PDF 1.1) - if let Ok(dests_obj) = catalog.get(b"Dests") { - let dests_dict = match dests_obj { - lopdf::Object::Dictionary(dict) => Some(dict), - lopdf::Object::Reference(id) => doc.get_dictionary(*id).ok(), - _ => None, - }; - if let Some(dict) = dests_dict { - let p_num = dict - .get(dest_name) - .ok() - .and_then(|v| v.as_array().ok()) - .and_then(|arr| arr.first().cloned()) - .and_then(|o| o.as_reference().ok()) - .and_then(|r| page_map.get(&r).copied()); - if p_num.is_some() { - return p_num; + if let Some(value) = catalog + .get(b"Dests") + .ok() + .and_then(|obj| dereference_dictionary(doc, obj)) + .and_then(|dests| dests.get(dest_name).ok()) + && let Some(page) = resolve_dest_at_depth(doc, value, page_map, depth + 1) + { + return Some(page); + } + + // Catalog /Names /Dests name tree (PDF 1.2+) + let tree = catalog + .get(b"Names") + .ok() + .and_then(|obj| dereference_dictionary(doc, obj)) + .and_then(|names| names.get(b"Dests").ok()) + .and_then(|obj| dereference_dictionary(doc, obj))?; + let value = lookup_name_tree(doc, tree, dest_name)?; + resolve_dest_at_depth(doc, value, page_map, depth + 1) +} + +/// Find the value stored under `key` in a PDF name tree (ISO 32000-1, 7.9.6). +/// +/// Intermediate `/Kids` nodes are followed: producers such as LaTeX/hyperref split the tree once a +/// document has more than a handful of names. `/Limits` let the walk skip subtrees that cannot +/// hold the key; if that pruned walk misses (for instance because of wrong limits), the tree is +/// scanned once more without pruning. +pub fn lookup_name_tree<'a>( + doc: &'a lopdf::Document, + root: &'a lopdf::Dictionary, + key: &[u8], +) -> Option<&'a lopdf::Object> { + walk_name_tree(doc, root, key, true).or_else(|| walk_name_tree(doc, root, key, false)) +} + +fn walk_name_tree<'a>( + doc: &'a lopdf::Document, + root: &'a lopdf::Dictionary, + key: &[u8], + use_limits: bool, +) -> Option<&'a lopdf::Object> { + let mut stack = vec![(root, 0usize)]; + let mut visited = HashSet::new(); + + while let Some((node, depth)) = stack.pop() { + if let Some(names) = node + .get(b"Names") + .ok() + .and_then(|obj| dereference_array(doc, obj)) + { + let (pairs, _) = names.as_chunks::<2>(); + for [name, value] in pairs { + if name.as_str().is_ok_and(|name| name == key) { + return Some(value); + } } } - } - // Catalog /Names /Dests tree (PDF 1.2+) - if let Ok(names_obj) = catalog.get(b"Names") { - let names_dict = match names_obj { - lopdf::Object::Dictionary(dict) => Some(dict), - lopdf::Object::Reference(id) => doc.get_dictionary(*id).ok(), - _ => None, + if depth >= MAX_TREE_DEPTH { + continue; + } + let Some(kids) = node + .get(b"Kids") + .ok() + .and_then(|obj| dereference_array(doc, obj)) + else { + continue; }; - if let Some(nd) = names_dict { - let dests_node = nd.get(b"Dests").ok(); - let node_dict = match dests_node { - Some(lopdf::Object::Dictionary(dict)) => Some(dict), - Some(lopdf::Object::Reference(id)) => doc.get_dictionary(*id).ok(), - _ => None, - }; - if let Some(arr) = node_dict - .and_then(|d| d.get(b"Names").ok()) - .and_then(|o| o.as_array().ok()) + // Push in reverse so kids are visited in document (sorted) order + for kid in kids.iter().rev() { + if let Ok(kid_id) = kid.as_reference() + && !visited.insert(kid_id) { - for chunk in arr.chunks(2) { - if chunk.len() < 2 { - continue; - } - let matches_key = chunk[0].as_str().map(|k| k == dest_name).unwrap_or(false); - if matches_key { - let p_num = chunk[1] - .as_array() - .ok() - .and_then(|arr| arr.first().cloned()) - .and_then(|o| o.as_reference().ok()) - .and_then(|r| page_map.get(&r).copied()); - if p_num.is_some() { - return p_num; - } - } - } + continue; + } + let Some(kid_node) = dereference_dictionary(doc, kid) else { + continue; + }; + if use_limits && !name_tree_limits_contain(doc, kid_node, key) { + continue; } + stack.push((kid_node, depth + 1)); } } None } +/// Whether a name tree node's `/Limits [low high]` may contain `key` (true when limits are absent). +fn name_tree_limits_contain(doc: &lopdf::Document, node: &lopdf::Dictionary, key: &[u8]) -> bool { + let Some(limits) = node + .get(b"Limits") + .ok() + .and_then(|obj| dereference_array(doc, obj)) + else { + return true; + }; + match ( + limits.first().and_then(|o| o.as_str().ok()), + limits.get(1).and_then(|o| o.as_str().ok()), + ) { + (Some(low), Some(high)) => low <= key && key <= high, + _ => true, + } +} + +/// Return the document catalog (`/Root`) dictionary. +pub(crate) fn catalog_dictionary(doc: &lopdf::Document) -> Option<&lopdf::Dictionary> { + let root = doc.trailer.get(b"Root").ok()?.as_reference().ok()?; + doc.get_dictionary(root).ok() +} + +/// Follow indirect references and return the dictionary they point to. +pub(crate) fn dereference_dictionary<'a>( + doc: &'a lopdf::Document, + obj: &'a lopdf::Object, +) -> Option<&'a lopdf::Dictionary> { + doc.dereference(obj).ok()?.1.as_dict().ok() +} + +/// Follow indirect references and return the array they point to. +pub(crate) fn dereference_array<'a>( + doc: &'a lopdf::Document, + obj: &'a lopdf::Object, +) -> Option<&'a [lopdf::Object]> { + doc.dereference(obj) + .ok()? + .1 + .as_array() + .ok() + .map(Vec::as_slice) +} + /// Helper to extract string values from `lopdf::Object`, supporting UTF-16BE decoding. pub fn object_to_string(obj: &lopdf::Object) -> Option { match obj { @@ -273,15 +364,16 @@ pub fn object_to_string(obj: &lopdf::Object) -> Option { /// Decode raw PDF string bytes handling UTF-16BE (with BOM \xFE\xFF) and UTF-8 lossy. pub fn decode_pdf_string(bytes: &[u8]) -> String { - if bytes.starts_with(&[0xFE, 0xFF]) { - let mut u16_chars = Vec::with_capacity(bytes.len().saturating_sub(2) / 2); - let mut i = 2; - while i + 1 < bytes.len() { - u16_chars.push(u16::from_be_bytes([bytes[i], bytes[i + 1]])); - i += 2; - } - String::from_utf16_lossy(&u16_chars).trim().to_string() + decode_pdf_text(bytes).trim().to_string() +} + +/// Decode raw PDF string bytes like [`decode_pdf_string`], keeping surrounding whitespace. +pub(crate) fn decode_pdf_text(bytes: &[u8]) -> String { + if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) { + let (pairs, _) = utf16.as_chunks::<2>(); + let units: Vec = pairs.iter().map(|&pair| u16::from_be_bytes(pair)).collect(); + String::from_utf16_lossy(&units) } else { - String::from_utf8_lossy(bytes).trim().to_string() + String::from_utf8_lossy(bytes).into_owned() } } diff --git a/src/document/mod.rs b/src/document/mod.rs index 7d470d6..a3d21f6 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -4,6 +4,7 @@ pub mod layout; pub mod links; pub mod model; pub mod outline_strategy; +pub mod page_labels; pub mod parser; pub mod provenance; pub mod structure; @@ -27,6 +28,10 @@ pub use model::{ pub use outline_strategy::{ FallbackOutlineStrategy, NativeOutlineExtractor, OutlineExtractor, TypographicOutlineExtractor, }; +pub use page_labels::{ + PageLabelRange, PageLabelStyle, extract_page_label_ranges, extract_page_labels, + format_page_number, printed_label_suffix, +}; pub use parser::{ PageSecurityScan, inspect_page_images, load_pdf_from_path, load_pdf_from_path_with_password, scan_page_security, diff --git a/src/document/model.rs b/src/document/model.rs index 332c297..16b89a3 100644 --- a/src/document/model.rs +++ b/src/document/model.rs @@ -259,6 +259,10 @@ pub struct Page { /// Hyperlinks extracted from this page (external URIs and internal cross-references) #[serde(default)] pub links: Vec, + /// Printed page label from the PDF `/PageLabels` tree (e.g. "iii", "17", "A-2"), if the + /// document defines a non-empty one. `page_number` stays the physical PDF position. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, } impl Page { @@ -274,6 +278,7 @@ impl Page { kind: PageKind::DigitalText, image_count: 0, links: Vec::new(), + label: None, } } } @@ -383,6 +388,17 @@ impl Document { self.pages.iter().find(|p| p.page_number == page_number) } + /// Printed label of a page (from `/PageLabels`), if the document defines a non-empty one. + pub fn page_label(&self, page_number: u32) -> Option<&str> { + self.get_page(page_number) + .and_then(|page| page.label.as_deref()) + } + + /// Whether any page carries a printed label from `/PageLabels`. + pub fn has_page_labels(&self) -> bool { + self.pages.iter().any(|page| page.label.is_some()) + } + /// Return all hyperlinks extracted across all pages in this document. pub fn all_links(&self) -> Vec<&DocumentLink> { self.pages.iter().flat_map(|p| &p.links).collect() diff --git a/src/document/outline_strategy.rs b/src/document/outline_strategy.rs index 02b149d..59917fe 100644 --- a/src/document/outline_strategy.rs +++ b/src/document/outline_strategy.rs @@ -1,9 +1,9 @@ //! Outline and bookmarks extraction strategy implementations (GoF Strategy Pattern). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tracing::debug; -use super::links::{object_to_string, resolve_dest}; +use super::links::{MAX_TREE_DEPTH, object_to_string, resolve_dest}; use super::model::{Page, SectionNode}; use super::structure::{infer_sections_from_pages, slugify_title}; @@ -57,7 +57,15 @@ impl OutlineExtractor for NativeOutlineExtractor { .ok() .and_then(|r| r.as_reference().ok()) { - traverse_outline_items(doc, item_id, 1, None, page_map, &mut sections); + traverse_outline_items( + doc, + item_id, + 1, + None, + page_map, + &mut sections, + &mut HashSet::new(), + ); } sections @@ -131,64 +139,76 @@ impl OutlineExtractor for FallbackOutlineStrategy { } } -/// Recursively traverse outline items following Next and First links. +/// Walk a chain of sibling outline items (following /Next) and recurse into their children (/First). +/// +/// Every item in the chain gets the same `parent_id`. Items already visited are skipped, so a +/// malformed /Next or /First cycle cannot loop forever. fn traverse_outline_items( doc: &lopdf::Document, - item_id: (u32, u16), + first_item_id: (u32, u16), level: u32, - parent_id: Option, + parent_id: Option<&str>, page_map: &HashMap<(u32, u16), u32>, acc: &mut Vec, + visited: &mut HashSet<(u32, u16)>, ) { - let Ok(item_dict) = doc.get_dictionary(item_id) else { + if level as usize > MAX_TREE_DEPTH { return; - }; - - let title = item_dict - .get(b"Title") - .ok() - .and_then(object_to_string) - .unwrap_or_else(|| "Untitled Section".to_string()); - - let page_target = resolve_outline_page(doc, item_dict, page_map).unwrap_or(1); - let sec_id = format!("{}-p{}", slugify_title(&title), page_target); - - let mut node = SectionNode { - id: sec_id.clone(), - title, - level, - page_start: page_target, - page_end: page_target, - parent_id, - children: Vec::new(), - content_preview: String::new(), - }; - - // Traverse child items if any - if let Some(child_id) = item_dict - .get(b"First") - .ok() - .and_then(|r| r.as_reference().ok()) - { - traverse_outline_items( - doc, - child_id, - level + 1, - Some(sec_id), - page_map, - &mut node.children, - ); } - acc.push(node); + let mut next_item = Some(first_item_id); + while let Some(item_id) = next_item.take() { + if !visited.insert(item_id) { + break; + } + let Ok(item_dict) = doc.get_dictionary(item_id) else { + break; + }; + + let title = item_dict + .get(b"Title") + .ok() + .and_then(object_to_string) + .unwrap_or_else(|| "Untitled Section".to_string()); + + let page_target = resolve_outline_page(doc, item_dict, page_map).unwrap_or(1); + let sec_id = format!("{}-p{}", slugify_title(&title), page_target); + + let mut node = SectionNode { + id: sec_id.clone(), + title, + level, + page_start: page_target, + page_end: page_target, + parent_id: parent_id.map(str::to_string), + children: Vec::new(), + content_preview: String::new(), + }; - // Traverse sibling items - if let Some(next_id) = item_dict - .get(b"Next") - .ok() - .and_then(|r| r.as_reference().ok()) - { - traverse_outline_items(doc, next_id, level, None, page_map, acc); + // Traverse child items if any + if let Some(child_id) = item_dict + .get(b"First") + .ok() + .and_then(|r| r.as_reference().ok()) + { + traverse_outline_items( + doc, + child_id, + level + 1, + Some(&sec_id), + page_map, + &mut node.children, + visited, + ); + } + + acc.push(node); + + // Continue with the next sibling, which shares this item's parent + next_item = item_dict + .get(b"Next") + .ok() + .and_then(|r| r.as_reference().ok()); } } diff --git a/src/document/page_labels.rs b/src/document/page_labels.rs new file mode 100644 index 0000000..62306d2 --- /dev/null +++ b/src/document/page_labels.rs @@ -0,0 +1,249 @@ +//! Printed page labels from the catalog `/PageLabels` number tree (ISO 32000-1, 12.4.2). +//! +//! PDF page numbers are physical positions (1..=N). Books usually print something else on the +//! page: roman numerals in the front matter, then arabic numbers that restart at 1. When the +//! producer records that scheme in `/PageLabels`, these helpers compute the label of every page. +//! Documents without `/PageLabels` get no labels: the offset is never guessed. + +use std::collections::HashSet; + +use super::links::{ + MAX_TREE_DEPTH, catalog_dictionary, decode_pdf_text, dereference_array, dereference_dictionary, +}; + +/// Longest label prefix kept, in characters, so a hostile prefix cannot be multiplied by every page. +const MAX_PREFIX_CHARS: usize = 64; + +/// Beyond this many repeated letters (A..Z, AA..ZZ, ...) an alphabetic label falls back to decimal. +const MAX_LETTER_REPEAT: u64 = 16; + +/// Numbering style of a page label range (`/S`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageLabelStyle { + /// `/D`: 1, 2, 3 + Decimal, + /// `/R`: I, II, III + UpperRoman, + /// `/r`: i, ii, iii + LowerRoman, + /// `/A`: A..Z, AA..ZZ + UpperLetters, + /// `/a`: a..z, aa..zz + LowerLetters, +} + +/// One `/PageLabels` range: it applies from `start_index` (0-based) until the next range starts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageLabelRange { + /// 0-based index of the first page in the range + pub start_index: u32, + /// Numbering style; `None` means the label is only the prefix + pub style: Option, + /// Label prefix (`/P`) + pub prefix: String, + /// Number of the first page in the range (`/St`, default 1) + pub first_number: u64, +} + +impl PageLabelRange { + /// Label of the page at 0-based `page_index` (expected to be `>= start_index`). + pub fn label_for(&self, page_index: u32) -> String { + let mut label = self.prefix.clone(); + if let Some(style) = self.style { + let offset = u64::from(page_index.saturating_sub(self.start_index)); + label.push_str(&format_page_number( + self.first_number.saturating_add(offset), + style, + )); + } + label + } +} + +/// Read the `/PageLabels` ranges of a document, sorted by start index. +/// +/// Returns `None` when the catalog has no `/PageLabels` entry. +pub fn extract_page_label_ranges(doc: &lopdf::Document) -> Option> { + let root = catalog_dictionary(doc)? + .get(b"PageLabels") + .ok() + .and_then(|obj| dereference_dictionary(doc, obj))?; + + let mut ranges = Vec::new(); + let mut stack = vec![(root, 0usize)]; + let mut visited = HashSet::new(); + + while let Some((node, depth)) = stack.pop() { + if let Some(nums) = node + .get(b"Nums") + .ok() + .and_then(|obj| dereference_array(doc, obj)) + { + let (pairs, _) = nums.as_chunks::<2>(); + for [start, value] in pairs { + let Some(start_index) = start.as_i64().ok().and_then(|n| u32::try_from(n).ok()) + else { + continue; + }; + if let Some(dict) = dereference_dictionary(doc, value) { + ranges.push(parse_range(doc, start_index, dict)); + } + } + } + + if depth >= MAX_TREE_DEPTH { + continue; + } + if let Some(kids) = node + .get(b"Kids") + .ok() + .and_then(|obj| dereference_array(doc, obj)) + { + for kid in kids { + if let Ok(kid_id) = kid.as_reference() + && !visited.insert(kid_id) + { + continue; + } + if let Some(kid_node) = dereference_dictionary(doc, kid) { + stack.push((kid_node, depth + 1)); + } + } + } + } + + ranges.sort_by_key(|range| range.start_index); + ranges.dedup_by_key(|range| range.start_index); + Some(ranges) +} + +/// Compute the printed label of every page; index 0 is PDF page 1. +/// +/// Returns `None` when the document has no `/PageLabels`. Pages before the first range, and +/// ranges that only define an empty prefix, get an empty label. +pub fn extract_page_labels(doc: &lopdf::Document, total_pages: u32) -> Option> { + let ranges = extract_page_label_ranges(doc)?; + Some(labels_for_ranges(&ranges, total_pages)) +} + +/// Expand sorted label ranges into one label per page. +pub fn labels_for_ranges(ranges: &[PageLabelRange], total_pages: u32) -> Vec { + let mut labels = Vec::with_capacity(total_pages as usize); + let mut current: Option<&PageLabelRange> = None; + let mut next = 0; + for index in 0..total_pages { + while let Some(range) = ranges.get(next).filter(|r| r.start_index <= index) { + current = Some(range); + next += 1; + } + labels.push(current.map(|r| r.label_for(index)).unwrap_or_default()); + } + labels +} + +/// Suffix that shows the printed label next to a physical page number: ` (impresa 17)`. +/// +/// Empty when there is no label or when the label is the same number as the PDF page. +pub fn printed_label_suffix(page_number: u32, label: Option<&str>) -> String { + match label { + Some(label) if !label.is_empty() && label != page_number.to_string() => { + format!(" (impresa {label})") + } + _ => String::new(), + } +} + +/// Format a page number in the given label style. +/// +/// Roman numerals cover 1..=3999 and letters up to [`MAX_LETTER_REPEAT`] repetitions; numbers +/// outside those ranges are written in decimal. +pub fn format_page_number(number: u64, style: PageLabelStyle) -> String { + let formatted = match style { + PageLabelStyle::Decimal => None, + PageLabelStyle::UpperRoman => to_roman(number), + PageLabelStyle::LowerRoman => to_roman(number).map(|s| s.to_lowercase()), + PageLabelStyle::UpperLetters => to_letters(number, b'A'), + PageLabelStyle::LowerLetters => to_letters(number, b'a'), + }; + formatted.unwrap_or_else(|| number.to_string()) +} + +fn parse_range( + doc: &lopdf::Document, + start_index: u32, + dict: &lopdf::Dictionary, +) -> PageLabelRange { + let style = match dict.get(b"S").and_then(|s| s.as_name()) { + Ok(b"D") => Some(PageLabelStyle::Decimal), + Ok(b"R") => Some(PageLabelStyle::UpperRoman), + Ok(b"r") => Some(PageLabelStyle::LowerRoman), + Ok(b"A") => Some(PageLabelStyle::UpperLetters), + Ok(b"a") => Some(PageLabelStyle::LowerLetters), + _ => None, + }; + let prefix = dict + .get(b"P") + .ok() + .and_then(|obj| doc.dereference(obj).ok()) + .and_then(|(_, obj)| obj.as_str().ok()) + .map(|bytes| { + decode_pdf_text(bytes) + .chars() + .take(MAX_PREFIX_CHARS) + .collect() + }) + .unwrap_or_default(); + let first_number = dict + .get(b"St") + .ok() + .and_then(|obj| obj.as_i64().ok()) + .and_then(|n| u64::try_from(n).ok()) + .filter(|n| *n >= 1) + .unwrap_or(1); + + PageLabelRange { + start_index, + style, + prefix, + first_number, + } +} + +fn to_roman(mut number: u64) -> Option { + if !(1..=3999).contains(&number) { + return None; + } + const NUMERALS: [(u64, &str); 13] = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + let mut out = String::new(); + for (value, numeral) in NUMERALS { + while number >= value { + out.push_str(numeral); + number -= value; + } + } + Some(out) +} + +fn to_letters(number: u64, base: u8) -> Option { + let zero_based = number.checked_sub(1)?; + let repeat = zero_based / 26 + 1; + if repeat > MAX_LETTER_REPEAT { + return None; + } + let letter = char::from(base + (zero_based % 26) as u8); + Some(std::iter::repeat_n(letter, repeat as usize).collect()) +} diff --git a/src/document/parser.rs b/src/document/parser.rs index 39c4415..04d763c 100644 --- a/src/document/parser.rs +++ b/src/document/parser.rs @@ -254,6 +254,9 @@ pub fn load_pdf_from_path_with_password( let total_pages = page_numbers.len() as u32; info!(target: "parser", total_pages = total_pages, "Extracting text and scanning stream security"); + // Printed page labels (/PageLabels); `None` when the PDF does not define them + let page_labels = super::page_labels::extract_page_labels(&pdf_doc, total_pages); + let mut pages: Vec = Vec::with_capacity(page_numbers.len()); let mut doc_untrusted_detected = false; let mut scanned_pages_count = 0u32; @@ -386,6 +389,11 @@ pub fn load_pdf_from_path_with_password( kind, image_count, links, + label: page_labels + .as_ref() + .and_then(|labels| labels.get(page_num.saturating_sub(1) as usize)) + .filter(|label| !label.is_empty()) + .cloned(), }); } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 8796871..a30eefb 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -10,6 +10,7 @@ use tracing::info; use super::tools::*; use crate::document::model::{Document, PageKind, SectionNode}; +use crate::document::page_labels::printed_label_suffix; use crate::multimodal::CachedPageRendererProxy; use crate::retrieval::{ContextBudget, ContextBuilder, HybridRetriever, HybridWeights}; use crate::storage::{DiskCache, DocumentStore}; @@ -163,8 +164,9 @@ impl DocuGraphServer { .map(|s| { let indent = " ".repeat(s.level.saturating_sub(1) as usize); format!( - "{indent}* {} (pp. {}-{})", - s.title, s.page_start, s.page_end + "{indent}* {} ({})", + s.title, + format_page_range(&doc, s.page_start, s.page_end) ) }) .collect(); @@ -195,6 +197,7 @@ impl DocuGraphServer { is_tagged: doc.metadata.is_tagged, has_attachments: doc.metadata.has_attachments, total_attachments: doc.metadata.total_attachments, + has_page_labels: doc.has_page_labels(), }; serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string()) } else { @@ -218,6 +221,7 @@ impl DocuGraphServer { is_tagged: false, has_attachments: false, total_attachments: 0, + has_page_labels: false, }) .unwrap_or_else(|_| "{}".to_string()) } @@ -236,7 +240,7 @@ impl DocuGraphServer { let tree: Vec = doc .sections .iter() - .filter_map(|s| map_outline_node(s, 1, max_depth)) + .filter_map(|s| map_outline_node(&doc, s, 1, max_depth)) .collect(); serde_json::to_string_pretty(&tree).unwrap_or_else(|_| "[]".to_string()) } else { @@ -387,17 +391,21 @@ impl DocuGraphServer { let mut chars_count = 0; for p in params.0.page_start..=params.0.page_end { if let Some(page) = doc.get_page(p) { + let label = printed_label_suffix(p, page.label.as_deref()); let page_header = if page.kind == PageKind::ScannedImage { format!( - "--- Página {} [📷 Imagen Escaneada / Sin Capa de Texto] ---\n", - p + "--- Página {}{} [📷 Imagen Escaneada / Sin Capa de Texto] ---\n", + p, label ) } else if page.untrusted_text_detected { - format!("--- Página {} [⚠️ Untrusted Hidden Text Detected] ---\n", p) + format!( + "--- Página {}{} [⚠️ Untrusted Hidden Text Detected] ---\n", + p, label + ) } else if page.kind == PageKind::Empty { - format!("--- Página {} [Página en Blanco] ---\n", p) + format!("--- Página {}{} [Página en Blanco] ---\n", p, label) } else { - format!("--- Página {} ---\n", p) + format!("--- Página {}{} ---\n", p, label) }; if chars_count + page_header.len() + page.text.len() > max_chars { let remaining = max_chars.saturating_sub(chars_count + page_header.len()); @@ -709,6 +717,7 @@ impl DocuGraphServer { } fn map_outline_node( + doc: &Document, node: &SectionNode, current_depth: u32, max_depth: u32, @@ -720,7 +729,7 @@ fn map_outline_node( let children = if current_depth < max_depth { node.children .iter() - .filter_map(|c| map_outline_node(c, current_depth + 1, max_depth)) + .filter_map(|c| map_outline_node(doc, c, current_depth + 1, max_depth)) .collect() } else { Vec::new() @@ -732,6 +741,19 @@ fn map_outline_node( level: node.level, page_start: node.page_start, page_end: node.page_end, + page_label_start: doc.page_label(node.page_start).map(str::to_string), + page_label_end: doc.page_label(node.page_end).map(str::to_string), children, }) } + +/// Format a page range as `pp. 96-101`, adding the printed labels (`impresas 89-94`) when the PDF +/// defines them for both ends. +fn format_page_range(doc: &Document, start: u32, end: u32) -> String { + match (doc.page_label(start), doc.page_label(end)) { + (Some(label_start), Some(label_end)) => { + format!("pp. {start}-{end}, impresas {label_start}-{label_end}") + } + _ => format!("pp. {start}-{end}"), + } +} diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 6246ec9..98a57fe 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -145,6 +145,10 @@ pub struct DocumentInfoResult { pub has_attachments: bool, #[serde(default)] pub total_attachments: u32, + /// Whether the PDF defines printed page labels (`/PageLabels`). Page numbers stay physical + /// PDF positions; the printed labels are shown next to them ("impresa"). + #[serde(default)] + pub has_page_labels: bool, } /// Outline node returned by `document_outline`. @@ -155,6 +159,12 @@ pub struct OutlineNodeResult { pub level: u32, pub page_start: u32, pub page_end: u32, + /// Printed label of `page_start` (from `/PageLabels`), when the PDF defines one + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_label_start: Option, + /// Printed label of `page_end` (from `/PageLabels`), when the PDF defines one + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_label_end: Option, pub children: Vec, } diff --git a/src/retrieval/bm25.rs b/src/retrieval/bm25.rs index 5602877..af7b26e 100644 --- a/src/retrieval/bm25.rs +++ b/src/retrieval/bm25.rs @@ -30,6 +30,44 @@ pub struct SearchUnit { pub text: String, pub term_counts: HashMap, pub length: usize, + /// Where each page's text starts inside `text`, to cite the page a snippet comes from + #[serde(default)] + pub page_spans: Vec, +} + +/// Start of one page's text inside a [`SearchUnit`]'s `text`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageSpan { + /// Byte offset in `SearchUnit::text` where the page text starts + pub offset: usize, + /// 1-based page number + pub page: u32, + /// Printed page label (from `/PageLabels`), when the PDF defines one + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +impl SearchUnit { + /// Page span that contains byte offset `pos` of `text`. + /// + /// Offsets before the first page (a section's title line) belong to the first page. + fn span_at(&self, pos: usize) -> Option<&PageSpan> { + self.page_spans + .iter() + .rev() + .find(|span| span.offset <= pos) + .or_else(|| self.page_spans.first()) + } + + /// Page that contains byte offset `pos` of `text`. + pub fn page_at(&self, pos: usize) -> u32 { + self.span_at(pos).map_or(self.page_start, |span| span.page) + } + + /// Printed label of the page that contains byte offset `pos` of `text`. + pub fn label_at(&self, pos: usize) -> Option<&str> { + self.span_at(pos).and_then(|span| span.label.as_deref()) + } } /// In-memory inverted index implementing Okapi BM25 ranking. @@ -52,6 +90,11 @@ pub struct SearchHit { pub page_end: u32, pub section_id: Option, pub snippet: String, + /// Page the snippet was taken from; for a section, the page inside its range that matched + pub snippet_page: u32, + /// Printed label of `snippet_page`, when the PDF defines page labels + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snippet_page_label: Option, pub score: f32, } @@ -85,6 +128,11 @@ impl Bm25Index { text: text.to_string(), term_counts, length, + page_spans: vec![PageSpan { + offset: 0, + page: page.page_number, + label: page.label.clone(), + }], }); } } @@ -154,6 +202,7 @@ impl Bm25Index { .take(limit) .map(|(idx, score)| { let unit = &self.units[idx]; + let (snippet, match_pos) = extract_snippet(&unit.text, &query_terms, 250); SearchHit { unit_id: unit.id.clone(), document_id: unit.document_id.clone(), @@ -161,7 +210,9 @@ impl Bm25Index { page_start: unit.page_start, page_end: unit.page_end, section_id: unit.section_id.clone(), - snippet: extract_snippet(&unit.text, &query_terms, 250), + snippet, + snippet_page: unit.page_at(match_pos), + snippet_page_label: unit.label_at(match_pos).map(str::to_string), score, } }) @@ -171,8 +222,14 @@ impl Bm25Index { fn collect_section_units(doc: &Document, section: &SectionNode, units: &mut Vec) { let mut combined_text = format!("{}\n", section.title); + let mut page_spans = Vec::new(); for p in section.page_start..=section.page_end { if let Some(page) = doc.get_page(p) { + page_spans.push(PageSpan { + offset: combined_text.len(), + page: p, + label: page.label.clone(), + }); combined_text.push_str(&page.text); combined_text.push('\n'); } @@ -192,6 +249,7 @@ fn collect_section_units(doc: &Document, section: &SectionNode, units: &mut Vec< text: combined_text, term_counts, length, + page_spans, }); for child in §ion.children { @@ -223,7 +281,9 @@ fn count_terms(tokens: &[String]) -> HashMap { } /// Extract a contextual snippet around matching terms, safely respecting UTF-8 boundaries. -fn extract_snippet(text: &str, query_terms: &[String], max_chars: usize) -> String { +/// +/// Returns the snippet and the byte offset of the matched term (0 when no term matched). +fn extract_snippet(text: &str, query_terms: &[String], max_chars: usize) -> (String, usize) { let lower = text.to_lowercase(); let mut best_pos = 0; @@ -255,7 +315,7 @@ fn extract_snippet(text: &str, query_terms: &[String], max_chars: usize) -> Stri snippet = format!("{} ...", snippet); } - snippet + (snippet, best_pos) } fn get_stop_words() -> HashSet<&'static str> { diff --git a/src/retrieval/context.rs b/src/retrieval/context.rs index 11bc8e6..6f05c27 100644 --- a/src/retrieval/context.rs +++ b/src/retrieval/context.rs @@ -2,6 +2,7 @@ use super::hybrid::HybridSearchHit; use crate::document::model::Document; +use crate::document::page_labels::printed_label_suffix; use serde::{Deserialize, Serialize}; /// Approximate characters per token for technical prose and code (tiktoken/Llama heuristics). @@ -38,6 +39,9 @@ impl Default for ContextBudget { pub struct EvidenceItem { pub document_id: String, pub page: u32, + /// Printed label of `page`, when the PDF defines page labels + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_label: Option, pub section_title: String, pub section_id: Option, pub citation: String, @@ -111,14 +115,22 @@ impl ContextBuilder { break; } + // Cite the page the snippet comes from, not the first page of a matched section, + // with its printed label when the PDF defines one + let page_ref = format!( + "p. {}{}", + hit.snippet_page, + printed_label_suffix(hit.snippet_page, hit.snippet_page_label.as_deref()) + ); let citation = match &hit.section_id { - Some(sec) => format!("[Doc: {} p. {} § {}]", hit.document_id, hit.page_start, sec), - None => format!("[Doc: {} p. {}]", hit.document_id, hit.page_start), + Some(sec) => format!("[Doc: {} {} § {}]", hit.document_id, page_ref, sec), + None => format!("[Doc: {} {}]", hit.document_id, page_ref), }; items.push(EvidenceItem { document_id: hit.document_id.clone(), - page: hit.page_start, + page: hit.snippet_page, + page_label: hit.snippet_page_label.clone(), section_title: hit.title.clone(), section_id: hit.section_id.clone(), citation, @@ -262,8 +274,11 @@ impl ContextBuilder { hit.snippet.clone() }; let snippet_text = format!( - "**[Doc: {} p. {}]** {}\n\n", - hit.document_id, hit.page_start, snippet_clean + "**[Doc: {} p. {}{}]** {}\n\n", + hit.document_id, + hit.snippet_page, + printed_label_suffix(hit.snippet_page, hit.snippet_page_label.as_deref()), + snippet_clean ); let snippet_tokens = estimate_tokens(&snippet_text); out.push_str(&snippet_text); diff --git a/src/retrieval/hybrid.rs b/src/retrieval/hybrid.rs index 11cfdc9..848ebdf 100644 --- a/src/retrieval/hybrid.rs +++ b/src/retrieval/hybrid.rs @@ -35,6 +35,11 @@ pub struct HybridSearchHit { pub page_end: u32, pub section_id: Option, pub snippet: String, + /// Page the snippet was taken from; for a section, the page inside its range that matched + pub snippet_page: u32, + /// Printed label of `snippet_page`, when the PDF defines page labels + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snippet_page_label: Option, pub final_score: f32, pub bm25_score: f32, pub semantic_score: f32, @@ -147,11 +152,20 @@ impl HybridRetriever { // Include if there is any meaningful relevance if final_score > 0.05 { - let snippet = if let Some((_, hit)) = bm25_map.get(unit.id.as_str()) { - hit.snippet.clone() - } else { - unit.text.chars().take(200).collect() - }; + let (snippet, snippet_page, snippet_page_label) = + if let Some((_, hit)) = bm25_map.get(unit.id.as_str()) { + ( + hit.snippet.clone(), + hit.snippet_page, + hit.snippet_page_label.clone(), + ) + } else { + ( + unit.text.chars().take(200).collect(), + unit.page_at(0), + unit.label_at(0).map(str::to_string), + ) + }; scored_hits.push(HybridSearchHit { unit_id: unit.id.clone(), @@ -161,6 +175,8 @@ impl HybridRetriever { page_end: unit.page_end, section_id: unit.section_id.clone(), snippet, + snippet_page, + snippet_page_label, final_score, bm25_score: bm25_raw, semantic_score, diff --git a/tests/outline_pages_test.rs b/tests/outline_pages_test.rs new file mode 100644 index 0000000..76a1ef1 --- /dev/null +++ b/tests/outline_pages_test.rs @@ -0,0 +1,425 @@ +//! Page resolution of native outlines whose items point to named destinations, and printed +//! page labels. +//! +//! The PDFs are synthesised in memory and mimic LaTeX/hyperref output: bookmarks use +//! `/A << /S /GoTo /D (name) >>` and the names live in a multi-level `/Names /Dests` tree. + +use docugraph::document::{ + NativeOutlineExtractor, OutlineExtractor, SectionNode, load_pdf_from_path, resolve_dest, +}; +use docugraph::mcp::{ + DocuGraphServer, DocumentGetEvidenceParams, DocumentInfoParams, DocumentInfoResult, + DocumentOutlineParams, DocumentReadPagesParams, OutlineNodeResult, +}; +use docugraph::storage::DocumentStore; +use lopdf::content::{Content, Operation}; +use lopdf::{Dictionary, Document as LopdfDoc, Object, ObjectId, Stream, StringFormat, dictionary}; +use rmcp::handler::server::wrapper::Parameters; +use std::collections::HashMap; +use tempfile::NamedTempFile; + +/// Destination names and their 1-based target pages. Keys look numeric or hexadecimal on purpose. +const DESTS: [(&str, usize); 7] = [ + ("0", 3), + ("1", 5), + ("10", 11), + ("1a", 10), + ("2", 6), + ("2c", 12), + ("3", 8), +]; + +/// Outline items: (title, destination name, index of the parent item). +const OUTLINE: [(&str, &str, Option); 8] = [ + ("Front matter", "0", None), + ("Chapter one", "1", None), + ("Section one A", "2", Some(1)), + ("Section one B", "3", Some(1)), + ("Detail one B1", "3", Some(3)), + ("Section one C", "1a", Some(1)), + ("Chapter two", "10", None), + ("Section two A", "2c", Some(6)), +]; + +fn text(s: &str) -> Object { + Object::String(s.as_bytes().to_vec(), StringFormat::Literal) +} + +fn catalog_mut(doc: &mut LopdfDoc, catalog_id: ObjectId) -> &mut Dictionary { + match doc.objects.get_mut(&catalog_id) { + Some(Object::Dictionary(dict)) => dict, + _ => panic!("catalog must be a dictionary"), + } +} + +/// Build a PDF whose page `i` (1-based) shows `bodies[i - 1]`; returns the page ids and catalog id. +fn build_pdf(bodies: &[String]) -> (LopdfDoc, Vec, ObjectId) { + let mut doc = LopdfDoc::with_version("1.5"); + let pages_id = doc.new_object_id(); + let font_id = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type1", + "BaseFont" => "Helvetica", + }); + let resources_id = doc.add_object(dictionary! { + "Font" => dictionary! { "F1" => font_id }, + }); + + let mut page_ids = Vec::new(); + for body in bodies { + let content = Content { + operations: vec![ + Operation::new("BT", vec![]), + Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), 12.into()]), + Operation::new("Td", vec![72.into(), 700.into()]), + Operation::new("Tj", vec![text(body)]), + Operation::new("ET", vec![]), + ], + }; + let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap())); + page_ids.push(doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "Contents" => content_id, + "Resources" => resources_id, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + })); + } + let kids: Vec = page_ids.iter().copied().map(Object::Reference).collect(); + doc.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => kids, + "Count" => page_ids.len() as i64, + }), + ); + let catalog_id = doc.add_object(dictionary! { + "Type" => "Catalog", + "Pages" => pages_id, + }); + doc.trailer.set("Root", catalog_id); + (doc, page_ids, catalog_id) +} + +/// Store `DESTS` in a three-level name tree: a root with only /Kids, intermediate nodes and +/// leaves, both with /Limits. Values alternate between the three encodings the spec allows. +fn add_name_tree(doc: &mut LopdfDoc, catalog_id: ObjectId, page_ids: &[ObjectId]) { + let mut sorted = DESTS.to_vec(); + sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + + let mut leaves = Vec::new(); + for (chunk_idx, chunk) in sorted.chunks(2).enumerate() { + let mut names = Vec::new(); + for (entry_idx, (name, page)) in chunk.iter().enumerate() { + let dest: Vec = vec![ + Object::Reference(page_ids[page - 1]), + Object::Name(b"XYZ".to_vec()), + 54.into(), + 580.into(), + Object::Null, + ]; + let value = match (chunk_idx + entry_idx) % 3 { + 0 => Object::Reference(doc.add_object(Object::Array(dest))), + 1 => Object::Reference(doc.add_object(dictionary! { "D" => dest })), + _ => Object::Array(dest), + }; + names.push(text(name)); + names.push(value); + } + let limits = vec![text(chunk[0].0), text(chunk[chunk.len() - 1].0)]; + let leaf_id = doc.add_object(dictionary! { + "Limits" => limits.clone(), + "Names" => names, + }); + leaves.push((leaf_id, limits)); + } + + let mut intermediate = Vec::new(); + for chunk in leaves.chunks(2) { + let limits = vec![chunk[0].1[0].clone(), chunk[chunk.len() - 1].1[1].clone()]; + let kids: Vec = chunk.iter().map(|(id, _)| Object::Reference(*id)).collect(); + intermediate.push(Object::Reference(doc.add_object(dictionary! { + "Limits" => limits, + "Kids" => kids, + }))); + } + + let dests_id = doc.add_object(dictionary! { "Kids" => intermediate }); + let names_id = doc.add_object(dictionary! { "Dests" => dests_id }); + catalog_mut(doc, catalog_id).set("Names", names_id); +} + +/// Add `OUTLINE` as native bookmarks using GoTo actions with string destinations. +fn add_outline(doc: &mut LopdfDoc, catalog_id: ObjectId) { + let ids: Vec = OUTLINE.iter().map(|_| doc.new_object_id()).collect(); + let outlines_id = doc.new_object_id(); + let children_of = |parent: Option| -> Vec { + (0..OUTLINE.len()) + .filter(|&i| OUTLINE[i].2 == parent) + .collect() + }; + + for (i, (title, dest, parent)) in OUTLINE.iter().enumerate() { + let siblings = children_of(*parent); + let pos = siblings.iter().position(|&s| s == i).unwrap(); + let mut item = dictionary! { + "Title" => text(title), + "Parent" => Object::Reference(parent.map_or(outlines_id, |p| ids[p])), + "A" => dictionary! { "S" => "GoTo", "D" => text(dest) }, + }; + if let Some(&next) = siblings.get(pos + 1) { + item.set("Next", Object::Reference(ids[next])); + } + if pos > 0 { + item.set("Prev", Object::Reference(ids[siblings[pos - 1]])); + } + let kids = children_of(Some(i)); + if let (Some(&first), Some(&last)) = (kids.first(), kids.last()) { + item.set("First", Object::Reference(ids[first])); + item.set("Last", Object::Reference(ids[last])); + item.set("Count", kids.len() as i64); + } + doc.objects.insert(ids[i], Object::Dictionary(item)); + } + + let roots = children_of(None); + doc.objects.insert( + outlines_id, + Object::Dictionary(dictionary! { + "Type" => "Outlines", + "First" => Object::Reference(ids[roots[0]]), + "Last" => Object::Reference(ids[roots[roots.len() - 1]]), + "Count" => roots.len() as i64, + }), + ); + catalog_mut(doc, catalog_id).set("Outlines", outlines_id); +} + +/// 12-page PDF with the hyperref-style name tree and outline. Page bodies never repeat a title, +/// so the title-search fallback cannot hide a wrong destination. +fn outline_fixture() -> (LopdfDoc, Vec, ObjectId) { + let bodies: Vec = (1..=12) + .map(|n| match n { + 9 => "Rollback notes mention the zanzibar release".to_string(), + _ => format!("Body paragraph printed on sheet number {n}"), + }) + .collect(); + let (mut doc, page_ids, catalog_id) = build_pdf(&bodies); + add_name_tree(&mut doc, catalog_id, &page_ids); + add_outline(&mut doc, catalog_id); + (doc, page_ids, catalog_id) +} + +fn page_map(doc: &LopdfDoc) -> HashMap { + doc.get_pages() + .into_iter() + .map(|(number, id)| (id, number)) + .collect() +} + +fn flatten(sections: &[SectionNode]) -> Vec<&SectionNode> { + sections.iter().flat_map(|s| s.flatten()).collect() +} + +#[test] +fn test_outline_pages_resolve_through_nested_name_tree() { + let (mut doc, _, _) = outline_fixture(); + let mut file = NamedTempFile::new().unwrap(); + doc.save_to(&mut file).unwrap(); + + let parsed = load_pdf_from_path(file.path()).unwrap(); + let pages: Vec<(&str, u32)> = flatten(&parsed.sections) + .iter() + .map(|s| (s.title.as_str(), s.page_start)) + .collect(); + + assert_eq!( + pages, + vec![ + ("Front matter", 3), + ("Chapter one", 5), + ("Section one A", 6), + ("Section one B", 8), + ("Detail one B1", 8), + ("Section one C", 10), + ("Chapter two", 11), + ("Section two A", 12), + ] + ); +} + +#[test] +fn test_named_destination_is_never_read_as_page_index() { + let (mut doc, page_ids, catalog_id) = outline_fixture(); + + // PDF 1.1 catalog /Dests dictionary whose value is a destination dictionary + let legacy = dictionary! { + "legacy" => dictionary! { + "D" => vec![Object::Reference(page_ids[1]), Object::Name(b"Fit".to_vec())], + }, + }; + catalog_mut(&mut doc, catalog_id).set("Dests", legacy); + let map = page_map(&doc); + + assert_eq!(resolve_dest(&doc, &text("1a"), &map), Some(10)); + assert_eq!(resolve_dest(&doc, &text("3"), &map), Some(8)); + assert_eq!( + resolve_dest(&doc, &Object::Name(b"2c".to_vec()), &map), + Some(12) + ); + assert_eq!( + resolve_dest(&doc, &Object::Name(b"legacy".to_vec()), &map), + Some(2) + ); + // "7" is not a destination: it must not be guessed as page 8 (index 7 + 1) + assert_eq!(resolve_dest(&doc, &text("7"), &map), None); +} + +/// Assert that every node's parent_id names the node containing it; returns how many were checked. +fn assert_parent_links(nodes: &[SectionNode], parent: Option<&str>) -> usize { + let mut checked = 0; + for node in nodes { + assert_eq!( + node.parent_id.as_deref(), + parent, + "wrong parent_id for '{}'", + node.title + ); + checked += 1 + assert_parent_links(&node.children, Some(&node.id)); + } + checked +} + +#[test] +fn test_native_outline_siblings_keep_parent_id() { + let (doc, _, _) = outline_fixture(); + let sections = NativeOutlineExtractor.extract(&doc, &page_map(&doc), &[], 12); + + let chapter_one = §ions[1]; + let titles: Vec<&str> = chapter_one + .children + .iter() + .map(|child| child.title.as_str()) + .collect(); + assert_eq!( + titles, + vec!["Section one A", "Section one B", "Section one C"] + ); + assert_eq!(assert_parent_links(§ions, None), OUTLINE.len()); +} + +/// /PageLabels as a number tree with /Kids: roman front matter, a page with an empty prefix, +/// arabic numbers from PDF page 6, and an appendix with a prefix and a start value. +fn add_page_labels(doc: &mut LopdfDoc, catalog_id: ObjectId) { + let front = doc.add_object(dictionary! { + "Limits" => vec![Object::Integer(0), Object::Integer(4)], + "Nums" => vec![ + Object::Integer(0), + Object::Dictionary(dictionary! { "S" => "r" }), + Object::Integer(4), + Object::Dictionary(dictionary! { "P" => text("") }), + ], + }); + let decimal = doc.add_object(dictionary! { "S" => "D" }); + let body = doc.add_object(dictionary! { + "Limits" => vec![Object::Integer(5), Object::Integer(10)], + "Nums" => vec![ + Object::Integer(5), + Object::Reference(decimal), + Object::Integer(10), + Object::Dictionary(dictionary! { + "S" => "A", + "P" => text("App-"), + "St" => Object::Integer(2), + }), + ], + }); + let root = doc.add_object(dictionary! { + "Kids" => vec![Object::Reference(front), Object::Reference(body)], + }); + catalog_mut(doc, catalog_id).set("PageLabels", root); +} + +#[tokio::test] +async fn test_printed_page_labels_from_number_tree() { + let (mut doc, _, catalog_id) = outline_fixture(); + add_page_labels(&mut doc, catalog_id); + let mut file = NamedTempFile::new().unwrap(); + doc.save_to(&mut file).unwrap(); + + let parsed = load_pdf_from_path(file.path()).unwrap(); + let labels: Vec> = parsed.pages.iter().map(|p| p.label.as_deref()).collect(); + assert_eq!( + labels, + vec![ + Some("i"), + Some("ii"), + Some("iii"), + Some("iv"), + None, + Some("1"), + Some("2"), + Some("3"), + Some("4"), + Some("5"), + Some("App-B"), + Some("App-C"), + ] + ); + + let doc_id = parsed.metadata.id.clone(); + let server = DocuGraphServer::with_store(DocumentStore::new(None)); + server.register_document(parsed).await; + + let info: DocumentInfoResult = serde_json::from_str( + &server + .document_info(Parameters(DocumentInfoParams { + document_id: doc_id.clone(), + })) + .await, + ) + .expect("valid info JSON"); + assert!(info.has_page_labels); + assert!( + info.sections_preview + .iter() + .any(|line| line.ends_with("Section one A (pp. 6-7, impresas 1-2)")), + "{:?}", + info.sections_preview + ); + + let outline: Vec = serde_json::from_str( + &server + .document_outline(Parameters(DocumentOutlineParams { + document_id: doc_id.clone(), + max_depth: None, + })) + .await, + ) + .expect("valid outline JSON"); + // PDF page 5 has an empty label, so "Chapter one" gets none + assert_eq!(outline[1].page_label_start, None); + assert_eq!(outline[2].page_label_start.as_deref(), Some("App-B")); + assert_eq!(outline[2].page_label_end.as_deref(), Some("App-C")); + + let pages = server + .document_read_pages(Parameters(DocumentReadPagesParams { + document_id: doc_id.clone(), + page_start: 9, + page_end: 9, + max_chars: None, + })) + .await; + assert!(pages.contains("--- Página 9 (impresa 4) ---"), "{pages}"); + + let evidence = server + .document_get_evidence(Parameters(DocumentGetEvidenceParams { + query: "zanzibar".to_string(), + document_id: Some(doc_id), + max_tokens: None, + max_items: None, + })) + .await; + assert!(evidence.contains("p. 9 (impresa 4)"), "{evidence}"); +} diff --git a/tests/page_citations_test.rs b/tests/page_citations_test.rs new file mode 100644 index 0000000..4bb4697 --- /dev/null +++ b/tests/page_citations_test.rs @@ -0,0 +1,246 @@ +//! Citations must point at the page a snippet comes from, not at the first page of its section, +//! and show the printed page label next to the PDF page when the document defines one. + +use docugraph::document::{ + Document, DocumentMetadata, Page, PageLabelRange, PageLabelStyle, SectionNode, + format_page_number, page_labels::labels_for_ranges, printed_label_suffix, +}; +use docugraph::mcp::{ + DocuGraphServer, DocumentGetEvidenceParams, DocumentInfoParams, DocumentInfoResult, + DocumentOutlineParams, DocumentReadPagesParams, DocumentSearchParams, OutlineNodeResult, +}; +use docugraph::retrieval::SearchHit; +use docugraph::storage::DocumentStore; +use rmcp::handler::server::wrapper::Parameters; + +/// Three-page guide covered by one section; the query term only appears on page 3. +fn guide_document() -> Document { + let mut doc = Document::new(DocumentMetadata { + id: "deploy-guide".to_string(), + title: "Deployment Guide".to_string(), + total_pages: 3, + total_sections: 1, + ..Default::default() + }); + doc.add_page(Page::new( + 1, + "Introduction to the deployment guide and its general scope.", + )); + doc.add_page(Page::new( + 2, + "Configuration of servers, networks and storage volumes.", + )); + doc.add_page(Page::new( + 3, + "Rollback procedure: restore the previous zanzibar release safely.", + )); + doc.sections.push(SectionNode::new( + "deployment-guide-p1", + "Deployment guide", + 1, + 1, + 3, + None, + )); + doc +} + +async fn server_with(doc: Document) -> DocuGraphServer { + let server = DocuGraphServer::with_store(DocumentStore::new(None)); + server.register_document(doc).await; + server +} + +#[tokio::test] +async fn test_search_hit_reports_page_of_snippet() { + let server = server_with(guide_document()).await; + let json = server + .document_search(Parameters(DocumentSearchParams { + query: "zanzibar".to_string(), + document_id: Some("deploy-guide".to_string()), + limit: Some(5), + })) + .await; + let hits: Vec = serde_json::from_str(&json).expect("valid search JSON"); + + let section = hits + .iter() + .find(|h| h.section_id.as_deref() == Some("deployment-guide-p1")) + .expect("section hit"); + assert_eq!((section.page_start, section.page_end), (1, 3)); + assert_eq!(section.snippet_page, 3); + assert!(hits.iter().all(|h| h.snippet_page == 3), "{json}"); +} + +#[tokio::test] +async fn test_evidence_cites_page_of_snippet_inside_section() { + let server = server_with(guide_document()).await; + let markdown = server + .document_get_evidence(Parameters(DocumentGetEvidenceParams { + query: "zanzibar".to_string(), + document_id: Some("deploy-guide".to_string()), + max_tokens: None, + max_items: None, + })) + .await; + + let citations: Vec<&str> = markdown + .lines() + .filter(|line| line.starts_with("**[")) + .collect(); + let section_citation = citations + .iter() + .find(|c| c.contains("§ deployment-guide-p1")) + .unwrap_or_else(|| panic!("section citation missing:\n{markdown}")); + assert!( + section_citation.contains("p. 3 §"), + "section hit must cite the snippet page, got: {section_citation}" + ); + assert!( + citations + .iter() + .any(|c| c.contains("[Doc: deploy-guide p. 3]")), + "{markdown}" + ); +} + +#[test] +fn test_page_label_number_formats() { + assert_eq!( + format_page_number(1994, PageLabelStyle::UpperRoman), + "MCMXCIV" + ); + assert_eq!(format_page_number(4, PageLabelStyle::LowerRoman), "iv"); + assert_eq!(format_page_number(27, PageLabelStyle::UpperLetters), "AA"); + assert_eq!(format_page_number(53, PageLabelStyle::LowerLetters), "aaa"); + assert_eq!(format_page_number(12, PageLabelStyle::Decimal), "12"); + // Out of range for roman numerals: decimal instead of a wrong numeral + assert_eq!(format_page_number(5000, PageLabelStyle::UpperRoman), "5000"); + + assert_eq!(printed_label_suffix(9, Some("6")), " (impresa 6)"); + assert_eq!(printed_label_suffix(9, Some("9")), ""); + assert_eq!(printed_label_suffix(9, None), ""); +} + +#[test] +fn test_page_label_ranges_expand_per_page() { + let range = |start_index, style, prefix: &str, first_number| PageLabelRange { + start_index, + style, + prefix: prefix.to_string(), + first_number, + }; + let ranges = vec![ + range(0, Some(PageLabelStyle::LowerRoman), "", 1), + range(2, None, "", 1), + range(3, Some(PageLabelStyle::Decimal), "", 1), + range(5, Some(PageLabelStyle::Decimal), "A-", 7), + ]; + assert_eq!( + labels_for_ranges(&ranges, 7), + vec!["i", "ii", "", "1", "2", "A-7", "A-8"] + ); +} + +/// The guide with printed labels that differ from the PDF page numbers. +fn labelled_guide() -> Document { + let mut doc = guide_document(); + for page in &mut doc.pages { + page.label = Some(format!("A-{}", page.page_number)); + } + doc +} + +#[tokio::test] +async fn test_page_labels_shown_in_info_outline_pages_and_evidence() { + let server = server_with(labelled_guide()).await; + let doc_id = "deploy-guide".to_string(); + + let info: DocumentInfoResult = serde_json::from_str( + &server + .document_info(Parameters(DocumentInfoParams { + document_id: doc_id.clone(), + })) + .await, + ) + .expect("valid info JSON"); + assert!(info.has_page_labels); + assert_eq!( + info.sections_preview, + vec!["* Deployment guide (pp. 1-3, impresas A-1-A-3)"] + ); + + let outline: Vec = serde_json::from_str( + &server + .document_outline(Parameters(DocumentOutlineParams { + document_id: doc_id.clone(), + max_depth: None, + })) + .await, + ) + .expect("valid outline JSON"); + assert_eq!(outline[0].page_label_start.as_deref(), Some("A-1")); + assert_eq!(outline[0].page_label_end.as_deref(), Some("A-3")); + + let pages = server + .document_read_pages(Parameters(DocumentReadPagesParams { + document_id: doc_id.clone(), + page_start: 3, + page_end: 3, + max_chars: None, + })) + .await; + assert!(pages.contains("--- Página 3 (impresa A-3) ---"), "{pages}"); + + let evidence = server + .document_get_evidence(Parameters(DocumentGetEvidenceParams { + query: "zanzibar".to_string(), + document_id: Some(doc_id), + max_tokens: None, + max_items: None, + })) + .await; + assert!( + evidence.contains("p. 3 (impresa A-3) § deployment-guide-p1"), + "{evidence}" + ); + assert!( + evidence.contains("[Doc: deploy-guide p. 3 (impresa A-3)]"), + "{evidence}" + ); +} + +#[tokio::test] +async fn test_no_page_labels_means_no_printed_numbers() { + let server = server_with(guide_document()).await; + let doc_id = "deploy-guide".to_string(); + + let info: DocumentInfoResult = serde_json::from_str( + &server + .document_info(Parameters(DocumentInfoParams { + document_id: doc_id.clone(), + })) + .await, + ) + .expect("valid info JSON"); + assert!(!info.has_page_labels); + assert_eq!(info.sections_preview, vec!["* Deployment guide (pp. 1-3)"]); + + let outline_json = server + .document_outline(Parameters(DocumentOutlineParams { + document_id: doc_id.clone(), + max_depth: None, + })) + .await; + assert!(!outline_json.contains("page_label"), "{outline_json}"); + + let pages = server + .document_read_pages(Parameters(DocumentReadPagesParams { + document_id: doc_id, + page_start: 3, + page_end: 3, + max_chars: None, + })) + .await; + assert!(pages.contains("--- Página 3 ---"), "{pages}"); +} diff --git a/tests/pdf_ingestion_test.rs b/tests/pdf_ingestion_test.rs index e901dbe..633d100 100644 --- a/tests/pdf_ingestion_test.rs +++ b/tests/pdf_ingestion_test.rs @@ -119,15 +119,21 @@ fn test_provenance_citation_formatting() { ); } +/// Parses a real digital PDF with more than 200 pages end to end. +/// +/// Ignored by default because the repository ships no PDFs. Run it with: +/// `DOCUGRAPH_TEST_PDF=/path/to/book.pdf cargo test --test pdf_ingestion_test -- --ignored` #[test] +#[ignore = "needs a real PDF: set DOCUGRAPH_TEST_PDF and run with --ignored"] fn test_real_pdf_loading_and_extraction() { - let sample_pdf_path = "C:/proyectos/chaika-stage/docs/aprendiendo-git-pdf.pdf"; - if !std::path::Path::new(sample_pdf_path).exists() { - eprintln!("Sample PDF not found; skipping real PDF loading test"); - return; - } + let sample_pdf_path = std::env::var("DOCUGRAPH_TEST_PDF") + .expect("DOCUGRAPH_TEST_PDF must point to a real PDF with more than 200 pages"); + assert!( + std::path::Path::new(&sample_pdf_path).is_file(), + "DOCUGRAPH_TEST_PDF is not a file: {sample_pdf_path}" + ); - let doc = load_pdf_from_path(sample_pdf_path).expect("PDF should parse without error"); + let doc = load_pdf_from_path(&sample_pdf_path).expect("PDF should parse without error"); assert!(doc.metadata.total_pages > 200, "Should have over 200 pages"); assert!(