From 01df6b790506e3cde229acc542f7e1f755ab30b2 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 1/8] refactor: fix clippy lints from Rust 1.98 Clippy 0.1.98 flags two new lints that break the CI step `cargo clippy --all-targets -- -D warnings` on stable: - chunks_exact_to_as_chunks (attachments.rs): iterate the /Names leaf with `as_chunks::<2>()`. The trailing odd element is still skipped, as with `chunks_exact(2)`. - collapsible_match (layout.rs): move the "no trailing space" check into the TJ match guards. A failing guard falls through to `_ => {}`, which is what the inner `if` did. No behaviour change. `as_chunks` needs Rust 1.88 or newer. --- src/document/attachments.rs | 5 ++--- src/document/layout.rs | 12 ++++-------- 2 files changed, 6 insertions(+), 11 deletions(-) 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(' '); } _ => {} } From 839c5bf9cc4f092370fc7b581dc3280e933667f7 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 2/8] chore: track Cargo.lock docugraph ships a binary, so the lockfile belongs in the repo. Without it every build resolves dependencies again and a fixed commit does not pin rmcp, lopdf, tokio and the rest. The lock is the one resolved for 2832efa (182 packages, rmcp 3.3.0, lopdf 0.45.0, tokio 1.53.1) and matches Cargo.toml under `--locked`. --- .gitignore | 1 - Cargo.lock | 1681 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1681 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock 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/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" From d0cccd55b32b62fbb4e19b17813bc22a9699daef Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 3/8] test: gate real PDF test behind env var test_real_pdf_loading_and_extraction read a hardcoded Windows path and returned early when it was missing, so it reported "ok" without testing anything on every machine but one. Read the path from DOCUGRAPH_TEST_PDF instead and mark the test #[ignore] with a reason, so it shows up as ignored by default. Run it with `DOCUGRAPH_TEST_PDF=... cargo test -- --ignored`; a missing variable or file now fails loudly. --- CONTRIBUTING.md | 3 +++ tests/pdf_ingestion_test.rs | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) 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/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!( From d2d6941e50a3be7895365c4fbe1b9a3f4b2f744e Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:38:03 +0000 Subject: [PATCH 4/8] ci: run clippy and tests with --locked Now that Cargo.lock is tracked, fail CI when it is out of date with Cargo.toml instead of silently rewriting it. There is no separate build step: clippy is the first step that resolves dependencies and `cargo test` builds the crate, so both get --locked. Without it on clippy the lock would be rewritten before the test step runs. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 37cf66604ff6aca52cfe5e99168ce2b3a14ccaaf Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:36:53 +0000 Subject: [PATCH 5/8] fix(links): resolve nested named destinations Named destinations were only looked up in the root /Names array of the /Dests name tree, so trees split into /Kids (as LaTeX/hyperref writes them) never matched. The fallback then read a numeric name "N" as page N+1, which put 61 of 96 bookmarks of a real hyperref book on the wrong page. Walk /Kids using /Limits to prune (with a full scan if that misses), accept destination dictionaries (<< /D [...] >>) and drop the numeric guess: an unknown name now resolves to None. Recursion through references, names and tree nodes is depth-limited. --- src/document/links.rs | 282 ++++++++++++++++++++++++------------ tests/outline_pages_test.rs | 265 +++++++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+), 95 deletions(-) create mode 100644 tests/outline_pages_test.rs 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/tests/outline_pages_test.rs b/tests/outline_pages_test.rs new file mode 100644 index 0000000..fef028d --- /dev/null +++ b/tests/outline_pages_test.rs @@ -0,0 +1,265 @@ +//! Page resolution of native outlines whose items point to named destinations. +//! +//! 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::{SectionNode, load_pdf_from_path, resolve_dest}; +use lopdf::content::{Content, Operation}; +use lopdf::{Dictionary, Document as LopdfDoc, Object, ObjectId, Stream, StringFormat, dictionary}; +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| 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); +} From 81bdc57ddee9caef761704889e5db636de784113 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:39:48 +0000 Subject: [PATCH 6/8] fix(outline): keep parent_id on sibling sections traverse_outline_items passed the parent id only to the first child (/First) and recursed into /Next with None, so every later sibling lost its parent: in a real book only 14 of 81 subsections had a parent_id. Walk the /Next chain in a loop that gives every sibling the same parent, skipping items already visited so a malformed cycle cannot loop forever. --- src/document/outline_strategy.rs | 122 ++++++++++++++++++------------- tests/outline_pages_test.rs | 37 +++++++++- 2 files changed, 107 insertions(+), 52 deletions(-) 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/tests/outline_pages_test.rs b/tests/outline_pages_test.rs index fef028d..1d8d252 100644 --- a/tests/outline_pages_test.rs +++ b/tests/outline_pages_test.rs @@ -3,7 +3,9 @@ //! 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::{SectionNode, load_pdf_from_path, resolve_dest}; +use docugraph::document::{ + NativeOutlineExtractor, OutlineExtractor, SectionNode, load_pdf_from_path, resolve_dest, +}; use lopdf::content::{Content, Operation}; use lopdf::{Dictionary, Document as LopdfDoc, Object, ObjectId, Stream, StringFormat, dictionary}; use std::collections::HashMap; @@ -263,3 +265,36 @@ fn test_named_destination_is_never_read_as_page_index() { // "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()); +} From a6b648cb8e331f832a7c0cf8627265aff38dc000 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:37:48 +0000 Subject: [PATCH 7/8] fix(evidence): cite the page of each snippet When a search hit was a section, get_evidence cited the section's first page even if the snippet came from a later page of that section. Search units now record the offset where each page's text starts, so a hit can report snippet_page: the page holding the matched term. Search results expose it, and get_evidence and the page snippets of get_context cite it instead of page_start. --- src/retrieval/bm25.rs | 48 ++++++++++++++++-- src/retrieval/context.rs | 12 +++-- src/retrieval/hybrid.rs | 10 ++-- tests/page_citations_test.rs | 98 ++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 tests/page_citations_test.rs diff --git a/src/retrieval/bm25.rs b/src/retrieval/bm25.rs index 5602877..cfe7f13 100644 --- a/src/retrieval/bm25.rs +++ b/src/retrieval/bm25.rs @@ -30,6 +30,32 @@ 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, +} + +impl SearchUnit { + /// Page that contains byte offset `pos` of `text`. + /// + /// Offsets before the first page (a section's title line) belong to the first page. + pub fn page_at(&self, pos: usize) -> u32 { + self.page_spans + .iter() + .rev() + .find(|span| span.offset <= pos) + .or_else(|| self.page_spans.first()) + .map_or(self.page_start, |span| span.page) + } } /// In-memory inverted index implementing Okapi BM25 ranking. @@ -52,6 +78,8 @@ 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, pub score: f32, } @@ -85,6 +113,10 @@ impl Bm25Index { text: text.to_string(), term_counts, length, + page_spans: vec![PageSpan { + offset: 0, + page: page.page_number, + }], }); } } @@ -154,6 +186,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 +194,8 @@ 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), score, } }) @@ -171,8 +205,13 @@ 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, + }); combined_text.push_str(&page.text); combined_text.push('\n'); } @@ -192,6 +231,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 +263,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 +297,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..3bd0f83 100644 --- a/src/retrieval/context.rs +++ b/src/retrieval/context.rs @@ -111,14 +111,18 @@ impl ContextBuilder { break; } + // Cite the page the snippet comes from, not the first page of a matched section 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: {} p. {} § {}]", + hit.document_id, hit.snippet_page, sec + ), + None => format!("[Doc: {} p. {}]", hit.document_id, hit.snippet_page), }; items.push(EvidenceItem { document_id: hit.document_id.clone(), - page: hit.page_start, + page: hit.snippet_page, section_title: hit.title.clone(), section_id: hit.section_id.clone(), citation, @@ -263,7 +267,7 @@ impl ContextBuilder { }; let snippet_text = format!( "**[Doc: {} p. {}]** {}\n\n", - hit.document_id, hit.page_start, snippet_clean + hit.document_id, hit.snippet_page, 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..719a279 100644 --- a/src/retrieval/hybrid.rs +++ b/src/retrieval/hybrid.rs @@ -35,6 +35,8 @@ 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, pub final_score: f32, pub bm25_score: f32, pub semantic_score: f32, @@ -147,10 +149,11 @@ 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() + let (snippet, snippet_page) = if let Some((_, hit)) = bm25_map.get(unit.id.as_str()) + { + (hit.snippet.clone(), hit.snippet_page) } else { - unit.text.chars().take(200).collect() + (unit.text.chars().take(200).collect(), unit.page_at(0)) }; scored_hits.push(HybridSearchHit { @@ -161,6 +164,7 @@ impl HybridRetriever { page_end: unit.page_end, section_id: unit.section_id.clone(), snippet, + snippet_page, final_score, bm25_score: bm25_raw, semantic_score, diff --git a/tests/page_citations_test.rs b/tests/page_citations_test.rs new file mode 100644 index 0000000..5cdbd88 --- /dev/null +++ b/tests/page_citations_test.rs @@ -0,0 +1,98 @@ +//! Citations must point at the page a snippet comes from, not at the first page of its section. + +use docugraph::document::{Document, DocumentMetadata, Page, SectionNode}; +use docugraph::mcp::{DocuGraphServer, DocumentGetEvidenceParams, DocumentSearchParams}; +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}" + ); +} From c1795c3e4012da612523bbfaa91aef61fb669231 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:44:49 +0000 Subject: [PATCH 8/8] feat(pages): expose printed page labels PDF page numbers are physical positions, while books print their own numbering (roman front matter, arabic pages restarting at 1). When the catalog has /PageLabels, compute each page's label from the number tree (/Nums and /Kids; styles D, R, r, A, a; /P prefix; /St start) and store it on the page. The label is shown next to the PDF page: page_label_start/end in document_outline, has_page_labels and "impresas" ranges in document_info, "(impresa N)" in document_read_pages headers and in get_evidence/get_context citations, and snippet_page_label in search hits. PDFs without /PageLabels get no labels: no offset is guessed. --- src/document/mod.rs | 5 + src/document/model.rs | 16 +++ src/document/page_labels.rs | 249 +++++++++++++++++++++++++++++++++++ src/document/parser.rs | 8 ++ src/mcp/server.rs | 40 ++++-- src/mcp/tools.rs | 10 ++ src/retrieval/bm25.rs | 24 +++- src/retrieval/context.rs | 27 ++-- src/retrieval/hybrid.rs | 24 +++- tests/outline_pages_test.rs | 129 +++++++++++++++++- tests/page_citations_test.rs | 154 +++++++++++++++++++++- 11 files changed, 655 insertions(+), 31 deletions(-) create mode 100644 src/document/page_labels.rs 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/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 cfe7f13..af7b26e 100644 --- a/src/retrieval/bm25.rs +++ b/src/retrieval/bm25.rs @@ -42,19 +42,31 @@ pub struct PageSpan { 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 that contains byte offset `pos` of `text`. + /// Page span that contains byte offset `pos` of `text`. /// /// Offsets before the first page (a section's title line) belong to the first page. - pub fn page_at(&self, pos: usize) -> u32 { + fn span_at(&self, pos: usize) -> Option<&PageSpan> { self.page_spans .iter() .rev() .find(|span| span.offset <= pos) .or_else(|| self.page_spans.first()) - .map_or(self.page_start, |span| span.page) + } + + /// 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()) } } @@ -80,6 +92,9 @@ pub struct SearchHit { 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, } @@ -116,6 +131,7 @@ impl Bm25Index { page_spans: vec![PageSpan { offset: 0, page: page.page_number, + label: page.label.clone(), }], }); } @@ -196,6 +212,7 @@ impl Bm25Index { section_id: unit.section_id.clone(), snippet, snippet_page: unit.page_at(match_pos), + snippet_page_label: unit.label_at(match_pos).map(str::to_string), score, } }) @@ -211,6 +228,7 @@ fn collect_section_units(doc: &Document, section: &SectionNode, units: &mut Vec< page_spans.push(PageSpan { offset: combined_text.len(), page: p, + label: page.label.clone(), }); combined_text.push_str(&page.text); combined_text.push('\n'); diff --git a/src/retrieval/context.rs b/src/retrieval/context.rs index 3bd0f83..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,18 +115,22 @@ impl ContextBuilder { break; } - // Cite the page the snippet comes from, not the first page of a matched section + // 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.snippet_page, sec - ), - None => format!("[Doc: {} p. {}]", hit.document_id, hit.snippet_page), + 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.snippet_page, + page_label: hit.snippet_page_label.clone(), section_title: hit.title.clone(), section_id: hit.section_id.clone(), citation, @@ -266,8 +274,11 @@ impl ContextBuilder { hit.snippet.clone() }; let snippet_text = format!( - "**[Doc: {} p. {}]** {}\n\n", - hit.document_id, hit.snippet_page, 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 719a279..848ebdf 100644 --- a/src/retrieval/hybrid.rs +++ b/src/retrieval/hybrid.rs @@ -37,6 +37,9 @@ pub struct HybridSearchHit { 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, @@ -149,12 +152,20 @@ impl HybridRetriever { // Include if there is any meaningful relevance if final_score > 0.05 { - let (snippet, snippet_page) = if let Some((_, hit)) = bm25_map.get(unit.id.as_str()) - { - (hit.snippet.clone(), hit.snippet_page) - } else { - (unit.text.chars().take(200).collect(), unit.page_at(0)) - }; + 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(), @@ -165,6 +176,7 @@ impl HybridRetriever { 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 index 1d8d252..76a1ef1 100644 --- a/tests/outline_pages_test.rs +++ b/tests/outline_pages_test.rs @@ -1,4 +1,5 @@ -//! Page resolution of native outlines whose items point to named destinations. +//! 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. @@ -6,8 +7,14 @@ 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; @@ -193,7 +200,10 @@ fn add_outline(doc: &mut LopdfDoc, catalog_id: ObjectId) { /// so the title-search fallback cannot hide a wrong destination. fn outline_fixture() -> (LopdfDoc, Vec, ObjectId) { let bodies: Vec = (1..=12) - .map(|n| format!("Body paragraph printed on sheet number {n}")) + .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); @@ -298,3 +308,118 @@ fn test_native_outline_siblings_keep_parent_id() { ); 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 index 5cdbd88..4bb4697 100644 --- a/tests/page_citations_test.rs +++ b/tests/page_citations_test.rs @@ -1,7 +1,14 @@ -//! Citations must point at the page a snippet comes from, not at the first page of its section. +//! 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, SectionNode}; -use docugraph::mcp::{DocuGraphServer, DocumentGetEvidenceParams, DocumentSearchParams}; +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; @@ -96,3 +103,144 @@ async fn test_evidence_cites_page_of_snippet_inside_section() { "{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}"); +}