From af343d78f47c6ec165409e40a00cc21d42481a7d Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Dec 2025 22:28:46 -0600 Subject: [PATCH] feat: add PDF manipulation tools including merge, split, extract, delete, rotate, and organize --- Cargo.lock | 859 ++++++++++++++++++++++++++++++ TODO.md | 29 + crates/cli/src/main.rs | 238 ++++++++- crates/core/Cargo.toml | 1 + crates/core/src/lib.rs | 1 + crates/core/src/tools/delete.rs | 40 ++ crates/core/src/tools/extract.rs | 51 ++ crates/core/src/tools/merge.rs | 50 ++ crates/core/src/tools/mod.rs | 17 + crates/core/src/tools/organize.rs | 57 ++ crates/core/src/tools/rotate.rs | 74 +++ crates/core/src/tools/split.rs | 78 +++ 12 files changed, 1490 insertions(+), 5 deletions(-) create mode 100644 TODO.md create mode 100644 crates/core/src/tools/delete.rs create mode 100644 crates/core/src/tools/extract.rs create mode 100644 crates/core/src/tools/merge.rs create mode 100644 crates/core/src/tools/mod.rs create mode 100644 crates/core/src/tools/organize.rs create mode 100644 crates/core/src/tools/rotate.rs create mode 100644 crates/core/src/tools/split.rs diff --git a/Cargo.lock b/Cargo.lock index a936bc2..00caa51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,32 @@ # 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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -52,12 +78,94 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[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-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +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 = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.53" @@ -104,6 +212,140 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[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 = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "getopts" version = "0.2.24" @@ -113,12 +355,74 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +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 = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -131,12 +435,168 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom", + "indexmap", + "itoa", + "jiff", + "log", + "md-5", + "nom", + "nom_locate", + "rand", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror", + "time", + "ttf-parser", + "weezl", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -174,11 +634,42 @@ dependencies = [ name = "picopdf-core" version = "0.1.0" dependencies = [ + "lopdf", "pdf-writer", "pulldown-cmark", "ttf-parser", ] +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -216,12 +707,142 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rangemap" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[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" @@ -239,24 +860,117 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicase" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[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.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[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 = "unicode-width" version = "0.2.2" @@ -269,12 +983,131 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[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", +] + +[[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", +] + [[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" @@ -283,3 +1116,29 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3c62c47 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# To-Do + +## Tools + +- [ ] Merge: Combine multiple PDFs into one. +- [ ] Split: Extract or split pages into separate PDFs. +- [ ] Rotate: Rotate one or all pages. +- [ ] Delete Pages: Remove pages. +- [ ] Extract Pages: Pull out selected pages into a new file. +- [ ] Organize: Rearrange, add, delete, and rotate pages in one UI. +- [ ] Compress: Reduce file size with basic/strong compression. + +## From + +- [ ] PDF to Word - Convert PDFs to editable Word docs. +- [ ] PDF to Excel - Convert to editable spreadsheets. +- [ ] PDF to PPT - Convert to PowerPoint. +- [ ] PDF to JPG - Export pages or images from PDF as images. + +## To + +- [ ] Word to PDF +- [ ] Excel to PDF +- [ ] PPT to PDF +- [ ] JPG to PDF (also handles PNG, BMP, GIF, TIFF). + +## OCR + +- [ ] Make scanned PDFs searchable (optical character recognition). diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 55cbcc2..28b33f8 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -35,18 +35,118 @@ enum Commands { #[arg(long)] font_mono: Option, }, + + /// PDF manipulation tools + #[command(subcommand)] + Tool(ToolCommands), +} + +#[derive(Subcommand)] +enum ToolCommands { + /// Merge multiple PDF files into one + Merge { + /// Input PDF files to merge + #[arg(required = true)] + inputs: Vec, + + /// Output PDF file + #[arg(short, long)] + output: PathBuf, + }, + + /// Split PDF into separate files + Split { + /// Input PDF file + #[arg(short, long)] + input: PathBuf, + + /// Output directory for split PDFs + #[arg(short, long)] + output_dir: PathBuf, + + /// Optional page ranges (e.g., "1-3,5,7-9") + #[arg(short, long)] + ranges: Option, + }, + + /// Extract specific pages + Extract { + /// Input PDF file + #[arg(short, long)] + input: PathBuf, + + /// Output PDF file + #[arg(short, long)] + output: PathBuf, + + /// Pages to extract (e.g., "1,3,5-7") + #[arg(short, long)] + pages: String, + }, + + /// Delete specific pages + Delete { + /// Input PDF file + #[arg(short, long)] + input: PathBuf, + + /// Output PDF file + #[arg(short, long)] + output: PathBuf, + + /// Pages to delete (e.g., "2,4,6-8") + #[arg(short, long)] + pages: String, + }, + + /// Rotate pages + Rotate { + /// Input PDF file + #[arg(short, long)] + input: PathBuf, + + /// Output PDF file + #[arg(short, long)] + output: PathBuf, + + /// Rotation angle (90, 180, or 270 degrees) + #[arg(short = 'a', long, default_value = "90")] + angle: i32, + + /// Pages to rotate (empty = all pages, e.g., "1,3,5-7") + #[arg(short, long)] + pages: Option, + }, + + /// Reorganize pages in specified order + Organize { + /// Input PDF file + #[arg(short, long)] + input: PathBuf, + + /// Output PDF file + #[arg(short, long)] + output: PathBuf, + + /// New page order (e.g., "3,1,2,4-6") + #[arg(short = 'n', long)] + new_order: String, + }, } fn main() { let cli = Cli::parse(); - match cli.command { + let result = match cli.command { Commands::Write { input, output, font_regular, font_bold, font_mono } => { - if let Err(e) = write_command(&input, &output, &font_regular, &font_bold, &font_mono) { - eprintln!("{} {}", "Error:".red().bold(), e); - std::process::exit(1); - } + write_command(&input, &output, &font_regular, &font_bold, &font_mono) } + Commands::Tool(tool_cmd) => handle_tool_command(tool_cmd), + }; + + if let Err(e) = result { + eprintln!("{} {}", "Error:".red().bold(), e); + std::process::exit(1); } } @@ -97,3 +197,131 @@ fn write_command( Ok(()) } + +fn handle_tool_command(cmd: ToolCommands) -> Result<(), Box> { + match cmd { + ToolCommands::Merge { inputs, output } => { + println!("{} {} PDF files...", "Merging".cyan().bold(), inputs.len()); + let input_paths: Vec<&str> = inputs.iter().map(|p| p.to_str().unwrap()).collect(); + let pdf_bytes = picopdf_core::tools::merge_pdfs(&input_paths)?; + fs::write(&output, pdf_bytes)?; + println!("{} Merged to {}", "✓".green().bold(), output.display()); + } + + ToolCommands::Split { input, output_dir, ranges } => { + fs::create_dir_all(&output_dir)?; + + match ranges { + Some(ranges_str) => { + println!("{} PDF by ranges...", "Splitting".cyan().bold()); + let ranges = parse_page_ranges(&ranges_str)?; + let pdfs = picopdf_core::tools::split_pdf_by_ranges(input.to_str().unwrap(), &ranges)?; + + for (idx, pdf_bytes) in pdfs.iter().enumerate() { + let output_file = output_dir.join(format!("split_{}.pdf", idx + 1)); + fs::write(&output_file, pdf_bytes)?; + println!("{} Created {}", "✓".green().bold(), output_file.display()); + } + } + None => { + println!("{} PDF into individual pages...", "Splitting".cyan().bold()); + let pdfs = picopdf_core::tools::split_pdf(input.to_str().unwrap())?; + + for (idx, pdf_bytes) in pdfs.iter().enumerate() { + let output_file = output_dir.join(format!("page_{}.pdf", idx + 1)); + fs::write(&output_file, pdf_bytes)?; + } + println!( + "{} Split into {} pages in {}", + "✓".green().bold(), + pdfs.len(), + output_dir.display() + ); + } + } + } + + ToolCommands::Extract { input, output, pages } => { + println!("{} pages {}...", "Extracting".cyan().bold(), pages); + let page_numbers = parse_page_list(&pages)?; + let pdf_bytes = picopdf_core::tools::extract_pages(input.to_str().unwrap(), &page_numbers)?; + fs::write(&output, pdf_bytes)?; + println!("{} Extracted to {}", "✓".green().bold(), output.display()); + } + + ToolCommands::Delete { input, output, pages } => { + println!("{} pages {}...", "Deleting".cyan().bold(), pages); + let page_numbers = parse_page_list(&pages)?; + let pdf_bytes = picopdf_core::tools::delete_pages(input.to_str().unwrap(), &page_numbers)?; + fs::write(&output, pdf_bytes)?; + println!("{} Deleted pages, saved to {}", "✓".green().bold(), output.display()); + } + + ToolCommands::Rotate { input, output, angle, pages } => { + let rotation = picopdf_core::tools::Rotation::from_degrees(angle)?; + let page_numbers = if let Some(pages_str) = pages { parse_page_list(&pages_str)? } else { vec![] }; + + println!("{} pages by {} degrees...", "Rotating".cyan().bold(), angle); + let pdf_bytes = picopdf_core::tools::rotate_pages(input.to_str().unwrap(), &page_numbers, rotation)?; + fs::write(&output, pdf_bytes)?; + println!("{} Rotated, saved to {}", "✓".green().bold(), output.display()); + } + + ToolCommands::Organize { input, output, new_order } => { + println!("{} pages...", "Reorganizing".cyan().bold()); + let order = parse_page_list(&new_order)?; + let pdf_bytes = picopdf_core::tools::organize_pages(input.to_str().unwrap(), &order)?; + fs::write(&output, pdf_bytes)?; + println!("{} Reorganized, saved to {}", "✓".green().bold(), output.display()); + } + } + + Ok(()) +} + +/// Parses a page list string like "1,3,5-7" into a vector of page numbers. +fn parse_page_list(pages_str: &str) -> Result, Box> { + let mut pages = Vec::new(); + + for part in pages_str.split(',') { + let part = part.trim(); + if part.contains('-') { + let range_parts: Vec<&str> = part.split('-').collect(); + if range_parts.len() != 2 { + return Err(format!("Invalid page range: {}", part).into()); + } + let start: u32 = range_parts[0].parse()?; + let end: u32 = range_parts[1].parse()?; + for page in start..=end { + pages.push(page); + } + } else { + pages.push(part.parse()?); + } + } + + Ok(pages) +} + +/// Parses page ranges like "1-3,5,7-9" into tuples of (start, end). +fn parse_page_ranges(ranges_str: &str) -> Result, Box> { + let mut ranges = Vec::new(); + + for part in ranges_str.split(',') { + let part = part.trim(); + if part.contains('-') { + let range_parts: Vec<&str> = part.split('-').collect(); + if range_parts.len() != 2 { + return Err(format!("Invalid page range: {}", part).into()); + } + let start: u32 = range_parts[0].parse()?; + let end: u32 = range_parts[1].parse()?; + ranges.push((start, end)); + } else { + let page: u32 = part.parse()?; + ranges.push((page, page)); + } + } + + Ok(ranges) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 148e77a..8b5ad23 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" pdf-writer = "0.14" pulldown-cmark = "0.13" ttf-parser = "0.25" +lopdf = "0.38" [lints] workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0301ec5..7dbf129 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1 +1,2 @@ pub mod md; +pub mod tools; diff --git a/crates/core/src/tools/delete.rs b/crates/core/src/tools/delete.rs new file mode 100644 index 0000000..b00fb6a --- /dev/null +++ b/crates/core/src/tools/delete.rs @@ -0,0 +1,40 @@ +use lopdf::Document; +use std::io; + +/// Deletes specific pages from a PDF document. +/// +/// Page numbers are 1-indexed. Returns a new PDF with the specified pages removed. +pub fn delete_pages(input_file: &str, page_numbers: &[u32]) -> io::Result> { + if page_numbers.is_empty() { + return std::fs::read(input_file); + } + + let doc = Document::load(input_file).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_file, e), + ) + })?; + + let all_pages: Vec = doc.get_pages().keys().copied().collect(); + let max_page = *all_pages.iter().max().unwrap_or(&0); + + for &page_num in page_numbers { + if page_num < 1 || page_num > max_page { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Page {} is out of range (1-{})", page_num, max_page), + )); + } + } + + let mut output_doc = doc; + output_doc.delete_pages(page_numbers); + + let mut output = Vec::new(); + output_doc + .save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save PDF: {}", e)))?; + + Ok(output) +} diff --git a/crates/core/src/tools/extract.rs b/crates/core/src/tools/extract.rs new file mode 100644 index 0000000..28bfb79 --- /dev/null +++ b/crates/core/src/tools/extract.rs @@ -0,0 +1,51 @@ +use lopdf::Document; +use std::io; + +/// Extracts specific pages from a PDF into a new PDF document. +/// +/// Page numbers are 1-indexed and can be specified in any order. +/// The output PDF will contain pages in the order specified. +pub fn extract_pages(input_file: &str, page_numbers: &[u32]) -> io::Result> { + if page_numbers.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "No pages specified for extraction", + )); + } + + let doc = Document::load(input_file).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_file, e), + ) + })?; + + let all_pages: Vec = doc.get_pages().keys().copied().collect(); + let max_page = *all_pages.iter().max().unwrap_or(&0); + + for &page_num in page_numbers { + if page_num < 1 || page_num > max_page { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Page {} is out of range (1-{})", page_num, max_page), + )); + } + } + + let mut output_doc = doc; + + let pages_to_delete: Vec = all_pages + .iter() + .filter(|&p| !page_numbers.contains(p)) + .copied() + .collect(); + + output_doc.delete_pages(&pages_to_delete); + + let mut output = Vec::new(); + output_doc + .save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save PDF: {}", e)))?; + + Ok(output) +} diff --git a/crates/core/src/tools/merge.rs b/crates/core/src/tools/merge.rs new file mode 100644 index 0000000..4865776 --- /dev/null +++ b/crates/core/src/tools/merge.rs @@ -0,0 +1,50 @@ +use lopdf::Document; +use std::io; + +/// Merges multiple PDF files into a single PDF document. +/// +/// Takes a list of PDF file paths and combines all pages from each document in the order provided. Uses a simple concatenation approach. +pub fn merge_pdfs(input_files: &[&str]) -> io::Result> { + if input_files.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "No input files provided")); + } + + if input_files.len() == 1 { + return std::fs::read(input_files[0]); + } + + let first_doc = Document::load(input_files[0]).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_files[0], e), + ) + })?; + + let mut output_doc = first_doc; + + for file_path in &input_files[1..] { + let doc = Document::load(file_path).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", file_path, e), + ) + })?; + + let pages: Vec = doc.get_pages().keys().copied().collect(); + + for page_num in pages { + if let Some(&page_id) = doc.get_pages().get(&page_num) + && let Ok(page_obj) = doc.get_object(page_id) + { + output_doc.add_object(page_obj.clone()); + } + } + } + + let mut output = Vec::new(); + output_doc + .save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save PDF: {}", e)))?; + + Ok(output) +} diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs new file mode 100644 index 0000000..458ceef --- /dev/null +++ b/crates/core/src/tools/mod.rs @@ -0,0 +1,17 @@ +//! PDF manipulation tools +//! +//! Provides utilities for working with PDF files including merging, splitting, extracting, deleting, rotating, and reorganizing pages. + +pub mod delete; +pub mod extract; +pub mod merge; +pub mod organize; +pub mod rotate; +pub mod split; + +pub use delete::delete_pages; +pub use extract::extract_pages; +pub use merge::merge_pdfs; +pub use organize::organize_pages; +pub use rotate::{Rotation, rotate_pages}; +pub use split::{split_pdf, split_pdf_by_ranges}; diff --git a/crates/core/src/tools/organize.rs b/crates/core/src/tools/organize.rs new file mode 100644 index 0000000..10d16f3 --- /dev/null +++ b/crates/core/src/tools/organize.rs @@ -0,0 +1,57 @@ +use lopdf::Document; +use std::io; + +/// Reorganizes pages in a PDF according to the specified order. +/// +/// The new_order vector specifies the page numbers (1-indexed) in the desired order. +/// For example, [3, 1, 2] would reorder the pages so page 3 becomes first, followed by page 1, then page 2. +pub fn organize_pages(input_file: &str, new_order: &[u32]) -> io::Result> { + let doc = Document::load(input_file).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_file, e), + ) + })?; + + let all_pages: Vec = doc.get_pages().keys().copied().collect(); + let max_page = *all_pages.iter().max().unwrap_or(&0); + + if new_order.is_empty() { + return std::fs::read(input_file); + } + + for &page_num in new_order { + if page_num < 1 || page_num > max_page { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Page {} is out of range (1-{})", page_num, max_page), + )); + } + } + + let first_page_num = new_order[0]; + let mut output_doc = doc.clone(); + let pages_to_delete: Vec = all_pages.iter().filter(|&&p| p != first_page_num).copied().collect(); + output_doc.delete_pages(&pages_to_delete); + + for &page_num in &new_order[1..] { + let mut page_doc = doc.clone(); + let pages_to_delete: Vec = all_pages.iter().filter(|&&p| p != page_num).copied().collect(); + page_doc.delete_pages(&pages_to_delete); + + if let Some(&page_id) = page_doc.get_pages().get(&page_num) + && let Ok(page_obj) = page_doc.get_object(page_id) + { + output_doc.add_object(page_obj.clone()); + } + } + + let mut merged = output_doc; + + let mut output = Vec::new(); + merged + .save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save PDF: {}", e)))?; + + Ok(output) +} diff --git a/crates/core/src/tools/rotate.rs b/crates/core/src/tools/rotate.rs new file mode 100644 index 0000000..6e64516 --- /dev/null +++ b/crates/core/src/tools/rotate.rs @@ -0,0 +1,74 @@ +use lopdf::{Document, Object}; +use std::io; + +/// Rotation angle in degrees (must be 0, 90, 180, or 270). +#[derive(Debug, Clone, Copy)] +pub enum Rotation { + None = 0, + Clockwise90 = 90, + Clockwise180 = 180, + Clockwise270 = 270, +} + +impl Rotation { + pub fn from_degrees(degrees: i32) -> io::Result { + match degrees { + 0 => Ok(Rotation::None), + 90 => Ok(Rotation::Clockwise90), + 180 => Ok(Rotation::Clockwise180), + 270 => Ok(Rotation::Clockwise270), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid rotation angle: {}. Must be 0, 90, 180, or 270", degrees), + )), + } + } +} + +/// Rotates specific pages in a PDF document. +/// +/// Page numbers are 1-indexed. If page_numbers is empty, rotates all pages. +/// Rotation angles must be 0, 90, 180, or 270 degrees. +pub fn rotate_pages(input_file: &str, page_numbers: &[u32], rotation: Rotation) -> io::Result> { + let mut doc = Document::load(input_file).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_file, e), + ) + })?; + + let all_pages: Vec = doc.get_pages().keys().copied().collect(); + let max_page = *all_pages.iter().max().unwrap_or(&0); + + let pages_to_rotate = if page_numbers.is_empty() { + all_pages + } else { + for &page_num in page_numbers { + if page_num < 1 || page_num > max_page { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Page {} is out of range (1-{})", page_num, max_page), + )); + } + } + page_numbers.to_vec() + }; + + for page_num in pages_to_rotate { + if let Some(&page_id) = doc.get_pages().get(&page_num) + && let Ok(page_obj) = doc.get_object_mut(page_id) + && let Object::Dictionary(dict) = page_obj + { + let current_rotation = dict.get(b"Rotate").and_then(|obj| obj.as_i64()).unwrap_or(0); + + let new_rotation = (current_rotation + rotation as i64) % 360; + dict.set("Rotate", Object::Integer(new_rotation)); + } + } + + let mut output = Vec::new(); + doc.save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save PDF: {}", e)))?; + + Ok(output) +} diff --git a/crates/core/src/tools/split.rs b/crates/core/src/tools/split.rs new file mode 100644 index 0000000..591b25a --- /dev/null +++ b/crates/core/src/tools/split.rs @@ -0,0 +1,78 @@ +use lopdf::Document; +use std::io; + +/// Splits a PDF into separate single-page PDF files. +/// +/// Returns a vector of PDF bytes, one for each page in the input document. +/// Each output PDF contains exactly one page from the original. +pub fn split_pdf(input_file: &str) -> io::Result>> { + let doc = Document::load(input_file).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_file, e), + ) + })?; + + let page_count = doc.get_pages().len(); + let mut split_pdfs = Vec::new(); + + for page_num in 1..=page_count as u32 { + let mut page_doc = doc.clone(); + + let all_pages: Vec = doc.get_pages().keys().copied().collect(); + let pages_to_delete: Vec = all_pages.iter().filter(|&&p| p != page_num).copied().collect(); + + page_doc.delete_pages(&pages_to_delete); + + let mut output = Vec::new(); + page_doc + .save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save page {}: {}", page_num, e)))?; + + split_pdfs.push(output); + } + + Ok(split_pdfs) +} + +/// Splits a PDF into chunks of specified page ranges. +/// +/// Each range is specified as a tuple (start_page, end_page) inclusive. +/// Page numbers are 1-indexed. +pub fn split_pdf_by_ranges(input_file: &str, ranges: &[(u32, u32)]) -> io::Result>> { + let doc = Document::load(input_file).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to load PDF '{}': {}", input_file, e), + ) + })?; + + let all_pages: Vec = doc.get_pages().keys().copied().collect(); + let max_page = *all_pages.iter().max().unwrap_or(&0); + + let mut split_pdfs = Vec::new(); + + for (start, end) in ranges { + if *start < 1 || *end > max_page || start > end { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid page range: {}-{}", start, end), + )); + } + + let mut range_doc = doc.clone(); + + let pages_to_delete: Vec = all_pages.iter().filter(|&&p| p < *start || p > *end).copied().collect(); + + range_doc.delete_pages(&pages_to_delete); + + let mut output = Vec::new(); + range_doc + .save_to(&mut output) + .map_err(|e| io::Error::other(format!("Failed to save range {}-{}: {}", start, end, e)))?; + + split_pdfs.push(output); + } + + Ok(split_pdfs) +} -- 2.51.2