diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5d68ef..f1cf7eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: cargo fmt --all -- --check - name: Run Clippy Linter (cargo clippy) - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --locked --all-targets -- -D warnings - name: Run Automated Tests (cargo test) - run: cargo test --all --verbose + run: cargo test --all --locked --verbose diff --git a/.gitignore b/.gitignore index 7f039cb..82b852a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Generated by Cargo /target/ **/*.rs.bk -Cargo.lock # Environment & Secrets (Never commit secrets!) .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f38b8f2..0b95f49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,9 @@ cargo clippy --all-targets -- -D warnings # 3. Suite completa de pruebas pasando cargo test --all + +# 4. (Opcional) Test con un PDF real, ignorado por defecto +DOCUGRAPH_TEST_PDF=/ruta/a/libro.pdf cargo test --test pdf_ingestion_test -- --ignored ``` --- diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..251b1e3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1681 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "docugraph" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "hex", + "lopdf", + "miniz_oxide 0.8.9", + "rmcp", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lopdf" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3" +dependencies = [ + "aes", + "bitflags", + "brotli-decompressor", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom", + "indexmap", + "itoa", + "log", + "md-5", + "nom", + "rand", + "rangemap", + "rayon", + "serde", + "sha2 0.11.0", + "stringprep", + "thiserror", + "weezl", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmcp" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88db56b8ae316560e9e868b6b978ea940f27cb883323fc90e07435e44158f5c" +dependencies = [ + "base64", + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873b730df6f0a9b74b13eb514e0dca4c2db0d8b68b74af98a2e9bf3f9d436585" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.5", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/document/attachments.rs b/src/document/attachments.rs index 8abc976..2dfc97b 100644 --- a/src/document/attachments.rs +++ b/src/document/attachments.rs @@ -158,9 +158,8 @@ fn traverse_name_tree( }; if let Some(arr) = names_arr { - for chunk in arr.chunks_exact(2) { - let name_hint = object_to_string(&chunk[0]); - let filespec_obj = &chunk[1]; + for [name_obj, filespec_obj] in arr.as_chunks::<2>().0 { + let name_hint = object_to_string(name_obj); if let Some(att) = parse_filespec(doc, filespec_obj, name_hint, None, seen_stream_ids) { diff --git a/src/document/layout.rs b/src/document/layout.rs index dde61b6..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,174 +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 => { - if !combined_text.ends_with(' ') { - combined_text.push(' '); - } - } - Object::Real(r) if *r < -100.0 => { - if !combined_text.ends_with(' ') { + 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, + ); } } _ => {} @@ -395,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; @@ -596,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); } @@ -775,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; @@ -792,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]) { 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!( 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" + ); +}