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/6] 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/6] 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/6] 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/6] 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 40ecc180b04cc0a34f5b9b1da006051b47927a55 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:06:51 +0000 Subject: [PATCH 5/6] fix(layout): space words by glyph position Single-column pages went through lopdf's extract_text, which ignores real-valued TJ adjustments, appends a space after every TJ array and drops Td/Tm moves between fragments. PDFs that place words by position (Prince, fonts without a space glyph) came out with glued words and spaces in the middle of words. Place every fragment with the font's real glyph widths (/Widths, /W, /DW, MissingWidth, Type3 FontMatrix) and the text state (Tc, Tw, Tz, Tm, CTM). Read TJ adjustments below -100/1000 em as word gaps for both integers and reals, and join fragments in stream order with a newline when the baseline changes and a space when the gap exceeds 0.15 em. All pages now use extract_page_text: multi-column reading order when a gutter is detected, stream order otherwise. lopdf's extract_text stays as a fallback when the content stream cannot be decoded. --- src/document/layout.rs | 621 ++++++++++++++++++++++++++++------------- src/document/mod.rs | 2 +- src/document/parser.rs | 9 +- 3 files changed, 432 insertions(+), 200 deletions(-) diff --git a/src/document/layout.rs b/src/document/layout.rs index 69d155e..b6b6215 100644 --- a/src/document/layout.rs +++ b/src/document/layout.rs @@ -6,7 +6,7 @@ //! without interleaved columns. use lopdf::content::Content; -use lopdf::{Encoding, Object}; +use lopdf::{Dictionary, Encoding, Object}; use std::collections::BTreeMap; use tracing::{debug, trace}; @@ -135,35 +135,268 @@ fn decode_bytes_with_encoding(bytes: &[u8], encoding: Option<&Encoding>) -> Stri } } +/// Advance assumed for glyphs whose font has no width table (thousandths of an em). +const FALLBACK_GLYPH_WIDTH: f32 = 520.0; + +/// A TJ adjustment more negative than this (thousandths of an em) is read as a word gap. +const TJ_WORD_GAP_THOUSANDTHS: f32 = 100.0; + +/// Horizontal gap between two fragments on the same baseline, as a fraction of the +/// font size, from which a word space is inserted between them. +const WORD_GAP_EM: f32 = 0.15; + +/// Width entries of a CID font `/W` array. +#[derive(Debug, Clone)] +enum CidWidths { + /// `c [w1 w2 ...]`: consecutive CIDs starting at `c` + List(u32, Vec), + /// `c_first c_last w`: every CID in the range shares one width + Range(u32, u32, f32), +} + +/// Glyph advance widths of a font resource, used to place text fragments precisely. +#[derive(Debug, Clone)] +struct FontMetrics { + /// Bytes per character code: 2 for Type0 (CID) fonts, 1 for simple fonts + code_len: usize, + first_char: u32, + widths: Vec, + cid_widths: Vec, + default_width: Option, + /// Glyph space to text space factor (1/1000, or the FontMatrix of Type3 fonts) + scale: f32, +} + +impl FontMetrics { + fn from_font(doc: &lopdf::Document, font: &Dictionary) -> Self { + let number = |obj: &Object| { + doc.dereference(obj) + .ok() + .and_then(|(_, o)| o.as_float().ok()) + }; + let mut metrics = FontMetrics { + code_len: 1, + first_char: 0, + widths: Vec::new(), + cid_widths: Vec::new(), + default_width: None, + scale: 0.001, + }; + let subtype = font + .get(b"Subtype") + .and_then(|o| o.as_name()) + .unwrap_or_default(); + + if subtype == b"Type0" { + metrics.code_len = 2; + let cid_font = font + .get_deref(b"DescendantFonts", doc) + .and_then(|o| o.as_array()) + .ok() + .and_then(|fonts| fonts.first()) + .and_then(|o| doc.dereference(o).ok()) + .and_then(|(_, o)| o.as_dict().ok()); + if let Some(cid_font) = cid_font { + metrics.default_width = + Some(cid_font.get(b"DW").ok().and_then(number).unwrap_or(1000.0)); + if let Ok(w) = cid_font.get_deref(b"W", doc).and_then(|o| o.as_array()) { + metrics.cid_widths = parse_cid_widths(w, number); + } + } + return metrics; + } + + if subtype == b"Type3" + && let Some(a) = font + .get_deref(b"FontMatrix", doc) + .and_then(|o| o.as_array()) + .ok() + .and_then(|m| m.first()) + .and_then(number) + { + metrics.scale = a.abs(); + } + metrics.first_char = font + .get(b"FirstChar") + .ok() + .and_then(number) + .map_or(0, |v| v.max(0.0) as u32); + if let Ok(widths) = font.get_deref(b"Widths", doc).and_then(|o| o.as_array()) { + metrics.widths = widths.iter().map(|w| number(w).unwrap_or(0.0)).collect(); + } + metrics.default_width = font + .get_deref(b"FontDescriptor", doc) + .and_then(|o| o.as_dict()) + .ok() + .and_then(|d| d.get(b"MissingWidth").ok()) + .and_then(number) + .filter(|w| *w > 0.0); + metrics + } + + /// Advance of a character code in text space units per unit of font size, if known. + fn glyph_width(&self, code: u32) -> Option { + let raw = if self.code_len == 2 { + self.cid_widths + .iter() + .find_map(|entry| match entry { + CidWidths::List(first, list) => code + .checked_sub(*first) + .and_then(|i| list.get(i as usize)) + .copied(), + CidWidths::Range(first, last, w) => { + (*first..=*last).contains(&code).then_some(*w) + } + }) + .or(self.default_width) + } else { + code.checked_sub(self.first_char) + .and_then(|i| self.widths.get(i as usize)) + .copied() + .or(self.default_width) + }; + raw.map(|w| w * self.scale) + } +} + +/// Parse a CID font `/W` array (PDF 32000-1 §9.7.4.3). +fn parse_cid_widths(w: &[Object], number: impl Fn(&Object) -> Option) -> Vec { + let mut entries = Vec::new(); + let mut i = 0; + while i + 1 < w.len() { + let Some(first) = number(&w[i]) else { + break; + }; + let first = first.max(0.0) as u32; + if let Object::Array(list) = &w[i + 1] { + entries.push(CidWidths::List( + first, + list.iter().map(|o| number(o).unwrap_or(0.0)).collect(), + )); + i += 2; + } else { + let (Some(last), Some(width)) = (number(&w[i + 1]), w.get(i + 2).and_then(&number)) + else { + break; + }; + entries.push(CidWidths::Range(first, last.max(0.0) as u32, width)); + i += 3; + } + } + entries +} + +/// Text state parameters that affect glyph placement (PDF 32000-1 §9.3). +#[derive(Debug, Clone)] +struct TextState { + font: Option>, + font_size: f32, + leading: f32, + char_spacing: f32, + word_spacing: f32, + horizontal_scale: f32, +} + +impl Default for TextState { + fn default() -> Self { + Self { + font: None, + font_size: 12.0, + leading: 14.4, + char_spacing: 0.0, + word_spacing: 0.0, + horizontal_scale: 1.0, + } + } +} + +/// Horizontal displacement, in text space, produced by showing `bytes` with the current font. +fn string_advance(bytes: &[u8], metrics: Option<&FontMetrics>, state: &TextState) -> f32 { + let code_len = metrics.map_or(1, |m| m.code_len); + bytes + .chunks(code_len) + .map(|chunk| { + let code = chunk.iter().fold(0u32, |acc, &b| (acc << 8) | u32::from(b)); + let glyph = metrics + .and_then(|m| m.glyph_width(code)) + .unwrap_or(FALLBACK_GLYPH_WIDTH / 1000.0); + let word = if code_len == 1 && code == 32 { + state.word_spacing + } else { + 0.0 + }; + (glyph * state.font_size + state.char_spacing + word) * state.horizontal_scale + }) + .sum() +} + +fn translation(tx: f32, ty: f32) -> Matrix2D { + Matrix2D { + e: tx, + f: ty, + ..Matrix2D::IDENTITY + } +} + +/// Record the fragment shown at the current text position and advance the text matrix. +fn show_fragment( + fragments: &mut Vec, + text: &str, + advance: f32, + text_matrix: &mut Matrix2D, + ctm: &Matrix2D, + state: &TextState, +) { + if !text.trim().is_empty() { + let eff = text_matrix.multiply(ctm); + let (x0, y0) = eff.transform_point(0.0, 0.0); + let (x1, _) = eff.transform_point(advance, 0.0); + let size = state.font_size * (eff.c * eff.c + eff.d * eff.d).sqrt(); + fragments.push(TextFragment { + bbox: BoundingBox::new(x0.min(x1), y0, (x1 - x0).abs(), size.max(1.0)), + text: text.to_string(), + }); + } + *text_matrix = translation(advance, 0.0).multiply(text_matrix); +} + /// Extract positioned text fragments from a decoded PDF content stream. +/// +/// Fonts are not resolved here, so glyph widths are estimated; use +/// [`extract_page_text`] to place fragments with the page's real font metrics. pub fn extract_positioned_fragments( content: &Content, encodings: &BTreeMap, Encoding>, +) -> Vec { + extract_fragments_with_metrics(content, encodings, &BTreeMap::new()) +} + +fn extract_fragments_with_metrics( + content: &Content, + encodings: &BTreeMap, Encoding>, + metrics: &BTreeMap, FontMetrics>, ) -> Vec { let mut fragments = Vec::new(); let mut ctm = Matrix2D::IDENTITY; let mut text_matrix = Matrix2D::IDENTITY; let mut line_matrix = Matrix2D::IDENTITY; - let mut font_size: f32 = 12.0; - let mut leading: f32 = 14.4; - let mut current_font: Option> = None; + let mut state = TextState::default(); - let mut state_stack: Vec<(Matrix2D, f32, f32, Option>)> = Vec::new(); + let mut state_stack: Vec<(Matrix2D, TextState)> = Vec::new(); + let float_at = |op: &lopdf::content::Operation, idx: usize| { + op.operands.get(idx).and_then(|o| o.as_float().ok()) + }; for op in &content.operations { match op.operator.as_str() { "q" => { - state_stack.push((ctm, font_size, leading, current_font.clone())); + state_stack.push((ctm, state.clone())); } "Q" => { - if let Some((saved_ctm, saved_font_size, saved_leading, saved_font)) = - state_stack.pop() - { + if let Some((saved_ctm, saved_state)) = state_stack.pop() { ctm = saved_ctm; - font_size = saved_font_size; - leading = saved_leading; - current_font = saved_font; + state = saved_state; } } "cm" => { @@ -181,25 +414,36 @@ pub fn extract_positioned_fragments( ctm = m.multiply(&ctm); } } - "BT" => { - text_matrix = Matrix2D::IDENTITY; - line_matrix = Matrix2D::IDENTITY; - } - "ET" => { + "BT" | "ET" => { text_matrix = Matrix2D::IDENTITY; line_matrix = Matrix2D::IDENTITY; } "Tf" => { if let Some(font_name) = op.operands.first().and_then(|o| o.as_name().ok()) { - current_font = Some(font_name.to_vec()); + state.font = Some(font_name.to_vec()); } - if let Some(size) = op.operands.get(1).and_then(|o| o.as_float().ok()) { - font_size = size; + if let Some(size) = float_at(op, 1) { + state.font_size = size; } } "TL" => { - if let Some(l) = op.operands.first().and_then(|o| o.as_float().ok()) { - leading = l; + if let Some(l) = float_at(op, 0) { + state.leading = l; + } + } + "Tc" => { + if let Some(v) = float_at(op, 0) { + state.char_spacing = v; + } + } + "Tw" => { + if let Some(v) = float_at(op, 0) { + state.word_spacing = v; + } + } + "Tz" => { + if let Some(v) = float_at(op, 0) { + state.horizontal_scale = v / 100.0; } } "Tm" => { @@ -218,170 +462,99 @@ pub fn extract_positioned_fragments( line_matrix = m; } } - "Td" => { - if op.operands.len() >= 2 - && let (Ok(tx), Ok(ty)) = (op.operands[0].as_float(), op.operands[1].as_float()) - { - let trans = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: tx, - f: ty, - }; - line_matrix = trans.multiply(&line_matrix); - text_matrix = line_matrix; - } - } - "TD" => { - if op.operands.len() >= 2 - && let (Ok(tx), Ok(ty)) = (op.operands[0].as_float(), op.operands[1].as_float()) - { - leading = -ty; - let trans = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: tx, - f: ty, - }; - line_matrix = trans.multiply(&line_matrix); + "Td" | "TD" => { + if let (Some(tx), Some(ty)) = (float_at(op, 0), float_at(op, 1)) { + if op.operator == "TD" { + state.leading = -ty; + } + line_matrix = translation(tx, ty).multiply(&line_matrix); text_matrix = line_matrix; } } - "T*" => { - let trans = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: 0.0, - f: -leading, - }; - line_matrix = trans.multiply(&line_matrix); - text_matrix = line_matrix; - } - "'" => { - let trans = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: 0.0, - f: -leading, - }; - line_matrix = trans.multiply(&line_matrix); - text_matrix = line_matrix; - - let enc = current_font.as_ref().and_then(|f| encodings.get(f)); - if let Some(Object::String(bytes, _)) = op.operands.first() { - let text = decode_bytes_with_encoding(bytes, enc); - if !text.trim().is_empty() { - let eff = text_matrix.multiply(&ctm); - let (x, y) = eff.transform_point(0.0, 0.0); - let w = (text.chars().count() as f32) * font_size * 0.52; - fragments.push(TextFragment { - bbox: BoundingBox::new(x, y, w.max(5.0), font_size.max(5.0)), - text, - }); + "T*" | "'" | "\"" => { + if op.operator == "\"" { + if let Some(aw) = float_at(op, 0) { + state.word_spacing = aw; + } + if let Some(ac) = float_at(op, 1) { + state.char_spacing = ac; } } - } - "\"" => { - let trans = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: 0.0, - f: -leading, - }; - line_matrix = trans.multiply(&line_matrix); + line_matrix = translation(0.0, -state.leading).multiply(&line_matrix); text_matrix = line_matrix; - let enc = current_font.as_ref().and_then(|f| encodings.get(f)); - if let Some(Object::String(bytes, _)) = op.operands.get(2) { - let text = decode_bytes_with_encoding(bytes, enc); - if !text.trim().is_empty() { - let eff = text_matrix.multiply(&ctm); - let (x, y) = eff.transform_point(0.0, 0.0); - let w = (text.chars().count() as f32) * font_size * 0.52; - fragments.push(TextFragment { - bbox: BoundingBox::new(x, y, w.max(5.0), font_size.max(5.0)), - text, - }); - } + let string_operand = match op.operator.as_str() { + "'" => op.operands.first(), + "\"" => op.operands.get(2), + _ => None, + }; + if let Some(Object::String(bytes, _)) = string_operand { + let font = state.font.as_ref(); + let text = + decode_bytes_with_encoding(bytes, font.and_then(|f| encodings.get(f))); + let advance = string_advance(bytes, font.and_then(|f| metrics.get(f)), &state); + show_fragment( + &mut fragments, + &text, + advance, + &mut text_matrix, + &ctm, + &state, + ); } } "Tj" => { - let enc = current_font.as_ref().and_then(|f| encodings.get(f)); if let Some(Object::String(bytes, _)) = op.operands.first() { - let text = decode_bytes_with_encoding(bytes, enc); - if !text.trim().is_empty() { - let eff = text_matrix.multiply(&ctm); - let (x, y) = eff.transform_point(0.0, 0.0); - let w = (text.chars().count() as f32) * font_size * 0.52; - fragments.push(TextFragment { - bbox: BoundingBox::new(x, y, w.max(5.0), font_size.max(5.0)), - text: text.clone(), - }); - - // Advance text matrix horizontally - let advance = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: w, - f: 0.0, - }; - text_matrix = advance.multiply(&text_matrix); - } + let font = state.font.as_ref(); + let text = + decode_bytes_with_encoding(bytes, font.and_then(|f| encodings.get(f))); + let advance = string_advance(bytes, font.and_then(|f| metrics.get(f)), &state); + show_fragment( + &mut fragments, + &text, + advance, + &mut text_matrix, + &ctm, + &state, + ); } } "TJ" => { - let enc = current_font.as_ref().and_then(|f| encodings.get(f)); if let Some(Object::Array(items)) = op.operands.first() { + let font = state.font.as_ref(); + let enc = font.and_then(|f| encodings.get(f)); + let font_metrics = font.and_then(|f| metrics.get(f)); let mut combined_text = String::new(); + let mut advance = 0.0; for item in items { match item { Object::String(bytes, _) => { - let part = decode_bytes_with_encoding(bytes, enc); - combined_text.push_str(&part); + combined_text.push_str(&decode_bytes_with_encoding(bytes, enc)); + advance += string_advance(bytes, font_metrics, &state); } - Object::Integer(i) if *i < -100 && !combined_text.ends_with(' ') => { - combined_text.push(' '); - } - Object::Real(r) if *r < -100.0 && !combined_text.ends_with(' ') => { - combined_text.push(' '); + Object::Integer(_) | Object::Real(_) => { + // Adjustments are in thousandths of an em; negative values move right + let adjustment = item.as_float().unwrap_or(0.0); + advance -= + adjustment / 1000.0 * state.font_size * state.horizontal_scale; + if adjustment < -TJ_WORD_GAP_THOUSANDTHS + && !combined_text.is_empty() + && !combined_text.ends_with(char::is_whitespace) + { + combined_text.push(' '); + } } _ => {} } } - - let clean = combined_text.trim(); - if !clean.is_empty() { - let eff = text_matrix.multiply(&ctm); - let (x, y) = eff.transform_point(0.0, 0.0); - let w = (clean.chars().count() as f32) * font_size * 0.52; - fragments.push(TextFragment { - bbox: BoundingBox::new(x, y, w.max(5.0), font_size.max(5.0)), - text: clean.to_string(), - }); - - // Advance text matrix horizontally - let advance = Matrix2D { - a: 1.0, - b: 0.0, - c: 0.0, - d: 1.0, - e: w, - f: 0.0, - }; - text_matrix = advance.multiply(&text_matrix); - } + show_fragment( + &mut fragments, + combined_text.trim(), + advance, + &mut text_matrix, + &ctm, + &state, + ); } } _ => {} @@ -391,6 +564,44 @@ pub fn extract_positioned_fragments( fragments } +/// Whether two fragments sit on the same baseline (within half the font size). +fn on_same_baseline(prev: &TextFragment, next: &TextFragment) -> bool { + let size = prev.bbox.height.max(next.bbox.height); + (next.bbox.y - prev.bbox.y).abs() <= size * 0.5 +} + +/// Whether the horizontal distance between two fragments on one line is a word space. +/// A jump backwards larger than the font size also separates words. +fn is_word_gap(prev: &TextFragment, next: &TextFragment) -> bool { + let size = prev.bbox.height.max(next.bbox.height); + let gap = next.bbox.x - prev.bbox.x_max(); + gap > size * WORD_GAP_EM || gap < -size +} + +/// Join fragments in content stream order, starting a new line when the baseline +/// changes and inserting a space when glyph positions leave a word gap. +pub fn join_fragments_in_stream_order(fragments: &[TextFragment]) -> String { + let mut output = String::new(); + for (idx, frag) in fragments.iter().enumerate() { + if idx > 0 { + let prev = &fragments[idx - 1]; + if !on_same_baseline(prev, frag) { + output.truncate(output.trim_end_matches([' ', '\t']).len()); + if !output.ends_with('\n') { + output.push('\n'); + } + } else if is_word_gap(prev, frag) + && !output.ends_with(char::is_whitespace) + && !frag.text.starts_with(char::is_whitespace) + { + output.push(' '); + } + } + output.push_str(&frag.text); + } + output +} + /// GoF Strategy pattern: Strategy for determining reading order of text fragments on a page. pub trait ReadingOrderStrategy { fn is_multi_column(&self) -> bool; @@ -592,12 +803,13 @@ pub fn group_fragments_into_lines(fragments: &[TextFragment]) -> Vec { for (idx, frag) in line_frags.iter().enumerate() { union_box = union_box.union(&frag.bbox); - if idx > 0 { - let prev_max = line_frags[idx - 1].bbox.x_max(); - // If there is visible horizontal spacing between fragments, insert space - if frag.bbox.x - prev_max >= 2.0 && !line_text.ends_with(' ') { - line_text.push(' '); - } + // If glyph positions leave a word gap between fragments, insert a space + if idx > 0 + && is_word_gap(&line_frags[idx - 1], frag) + && !line_text.ends_with(char::is_whitespace) + && !frag.text.starts_with(char::is_whitespace) + { + line_text.push(' '); } line_text.push_str(&frag.text); } @@ -771,14 +983,9 @@ pub fn select_reading_order_strategy(fragments: &[TextFragment]) -> Box Option { +/// Decode a page content stream into positioned fragments using the page's font +/// encodings and glyph widths. +fn page_fragments(doc: &lopdf::Document, page_id: (u32, u16)) -> Option> { let content_data = doc.get_page_content(page_id); if content_data.is_empty() { return None; @@ -788,31 +995,55 @@ pub fn extract_page_text_spatial( return None; }; - // Load font encodings for proper glyph mapping - let encodings: BTreeMap, Encoding> = doc - .get_page_fonts(page_id) - .map(|fonts| { - fonts - .into_iter() - .filter_map(|(name, font)| font.get_font_encoding(doc).ok().map(|enc| (name, enc))) - .collect() - }) - .unwrap_or_default(); - - let fragments = extract_positioned_fragments(&content, &encodings); - if fragments.is_empty() { - return None; + // Load font encodings for proper glyph mapping, and widths for glyph placement + let fonts = doc.get_page_fonts(page_id).unwrap_or_default(); + let mut encodings: BTreeMap, Encoding> = BTreeMap::new(); + let mut metrics: BTreeMap, FontMetrics> = BTreeMap::new(); + for (name, font) in fonts { + if let Ok(enc) = font.get_font_encoding(doc) { + encodings.insert(name.clone(), enc); + } + metrics.insert(name, FontMetrics::from_font(doc, font)); } + let fragments = extract_fragments_with_metrics(&content, &encodings, &metrics); + (!fragments.is_empty()).then_some(fragments) +} + +fn non_empty(text: String) -> Option { + (!text.trim().is_empty()).then_some(text) +} + +/// Extract and reconstruct page text respecting multi-column spatial reading order. +/// If `only_if_multi_column` is true, returns `None` when the page layout is single-column, +/// allowing the caller to use default linear stream extraction. +pub fn extract_page_text_spatial( + doc: &lopdf::Document, + page_id: (u32, u16), + only_if_multi_column: bool, +) -> Option { + let fragments = page_fragments(doc, page_id)?; + let strategy = select_reading_order_strategy(&fragments); if only_if_multi_column && !strategy.is_multi_column() { return None; } - let reconstructed = strategy.reconstruct_text(&fragments); - if reconstructed.trim().is_empty() { - None + non_empty(strategy.reconstruct_text(&fragments)) +} + +/// Extract page text from positioned glyphs: multi-column reading order when gutters +/// are detected, otherwise content stream order. Word spaces come from glyph +/// positions (TJ adjustments and text moves), so fonts without a space glyph and +/// words split across operators still read correctly. +pub fn extract_page_text(doc: &lopdf::Document, page_id: (u32, u16)) -> Option { + let fragments = page_fragments(doc, page_id)?; + + let strategy = select_reading_order_strategy(&fragments); + let text = if strategy.is_multi_column() { + strategy.reconstruct_text(&fragments) } else { - Some(reconstructed) - } + join_fragments_in_stream_order(&fragments) + }; + non_empty(text) } diff --git a/src/document/mod.rs b/src/document/mod.rs index 7d470d6..7dcbeda 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -14,7 +14,7 @@ pub use attachments::extract_document_attachments; pub use forms::extract_document_forms; pub use layout::{ BoundingBox, MultiColumnSpatialFlow, ReadingOrderStrategy, SingleColumnFlow, TextFragment, - TextLine, extract_page_text_spatial, + TextLine, extract_page_text, extract_page_text_spatial, join_fragments_in_stream_order, }; pub use links::{ decode_pdf_string, extract_page_links, object_to_string, resolve_dest, diff --git a/src/document/parser.rs b/src/document/parser.rs index 39c4415..a154473 100644 --- a/src/document/parser.rs +++ b/src/document/parser.rs @@ -273,12 +273,13 @@ pub fn load_pdf_from_path_with_password( }; let image_count = images.len(); - let spatial_text = - page_id.and_then(|id| super::layout::extract_page_text_spatial(&pdf_doc, id, true)); + // Rebuild text from positioned glyphs so word spaces follow glyph placement + // (TJ adjustments, Td/Tm moves) and multi-column pages keep reading order. + let positioned_text = page_id.and_then(|id| super::layout::extract_page_text(&pdf_doc, id)); - let mut text = match spatial_text { + let mut text = match positioned_text { Some(reconstructed) => { - debug!(target: "parser", page = page_num, "Applied multi-column spatial reading order reconstruction"); + debug!(target: "parser", page = page_num, "Reconstructed page text from positioned glyphs"); reconstructed } None => match pdf_doc.extract_text(&[page_num]) { From d949a07b142169274d1017f3cd0b3469c0fe7c05 Mon Sep 17 00:00:00 2001 From: Yoiber dev <78499112+yoiberdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:06:51 +0000 Subject: [PATCH 6/6] test(layout): cover TJ and Td word spacing Generate PDFs with a font of known widths: TJ arrays with real and integer adjustments, kerning inside a word and a word split across two TJ operators; Td moves between words, one text object per word with separate punctuation, and a scaled text matrix. Also check the stream order join on synthetic fragments. --- tests/text_word_spacing_test.rs | 220 ++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/text_word_spacing_test.rs diff --git a/tests/text_word_spacing_test.rs b/tests/text_word_spacing_test.rs new file mode 100644 index 0000000..9e72616 --- /dev/null +++ b/tests/text_word_spacing_test.rs @@ -0,0 +1,220 @@ +//! Word spacing in extracted text for PDFs that place words by position instead of +//! space glyphs: TJ arrays with large negative adjustments and Td/Tm moves between +//! words (as emitted by Prince, XeTeX and similar engines). + +use docugraph::document::layout::{BoundingBox, TextFragment, join_fragments_in_stream_order}; +use docugraph::document::load_pdf_from_path; +use lopdf::content::{Content, Operation}; +use lopdf::{Document, Object, Stream, dictionary}; +use tempfile::NamedTempFile; + +/// Build a one-page PDF whose font /F1 declares explicit glyph widths: +/// every printable glyph is 500/1000 em wide and the space glyph 250/1000 em. +fn load_page_text(ops: Vec) -> String { + let mut doc = Document::with_version("1.5"); + let pages_id = doc.new_object_id(); + + let widths: Vec = (32..=126) + .map(|code| Object::Integer(if code == 32 { 250 } else { 500 })) + .collect(); + let font_id = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "TrueType", + "BaseFont" => "DocuGraphTestSans", + "FirstChar" => 32, + "LastChar" => 126, + "Widths" => widths, + "Encoding" => "WinAnsiEncoding", + }); + let resources_id = doc.add_object(dictionary! { + "Font" => dictionary! { "F1" => font_id }, + }); + + let content = Content { operations: ops }; + let content_id = doc.add_object(Stream::new( + dictionary! {}, + content.encode().expect("content encoding should succeed"), + )); + let page_id = 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()], + }); + doc.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => vec![page_id.into()], + "Count" => 1, + }), + ); + let catalog_id = doc.add_object(dictionary! { + "Type" => "Catalog", + "Pages" => pages_id, + }); + doc.trailer.set("Root", catalog_id); + + let mut temp_file = NamedTempFile::new().unwrap(); + doc.save_to(&mut temp_file).unwrap(); + let parsed = load_pdf_from_path(temp_file.path()).expect("synthetic PDF should load"); + assert!( + !parsed.pages[0].untrusted_text_detected, + "synthetic page must not trip the hidden text detector" + ); + parsed.pages[0].text.clone() +} + +fn op(operator: &str, operands: Vec) -> Operation { + Operation::new(operator, operands) +} + +fn text(s: &str) -> Object { + Object::string_literal(s) +} + +fn font(size: f32) -> Operation { + op("Tf", vec![Object::Name(b"F1".to_vec()), size.into()]) +} + +fn td(tx: f32, ty: f32) -> Operation { + op("Td", vec![tx.into(), ty.into()]) +} + +fn tm(scale: f32, x: f32, y: f32) -> Operation { + op( + "Tm", + vec![ + scale.into(), + 0.into(), + 0.into(), + scale.into(), + x.into(), + y.into(), + ], + ) +} + +#[test] +fn test_tj_adjustments_become_word_spaces() { + let ops = vec![ + op("BT", vec![]), + font(12.0), + td(50.0, 700.0), + // Word gaps as real and integer adjustments, with no space glyph in the strings + op( + "TJ", + vec![Object::Array(vec![ + text("Sin"), + Object::Real(-266.24), + text("glifo"), + Object::Integer(-300), + text("de"), + Object::Real(-317.93), + text("espacio"), + ])], + ), + op("ET", vec![]), + op("BT", vec![]), + font(12.0), + td(50.0, 670.0), + // Kerning adjustments inside a word must not split it + op( + "TJ", + vec![Object::Array(vec![ + text("Pa"), + Object::Integer(40), + text("tro"), + Object::Real(-15.5), + text("nes"), + ])], + ), + // A word continued by a second TJ right where the first one ends + td(0.0, -30.0), + op("TJ", vec![Object::Array(vec![text("crea")])]), + op("TJ", vec![Object::Array(vec![text("cionales")])]), + op("ET", vec![]), + ]; + + let page_text = load_page_text(ops); + let lines: Vec<&str> = page_text.lines().collect(); + assert_eq!( + lines, + vec!["Sin glifo de espacio", "Patrones", "creacionales"], + "unexpected text: {page_text:?}" + ); +} + +#[test] +fn test_td_moves_between_words_become_word_spaces() { + // At 12pt every glyph advances 6pt, so "Hola" ends 24pt after its origin. + let ops = vec![ + op("BT", vec![]), + font(12.0), + td(50.0, 700.0), + op("Tj", vec![text("Hola")]), + td(27.0, 0.0), // 3pt gap: word space + op("Tj", vec![text("mundo")]), + td(33.0, 0.0), // 3pt gap: word space + op("Tj", vec![text("sin")]), + td(21.0, 0.0), // 3pt gap: word space + op("Tj", vec![text("espa")]), + td(24.5, 0.0), // 0.5pt gap: same word + op("Tj", vec![text("cios")]), + op("ET", vec![]), + // One word per text object, placed with absolute moves on the next line + op("BT", vec![]), + font(12.0), + td(50.0, 680.0), + op("Tj", vec![text("Otra")]), + op("ET", vec![]), + op("BT", vec![]), + font(12.0), + td(77.0, 680.0), + op("Tj", vec![text("linea")]), + op("ET", vec![]), + // Punctuation shown separately right after the word + op("BT", vec![]), + font(12.0), + td(107.0, 680.0), + op("Tj", vec![text(".")]), + op("ET", vec![]), + // A scaled text matrix: 6pt font drawn at 2x is 12pt in user space + op("BT", vec![]), + font(6.0), + tm(2.0, 50.0, 660.0), + op("Tj", vec![text("Texto")]), + tm(2.0, 83.0, 660.0), + op("Tj", vec![text("escalado")]), + op("ET", vec![]), + ]; + + let page_text = load_page_text(ops); + let lines: Vec<&str> = page_text.lines().collect(); + assert_eq!( + lines, + vec!["Hola mundo sin espacios", "Otra linea.", "Texto escalado"], + "unexpected text: {page_text:?}" + ); +} + +#[test] +fn test_stream_order_join_breaks_lines_and_words_by_position() { + let frag = |x: f32, y: f32, width: f32, text: &str| TextFragment { + bbox: BoundingBox::new(x, y, width, 10.0), + text: text.to_string(), + }; + let fragments = vec![ + frag(50.0, 700.0, 20.0, "pala"), + frag(70.5, 700.0, 15.0, "bra"), // 0.5pt gap: same word + frag(88.0, 701.0, 20.0, "tres"), // 2.5pt gap on a slightly raised baseline + frag(50.0, 686.0, 20.0, "cuatro"), // next line + frag(20.0, 686.0, 10.0, "cinco"), // jump backwards on the same baseline + ]; + + assert_eq!( + join_fragments_in_stream_order(&fragments), + "palabra tres\ncuatro cinco" + ); +}