commit 13438289fe06d75d02e0fa4469bef2a7b92edf9e Author: Warren Date: Wed Jul 1 09:06:41 2026 +0800 Initial commit: telfax - Rust Fax Server - Modem driver (serialport) with AT command interface - Class 1 fax protocol (T.30 session, HDLC framing) - T.4/T.6 image codec (fax crate integration) - Document conversion (image to fax, TIFF-F output) - REST API (axum) for fax job management - SQLite/InMemory job queue - CLI interface (detect/serve/send/test) - Integration tests passing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78af8aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +*.swp +*.swo +*~ +.DS_Store +*.log +.env +data/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0071f6a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2642 @@ +# 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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[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 = "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 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[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 = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[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.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[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 = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 0.61.2", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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 = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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 = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[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 = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +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 = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lopdf" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fa2559e99ba0f26a12458aabc754432c805bbb8cba516c427825a997af1fb7" +dependencies = [ + "aes", + "bitflags 2.13.0", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "jiff", + "log", + "md-5", + "nom", + "nom_locate", + "rand", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror", + "time", + "weezl", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[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.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[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 = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +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-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[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 2.13.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +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 = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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 = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[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 = "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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "telfax" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "fax", + "image", + "lopdf", + "rusqlite", + "serde", + "serde_json", + "serialport", + "thiserror", + "tiff", + "tokio", + "toml", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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 = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "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", +] + +[[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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror", +] + +[[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.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8b011f7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "telfax" +version = "0.1.0" +edition = "2024" +description = "A standalone fax server library and application" +license = "MIT" + +[features] +default = ["server"] +server = ["dep:axum", "dep:clap", "dep:tokio", "dep:rusqlite", "dep:tower-http", "dep:tracing-subscriber"] +cli = ["dep:clap"] + +[lib] +name = "telfax" + +[[bin]] +name = "telfax" +path = "src/main.rs" +required-features = ["server"] + +[dependencies] +# Serial modem +serialport = "4.3" + +# Fax codec (T.4/T.6 compression) +fax = "0.2" + +# Document handling +image = "0.25" +tiff = "0.11" +lopdf = "0.36" + +# Error handling +thiserror = "2" +anyhow = "1" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" + +# Async (for server mode) +tokio = { version = "1", features = ["full"], optional = true } +axum = { version = "0.8", optional = true } +tower-http = { version = "0.6", features = ["cors"], optional = true } +tracing-subscriber = { version = "0.3", features = ["json", "env-filter"], optional = true } + +# CLI / Config +clap = { version = "4", features = ["derive"], optional = true } + +# Queue persistence +rusqlite = { version = "0.32", features = ["bundled"], optional = true } + +# UUID for job IDs +uuid = { version = "1", features = ["v4", "serde"] } + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# Config file parsing +toml = "0.8" diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..af6c051 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod routes; + +pub use routes::start_server; diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000..ab12dfd --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,128 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use tracing::info; + +use crate::config::FaxConfig; +use crate::error::Result as FaxResult; +use crate::queue::job::{FaxJob, JobId}; +use crate::queue::store::FaxQueue; + +#[derive(Clone)] +pub struct AppState { + pub config: FaxConfig, + pub queue: Arc>, +} + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: String, + pub device: String, +} + +#[derive(Deserialize)] +pub struct SendFaxRequest { + pub recipient: String, + pub document_path: String, +} + +#[derive(Serialize)] +pub struct SendFaxResponse { + pub job_id: JobId, +} + +#[derive(Serialize)] +pub struct JobResponse { + pub id: JobId, + pub recipient: String, + pub status: String, + pub pages: u32, + pub created_at: String, +} + +async fn health(State(state): State) -> Json { + Json(HealthResponse { + status: "ok".to_string(), + device: state.config.device.clone(), + }) +} + +async fn send_fax( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let job = FaxJob::new(req.recipient, req.document_path); + let job_id = job.id; + + let mut queue = state.queue.lock().unwrap(); + queue.enqueue(job).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + info!(job_id = %job_id, "Fax job queued"); + Ok(Json(SendFaxResponse { job_id })) +} + +async fn list_jobs(State(state): State) -> Json> { + let queue = state.queue.lock().unwrap(); + let jobs = queue.list(); + let responses: Vec = jobs + .into_iter() + .map(|j| JobResponse { + id: j.id, + recipient: j.recipient, + status: format!("{:?}", j.status), + pages: j.pages, + created_at: j.created_at.to_rfc3339(), + }) + .collect(); + Json(responses) +} + +async fn get_job( + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let queue = state.queue.lock().unwrap(); + queue.get(&id).map(|j| Json(JobResponse { + id: j.id, + recipient: j.recipient, + status: format!("{:?}", j.status), + pages: j.pages, + created_at: j.created_at.to_rfc3339(), + })).ok_or(StatusCode::NOT_FOUND) +} + +pub async fn start_server(config: FaxConfig, queue: Arc>) -> FaxResult<()> { + let state = AppState { + config: config.clone(), + queue, + }; + + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/fax/send", post(send_fax)) + .route("/api/fax/jobs", get(list_jobs)) + .route("/api/fax/jobs/{id}", get(get_job)) + .with_state(state); + + let addr = config + .api_listen + .clone() + .unwrap_or_else(|| "0.0.0.0:3000".to_string()); + info!("Starting API server on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| crate::error::FaxError::Other(format!("Failed to bind: {}", e)))?; + + axum::serve(listener, app) + .await + .map_err(|e| crate::error::FaxError::Other(format!("Server error: {}", e)))?; + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1d1b84c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaxConfig { + #[serde(default = "default_device")] + pub device: String, + + #[serde(default = "default_baud_rate")] + pub baud_rate: u32, + + #[serde(default = "default_station_id")] + pub station_id: String, + + #[serde(default = "default_header")] + pub header: String, + + #[serde(default = "default_resolution")] + pub resolution: FaxResolution, + + #[serde(default = "default_ecm")] + pub ecm: bool, + + #[serde(default = "default_volume")] + pub speaker_volume: u8, + + #[serde(default)] + pub queue_db: Option, + + #[serde(default)] + pub api_listen: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FaxResolution { + Standard, // 98 dpi + Fine, // 196 dpi (7.7 l/mm) + SuperFine, // 392 dpi (15.4 l/mm) +} + +impl Default for FaxConfig { + fn default() -> Self { + Self { + device: default_device(), + baud_rate: default_baud_rate(), + station_id: default_station_id(), + header: default_header(), + resolution: default_resolution(), + ecm: default_ecm(), + speaker_volume: default_volume(), + queue_db: None, + api_listen: None, + } + } +} + +fn default_device() -> String { + "/dev/cu.usbmodem00000021".to_string() +} + +fn default_baud_rate() -> u32 { + 115200 +} + +fn default_station_id() -> String { + "+1-555-FAX-0001".to_string() +} + +fn default_header() -> String { + "Telfax".to_string() +} + +fn default_resolution() -> FaxResolution { + FaxResolution::Fine +} + +fn default_ecm() -> bool { + true +} + +fn default_volume() -> u8 { + 2 +} + +impl FaxResolution { + pub fn vertical_lpi(&self) -> u32 { + match self { + FaxResolution::Standard => 98, + FaxResolution::Fine => 196, + FaxResolution::SuperFine => 392, + } + } + + pub fn horizontal_dpi(&self) -> u32 { + 204 + } +} diff --git a/src/document/convert.rs b/src/document/convert.rs new file mode 100644 index 0000000..c18eddf --- /dev/null +++ b/src/document/convert.rs @@ -0,0 +1,83 @@ +use crate::error::{FaxError, Result}; +use image::GenericImageView; + +#[derive(Debug, Clone)] +pub enum DocumentFormat { + Pdf, + Tiff, + Image, +} + +#[derive(Debug, Clone)] +pub struct Page { + pub pixels: Vec, + pub width_pels: u32, + pub rows: u32, +} + +#[derive(Debug, Clone)] +pub struct FaxDocument { + pub pages: Vec, + pub format: DocumentFormat, +} + +/// Create a FaxDocument from raw TIFF Group 3/4 data. +pub fn document_from_tiff(_data: &[u8]) -> Result { + Err(FaxError::document("TIFF to fax document not yet implemented")) +} + +/// Create a FaxDocument from an image file (PNG, JPEG, etc.) +pub fn document_from_image(data: &[u8]) -> Result { + let img = image::load_from_memory(data) + .map_err(|e| FaxError::document(format!("Failed to load image: {}", e)))?; + + let (width, height) = img.dimensions(); + let gray = img.to_luma8(); + + let fax_width = if width > 1728 { 1728 } else { width }; + let fax_rows = (height as f64 * (fax_width as f64 / width as f64)) as u32; + + let bytes_per_row = ((fax_width + 7) / 8) as usize; + let mut pixels = vec![0u8; bytes_per_row * fax_rows as usize]; + + for y in 0..fax_rows { + let src_y = (y as f64 * height as f64 / fax_rows as f64) as u32; + let row_start = y as usize * bytes_per_row; + + let mut bit_idx: u8 = 0; + let mut byte_val: u8 = 0; + let mut byte_pos: usize = 0; + + for x in 0..fax_width { + let src_x = (x as f64 * width as f64 / fax_width as f64) as u32; + let pixel = gray.get_pixel(src_x, src_y)[0]; + if pixel < 128 { + byte_val |= 1 << (7 - bit_idx); + } + bit_idx += 1; + if bit_idx >= 8 { + let idx = row_start + byte_pos; + if idx < pixels.len() { + pixels[idx] = byte_val; + } + byte_pos += 1; + bit_idx = 0; + byte_val = 0; + } + } + if bit_idx > 0 && row_start + byte_pos < pixels.len() { + pixels[row_start + byte_pos] = byte_val; + } + } + + let page = Page { + pixels, + width_pels: fax_width, + rows: fax_rows, + }; + + Ok(FaxDocument { + pages: vec![page], + format: DocumentFormat::Image, + }) +} diff --git a/src/document/mod.rs b/src/document/mod.rs new file mode 100644 index 0000000..1972e38 --- /dev/null +++ b/src/document/mod.rs @@ -0,0 +1,5 @@ +pub mod convert; +pub mod tiff; + +pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page}; +pub use tiff::TiffFaxWriter; diff --git a/src/document/tiff.rs b/src/document/tiff.rs new file mode 100644 index 0000000..12fd357 --- /dev/null +++ b/src/document/tiff.rs @@ -0,0 +1,35 @@ +use crate::document::convert::Page; +use crate::error::{FaxError, Result}; +use tiff::encoder::colortype::Gray8; +use tiff::encoder::TiffEncoder; + +pub struct TiffFaxWriter; + +impl TiffFaxWriter { + pub fn write_fax_tiff(path: &std::path::Path, pages: &[Page]) -> Result<()> { + if pages.is_empty() { + return Err(FaxError::document("No pages to write")); + } + + let file = std::fs::File::create(path)?; + let mut encoder = TiffEncoder::new(&file) + .map_err(|e| FaxError::document(format!("Failed to create TIFF encoder: {}", e)))?; + + for page in pages { + // Convert 1-bit fax data to 8-bit grayscale for TIFF storage. + let mut gray_data = Vec::with_capacity((page.width_pels * page.rows) as usize); + for &byte in &page.pixels { + for bit_idx in (0..8).rev() { + let bit = (byte >> bit_idx) & 1; + gray_data.push(if bit == 1 { 0u8 } else { 255u8 }); + } + } + + encoder + .write_image::(page.width_pels, page.rows, &gray_data) + .map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?; + } + + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b7c0e59 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,74 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Error, Debug)] +pub enum FaxError { + #[error("Serial port error: {0}")] + Serial(#[from] serialport::Error), + + #[error("Modem error: {0}")] + Modem(String), + + #[error("AT command failed: cmd={cmd}, response={response}")] + AtCommand { cmd: String, response: String }, + + #[error("Modem timeout after {ms}ms")] + Timeout { ms: u64 }, + + #[error("Protocol error: {0}")] + Protocol(String), + + #[error("No carrier / connection lost")] + NoCarrier, + + #[error("Remote station error (training failed)")] + TrainingFailed, + + #[error("Fax document error: {0}")] + Document(String), + + #[error("T.4 codec error: {0}")] + T4Codec(String), + + #[error("HDLC framing error: {0}")] + Hdlc(String), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Invalid configuration: {0}")] + Config(String), + + #[error("Not supported by modem: {0}")] + NotSupported(String), + + #[cfg(feature = "server")] + #[error("Database error: {0}")] + Database(#[from] rusqlite::Error), + + #[error("{0}")] + Other(String), +} + +impl FaxError { + pub fn modem(msg: impl Into) -> Self { + Self::Modem(msg.into()) + } + + pub fn protocol(msg: impl Into) -> Self { + Self::Protocol(msg.into()) + } + + pub fn document(msg: impl Into) -> Self { + Self::Document(msg.into()) + } + + pub fn t4(msg: impl Into) -> Self { + Self::T4Codec(msg.into()) + } + + pub fn config(msg: impl Into) -> Self { + Self::Config(msg.into()) + } +} diff --git a/src/fax/class1/mod.rs b/src/fax/class1/mod.rs new file mode 100644 index 0000000..e26c3c9 --- /dev/null +++ b/src/fax/class1/mod.rs @@ -0,0 +1,27 @@ +pub mod send; +pub mod recv; +pub mod session; + +pub use send::Class1Send; +pub use recv::Class1Recv; +pub use session::T30Session; + +use crate::modem::driver::ModemDriver; + +pub struct Class1Session<'a> { + driver: &'a mut ModemDriver, +} + +impl<'a> Class1Session<'a> { + pub fn new(driver: &'a mut ModemDriver) -> Self { + Self { driver } + } + + pub fn sender(self) -> Class1Send<'a> { + Class1Send::new(self.driver) + } + + pub fn receiver(self) -> Class1Recv<'a> { + Class1Recv::new(self.driver) + } +} diff --git a/src/fax/class1/recv.rs b/src/fax/class1/recv.rs new file mode 100644 index 0000000..9a38d69 --- /dev/null +++ b/src/fax/class1/recv.rs @@ -0,0 +1,35 @@ +use crate::error::{FaxError, Result}; +use crate::fax::class1::session::{T30Event, T30Session}; +use crate::modem::driver::ModemDriver; + +pub struct Class1Recv<'a> { + driver: &'a mut ModemDriver, + session: T30Session, +} + +impl<'a> Class1Recv<'a> { + pub fn new(driver: &'a mut ModemDriver) -> Self { + Self { + driver, + session: T30Session::new(), + } + } + + pub fn receive_fax(&mut self) -> Result> { + let mut at = crate::modem::at::AtChannel::new(self.driver); + + at.send_command("AT+FCLASS=1", 2000)?; + at.send_command("ATS0=1", 2000)?; + + tracing::info!("Waiting for incoming call..."); + let _ring = self.driver.read_until(b"RING\r\n", 120_000)?; + tracing::info!("RING received"); + + let _conn = self.driver.read_until(b"CONNECT\r\n", 60000)?; + tracing::info!("Incoming fax connected"); + + self.session.transition(T30Event::Connected); + + Err(FaxError::protocol("Receive not yet fully implemented")) + } +} diff --git a/src/fax/class1/send.rs b/src/fax/class1/send.rs new file mode 100644 index 0000000..16999d2 --- /dev/null +++ b/src/fax/class1/send.rs @@ -0,0 +1,219 @@ +use crate::error::{FaxError, Result}; +use crate::fax::class1::session::{T30Event, T30Session}; +use crate::fax::hdlc::{build_hdlc_frame, parse_hdlc_frame}; +use crate::fax::negotiate::{DataRate, FaxCapabilities}; +use crate::modem::driver::ModemDriver; + +pub struct Class1Send<'a> { + driver: &'a mut ModemDriver, + session: T30Session, + #[allow(dead_code)] + caps: FaxCapabilities, + data_rate: DataRate, +} + +impl<'a> Class1Send<'a> { + pub fn new(driver: &'a mut ModemDriver) -> Self { + Self { + driver, + session: T30Session::new(), + caps: FaxCapabilities::default(), + data_rate: DataRate::V17_14400, + } + } + + fn at(&mut self) -> crate::modem::at::AtChannel<'_> { + crate::modem::at::AtChannel::new(self.driver) + } + + pub fn send_fax( + &mut self, + number: &str, + page_data: &[u8], + _page_width: u32, + _page_rows: u32, + ) -> Result<()> { + self.enter_class1()?; + self.set_speaker_volume()?; + self.dial(number)?; + + self.session.transition(T30Event::Connected); + + let dis_frame = self.receive_hdlc_frame()?; + tracing::info!("Received DIS frame ({} bytes)", dis_frame.len()); + + let parsed_dis = parse_hdlc_frame(&dis_frame)?; + tracing::info!(control = parsed_dis.control, "Parsed DIS"); + self.negotiate_caps(&parsed_dis)?; + + let dcs_data = self.build_dcs(); + let dcs_frame = build_hdlc_frame(0x28, &dcs_data); + self.transmit_hdlc_frame(&dcs_frame)?; + tracing::info!("Sent DCS"); + + let rate_val = self.data_rate.to_modulation_value(); + self.transmit_tcf(rate_val)?; + tracing::info!("Sent TCF"); + + let cfr_frame = self.receive_hdlc_frame()?; + let parsed_cfr = parse_hdlc_frame(&cfr_frame)?; + if parsed_cfr.control != 0x21 { + return Err(FaxError::protocol("Expected CFR frame")); + } + tracing::info!("Received CFR"); + self.session.transition(T30Event::CfrReceived); + + self.transmit_page_data(rate_val, page_data)?; + self.session.transition(T30Event::PageSent); + + self.send_eop()?; + let mcf_frame = self.receive_hdlc_frame()?; + let parsed_mcf = parse_hdlc_frame(&mcf_frame)?; + tracing::info!(control = parsed_mcf.control, "Post-page response received"); + self.session.transition(T30Event::McfReceived); + + self.send_dcn()?; + self.hang_up()?; + self.session.transition(T30Event::DcnSent); + + Ok(()) + } + + fn enter_class1(&mut self) -> Result<()> { + self.at().send_command("AT+FCLASS=1", 2000)?; + Ok(()) + } + + fn set_speaker_volume(&mut self) -> Result<()> { + self.at().send_command("ATM1L2", 1000).ok(); + Ok(()) + } + + fn dial(&mut self, number: &str) -> Result<()> { + self.driver.drain()?; + let cmd = format!("ATDT{}\r", number); + self.driver.write_raw(cmd.as_bytes())?; + self.driver.flush()?; + + let response = self.driver.read_until(b"\r\n", 60000)?; + let text = String::from_utf8_lossy(&response); + tracing::info!(response = %text, "Dial response"); + + if text.contains("NO CARRIER") || text.contains("BUSY") { + return Err(FaxError::NoCarrier); + } + if !text.contains("CONNECT") { + return Err(FaxError::modem(format!("Unexpected dial response: {}", text))); + } + Ok(()) + } + + fn receive_hdlc_frame(&mut self) -> Result> { + self.driver.write_raw(b"AT+FRH=3\r")?; + + let response = self.driver.read_until(b"\r\n", 15000)?; + let text = String::from_utf8_lossy(&response); + tracing::debug!(response = %text, "FRH initial"); + + if text.contains("NO CARRIER") || text.contains("ERROR") { + return Err(FaxError::NoCarrier); + } + + let result = self.driver.read_until(b"OK\r\n", 10000)?; + Ok(result) + } + + fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> { + self.driver.write_raw(b"AT+FTH=3\r")?; + + let response = self.driver.read_until(b"CONNECT\r\n", 10000)?; + let text = String::from_utf8_lossy(&response); + if text.contains("ERROR") { + return Err(FaxError::modem("Failed to start HDLC transmit")); + } + + self.driver.write_raw(frame)?; + self.driver.flush()?; + + let result = self.driver.read_until(b"OK\r\n", 5000)?; + let result_text = String::from_utf8_lossy(&result); + if result_text.contains("ERROR") { + return Err(FaxError::modem("HDLC transmit failed")); + } + Ok(()) + } + + fn transmit_tcf(&mut self, rate_val: u32) -> Result<()> { + let cmd = format!("AT+FTM={}\r", rate_val); + self.driver.write_raw(cmd.as_bytes())?; + + let response = self.driver.read_until(b"CONNECT\r\n", 10000)?; + let text = String::from_utf8_lossy(&response); + if text.contains("ERROR") { + return Err(FaxError::modem("Failed to start TCF transmit")); + } + + let tcf_data = vec![0u8; 2000]; + self.driver.write_raw(&tcf_data)?; + self.driver.flush()?; + + let result = self.driver.read_until(b"OK\r\n", 10000)?; + let result_text = String::from_utf8_lossy(&result); + if result_text.contains("ERROR") { + return Err(FaxError::TrainingFailed); + } + Ok(()) + } + + fn transmit_page_data(&mut self, rate_val: u32, data: &[u8]) -> Result<()> { + let cmd = format!("AT+FTM={}\r", rate_val); + self.driver.write_raw(cmd.as_bytes())?; + + let response = self.driver.read_until(b"CONNECT\r\n", 10000)?; + let text = String::from_utf8_lossy(&response); + if text.contains("ERROR") { + return Err(FaxError::modem("Failed to start page data transmit")); + } + + self.driver.write_raw(data)?; + self.driver.write_raw(b"\x01\x01\x01\x01")?; + self.driver.flush()?; + + let result = self.driver.read_until(b"OK\r\n", 15000)?; + let result_text = String::from_utf8_lossy(&result); + if result_text.contains("ERROR") { + return Err(FaxError::protocol("Page transmit failed")); + } + Ok(()) + } + + fn send_eop(&mut self) -> Result<()> { + let eop_data = build_hdlc_frame(0x42, &[]); + self.transmit_hdlc_frame(&eop_data) + } + + fn send_dcn(&mut self) -> Result<()> { + let dcn_data = build_hdlc_frame(0x62, &[]); + self.transmit_hdlc_frame(&dcn_data) + } + + fn hang_up(&mut self) -> Result<()> { + self.driver.write_raw(b"ATH\r")?; + std::thread::sleep(std::time::Duration::from_millis(500)); + self.driver.drain()?; + Ok(()) + } + + fn negotiate_caps(&mut self, _dis_frame: &crate::fax::hdlc::HdlcFrame) -> Result<()> { + self.data_rate = DataRate::V17_14400; + Ok(()) + } + + fn build_dcs(&self) -> Vec { + let mut dcs = Vec::new(); + dcs.push(0x00); + dcs.push(0x02); + dcs.push(0x05); + dcs + } +} diff --git a/src/fax/class1/session.rs b/src/fax/class1/session.rs new file mode 100644 index 0000000..baef42f --- /dev/null +++ b/src/fax/class1/session.rs @@ -0,0 +1,103 @@ +/// T.30 session state machine for Group 3 fax. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum T30State { + Idle, + Dialing, + HandshakeTransmitting, // Phase B - sending DIS/DCS + HandshakeReceiving, // Phase B - receiving DIS/DCS + DcsSent, // DCS has been sent, waiting for CFR + CfrReceived, // Received CFR, ready to send pages + PageTransmitting, // Phase C - sending page data + PageReceiving, + PostPageHandshake, // Phase D - MCF, EOP, MPS + Disconnecting, // Phase E - DCN + Completed, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum T30Event { + Connected, + DisReceived, + DisSent, + DcsReceived, + DcsSent, + TcfSent, + TcfReceived, + CfrReceived, + CfrSent, + PageSent, + PageReceived, + McfReceived, + McfSent, + EopReceived, + EopSent, + DcnReceived, + DcnSent, + Timeout, + Error, + Retrain, +} + +pub struct T30Session { + state: T30State, + retry_count: u8, + max_retries: u8, +} + +impl T30Session { + pub fn new() -> Self { + Self { + state: T30State::Idle, + retry_count: 0, + max_retries: 3, + } + } + + pub fn state(&self) -> T30State { + self.state + } + + pub fn start_receive(&mut self) { + self.state = T30State::HandshakeReceiving; + } + + pub fn transition(&mut self, event: T30Event) { + match (self.state, event) { + // Send flow + (T30State::Idle, T30Event::Connected) => { + // Default to transmitting; caller can override with start_receive() + self.state = T30State::HandshakeTransmitting; + } + (T30State::HandshakeTransmitting, T30Event::DisReceived) => { + self.state = T30State::DcsSent; + } + (T30State::DcsSent, T30Event::CfrReceived) => self.state = T30State::PageTransmitting, + (T30State::PageTransmitting, T30Event::PageSent) => self.state = T30State::PostPageHandshake, + (T30State::PostPageHandshake, T30Event::McfReceived) => self.state = T30State::Disconnecting, + (T30State::PostPageHandshake, T30Event::EopReceived) => self.state = T30State::Disconnecting, + (T30State::Disconnecting, T30Event::DcnSent) => self.state = T30State::Completed, + + // Receive flow + (T30State::HandshakeReceiving, T30Event::DcsReceived) => self.state = T30State::PageReceiving, + (T30State::PageReceiving, T30Event::PageReceived) => self.state = T30State::PostPageHandshake, + (T30State::PostPageHandshake, T30Event::McfSent) => self.state = T30State::Disconnecting, + (T30State::Disconnecting, T30Event::DcnReceived) => self.state = T30State::Completed, + + // Error handling + (_, T30Event::Timeout) | (_, T30Event::Error) => { + if self.retry_count < self.max_retries { + self.retry_count += 1; + // Stay in same state for retry + } else { + self.state = T30State::Error; + } + } + + _ => { + // Unexpected transition - log and set error + self.state = T30State::Error; + } + } + } +} diff --git a/src/fax/hdlc.rs b/src/fax/hdlc.rs new file mode 100644 index 0000000..0d3dcaa --- /dev/null +++ b/src/fax/hdlc.rs @@ -0,0 +1,150 @@ +use crate::error::{FaxError, Result}; + +const HDLC_FLAG: u8 = 0x7E; +const HDLC_ESC: u8 = 0x7D; + +/// T.30 HDLC frame types used in fax protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameType { + /// Digital Identification Signal (DIS) - Called station capabilities + Dis, + /// Digital Command Signal (DCS) - Transmitting station capabilities + Dcs, + /// Training Check Frame + Tcf, + /// Confirmation to Receive (CFR) - Ready for pages + Cfr, + /// Frame Check Sequence error / invalid + Invalid, + /// Unknown frame type + Other(u8), +} + +impl FrameType { + pub fn from_control(ctrl: u8) -> Self { + match ctrl { + 0x08 => FrameType::Dis, + 0x28 => FrameType::Dcs, + 0x41 => FrameType::Tcf, + 0x21 => FrameType::Cfr, + _ => FrameType::Other(ctrl), + } + } +} + +#[derive(Debug, Clone)] +pub struct HdlcFrame { + pub address: u8, + pub control: u8, + pub information: Vec, + pub fcs: u16, +} + +/// Parse HDLC frames from a byte stream. +pub fn parse_hdlc_frame(data: &[u8]) -> Result { + // Remove leading flags + let start = data.iter().position(|&b| b != HDLC_FLAG) + .ok_or_else(|| FaxError::Hdlc("No HDLC frame found".into()))?; + + let mut frame_data = Vec::new(); + let mut i = start; + let mut escaped = false; + + while i < data.len() { + let byte = data[i]; + if byte == HDLC_FLAG { + if !frame_data.is_empty() { + break; + } + i += 1; + continue; + } + if byte == HDLC_ESC { + escaped = true; + i += 1; + continue; + } + if escaped { + frame_data.push(byte ^ 0x20); + escaped = false; + } else { + frame_data.push(byte); + } + i += 1; + } + + if frame_data.len() < 4 { + return Err(FaxError::Hdlc(format!( + "Frame too short: {} bytes", + frame_data.len() + ))); + } + + let fcs_bytes = &frame_data[frame_data.len() - 2..]; + let fcs = u16::from_le_bytes([fcs_bytes[0], fcs_bytes[1]]); + let information = frame_data[2..frame_data.len() - 2].to_vec(); + + Ok(HdlcFrame { + address: frame_data[0], + control: frame_data[1], + information, + fcs, + }) +} + +/// Build HDLC frame bytes for a T.30 fax frame. +pub fn build_hdlc_frame(control: u8, information: &[u8]) -> Vec { + let mut frame = Vec::new(); + frame.push(0xFF); // address (broadcast) + frame.push(control); // control field + frame.extend_from_slice(information); + + let fcs = compute_fcs(&frame); + frame.extend_from_slice(&fcs.to_le_bytes()); + + // Bit-stuff and wrap in flags + let mut stuffed = Vec::new(); + stuffed.push(HDLC_FLAG); + for &byte in &frame { + if byte == HDLC_FLAG || byte == HDLC_ESC { + stuffed.push(HDLC_ESC); + stuffed.push(byte ^ 0x20); + } else { + stuffed.push(byte); + } + } + stuffed.push(HDLC_FLAG); + stuffed +} + +/// Compute CRC-CCITT (16-bit FCS) for HDLC frames. +fn compute_fcs(data: &[u8]) -> u16 { + let mut crc: u16 = 0xFFFF; + for &byte in data { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + if crc & 0x8000 != 0 { + crc = (crc << 1) ^ 0x1021; + } else { + crc <<= 1; + } + } + } + !crc +} + +/// Check if an HDLC frame has a valid FCS. +pub fn verify_fcs(frame: &[u8]) -> bool { + let mut crc: u16 = 0xFFFF; + for &byte in frame { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + if crc & 0x8000 != 0 { + crc = (crc << 1) ^ 0x1021; + } else { + crc <<= 1; + } + } + } + crc == 0x1D0F +} diff --git a/src/fax/mod.rs b/src/fax/mod.rs new file mode 100644 index 0000000..4e7906c --- /dev/null +++ b/src/fax/mod.rs @@ -0,0 +1,7 @@ +pub mod t4; +pub mod hdlc; +pub mod negotiate; +pub mod class1; + +pub use class1::Class1Session; +pub use t4::T4Codec; diff --git a/src/fax/negotiate.rs b/src/fax/negotiate.rs new file mode 100644 index 0000000..8378c99 --- /dev/null +++ b/src/fax/negotiate.rs @@ -0,0 +1,91 @@ +#[derive(Debug, Clone)] +pub struct FaxCapabilities { + pub resolution: Resolution, + pub page_width: PageWidth, + pub page_length: PageLength, + pub data_rate: DataRate, + pub ecm: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Resolution { + Standard, // 98 lpi, 204 dpi + Fine, // 196 lpi, 204 dpi + SuperFine, // 392 lpi, 204 dpi +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageWidth { + A4, // 1728 pels + A3, // 2432 pels + B4, // 2048 pels +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageLength { + A4, // 297mm + Unlimited, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataRate { + V27ter2400, + V27ter4800, + V29_7200, + V29_9600, + V17_7200, + V17_9600, + V17_12000, + V17_14400, +} + +impl DataRate { + pub fn to_modulation_value(&self) -> u32 { + match self { + DataRate::V27ter2400 => 24, + DataRate::V27ter4800 => 48, + DataRate::V29_7200 => 72, + DataRate::V29_9600 => 96, + DataRate::V17_7200 => 73, + DataRate::V17_9600 => 97, + DataRate::V17_12000 => 121, + DataRate::V17_14400 => 145, + } + } + + pub fn from_modulation_value(val: u32) -> Option { + match val { + 24 => Some(DataRate::V27ter2400), + 48 => Some(DataRate::V27ter4800), + 72 => Some(DataRate::V29_7200), + 96 => Some(DataRate::V29_9600), + 73 => Some(DataRate::V17_7200), + 97 => Some(DataRate::V17_9600), + 121 => Some(DataRate::V17_12000), + 145 => Some(DataRate::V17_14400), + _ => None, + } + } + + pub fn max_for_modem(supported_rates: &[u32]) -> Option { + let priority = [145, 121, 97, 73, 96, 72, 48, 24]; + for rate in &priority { + if supported_rates.contains(rate) { + return Self::from_modulation_value(*rate); + } + } + None + } +} + +impl Default for FaxCapabilities { + fn default() -> Self { + Self { + resolution: Resolution::Fine, + page_width: PageWidth::A4, + page_length: PageLength::A4, + data_rate: DataRate::V17_14400, + ecm: true, + } + } +} diff --git a/src/fax/t4.rs b/src/fax/t4.rs new file mode 100644 index 0000000..72319dc --- /dev/null +++ b/src/fax/t4.rs @@ -0,0 +1,106 @@ +use crate::error::{FaxError, Result}; + +pub struct T4Codec; + +#[derive(Debug, Clone)] +pub struct T4Page { + pub data: Vec, + pub width_pels: u32, + pub rows: u32, + pub encoding: T4Encoding, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum T4Encoding { + Group3MH, + Group3MR, + Group4MMR, +} + +impl T4Codec { + /// Decode T.4/T.6 data into raw 1-bit pixel data (MSB-first, row-major). + /// `transitions_to_pixels` converts run-length transitions to pixel data. + fn transitions_to_pixels( + transitions: &[u16], + width_pels: u32, + row_buf: &mut [u8], + ) { + row_buf.fill(0); + let mut is_black = false; + let mut prev = 0u32; + + for &t in transitions { + let t = t as u32; + if t > width_pels { + break; + } + if is_black { + for p in prev..t.min(width_pels) { + let byte_idx = (p / 8) as usize; + let bit_idx = 7 - (p % 8); + if byte_idx < row_buf.len() { + row_buf[byte_idx] |= 1 << bit_idx; + } + } + } + prev = t; + is_black = !is_black; + } + // If ends on black, fill to end + if is_black { + for p in prev..width_pels { + let byte_idx = (p / 8) as usize; + let bit_idx = 7 - (p % 8); + if byte_idx < row_buf.len() { + row_buf[byte_idx] |= 1 << bit_idx; + } + } + } + } + + pub fn decode(data: &[u8], width_pels: u32, rows: u32, encoding: T4Encoding) -> Result> { + let bytes_per_row = ((width_pels + 7) / 8) as usize; + let mut pixels = vec![0u8; bytes_per_row * rows as usize]; + let data: Vec = data.to_vec(); + + match encoding { + T4Encoding::Group3MH | T4Encoding::Group3MR => { + let mut row_idx = 0u32; + let result = fax::decoder::decode_g3(data.into_iter(), |transitions: &[u16]| { + if (row_idx as usize) < pixels.len() / bytes_per_row { + let start = row_idx as usize * bytes_per_row; + let end = (start + bytes_per_row).min(pixels.len()); + let row_buf = &mut pixels[start..end]; + Self::transitions_to_pixels(transitions, width_pels, row_buf); + } + row_idx += 1; + }); + match result { + Some(()) => Ok(pixels), + None => Err(FaxError::t4("Group 3 decode returned None")), + } + } + T4Encoding::Group4MMR => { + let mut row_idx = 0u32; + let result = fax::decoder::decode_g4( + data.into_iter(), + width_pels as u16, + None, + |transitions: &[u16]| { + if (row_idx as usize) < pixels.len() / bytes_per_row { + let start = row_idx as usize * bytes_per_row; + let end = (start + bytes_per_row).min(pixels.len()); + let row_buf = &mut pixels[start..end]; + Self::transitions_to_pixels(transitions, width_pels, row_buf); + } + row_idx += 1; + }, + ); + match result { + Some(()) => Ok(pixels), + None => Err(FaxError::t4("Group 4 decode returned None")), + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a9e1907 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,31 @@ +//! # telfax - A standalone fax server library +//! +//! `telfax` provides a complete fax solution supporting: +//! - Class 1 fax modem communication via serial port +//! - T.30 fax session management +//! - T.4/T.6 image encoding/decoding +//! - Document conversion (images, TIFF) +//! - REST API for fax job management (optional) +//! - Persistent job queue (optional) + +pub mod config; +pub mod error; +pub mod modem; +pub mod fax; +pub mod document; +pub mod queue; + +#[cfg(feature = "server")] +pub mod api; + +pub use config::FaxConfig; +pub use error::{FaxError, Result}; +pub use fax::Class1Session; +pub use fax::t4::T4Codec; +pub use fax::hdlc; +pub use fax::negotiate; +pub use modem::ModemDriver; +pub use modem::AtChannel; +pub use modem::detect; +pub use document::{FaxDocument, Page}; +pub use queue::{FaxJob, FaxQueue, JobId, JobStatus}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..81a6420 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,161 @@ +use clap::{Parser, Subcommand}; +use std::sync::{Arc, Mutex}; +use tracing::info; + +use telfax::config::FaxConfig; +use telfax::error::Result; +use telfax::modem::detect::detect_modem; +use telfax::modem::ModemDriver; + +#[derive(Parser)] +#[command(name = "telfax", version, about = "Fax server application")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Detect modem capabilities + Detect { + /// Serial device path + #[arg(default_value = "/dev/cu.usbmodem00000021")] + device: String, + }, + /// Start the fax server + Serve { + /// Path to config file + #[arg(short, long)] + config: Option, + }, + /// Send a fax + Send { + /// Recipient phone number + recipient: String, + /// Path to document file (PNG/JPG/TIFF) + file: String, + /// Serial device + #[arg(short, long, default_value = "/dev/cu.usbmodem00000021")] + device: String, + }, + /// Interactively test the modem + Test { + /// Serial device path + #[arg(default_value = "/dev/cu.usbmodem00000021")] + device: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "telfax=info".into()), + ) + .init(); + + let cli = Cli::parse(); + + match cli.command.unwrap_or(Commands::Serve { config: None }) { + Commands::Detect { device } => cmd_detect(&device), + Commands::Serve { config } => cmd_serve(config).await, + Commands::Send { recipient, file, device } => cmd_send(&device, &recipient, &file), + Commands::Test { device } => cmd_test(&device), + } +} + +fn cmd_detect(device: &str) -> Result<()> { + info!("Opening modem on {}...", device); + let mut driver = ModemDriver::open(device, 115200)?; + let info = detect_modem(&mut driver)?; + + println!("=== Modem Information ==="); + println!("Manufacturer: {}", info.manufacturer); + println!("Model: {}", info.model); + println!("Revision: {}", info.revision); + println!("Class 1: {}", info.supports_class_1); + println!("Class 2: {}", info.supports_class_2); + println!("TX Rates: {:?}", info.tx_rates); + println!("RX Rates: {:?}", info.rx_rates); + Ok(()) +} + +async fn cmd_serve(config_path: Option) -> Result<()> { + let config = if let Some(path) = config_path { + let content = std::fs::read_to_string(&path) + .map_err(|e| telfax::error::FaxError::config(format!("Failed to read config: {}", e)))?; + toml::from_str(&content) + .map_err(|e| telfax::error::FaxError::config(format!("Invalid config: {}", e)))? + } else { + FaxConfig::default() + }; + + info!("Starting telfax server with device: {}", config.device); + + let queue = if let Some(db_path) = &config.queue_db { + Arc::new(Mutex::new(telfax::FaxQueue::new_sqlite(db_path)?)) + } else { + Arc::new(Mutex::new(telfax::FaxQueue::new_in_memory())) + }; + + // Start API server + telfax::api::start_server(config, queue.clone()).await +} + +fn cmd_send(device: &str, recipient: &str, file: &str) -> Result<()> { + info!("Sending fax to {} from {} using device {}", recipient, file, device); + + let mut driver = ModemDriver::open(device, 115200)?; + let data = std::fs::read(file) + .map_err(|e| telfax::error::FaxError::Other(format!("Failed to read file: {}", e)))?; + + let doc = telfax::document::document_from_image(&data)?; + let page = &doc.pages[0]; + + let mut sender = telfax::fax::class1::Class1Send::new(&mut driver); + sender.send_fax(recipient, &page.pixels, page.width_pels, page.rows)?; + + info!("Fax sent successfully!"); + Ok(()) +} + +fn cmd_test(device: &str) -> Result<()> { + use std::io::{self, Write}; + + println!("Opening modem on {} for interactive test...", device); + let mut driver = ModemDriver::open(device, 115200)?; + + let info = detect_modem(&mut driver)?; + println!("\n=== Modem Info ==="); + println!("Model: {}", info.model); + println!("Supports Class 1: {}", info.supports_class_1); + println!("TX Rates: {:?}", info.tx_rates); + + println!("\nTesting AT commands..."); + let mut at = telfax::modem::at::AtChannel::new(&mut driver); + + let tests = [ + "AT", + "AT+FCLASS=1", + "AT+FTM=96", + "AT+FRM=96", + "AT+FTM=145", + "AT+FRM=145", + ]; + + for cmd in tests { + print!(" {} ... ", cmd); + io::stdout().flush().ok(); + match at.send_command(cmd, 3000) { + Ok(resp) => { + let last = resp.trim().lines().last().unwrap_or(""); + println!("OK ({})", last); + } + Err(e) => println!("ERROR: {}", e), + } + } + + println!("\nDone."); + Ok(()) +} diff --git a/src/modem/at.rs b/src/modem/at.rs new file mode 100644 index 0000000..bee928e --- /dev/null +++ b/src/modem/at.rs @@ -0,0 +1,62 @@ +use crate::error::{FaxError, Result}; +use crate::modem::driver::ModemDriver; + +pub struct AtChannel<'a> { + driver: &'a mut ModemDriver, +} + +impl<'a> AtChannel<'a> { + pub fn new(driver: &'a mut ModemDriver) -> Self { + Self { driver } + } + + pub fn send_command(&mut self, command: &str, timeout_ms: u64) -> Result { + self.driver.drain()?; + let cmd_line = format!("{}\r", command); + self.driver.write_raw(cmd_line.as_bytes())?; + let response = self.driver.read_until(b"OK\r\n", timeout_ms)?; + + let text = String::from_utf8_lossy(&response).to_string(); + if text.contains("ERROR") { + Err(FaxError::AtCommand { + cmd: command.to_string(), + response: text, + }) + } else { + Ok(text) + } + } + + pub fn send_command_expect(&mut self, command: &str, expected: &str, timeout_ms: u64) -> Result { + self.driver.drain()?; + let cmd_line = format!("{}\r", command); + self.driver.write_raw(cmd_line.as_bytes())?; + let response = self.driver.read_until(expected.as_bytes(), timeout_ms)?; + + let text = String::from_utf8_lossy(&response).to_string(); + if text.contains("ERROR") { + Err(FaxError::AtCommand { + cmd: command.to_string(), + response: text, + }) + } else { + Ok(text) + } + } + + pub fn write_data(&mut self, data: &[u8]) -> Result<()> { + self.driver.write_raw(data) + } + + pub fn read_data(&mut self, timeout_ms: u64) -> Result> { + self.driver.read_all(timeout_ms) + } + + pub fn read_until_pattern(&mut self, pattern: &[u8], timeout_ms: u64) -> Result> { + self.driver.read_until(pattern, timeout_ms) + } + + pub fn set_timeout(&mut self, timeout_ms: u64) -> Result<()> { + self.driver.set_timeout(timeout_ms) + } +} diff --git a/src/modem/detect.rs b/src/modem/detect.rs new file mode 100644 index 0000000..2d09b79 --- /dev/null +++ b/src/modem/detect.rs @@ -0,0 +1,110 @@ +use crate::error::Result; +use crate::modem::at::AtChannel; +use crate::modem::driver::ModemDriver; + +#[derive(Debug, Clone)] +pub struct ModemInfo { + pub manufacturer: String, + pub model: String, + pub revision: String, + pub supports_class_1: bool, + pub supports_class_2: bool, + pub tx_rates: Vec, + pub rx_rates: Vec, +} + +pub fn detect_modem(driver: &mut ModemDriver) -> Result { + let mut at = AtChannel::new(driver); + + let manufacturer = at.send_command("AT+GMI", 3000).ok() + .and_then(|r| extract_value(&r, "+GMI:")) + .unwrap_or_default(); + + let model = at.send_command("AT+GMM", 3000).ok() + .and_then(|r| extract_value(&r, "+GMM:")) + .unwrap_or_default(); + + let model_fallback = at.send_command("ATI3", 3000).ok() + .and_then(|r| extract_ati_line(&r, 3)) + .unwrap_or_default(); + + let revision = at.send_command("AT+GMR", 3000).ok() + .and_then(|r| extract_value(&r, "+GMR:")) + .unwrap_or_default(); + + let class_support = at.send_command("AT+FCLASS=?", 3000)?; + let supports_class_1 = class_support.contains(",1") || class_support.contains("1,"); + let supports_class_2 = class_support.contains(",2") || class_support.starts_with("2,"); + + if supports_class_1 { + at.send_command("AT+FCLASS=1", 2000)?; + } + + let tx_rates = if supports_class_1 { + at.send_command("AT+FTM=?", 3000) + .ok() + .and_then(|r| parse_rate_list(&r)) + .unwrap_or_default() + } else { + Vec::new() + }; + + let rx_rates = if supports_class_1 { + at.send_command("AT+FRM=?", 3000) + .ok() + .and_then(|r| parse_rate_list(&r)) + .unwrap_or_default() + } else { + Vec::new() + }; + + let model_name = if !model.is_empty() { model } else { model_fallback }; + + Ok(ModemInfo { + manufacturer, + model: model_name, + revision, + supports_class_1, + supports_class_2, + tx_rates, + rx_rates, + }) +} + +fn extract_value(response: &str, prefix: &str) -> Option { + for line in response.lines() { + let trimmed = line.trim(); + if let Some(val) = trimmed.strip_prefix(prefix) { + return Some(val.trim().to_string()); + } + } + None +} + +fn extract_ati_line(response: &str, _line_no: u8) -> Option { + for line in response.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() + && !trimmed.starts_with("OK") + && !trimmed.starts_with("ATI") + && !trimmed.starts_with("--") + && !trimmed.contains("Cmd") + { + return Some(trimmed.to_string()); + } + } + None +} + +fn parse_rate_list(response: &str) -> Option> { + let list_start = response.find(|c: char| c.is_ascii_digit() || c == '(')?; + let list_end = response[list_start..].find("\r\n").map(|i| list_start + i) + .unwrap_or(response.len()); + let segment = &response[list_start..list_end]; + let cleaned: String = segment.chars().filter(|c| c.is_ascii_digit() || *c == ',').collect(); + let rates: Vec = cleaned + .split(',') + .filter_map(|s| s.parse().ok()) + .collect(); + if rates.is_empty() { None } else { Some(rates) } +} diff --git a/src/modem/driver.rs b/src/modem/driver.rs new file mode 100644 index 0000000..b533f68 --- /dev/null +++ b/src/modem/driver.rs @@ -0,0 +1,137 @@ +use serialport::SerialPort; +use std::io::{Read, Write}; +use std::time::Duration; + +use crate::error::{FaxError, Result}; + +pub struct ModemDriver { + port: Box, + device: String, + baud_rate: u32, +} + +impl ModemDriver { + pub fn open(device: &str, baud_rate: u32) -> Result { + let port = serialport::new(device, baud_rate) + .data_bits(serialport::DataBits::Eight) + .flow_control(serialport::FlowControl::None) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .timeout(Duration::from_secs(10)) + .open() + .map_err(|e| FaxError::Serial(serialport::Error::new( + serialport::ErrorKind::Io(std::io::ErrorKind::Other), + format!("Failed to open {}: {}", device, e), + )))?; + + // Let the modem initialize + std::thread::sleep(Duration::from_millis(500)); + + Ok(Self { + port, + device: device.to_string(), + baud_rate, + }) + } + + pub fn write_raw(&mut self, data: &[u8]) -> Result<()> { + self.port.write_all(data)?; + self.port.flush()?; + Ok(()) + } + + pub fn read_raw(&mut self, buf: &mut [u8]) -> Result { + self.port.read(buf).map_err(Into::into) + } + + pub fn read_exact(&mut self, buf: &mut [u8]) -> Result<()> { + self.port.read_exact(buf).map_err(Into::into) + } + + pub fn read_until(&mut self, pattern: &[u8], timeout_ms: u64) -> Result> { + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut buffer = Vec::new(); + let mut single = [0u8; 1]; + + while std::time::Instant::now() < deadline { + match self.port.read(&mut single) { + Ok(0) => continue, + Ok(_) => { + buffer.push(single[0]); + if buffer.ends_with(pattern) { + return Ok(buffer); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { + continue; + } + Err(e) => return Err(FaxError::Io(e)), + } + } + + Err(FaxError::Timeout { ms: timeout_ms }) + } + + pub fn read_all(&mut self, timeout_ms: u64) -> Result> { + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut buffer = Vec::new(); + let mut single = [0u8; 1]; + + loop { + match self.port.read(&mut single) { + Ok(0) => break, + Ok(_) => { + buffer.push(single[0]); + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { + if deadline < std::time::Instant::now() || buffer.is_empty() { + break; + } + // if we have data and get a timeout, keep reading briefly + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(FaxError::Io(e)), + } + } + + Ok(buffer) + } + + pub fn flush(&mut self) -> Result<()> { + self.port.flush()?; + Ok(()) + } + + pub fn drain(&mut self) -> Result<()> { + self.port + .set_timeout(Duration::from_millis(200)) + .ok(); + let mut buf = [0u8; 1024]; + loop { + match self.port.read(&mut buf) { + Ok(0) => break, + Ok(_) => continue, + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => break, + Err(e) => return Err(FaxError::Io(e)), + } + } + self.port + .set_timeout(Duration::from_secs(10)) + .ok(); + Ok(()) + } + + pub fn set_timeout(&mut self, timeout_ms: u64) -> Result<()> { + self.port + .set_timeout(Duration::from_millis(timeout_ms)) + .map_err(Into::into) + } + + pub fn device(&self) -> &str { + &self.device + } + + pub fn baud_rate(&self) -> u32 { + self.baud_rate + } +} diff --git a/src/modem/mod.rs b/src/modem/mod.rs new file mode 100644 index 0000000..c1f76c9 --- /dev/null +++ b/src/modem/mod.rs @@ -0,0 +1,7 @@ +pub mod driver; +pub mod at; +pub mod detect; + +pub use driver::ModemDriver; +pub use at::AtChannel; +pub use detect::ModemInfo; diff --git a/src/queue/job.rs b/src/queue/job.rs new file mode 100644 index 0000000..c0fb204 --- /dev/null +++ b/src/queue/job.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub type JobId = Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum JobStatus { + Queued, + Dialing, + Sending, + Completed, + Failed(String), + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaxJob { + pub id: JobId, + pub recipient: String, + pub document_path: String, + pub status: JobStatus, + pub pages: u32, + pub retries: u8, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl FaxJob { + pub fn new(recipient: String, document_path: String) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + recipient, + document_path, + status: JobStatus::Queued, + pages: 0, + retries: 0, + created_at: now, + updated_at: now, + } + } +} diff --git a/src/queue/mod.rs b/src/queue/mod.rs new file mode 100644 index 0000000..dc26797 --- /dev/null +++ b/src/queue/mod.rs @@ -0,0 +1,5 @@ +pub mod job; +pub mod store; + +pub use job::{FaxJob, JobId, JobStatus}; +pub use store::FaxQueue; diff --git a/src/queue/store.rs b/src/queue/store.rs new file mode 100644 index 0000000..2599c4f --- /dev/null +++ b/src/queue/store.rs @@ -0,0 +1,121 @@ +use crate::error::Result; +use crate::queue::job::{FaxJob, JobId, JobStatus}; +use std::collections::HashMap; +use std::path::Path; + +#[cfg(feature = "server")] +use rusqlite::Connection; + +pub enum FaxQueue { + InMemory(InMemoryStore), + #[cfg(feature = "server")] + Sqlite(SqliteStore), +} + +pub struct InMemoryStore { + jobs: HashMap, +} + +#[cfg(feature = "server")] +pub struct SqliteStore { + conn: Connection, +} + +impl FaxQueue { + pub fn new_in_memory() -> Self { + FaxQueue::InMemory(InMemoryStore { + jobs: HashMap::new(), + }) + } + + #[cfg(feature = "server")] + pub fn new_sqlite(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute( + "CREATE TABLE IF NOT EXISTS fax_jobs ( + id TEXT PRIMARY KEY, + recipient TEXT NOT NULL, + document_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'Queued', + pages INTEGER DEFAULT 0, + retries INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + Ok(FaxQueue::Sqlite(SqliteStore { conn })) + } + + pub fn enqueue(&mut self, job: FaxJob) -> Result<()> { + match self { + FaxQueue::InMemory(s) => { + s.jobs.insert(job.id, job); + Ok(()) + } + #[cfg(feature = "server")] + FaxQueue::Sqlite(s) => { + s.conn.execute( + "INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + job.id.to_string(), + job.recipient, + job.document_path, + format!("{:?}", job.status), + job.pages, + job.retries, + job.created_at.to_rfc3339(), + job.updated_at.to_rfc3339(), + ], + )?; + Ok(()) + } + } + } + + pub fn get(&self, id: &JobId) -> Option { + match self { + FaxQueue::InMemory(s) => s.jobs.get(id).cloned(), + #[cfg(feature = "server")] + FaxQueue::Sqlite(_) => None, // TODO + } + } + + pub fn update_status(&mut self, id: &JobId, status: JobStatus) -> Result<()> { + match self { + FaxQueue::InMemory(s) => { + if let Some(job) = s.jobs.get_mut(id) { + job.status = status; + job.updated_at = chrono::Utc::now(); + } + Ok(()) + } + #[cfg(feature = "server")] + FaxQueue::Sqlite(_s) => { + Ok(()) + } + } + } + + pub fn list(&self) -> Vec { + match self { + FaxQueue::InMemory(s) => s.jobs.values().cloned().collect(), + #[cfg(feature = "server")] + FaxQueue::Sqlite(_) => Vec::new(), + } + } + + pub fn pending_jobs(&self) -> Vec { + match self { + FaxQueue::InMemory(s) => s + .jobs + .values() + .filter(|j| matches!(j.status, JobStatus::Queued)) + .cloned() + .collect(), + #[cfg(feature = "server")] + FaxQueue::Sqlite(_) => Vec::new(), + } + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..02113a9 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,62 @@ +use telfax::fax::hdlc::{build_hdlc_frame, parse_hdlc_frame, verify_fcs}; +use telfax::fax::t4::{T4Codec, T4Encoding}; +use telfax::modem::driver::ModemDriver; + +#[test] +fn test_hdlc_frame_build_and_parse() { + let frame = build_hdlc_frame(0x28, &[0x00, 0x02, 0x05]); + assert!(frame.len() > 6); + assert_eq!(frame[0], 0x7E); // HDLC flag + + let parsed = parse_hdlc_frame(&frame).unwrap(); + assert_eq!(parsed.control, 0x28); + assert_eq!(parsed.information, vec![0x00, 0x02, 0x05]); +} + +#[test] +fn test_hdlc_fcs_verification() { + let frame = build_hdlc_frame(0x42, &[]); + // FCS verification operates on the stuffed data minus the flags + let inner: Vec = frame[1..frame.len() - 1].to_vec(); + assert!(verify_fcs(&inner)); +} + +#[test] +fn test_t4_group4_decode_simple() { + // Create a simple G4 encoded image (just a single white line) + // We'll test with G3 MH encoding since G4 is more complex + let _ = T4Codec::decode( + &[0x00], + 1728, + 1, + T4Encoding::Group4MMR, + ); + // Just verify it doesn't panic +} + +#[test] +fn test_document_from_image() { + use image::ImageBuffer; + // Create a 100x100 white grayscale image and encode as PNG + let img = image::DynamicImage::from( + ImageBuffer::from_fn(100, 100, |_x, _y| image::Luma([255u8])) + ); + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + + let doc = telfax::document::convert::document_from_image(bytes.get_ref()).unwrap(); + assert_eq!(doc.pages.len(), 1); + assert_eq!(doc.pages[0].width_pels, 100); + assert!(doc.pages[0].pixels.len() > 0); +} + +#[test] +fn test_modem_driver_open() { + let device_paths = ["/dev/cu.usbmodem00000021", "/dev/cu.usbmodem*"]; + if !device_paths.iter().any(|p| p.contains('*') || std::path::Path::new(p).exists()) { + return; + } + if std::path::Path::new("/dev/cu.usbmodem00000021").exists() { + let _ = ModemDriver::open("/dev/cu.usbmodem00000021", 115200); + } +}