From d1e92b32fb6d99f1772f2165414b7a86543e85ee Mon Sep 17 00:00:00 2001 From: Warren Date: Thu, 2 Jul 2026 18:43:16 +0800 Subject: [PATCH] Add cover page support with Chinese font rendering Features: - Cover page generation using Python/Pillow with Apple PingFang font - CLI commands: cover-create, job-cover for cover page management - API endpoints: PUT /api/fax/jobs/{id}/cover for cover modification - Send fax with cover page: --to, --from, --subject, --note options - Multi-page TIFF parsing for PDF conversion (fix: all pages now sent) - Preview HTML generation with pan/zoom support - OCR verification for cover page content - Class 2 receive support for incoming faxes Fixes: - Modem initialization before Class 2 mode entry - Dial timeout handling with retry logic - Multi-page fax transmission between pages - Chinese character rendering in cover pages (Apple native fonts) --- Cargo.lock | 7 + Cargo.toml | 1 + src/api/routes.rs | 63 +++++- src/document/cover.rs | 141 ++++++++++++ src/document/mod.rs | 2 + src/document/pdf.rs | 68 +++--- src/document/tiff.rs | 15 +- src/fax/class1/send.rs | 86 ++++++-- src/fax/class2/mod.rs | 2 + src/fax/class2/receive.rs | 230 ++++++++++++++++++++ src/fax/class2/send.rs | 117 +++++++--- src/fax/hdlc.rs | 34 ++- src/lib.rs | 2 + src/main.rs | 436 +++++++++++++++++++++++++++++++++++++- src/modem/driver.rs | 18 ++ src/ocr.rs | 165 +++++++++++++++ src/preview.rs | 315 +++++++++++++++++++++++++++ src/queue/job.rs | 8 + src/queue/store.rs | 45 +++- test/class1_receive.py | 177 ++++++++++++++++ 20 files changed, 1849 insertions(+), 83 deletions(-) create mode 100644 src/document/cover.rs create mode 100644 src/fax/class2/receive.rs create mode 100644 src/ocr.rs create mode 100644 src/preview.rs create mode 100755 test/class1_receive.py diff --git a/Cargo.lock b/Cargo.lock index 0071f6a..319a959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,6 +262,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit_field" version = "0.10.3" @@ -1981,6 +1987,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64", "chrono", "clap", "fax", diff --git a/Cargo.toml b/Cargo.toml index 8b011f7..997fd29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ anyhow = "1" # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" +base64 = "0.22" # Logging tracing = "0.1" diff --git a/src/api/routes.rs b/src/api/routes.rs index ab12dfd..9ab5268 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -2,7 +2,7 @@ use axum::{ extract::State, http::StatusCode, response::Json, - routing::{get, post}, + routing::{get, post, put}, Router, }; use serde::{Deserialize, Serialize}; @@ -30,6 +30,10 @@ pub struct HealthResponse { pub struct SendFaxRequest { pub recipient: String, pub document_path: String, + pub cover_to: Option, + pub cover_from: Option, + pub cover_subject: Option, + pub cover_notes: Option, } #[derive(Serialize)] @@ -37,6 +41,23 @@ pub struct SendFaxResponse { pub job_id: JobId, } +#[derive(Deserialize)] +pub struct UpdateCoverRequest { + pub to: Option, + pub from: Option, + pub subject: Option, + pub notes: Option, +} + +#[derive(Serialize)] +pub struct UpdateCoverResponse { + pub job_id: JobId, + pub cover_to: Option, + pub cover_from: Option, + pub cover_subject: Option, + pub cover_notes: Option, +} + #[derive(Serialize)] pub struct JobResponse { pub id: JobId, @@ -44,6 +65,10 @@ pub struct JobResponse { pub status: String, pub pages: u32, pub created_at: String, + pub cover_to: Option, + pub cover_from: Option, + pub cover_subject: Option, + pub cover_notes: Option, } async fn health(State(state): State) -> Json { @@ -57,7 +82,11 @@ async fn send_fax( State(state): State, Json(req): Json, ) -> Result, StatusCode> { - let job = FaxJob::new(req.recipient, req.document_path); + let mut job = FaxJob::new(req.recipient, req.document_path); + job.cover_to = req.cover_to; + job.cover_from = req.cover_from; + job.cover_subject = req.cover_subject; + job.cover_notes = req.cover_notes; let job_id = job.id; let mut queue = state.queue.lock().unwrap(); @@ -78,6 +107,10 @@ async fn list_jobs(State(state): State) -> Json> { status: format!("{:?}", j.status), pages: j.pages, created_at: j.created_at.to_rfc3339(), + cover_to: j.cover_to, + cover_from: j.cover_from, + cover_subject: j.cover_subject, + cover_notes: j.cover_notes, }) .collect(); Json(responses) @@ -94,9 +127,34 @@ async fn get_job( status: format!("{:?}", j.status), pages: j.pages, created_at: j.created_at.to_rfc3339(), + cover_to: j.cover_to, + cover_from: j.cover_from, + cover_subject: j.cover_subject, + cover_notes: j.cover_notes, })).ok_or(StatusCode::NOT_FOUND) } +async fn update_job_cover( + State(state): State, + axum::extract::Path(id): axum::extract::Path, + Json(req): Json, +) -> Result, StatusCode> { + let mut queue = state.queue.lock().unwrap(); + queue + .update_cover(&id, req.to.clone(), req.from.clone(), req.subject.clone(), req.notes.clone()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Return updated fields + let job = queue.get(&id).ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(UpdateCoverResponse { + job_id: job.id, + cover_to: job.cover_to, + cover_from: job.cover_from, + cover_subject: job.cover_subject, + cover_notes: job.cover_notes, + })) +} + pub async fn start_server(config: FaxConfig, queue: Arc>) -> FaxResult<()> { let state = AppState { config: config.clone(), @@ -108,6 +166,7 @@ pub async fn start_server(config: FaxConfig, queue: Arc>) -> Fax .route("/api/fax/send", post(send_fax)) .route("/api/fax/jobs", get(list_jobs)) .route("/api/fax/jobs/{id}", get(get_job)) + .route("/api/fax/jobs/{id}/cover", put(update_job_cover)) .with_state(state); let addr = config diff --git a/src/document/cover.rs b/src/document/cover.rs new file mode 100644 index 0000000..0971824 --- /dev/null +++ b/src/document/cover.rs @@ -0,0 +1,141 @@ +use crate::error::{FaxError, Result}; +use crate::document::convert::Page; +use std::process::Command; + +const COVER_PY: &str = r#"from PIL import Image, ImageDraw, ImageFont +import sys, json, os + +params = json.loads(sys.argv[1]) +to = params['to'] +sender = params['from'] +subject = params['subject'] +notes = params['notes'] +total_pages = params['total_pages'] +output_path = params['output'] + +W, H = 1728, 2291 +img = Image.new('L', (W, H), 255) +draw = ImageDraw.Draw(img) + +PINGFANG_PATH = '/System/Library/AssetsV2/com_apple_MobileAsset_Font8/86ba2c91f017a3749571a82f2c6d890ac7ffb2fb.asset/AssetData/PingFang.ttc' +STHEITI_PATH = '/System/Library/Fonts/STHeiti Medium.ttc' + +font_path = PINGFANG_PATH if os.path.exists(PINGFANG_PATH) else STHEITI_PATH if os.path.exists(STHEITI_PATH) else None + +font_title = None +font_bold = None +font_normal = None +font_small = None + +if font_path: + try: + if 'PingFang' in font_path: + font_title = ImageFont.truetype(font_path, 72, index=10) + font_bold = ImageFont.truetype(font_path, 44, index=6) + font_normal = ImageFont.truetype(font_path, 44, index=2) + font_small = ImageFont.truetype(font_path, 36, index=2) + else: + font_title = ImageFont.truetype(font_path, 72, index=0) + font_bold = ImageFont.truetype(font_path, 44, index=0) + font_normal = ImageFont.truetype(font_path, 44, index=0) + font_small = ImageFont.truetype(font_path, 36, index=0) + except Exception as e: + font_path = None + +if not font_path: + try: + font_title = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 72) + font_bold = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 44) + font_normal = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 44) + font_small = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 36) + except Exception: + font_title = ImageFont.load_default() + font_bold = font_title + font_normal = font_title + font_small = font_title + +draw.rectangle([(0, 2030), (1728, 2230)], fill=0) +_, _, tw, _ = draw.textbbox((0, 0), 'FACSIMILE', font=font_title) +draw.text(((W - tw) / 2, 2080), 'FACSIMILE', fill=255, font=font_title) + +draw.line([(100, 1980), (1628, 1980)], fill=0, width=3) + +from datetime import datetime +date_str = datetime.now().strftime('%Y/%m/%d %H:%M') + +labels = ['TO:', 'FROM:', 'DATE:', 'PAGES:', 'SUBJECT:'] +values = [to, sender, date_str, str(total_pages), subject] +y = 1880 + +for label, value in zip(labels, values): + draw.text((100, y), label, fill=0, font=font_bold) + draw.text((300, y), value, fill=0, font=font_normal) + y -= 90 + +draw.line([(100, y), (1628, y)], fill=0, width=3) +y -= 70 + +draw.text((100, y), 'NOTES:', fill=0, font=font_bold) +y -= 50 +for line in notes.split('\n'): + draw.text((100, y), line, fill=0, font=font_small) + y -= 40 + +img.save(output_path, dpi=(204, 196)) +print(json.dumps({"ok": True})) +"#; + +pub struct CoverParams { + pub to: String, + pub from: String, + pub subject: String, + pub notes: String, + pub total_pages: u32, +} + +pub fn generate_cover_page(params: &CoverParams) -> Result { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + let py_dir = std::env::temp_dir(); + let py_path = py_dir.join(format!("telfax_cover_{}.py", stamp)); + let tiff_path = py_dir.join(format!("telfax_cover_{}.tif", stamp)); + + std::fs::write(&py_path, COVER_PY)?; + + let py_params = serde_json::json!({ + "to": params.to, + "from": params.from, + "subject": params.subject, + "notes": params.notes, + "total_pages": params.total_pages, + "output": tiff_path.to_string_lossy(), + }); + + let python = if cfg!(target_os = "windows") { "python" } else { "python3" }; + + let output = Command::new(python) + .arg(py_path.to_string_lossy().as_ref()) + .arg(py_params.to_string()) + .output() + .map_err(|e| FaxError::document(format!("Python cover script failed: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + std::fs::remove_file(&py_path).ok(); + std::fs::remove_file(&tiff_path).ok(); + return Err(FaxError::document(format!("Cover generation error: {}", stderr))); + } + + let tiff_data = std::fs::read(&tiff_path)?; + let doc = super::pdf::tiff_to_fax_document(&tiff_data)?; + + std::fs::remove_file(&py_path).ok(); + std::fs::remove_file(&tiff_path).ok(); + + Ok(doc.pages.into_iter().next().ok_or_else(|| { + FaxError::document("Cover page generation produced no pages") + })?) +} diff --git a/src/document/mod.rs b/src/document/mod.rs index 5248295..f4758fb 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -1,7 +1,9 @@ pub mod convert; pub mod tiff; pub mod pdf; +pub mod cover; pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page}; pub use tiff::TiffFaxWriter; pub use pdf::pdf_to_fax_document; +pub use cover::{generate_cover_page, CoverParams}; diff --git a/src/document/pdf.rs b/src/document/pdf.rs index 89159b4..e0b947a 100644 --- a/src/document/pdf.rs +++ b/src/document/pdf.rs @@ -55,44 +55,58 @@ pub fn tiff_to_fax_document(tiff_data: &[u8]) -> Result { let mut decoder = Decoder::new(&mut cursor) .map_err(|e| FaxError::document(format!("Failed to open TIFF: {}", e)))?; - let (width, height) = decoder.dimensions() - .map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?; + let mut pages = Vec::new(); - let bytes_per_row = ((width + 7) / 8) as usize; - let mut pixels = vec![0u8; bytes_per_row * height as usize]; + loop { + let (width, height) = decoder.dimensions() + .map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?; - let mut result = decoder.read_image() - .map_err(|e| FaxError::document(format!("Failed to read TIFF image: {}", e)))?; + let bytes_per_row = ((width + 7) / 8) as usize; + let mut pixels = vec![0u8; bytes_per_row * height as usize]; - // Convert to 1-bit packed format - let buffer = result.as_buffer(0); - let img_data = buffer.as_bytes(); + let mut result = decoder.read_image() + .map_err(|e| FaxError::document(format!("Failed to read TIFF image: {}", e)))?; - // Process each pixel - TIFF is grayscale (8-bit), convert to 1-bit - for (i, &byte) in img_data.iter().enumerate() { - let pixel_idx = i % width as usize; - let row_idx = i / width as usize; - let byte_idx = pixel_idx / 8; - let bit_idx = 7 - (pixel_idx % 8); + // Convert to 1-bit packed format + let buffer = result.as_buffer(0); + let img_data = buffer.as_bytes(); - // WhiteIsZero: 0=white, 255=black - // We want: 1=black, 0=white (MSB first) - if byte < 128 { - let idx = row_idx * bytes_per_row + byte_idx; - if idx < pixels.len() { - pixels[idx] |= 1 << bit_idx; + // Process each pixel - TIFF is grayscale (8-bit), convert to 1-bit + for (i, &byte) in img_data.iter().enumerate() { + let pixel_idx = i % width as usize; + let row_idx = i / width as usize; + let byte_idx = pixel_idx / 8; + let bit_idx = 7 - (pixel_idx % 8); + + // WhiteIsZero: 0=white, 255=black + // We want: 1=black, 0=white (MSB first) + if byte < 128 { + let idx = row_idx * bytes_per_row + byte_idx; + if idx < pixels.len() { + pixels[idx] |= 1 << bit_idx; + } } } + + pages.push(Page { + pixels, + width_pels: width, + rows: height, + }); + + // Try to advance to next page + match decoder.next_image() { + Ok(_) => continue, + Err(_) => break, + } } - let page = Page { - pixels, - width_pels: width, - rows: height, - }; + if pages.is_empty() { + return Err(FaxError::document("TIFF contains no pages")); + } Ok(FaxDocument { - pages: vec![page], + pages, format: DocumentFormat::Tiff, }) } \ No newline at end of file diff --git a/src/document/tiff.rs b/src/document/tiff.rs index 12fd357..8d5711b 100644 --- a/src/document/tiff.rs +++ b/src/document/tiff.rs @@ -2,6 +2,11 @@ use crate::document::convert::Page; use crate::error::{FaxError, Result}; use tiff::encoder::colortype::Gray8; use tiff::encoder::TiffEncoder; +use tiff::encoder::Rational; +use tiff::tags::ResolutionUnit; + +const FAX_X_DPI: u32 = 204; +const FAX_Y_DPI: u32 = 196; pub struct TiffFaxWriter; @@ -25,8 +30,14 @@ impl TiffFaxWriter { } } - encoder - .write_image::(page.width_pels, page.rows, &gray_data) + let mut img = encoder + .new_image::(page.width_pels, page.rows) + .map_err(|e| FaxError::document(format!("Failed to create TIFF image: {}", e)))?; + + img.resolution_unit(ResolutionUnit::Inch); + img.x_resolution(Rational { n: FAX_X_DPI, d: 1 }); + img.y_resolution(Rational { n: FAX_Y_DPI, d: 1 }); + img.write_data(&gray_data) .map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?; } diff --git a/src/fax/class1/send.rs b/src/fax/class1/send.rs index 16999d2..ba20965 100644 --- a/src/fax/class1/send.rs +++ b/src/fax/class1/send.rs @@ -39,6 +39,27 @@ impl<'a> Class1Send<'a> { self.session.transition(T30Event::Connected); + // After CONNECT, some modems need escape to enter online command mode + // Wait for silence (no CED tone yet), then escape + tracing::info!("Connection established, waiting for silence..."); + std::thread::sleep(std::time::Duration::from_millis(1000)); + + // Send escape sequence +++ (with guard times) + self.driver.write_raw(b"+++")?; + self.driver.flush()?; + std::thread::sleep(std::time::Duration::from_millis(1000)); + + // Drain response + self.driver.drain()?; + + // Wait for CED tone and DIS from receiver (per T.30 spec) + tracing::info!("Waiting for receiver to send CED and DIS..."); + std::thread::sleep(std::time::Duration::from_millis(4000)); + + // Drain any pending data before entering HDLC receive mode + self.driver.drain()?; + tracing::info!("Entering HDLC receive mode..."); + let dis_frame = self.receive_hdlc_frame()?; tracing::info!("Received DIS frame ({} bytes)", dis_frame.len()); @@ -94,33 +115,72 @@ impl<'a> Class1Send<'a> { let cmd = format!("ATDT{}\r", number); self.driver.write_raw(cmd.as_bytes())?; self.driver.flush()?; + + tracing::info!("Dialing {}, waiting for response...", number); - let response = self.driver.read_until(b"\r\n", 60000)?; - let text = String::from_utf8_lossy(&response); - tracing::info!(response = %text, "Dial response"); + let mut full_response = Vec::new(); + let start = std::time::Instant::now(); + let timeout_ms = 60000u64; - if text.contains("NO CARRIER") || text.contains("BUSY") { - return Err(FaxError::NoCarrier); + loop { + match self.driver.read_until(b"\r\n", 5000) { + Ok(line) => { + let text = String::from_utf8_lossy(&line); + tracing::info!("Dial line: {}", text.trim()); + full_response.extend_from_slice(&line); + + if text.contains("CONNECT") { + tracing::info!("Connection established"); + return Ok(()); + } + if text.contains("BUSY") { + return Err(FaxError::modem("Line is BUSY")); + } + if text.contains("NO CARRIER") { + return Err(FaxError::NoCarrier); + } + if text.contains("NO DIALTONE") { + return Err(FaxError::modem("No dial tone")); + } + } + Err(_) => { + if start.elapsed().as_millis() as u64 > timeout_ms { + break; + } + } + } } - if !text.contains("CONNECT") { - return Err(FaxError::modem(format!("Unexpected dial response: {}", text))); - } - Ok(()) + + let text = String::from_utf8_lossy(&full_response); + Err(FaxError::modem(format!("Dial timeout, response: {}", text))) } fn receive_hdlc_frame(&mut self) -> Result> { self.driver.write_raw(b"AT+FRH=3\r")?; + self.driver.flush()?; - let response = self.driver.read_until(b"\r\n", 15000)?; + // Wait for CONNECT response - modem may take time to enter HDLC receive mode + let response = self.driver.read_until(b"CONNECT\r\n", 30000)?; let text = String::from_utf8_lossy(&response); - tracing::debug!(response = %text, "FRH initial"); + tracing::info!(response = %text, "FRH response"); 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) + // Now read the actual HDLC frame data until OK + // The modem sends the HDLC frame bytes, then "OK\r\n" + let frame_and_ok = self.driver.read_until(b"OK\r\n", 60000)?; + + // Remove trailing "OK\r\n" if present + let frame_data = if frame_and_ok.ends_with(b"OK\r\n") { + &frame_and_ok[..frame_and_ok.len() - 4] + } else { + &frame_and_ok[..] + }; + + tracing::info!("Received {} bytes of HDLC data", frame_data.len()); + Ok(frame_data.to_vec()) } fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> { diff --git a/src/fax/class2/mod.rs b/src/fax/class2/mod.rs index 758f82d..b7f3425 100644 --- a/src/fax/class2/mod.rs +++ b/src/fax/class2/mod.rs @@ -1,5 +1,7 @@ pub mod send; +pub mod receive; pub mod commands; pub use send::Class2Send; +pub use receive::Class2Receive; pub use commands::Class2Commands; \ No newline at end of file diff --git a/src/fax/class2/receive.rs b/src/fax/class2/receive.rs new file mode 100644 index 0000000..51cf768 --- /dev/null +++ b/src/fax/class2/receive.rs @@ -0,0 +1,230 @@ +use crate::error::{FaxError, Result}; +use crate::modem::driver::ModemDriver; + +const DLE: u8 = 0x10; +const ETX: u8 = 0x03; + +pub struct Class2Receive<'a> { + driver: &'a mut ModemDriver, + station_id: String, +} + +impl<'a> Class2Receive<'a> { + pub fn new(driver: &'a mut ModemDriver) -> Self { + Self { + driver, + station_id: "TELFAX".to_string(), + } + } + + pub fn with_station_id(mut self, id: &str) -> Self { + self.station_id = id.to_string(); + self + } + + fn at(&mut self) -> crate::modem::at::AtChannel<'_> { + crate::modem::at::AtChannel::new(self.driver) + } + + pub fn setup_auto_answer(&mut self, rings: u8) -> Result<()> { + tracing::info!("Setting up Class 2 auto-answer ({} rings)", rings); + + self.at().send_command("AT", 2000)?; + self.at().send_command("AT+FCLASS=2", 3000)?; + + let station_cmd = format!("AT+FLID=\"{}\"", self.station_id); + self.at().send_command(&station_cmd, 3000)?; + + let rings_cmd = format!("ATS0={}", rings); + self.at().send_command(&rings_cmd, 2000)?; + + self.at().send_command("AT+FAA=1", 2000)?; + + tracing::info!("Auto-answer configured"); + Ok(()) + } + + pub fn wait_for_call(&mut self, timeout_ms: u64) -> Result<()> { + tracing::info!("Waiting for incoming call..."); + + // In Class 2, the modem handles T.30 negotiation automatically + // We need to wait for +FCON which indicates fax session established + let response = self.driver.read_until(b"+FCON", timeout_ms)?; + let text = String::from_utf8_lossy(&response); + tracing::info!("Fax connection: {}", text.trim()); + + // Wait for +FDCS (Digital Command Signal) or +FPTS (Page Transfer Start) + // The modem will handle DIS/DCS negotiation automatically + tracing::info!("Waiting for page data..."); + + Ok(()) + } + + pub fn receive_pages(&mut self) -> Result>> { + let mut pages = Vec::new(); + let mut page_count = 0; + let mut receiving_page = false; + let mut page_data = Vec::new(); + + tracing::info!("Monitoring for page events..."); + + loop { + let event = self.wait_for_event(60000)?; + + match event { + ReceiveEvent::Fpts => { + tracing::info!("Page {} transfer starting...", page_count + 1); + receiving_page = true; + page_data = Vec::new(); + } + ReceiveEvent::Ftsi(id) => { + tracing::info!("Transmitting station: {}", id); + } + ReceiveEvent::PageData(data) => { + if receiving_page { + page_data.extend_from_slice(&data); + } + } + ReceiveEvent::PageEnd(more) => { + if receiving_page && !page_data.is_empty() { + tracing::info!("Page {} received ({} bytes)", page_count + 1, page_data.len()); + pages.push(page_data.clone()); + page_count += 1; + } + receiving_page = false; + if !more { + tracing::info!("No more pages"); + break; + } + } + ReceiveEvent::Fhng(code) => { + tracing::info!("Session ended with code {}", code); + break; + } + ReceiveEvent::NoCarrier => { + tracing::info!("Carrier lost"); + break; + } + ReceiveEvent::Error(msg) => { + tracing::error!("Error: {}", msg); + if receiving_page && !page_data.is_empty() { + pages.push(page_data.clone()); + } + break; + } + _ => {} + } + } + + Ok(pages) + } + + fn wait_for_event(&mut self, timeout_ms: u64) -> Result { + let response = self.driver.read_until(b"\r\n", timeout_ms)?; + let text = String::from_utf8_lossy(&response); + let line = text.trim(); + + tracing::debug!("Event: {}", line); + + if line.contains("+FPTS") { + Ok(ReceiveEvent::Fpts) + } else if line.contains("+FTSI") { + let id = extract_quoted_value(line); + Ok(ReceiveEvent::Ftsi(id)) + } else if line.contains("+FDCS") { + Ok(ReceiveEvent::Fdcs) + } else if line.contains("+FCON") { + Ok(ReceiveEvent::Fcon) + } else if line.contains("+FET") || line.contains("+FCFR") { + let more = line.contains(",1") || line.contains("+FCFR"); + Ok(ReceiveEvent::PageEnd(more)) + } else if line.contains("+FHNG") { + let code = extract_number(line); + Ok(ReceiveEvent::Fhng(code)) + } else if line.contains("NO CARRIER") { + Ok(ReceiveEvent::NoCarrier) + } else if line.contains("ERROR") { + Ok(ReceiveEvent::Error(line.to_string())) + } else if line.contains("OK") { + // Data transmission complete marker + Ok(ReceiveEvent::DataOk) + } else if !line.is_empty() && !line.starts_with("AT") { + // Might be data - try to read raw bytes + let data = self.read_page_stream()?; + Ok(ReceiveEvent::PageData(data)) + } else { + Ok(ReceiveEvent::Unknown(line.to_string())) + } + } + + fn read_page_stream(&mut self) -> Result> { + let mut data = Vec::new(); + let mut dle_seen = false; + + // Read until DLE-ETX + for _ in 0..100000 { + let byte = self.driver.read_byte(5000)?; + + if dle_seen { + if byte == ETX { + break; + } else if byte == DLE { + data.push(DLE); + } + dle_seen = false; + } else if byte == DLE { + dle_seen = true; + } else { + data.push(byte); + } + } + + Ok(data) + } + + pub fn end_session(&mut self) -> Result<()> { + tracing::info!("Ending receive session"); + self.driver.write_raw(b"AT+FKS\r")?; + self.driver.flush()?; + + std::thread::sleep(std::time::Duration::from_millis(500)); + self.driver.drain()?; + + Ok(()) + } +} + +enum ReceiveEvent { + Fcon, + Fdcs, + Fpts, + Ftsi(String), + PageData(Vec), + PageEnd(bool), + Fhng(u8), + NoCarrier, + DataOk, + Error(String), + Unknown(String), +} + +fn extract_quoted_value(s: &str) -> String { + if let Some(start) = s.find('"') { + if let Some(end) = s[start + 1..].find('"') { + return s[start + 1..start + 1 + end].to_string(); + } + } + s.to_string() +} + +fn extract_number(s: &str) -> u8 { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() > 1 { + parts[1].trim().split(',') + .next() + .and_then(|n| n.trim().parse().ok()) + .unwrap_or(0) + } else { + 0 + } +} \ No newline at end of file diff --git a/src/fax/class2/send.rs b/src/fax/class2/send.rs index bb3c20f..ebeced8 100644 --- a/src/fax/class2/send.rs +++ b/src/fax/class2/send.rs @@ -51,6 +51,10 @@ impl<'a> Class2Send<'a> { pub fn send_fax(&mut self, number: &str, pages: &[Page]) -> Result<()> { tracing::info!("Starting Class 2 fax to {}", number); + // Initialize modem first + self.at().send_command("AT", 3000)?; + tracing::debug!("Modem initialized"); + // Enter Class 2 mode self.at().send_command(Class2Commands::ENTER_CLASS2, 5000)?; tracing::info!("Entered Class 2 mode"); @@ -60,14 +64,18 @@ impl<'a> Class2Send<'a> { self.at().send_command(&station_cmd, 3000)?; tracing::info!("Station ID set: {}", self.station_id); - // Set header + // Set header - may not be supported on some modems let header_cmd = Class2Commands::set_header(&self.header); - self.at().send_command(&header_cmd, 3000)?; + if self.at().send_command(&header_cmd, 3000).is_err() { + tracing::warn!("Header command not supported, skipping"); + } - // Set capabilities + // Set capabilities - skip if not supported let speed = if self.params.speed > 0 { self.params.speed } else { BR_V17_14400 }; let dis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm); - self.at().send_command(&dis_cmd, 3000)?; + if self.at().send_command(&dis_cmd, 3000).is_err() { + tracing::warn!("DIS params command not supported, using defaults"); + } // Dial self.dial(number)?; @@ -94,25 +102,64 @@ impl<'a> Class2Send<'a> { self.driver.write_raw(cmd.as_bytes())?; self.driver.write_raw(b"\r")?; self.driver.flush()?; + tracing::info!("Dialing: {}", cmd); - // Wait for CONNECT or response - let response = self.driver.read_until(b"\r\n", 60000)?; - let text = String::from_utf8_lossy(&response); - tracing::info!("Dial response: {}", text); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); - // Check for successful connection - if text.contains("NO CARRIER") || text.contains("BUSY") { - return Err(FaxError::NoCarrier); + // Read lines until CONNECT or error + while std::time::Instant::now() < deadline { + let line = match self.driver.read_until(b"\r\n", 5000) { + Ok(l) => l, + Err(crate::error::FaxError::Timeout { .. }) => continue, + Err(e) => return Err(e), + }; + let text = String::from_utf8_lossy(&line).trim().to_string(); + + if text.is_empty() { + continue; + } + + tracing::info!("Dial response: {}", text); + + // Skip command echo + if text.starts_with("ATDT") || text.starts_with("ATD") { + continue; + } + + // Error conditions + if text.contains("NO CARRIER") || text.contains("BUSY") { + return Err(FaxError::NoCarrier); + } + if text.contains("ERROR") { + return Err(FaxError::modem("Dial command failed")); + } + if text.contains("+FHNG:") { + return Err(FaxError::protocol(format!("Fax hangup: {}", text))); + } + + // Connection established + if text.contains("CONNECT") { + tracing::info!("Connected!"); + // Read remaining session info (e.g. +FCON) + let extra = self.driver.read_all(2000)?; + let extra_text = String::from_utf8_lossy(&extra); + if !extra_text.trim().is_empty() { + tracing::info!("Session info: {}", extra_text); + if extra_text.contains("+FHNG:") { + return Err(FaxError::protocol(format!("Fax hangup: {}", extra_text.trim()))); + } + } + return Ok(()); + } + + // Some modems send +FCON without CONNECT + if text.contains("+FCON") { + tracing::info!("Fax conference established"); + return Ok(()); + } } - // Class 2: wait for fax session indicators - // CONNECT indicates fax session is ready - // The modem returns: CONNECT, +FCON: - let extra = self.driver.read_all(5000)?; - let extra_text = String::from_utf8_lossy(&extra); - tracing::info!("Session info: {}", extra_text); - - Ok(()) + Err(FaxError::Timeout { ms: 60000 }) } fn send_page(&mut self, page: &Page) -> Result<()> { @@ -120,13 +167,29 @@ impl<'a> Class2Send<'a> { self.driver.write_raw(b"AT+FDT\r")?; self.driver.flush()?; - // Wait for CONNECT - let response = self.driver.read_until(b"CONNECT\r\n", 10000)?; - let text = String::from_utf8_lossy(&response); - tracing::debug!("FDT response: {}", text); + // Wait for CONNECT with longer timeout and retry logic + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut got_connect = false; + while std::time::Instant::now() < deadline { + let response = match self.driver.read_until(b"\r\n", 5000) { + Ok(r) => r, + Err(crate::error::FaxError::Timeout { .. }) => continue, + Err(e) => return Err(e), + }; + let text = String::from_utf8_lossy(&response); + tracing::debug!("FDT response: {}", text); - if text.contains("ERROR") { - return Err(FaxError::modem("FDT command failed")); + if text.contains("CONNECT") { + got_connect = true; + break; + } + if text.contains("ERROR") { + return Err(FaxError::modem("FDT command failed")); + } + } + + if !got_connect { + return Err(FaxError::Timeout { ms: 30000 }); } // Encode pixel data to T.4 MH format @@ -163,6 +226,10 @@ impl<'a> Class2Send<'a> { // Wait for confirmation let response = self.driver.read_until(b"\r\n", 10000)?; tracing::debug!("End page response: {}", String::from_utf8_lossy(&response)); + + // Brief pause before next page + std::thread::sleep(std::time::Duration::from_millis(500)); + self.driver.drain()?; Ok(()) } diff --git a/src/fax/hdlc.rs b/src/fax/hdlc.rs index 0d3dcaa..4caa732 100644 --- a/src/fax/hdlc.rs +++ b/src/fax/hdlc.rs @@ -23,10 +23,13 @@ pub enum FrameType { impl FrameType { pub fn from_control(ctrl: u8) -> Self { match ctrl { - 0x08 => FrameType::Dis, - 0x28 => FrameType::Dcs, - 0x41 => FrameType::Tcf, - 0x21 => FrameType::Cfr, + 0x80 | 0x08 => FrameType::Dis, // DIS from called (0x80) or caller + 0x28 => FrameType::Dcs, // DCS (Digital Command Signal) + 0x21 => FrameType::Cfr, // CFR (Confirmation to Receive) + 0x31 => FrameType::Other(0x31), // MCF (Message Confirmation) + 0x41 => FrameType::Tcf, // TCF (Training Check) + 0x5D | 0x42 => FrameType::Other(ctrl), // EOP / EOM + 0x73 | 0x74 => FrameType::Other(ctrl), // DCN _ => FrameType::Other(ctrl), } } @@ -73,6 +76,8 @@ pub fn parse_hdlc_frame(data: &[u8]) -> Result { i += 1; } + tracing::info!("HDLC raw frame: {:02X?}", frame_data); + if frame_data.len() < 4 { return Err(FaxError::Hdlc(format!( "Frame too short: {} bytes", @@ -80,13 +85,30 @@ pub fn parse_hdlc_frame(data: &[u8]) -> Result { ))); } + // T.30 HDLC frame format: + // [Address] [HDLC Control] [FCF (Facsimile Control Field)] [Information] [FCS] + // FCF indicates frame type (DIS=0x80, DCS=0x28, CFR=0x21, etc.) 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(); + + // Use FCF (frame_data[2]) as the control field for T.30 purposes + // If frame has only 3 bytes before FCS, use frame_data[1] (HDLC control) + let control = if frame_data.len() > 4 { + frame_data[2] // T.30 FCF + } else { + frame_data[1] // HDLC control (fallback) + }; + + let information_start = if frame_data.len() > 4 { 3 } else { 2 }; + let information = if information_start < frame_data.len() - 2 { + frame_data[information_start..frame_data.len() - 2].to_vec() + } else { + Vec::new() + }; Ok(HdlcFrame { address: frame_data[0], - control: frame_data[1], + control, information, fcs, }) diff --git a/src/lib.rs b/src/lib.rs index a9e1907..2b2451e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ pub mod error; pub mod modem; pub mod fax; pub mod document; +pub mod ocr; +pub mod preview; pub mod queue; #[cfg(feature = "server")] diff --git a/src/main.rs b/src/main.rs index 8a34131..5517717 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,30 @@ enum Commands { /// Fax class (1 or 2). Class 2 is simpler but requires Class 2 capable modem. #[arg(short = 'c', long, default_value = "2")] class: String, + /// Name of recipient (for cover page) + #[arg(long)] + to: Option, + /// Sender name (for cover page) + #[arg(long)] + from: Option, + /// Subject (for cover page) + #[arg(long)] + subject: Option, + /// Notes (for cover page) + #[arg(long)] + note: Option, + }, + /// Receive a fax (Class 2 auto-answer) + Receive { + /// Serial device path + #[arg(short, long, default_value = "/dev/cu.usbmodem123456781")] + device: String, + /// Number of rings before auto-answer + #[arg(short, long, default_value = "2")] + rings: u8, + /// Output directory for received faxes + #[arg(short, long, default_value = "/tmp")] + output: String, }, /// Interactively test the modem Test { @@ -55,6 +79,91 @@ enum Commands { #[arg(short = '2', long)] class2: bool, }, + /// Generate a cover page file (TIFF or HTML) + CoverCreate { + /// Recipient name + #[arg(long)] + to: Option, + /// Sender name + #[arg(long)] + from: Option, + /// Subject + #[arg(long)] + subject: Option, + /// Notes + #[arg(long)] + note: Option, + /// Total pages + #[arg(long, default_value_t = 1)] + pages: u32, + /// Output file path + #[arg(short, long)] + output: Option, + /// Output format: tif, html, or both + #[arg(long, default_value = "html")] + format: String, + }, + /// Update cover page settings for a queued fax job + JobCover { + /// Job ID + job_id: String, + /// Recipient name + #[arg(long)] + to: Option, + /// Sender name + #[arg(long)] + from: Option, + /// Subject + #[arg(long)] + subject: Option, + /// Notes + #[arg(long)] + note: Option, + /// Path to SQLite queue database (omit to use API) + #[arg(long)] + db: Option, + }, + /// Generate interactive HTML preview of a fax page + Preview { + /// Input file (PDF, TIFF, or image). Omit to preview only the cover page. + input: Option, + /// Output HTML file (default: preview.html) + #[arg(short, long)] + output: Option, + /// Resolution (standard/fine/superfine) + #[arg(short, long, default_value = "fine")] + resolution: String, + /// Cover page recipient name + #[arg(long)] + to: Option, + /// Cover page sender name + #[arg(long)] + from: Option, + /// Cover page subject + #[arg(long)] + subject: Option, + /// Cover page notes + #[arg(long)] + note: Option, + }, + /// Generate cover page and verify content via OCR + VerifyCover { + /// Recipient name + #[arg(long)] + to: Option, + /// Sender name + #[arg(long)] + from: Option, + /// Subject + #[arg(long)] + subject: Option, + /// Notes + #[arg(long)] + note: Option, + /// Total pages + #[arg(long, default_value_t = 1)] + pages: u32, + }, /// Convert PDF to TIFF-F fax format (dry test) Convert { /// Input PDF file path @@ -82,9 +191,18 @@ async fn main() -> Result<()> { 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, resolution, class } => - cmd_send(&device, &recipient, &file, &resolution, &class), + Commands::Send { recipient, file, device, resolution, class, to, from, subject, note } => + cmd_send(&device, &recipient, &file, &resolution, &class, to, from, subject, note), + Commands::Receive { device, rings, output } => cmd_receive(&device, rings, &output), Commands::Test { device, class2 } => cmd_test(&device, class2), + Commands::CoverCreate { to, from, subject, note, pages, output, format } => + cmd_cover_create(to, from, subject, note, pages, output, &format), + Commands::JobCover { job_id, to, from, subject, note, db } => + cmd_job_cover(&job_id, to, from, subject, note, db.as_deref()), + Commands::Preview { input, output, resolution, to, from, subject, note } => + cmd_preview(input.as_deref(), output.as_deref(), &resolution, to, from, subject, note), + Commands::VerifyCover { to, from, subject, note, pages } => + cmd_verify_cover(to, from, subject, note, pages), Commands::Convert { input, output, resolution } => cmd_convert(&input, output.as_deref(), &resolution), } } @@ -127,7 +245,8 @@ async fn cmd_serve(config_path: Option) -> Result<()> { telfax::api::start_server(config, queue.clone()).await } -fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: &str) -> Result<()> { +fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: &str, + to: Option, from: Option, subject: Option, note: Option) -> Result<()> { info!("Sending fax to {} from {} using device {} (Class {})", recipient, file, device, class); let mut driver = ModemDriver::open(device, 115200)?; @@ -140,7 +259,7 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: }; let path = std::path::Path::new(file); - let doc = if file.to_lowercase().ends_with(".pdf") { + let mut doc = if file.to_lowercase().ends_with(".pdf") { info!("Converting PDF to fax format..."); telfax::document::pdf_to_fax_document(path, fax_resolution)? } else { @@ -149,8 +268,23 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: telfax::document::document_from_image(&data)? }; + // Optionally prepend cover page + if to.is_some() || from.is_some() || subject.is_some() || note.is_some() { + let total_pages = doc.pages.len() as u32 + 1; + let params = telfax::document::CoverParams { + to: to.unwrap_or_default(), + from: from.unwrap_or_default(), + subject: subject.unwrap_or_default(), + notes: note.unwrap_or_default(), + total_pages, + }; + info!("Generating cover page..."); + let cover_page = telfax::document::generate_cover_page(¶ms)?; + doc.pages.insert(0, cover_page); + } + let page = &doc.pages[0]; - info!("Document: {}x{} pixels, {} bytes", page.width_pels, page.rows, page.pixels.len()); + info!("Document: {}x{} pixels, {} bytes, {} pages", page.width_pels, page.rows, page.pixels.len(), doc.pages.len()); // Choose fax class match class { @@ -176,6 +310,42 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: Ok(()) } +fn cmd_receive(device: &str, rings: u8, output_dir: &str) -> Result<()> { + info!("Setting up Class 2 receive on {} ({} rings)", device, rings); + + let mut driver = ModemDriver::open(device, 115200)?; + + let mut receiver = telfax::fax::class2::Class2Receive::new(&mut driver) + .with_station_id("TELFAX"); + + receiver.setup_auto_answer(rings)?; + + info!("Waiting for incoming fax call..."); + + receiver.wait_for_call(300000)?; + + info!("Call received, receiving pages..."); + + let pages = receiver.receive_pages()?; + + info!("Received {} pages", pages.len()); + + // Save received pages as TIFF files + for (i, page_data) in pages.iter().enumerate() { + let filename = format!("fax_{:?}_page{}.tif", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(), i + 1); + let output_path = std::path::Path::new(output_dir).join(filename); + + std::fs::write(&output_path, page_data)?; + + info!("Page {} saved to {} ({} bytes)", i + 1, output_path.display(), page_data.len()); + } + + receiver.end_session()?; + + info!("Fax receive completed!"); + Ok(()) +} + fn cmd_test(device: &str, test_class2: bool) -> Result<()> { use std::io::{self, Write}; @@ -242,6 +412,225 @@ fn cmd_test(device: &str, test_class2: bool) -> Result<()> { Ok(()) } +fn cmd_preview( + input: Option<&str>, + output: Option<&str>, + resolution: &str, + to: Option, + from: Option, + subject: Option, + note: Option, +) -> Result<()> { + use telfax::document::cover::{generate_cover_page, CoverParams}; + use telfax::document::tiff::TiffFaxWriter; + use telfax::preview; + + let mut doc = if let Some(input_path) = input { + let fax_resolution = match resolution.to_lowercase().as_str() { + "standard" | "std" => telfax::config::FaxResolution::Standard, + "fine" => telfax::config::FaxResolution::Fine, + "superfine" | "super" => telfax::config::FaxResolution::SuperFine, + _ => telfax::config::FaxResolution::Fine, + }; + let path = std::path::Path::new(input_path); + if input_path.to_lowercase().ends_with(".pdf") { + info!("Converting PDF to fax format..."); + telfax::document::pdf_to_fax_document(path, fax_resolution)? + } else { + let data = std::fs::read(input_path) + .map_err(|e| telfax::error::FaxError::Other(format!("Failed to read file: {}", e)))?; + telfax::document::document_from_image(&data)? + } + } else { + telfax::document::FaxDocument { + pages: Vec::new(), + format: telfax::document::DocumentFormat::Image, + } + }; + + if to.is_some() || from.is_some() || subject.is_some() || note.is_some() { + let total_pages = doc.pages.len() as u32 + 1; + let params = CoverParams { + to: to.unwrap_or_default(), + from: from.unwrap_or_default(), + subject: subject.unwrap_or_default(), + notes: note.unwrap_or_default(), + total_pages, + }; + let cover_page = generate_cover_page(¶ms)?; + doc.pages.insert(0, cover_page); + } + + if doc.pages.is_empty() { + return Err(telfax::error::FaxError::Other( + "No content to preview. Provide an input file or cover page options.".to_string(), + )); + } + + let output_path = output + .map(|s| std::path::PathBuf::from(s)) + .unwrap_or_else(|| std::path::PathBuf::from("preview.html")); + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tiff_path = std::env::temp_dir().join(format!("telfax_preview_{}.tif", stamp)); + TiffFaxWriter::write_fax_tiff(&tiff_path, &doc.pages)?; + + let first_page = &doc.pages[0]; + let file_name = input.and_then(|p| std::path::Path::new(p).file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "cover".to_string()); + let title = format!("{} — {} pages", file_name, doc.pages.len()); + preview::generate_preview_html(first_page, &title, &output_path)?; + + std::fs::remove_file(&tiff_path).ok(); + + println!("Preview saved to: {}", output_path.display()); + println!("Open in your browser to view with pan and zoom."); + + Ok(()) +} + +fn cmd_verify_cover( + to: Option, + from: Option, + subject: Option, + note: Option, + total_pages: u32, +) -> Result<()> { + use telfax::document::cover::{generate_cover_page, CoverParams}; + use telfax::document::tiff::TiffFaxWriter; + use telfax::ocr::{self, verify_cover}; + + let params = CoverParams { + to: to.unwrap_or_default(), + from: from.unwrap_or_default(), + subject: subject.unwrap_or_default(), + notes: note.unwrap_or_default(), + total_pages, + }; + + println!("=== Cover Page OCR Verification ==="); + println!("Params: TO={}, FROM={}, SUBJECT={}, NOTES={}, PAGES={}", + params.to, params.from, params.subject, params.notes, params.total_pages); + println!(); + + let page = generate_cover_page(¶ms)?; + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tiff_path = std::env::temp_dir().join(format!("telfax_ocr_verify_{}.tif", stamp)); + + TiffFaxWriter::write_fax_tiff(&tiff_path, &[page])?; + println!("Cover page: {} ({} bytes)", tiff_path.display(), + std::fs::metadata(&tiff_path).map(|m| m.len()).unwrap_or(0)); + + println!(); + println!("Running tesseract OCR..."); + let ocr_text = ocr::ocr_text(&tiff_path)?; + println!(); + println!("=== OCR Output ==="); + for line in ocr_text.lines() { + println!(" {}", line); + } + println!(); + + let result = verify_cover(&ocr_text, ¶ms)?; + println!("=== Field Verification ==="); + + let all_field_names = ["FACSIMILE", "TO", "FROM", "DATE", "PAGES", "SUBJECT", "NOTES"]; + for name in &all_field_names { + let missing = result.missing.iter().any(|m| m.starts_with(name) || m == name); + println!(" {} {}", if !missing { "✅" } else { "❌" }, name); + } + + println!(); + + if result.all_fields_present { + println!("✅ All fields verified successfully!"); + } else { + println!("❌ Missing fields: {}", result.missing.join(", ")); + } + + std::fs::remove_file(&tiff_path).ok(); + println!(); + println!("=== Verification Complete ==="); + + Ok(()) +} + +fn cmd_cover_create( + to: Option, + from: Option, + subject: Option, + note: Option, + total_pages: u32, + output: Option, + format: &str, +) -> Result<()> { + use telfax::document::cover::{generate_cover_page, CoverParams}; + use telfax::document::tiff::TiffFaxWriter; + use telfax::preview; + + let params = CoverParams { + to: to.unwrap_or_default(), + from: from.unwrap_or_default(), + subject: subject.unwrap_or_default(), + notes: note.unwrap_or_default(), + total_pages, + }; + + println!("=== Generate Cover Page ==="); + println!("TO: {}, FROM: {}, SUBJECT: {}, NOTES: {}, PAGES: {}", + params.to, params.from, params.subject, params.notes, params.total_pages); + + let page = generate_cover_page(¶ms)?; + println!(" Page: {}x{} pixels", page.width_pels, page.rows); + + let output_path = output + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("cover")); + + match format { + "tif" | "tiff" => { + let path = output_path.with_extension("tif"); + TiffFaxWriter::write_fax_tiff(&path, &[page])?; + println!(" TIFF saved: {} ({} bytes)", path.display(), + std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)); + } + "html" => { + let path = output_path.with_extension("html"); + let title = format!("Cover — {} pages", params.total_pages); + preview::generate_preview_html(&page, &title, &path)?; + println!(" HTML saved: {}", path.display()); + } + "both" => { + let tiff_path = output_path.with_extension("tif"); + TiffFaxWriter::write_fax_tiff(&tiff_path, &[page.clone()])?; + println!(" TIFF saved: {} ({} bytes)", tiff_path.display(), + std::fs::metadata(&tiff_path).map(|m| m.len()).unwrap_or(0)); + + let html_path = output_path.with_extension("html"); + let title = format!("Cover — {} pages", params.total_pages); + preview::generate_preview_html(&page, &title, &html_path)?; + println!(" HTML saved: {}", html_path.display()); + } + _ => { + return Err(telfax::error::FaxError::Other(format!( + "Unknown format: {}. Use tif, html, or both.", format + ))); + } + } + + println!(); + println!("=== Done ==="); + Ok(()) +} + fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()> { let fax_resolution = match resolution.to_lowercase().as_str() { "standard" | "std" => FaxResolution::Standard, @@ -313,3 +702,40 @@ fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<() Ok(()) } + +fn cmd_job_cover( + job_id: &str, + to: Option, + from: Option, + subject: Option, + note: Option, + db_path: Option<&str>, +) -> Result<()> { + use std::str::FromStr; + + let job_id = uuid::Uuid::from_str(job_id) + .map_err(|e| telfax::error::FaxError::Other(format!("Invalid job ID: {}", e)))?; + + if let Some(db) = db_path { + let mut queue = telfax::queue::FaxQueue::new_sqlite(std::path::Path::new(db))?; + let found = queue.update_cover(&job_id, to.clone(), from.clone(), subject.clone(), note.clone())?; + if found { + println!("✅ Cover updated for job {}", job_id); + } else { + return Err(telfax::error::FaxError::Other(format!("Job {} not found", job_id))); + } + } else { + return Err(telfax::error::FaxError::Other( + "No database path specified. Use --db to modify a queued job's cover page, \ + or use the API endpoint PUT /api/fax/jobs/{id}/cover".to_string() + )); + } + + println!(); + println!(" TO: {}", to.as_deref().unwrap_or("(unchanged)")); + println!(" FROM: {}", from.as_deref().unwrap_or("(unchanged)")); + println!(" SUBJECT: {}", subject.as_deref().unwrap_or("(unchanged)")); + println!(" NOTES: {}", note.as_deref().unwrap_or("(unchanged)")); + + Ok(()) +} diff --git a/src/modem/driver.rs b/src/modem/driver.rs index b533f68..65f35a3 100644 --- a/src/modem/driver.rs +++ b/src/modem/driver.rs @@ -48,6 +48,24 @@ impl ModemDriver { self.port.read_exact(buf).map_err(Into::into) } + pub fn read_byte(&mut self, timeout_ms: u64) -> Result { + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut single = [0u8; 1]; + + while std::time::Instant::now() < deadline { + match self.port.read(&mut single) { + Ok(0) => continue, + Ok(_) => return Ok(single[0]), + 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_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(); diff --git a/src/ocr.rs b/src/ocr.rs new file mode 100644 index 0000000..32c369c --- /dev/null +++ b/src/ocr.rs @@ -0,0 +1,165 @@ +use crate::document::CoverParams; +use crate::error::{FaxError, Result}; +use std::path::Path; +use std::process::Command; + +pub struct OcrResult { + pub all_fields_present: bool, + pub missing: Vec, + pub raw_text: String, +} + +pub fn ocr_text(tiff_path: &Path) -> Result { + let output = Command::new("tesseract") + .arg(tiff_path.to_str().unwrap()) + .arg("stdout") + .arg("-l") + .arg("eng") + .arg("--psm") + .arg("6") + .output() + .map_err(|e| FaxError::Other(format!("Failed to run tesseract: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(FaxError::Other(format!("tesseract error: {}", stderr))); + } + + let text = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(text) +} + +pub fn verify_cover(text: &str, params: &CoverParams) -> Result { + let mut missing: Vec = Vec::new(); + let lines: Vec<&str> = text.lines().collect(); + + if !contains_any(text, &["FACSIMILE"]) { + missing.push("FACSIMILE header".to_string()); + } + + if !params.to.is_empty() { + let found = if is_ascii_text(¶ms.to) { + let needle = format!("TO:{}", params.to); + let needle2 = format!("TO: {}", params.to); + lines.iter().any(|l| { + let trimmed = l.trim(); + trimmed == needle.as_str() || trimmed == needle2.as_str() || trimmed.contains(¶ms.to) + }) + } else { + contains_any(text, &["TO:"]) + }; + if !found { + missing.push(format!("TO: {}", params.to)); + } + } else { + if !contains_any(text, &["TO:"]) { + missing.push("TO:".to_string()); + } + } + + if !params.from.is_empty() { + let found = if is_ascii_text(¶ms.from) { + let needle = format!("FROM:{}", params.from); + let needle2 = format!("FROM: {}", params.from); + lines.iter().any(|l| { + let trimmed = l.trim(); + trimmed == needle.as_str() || trimmed == needle2.as_str() || trimmed.contains(¶ms.from) + }) + } else { + contains_any(text, &["FROM:"]) + }; + if !found { + missing.push(format!("FROM: {}", params.from)); + } + } else { + if !contains_any(text, &["FROM:"]) { + missing.push("FROM:".to_string()); + } + } + + if !contains_pattern_like_date(text) { + if !contains_any(text, &["DATE:"]) { + missing.push("DATE".to_string()); + } + } + + let pages_str = params.total_pages.to_string(); + let found_pages = lines.iter().any(|l| l.contains("PAGES:") && l.contains(&pages_str)); + if !found_pages { + if !contains_any(text, &["PAGES:"]) { + missing.push(format!("PAGES: {}", params.total_pages)); + } + } + + if !params.subject.is_empty() { + let found = if is_ascii_text(¶ms.subject) { + lines.iter().any(|l| { + let trimmed = l.trim(); + (trimmed.starts_with("SUBJECT") || trimmed.starts_with("SUBJECT:")) + && trimmed.contains(¶ms.subject) + }) + } else { + contains_any(text, &["SUBJECT"]) + }; + if !found { + if !contains_any(text, &["SUBJECT"]) { + missing.push(format!("SUBJECT: {}", params.subject)); + } + } + } + + if !params.notes.is_empty() { + let note_line = params.notes.lines().next().unwrap_or(""); + let found = if is_ascii_text(note_line) { + lines.iter().any(|l| { + let trimmed = l.trim(); + trimmed.contains(note_line) || (trimmed.starts_with("NOTES") && trimmed.len() > 6) + }) + } else { + contains_any(text, &["NOTES"]) + }; + if !found { + if !contains_any(text, &["NOTES"]) { + missing.push(format!("NOTES: {}", params.notes)); + } + } + } + + Ok(OcrResult { + all_fields_present: missing.is_empty(), + missing, + raw_text: text.to_string(), + }) +} + +fn contains_any(text: &str, patterns: &[&str]) -> bool { + patterns.iter().any(|p| text.contains(p)) +} + +fn contains_pattern_like_date(text: &str) -> bool { + let bytes = text.as_bytes(); + let len = bytes.len(); + if len < 10 { + return false; + } + for i in 0..=len.saturating_sub(10) { + if bytes[i].is_ascii_digit() + && bytes[i + 1].is_ascii_digit() + && bytes[i + 2].is_ascii_digit() + && bytes[i + 3].is_ascii_digit() + && bytes[i + 4] == b'/' + && bytes[i + 5].is_ascii_digit() + && bytes[i + 6].is_ascii_digit() + && bytes[i + 7] == b'/' + && bytes[i + 8].is_ascii_digit() + && bytes[i + 9].is_ascii_digit() + { + return true; + } + } + false +} + +fn is_ascii_text(s: &str) -> bool { + s.chars().all(|c| c.is_ascii()) +} diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..c7b5416 --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,315 @@ +use crate::document::convert::Page; +use crate::error::Result; +use base64::Engine; + +pub fn generate_preview_html(page: &Page, title: &str, output_path: &std::path::Path) -> Result<()> { + let png_data = fax_page_to_png_bytes(page)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data); + let data_uri = format!("data:image/png;base64,{}", b64); + + let w = page.width_pels; + let h = page.rows; + let html = build_viewer_html(&data_uri, w, h, title); + + std::fs::write(output_path, html)?; + Ok(()) +} + +fn fax_page_to_png_bytes(page: &Page) -> Result> { + let width = page.width_pels as usize; + let height = page.rows as usize; + let bytes_per_row = (width + 7) / 8; + + let mut img_data = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let byte_idx = y * bytes_per_row + x / 8; + let bit = (page.pixels[byte_idx] >> (7 - (x % 8))) & 1; + img_data.push(if bit == 1 { 0u8 } else { 255u8 }); + } + } + + let mut png_bytes = Vec::new(); + { + use image::ImageEncoder; + let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes); + encoder + .write_image(&img_data, page.width_pels, page.rows, image::ExtendedColorType::L8) + .map_err(|e| crate::error::FaxError::Other(format!("PNG encode error: {}", e)))?; + } + Ok(png_bytes) +} + +fn build_viewer_html(data_uri: &str, width: u32, height: u32, title: &str) -> String { + let html = format!( + r#" + + + + +Fax Preview — {title} + + + +
+ {title} + {width}×{height} px + + + + +
+
+ +
+ + +"#, + title = title, + width = width, + height = height, + data_uri_str = serde_json::to_string(data_uri).unwrap(), + ); + html +} diff --git a/src/queue/job.rs b/src/queue/job.rs index c0fb204..2372d8e 100644 --- a/src/queue/job.rs +++ b/src/queue/job.rs @@ -24,6 +24,10 @@ pub struct FaxJob { pub retries: u8, pub created_at: DateTime, pub updated_at: DateTime, + pub cover_to: Option, + pub cover_from: Option, + pub cover_subject: Option, + pub cover_notes: Option, } impl FaxJob { @@ -38,6 +42,10 @@ impl FaxJob { retries: 0, created_at: now, updated_at: now, + cover_to: None, + cover_from: None, + cover_subject: None, + cover_notes: None, } } } diff --git a/src/queue/store.rs b/src/queue/store.rs index 2599c4f..c3cae8a 100644 --- a/src/queue/store.rs +++ b/src/queue/store.rs @@ -40,7 +40,11 @@ impl FaxQueue { pages INTEGER DEFAULT 0, retries INTEGER DEFAULT 0, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + cover_to TEXT, + cover_from TEXT, + cover_subject TEXT, + cover_notes TEXT )", [], )?; @@ -56,8 +60,8 @@ impl FaxQueue { #[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)", + "INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at, cover_to, cover_from, cover_subject, cover_notes) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ job.id.to_string(), job.recipient, @@ -67,6 +71,10 @@ impl FaxQueue { job.retries, job.created_at.to_rfc3339(), job.updated_at.to_rfc3339(), + job.cover_to, + job.cover_from, + job.cover_subject, + job.cover_notes, ], )?; Ok(()) @@ -106,6 +114,37 @@ impl FaxQueue { } } + pub fn update_cover(&mut self, id: &JobId, to: Option, from: Option, subject: Option, notes: Option) -> Result { + let now = chrono::Utc::now(); + match self { + FaxQueue::InMemory(s) => { + if let Some(job) = s.jobs.get_mut(id) { + if to.is_some() { job.cover_to = to; } + if from.is_some() { job.cover_from = from; } + if subject.is_some() { job.cover_subject = subject; } + if notes.is_some() { job.cover_notes = notes; } + job.updated_at = now; + Ok(true) + } else { + Ok(false) + } + } + #[cfg(feature = "server")] + FaxQueue::Sqlite(s) => { + let affected = s.conn.execute( + "UPDATE fax_jobs SET cover_to = COALESCE(?1, cover_to), + cover_from = COALESCE(?2, cover_from), + cover_subject = COALESCE(?3, cover_subject), + cover_notes = COALESCE(?4, cover_notes), + updated_at = ?5 + WHERE id = ?6", + rusqlite::params![to, from, subject, notes, now.to_rfc3339(), id.to_string()], + )?; + Ok(affected > 0) + } + } + } + pub fn pending_jobs(&self) -> Vec { match self { FaxQueue::InMemory(s) => s diff --git a/test/class1_receive.py b/test/class1_receive.py new file mode 100755 index 0000000..e2c9aff --- /dev/null +++ b/test/class1_receive.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Class 1 Fax Receiver - for testing with USR sender""" +import termios +import os +import time +import sys + +def main(): + if len(sys.argv) < 2: + print("Usage: class1_receive.py ") + sys.exit(1) + + device = sys.argv[1] + fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + + attrs = termios.tcgetattr(fd) + attrs[4] = termios.B115200 + attrs[5] = termios.B115200 + attrs[2] |= termios.CLOCAL | termios.CREAD + attrs[3] &= ~termios.ICANON + attrs[3] &= ~termios.ECHO + attrs[3] &= ~termios.ECHOE + attrs[3] &= ~termios.ISIG + termios.tcsetattr(fd, termios.TCSANOW, attrs) + termios.tcflush(fd, termios.TCIOFLUSH) + + def drain(): + try: + while True: + os.read(fd, 4096) + except BlockingIOError: + pass + + def send_cmd(cmd, wait=0.5): + drain() + os.write(fd, cmd.encode() + b"\r") + time.sleep(wait) + result = b"" + try: + while True: + data = os.read(fd, 4096) + result += data + except BlockingIOError: + pass + return result.decode(errors="replace") + + def wait_for(pattern, timeout=30): + start = time.time() + result = b"" + while time.time() - start < timeout: + try: + data = os.read(fd, 4096) + result += data + decoded = result.decode(errors="replace") + for p in pattern if isinstance(pattern, list) else [pattern]: + if p in decoded: + return decoded + except BlockingIOError: + time.sleep(0.1) + return result.decode(errors="replace") + + print("[RECV] Initializing Class 1...") + send_cmd("AT", 0.3) + send_cmd("AT+FCLASS=1", 0.3) + + print("[RECV] Waiting for rings...") + + ring_count = 0 + while ring_count < 2: + try: + data = os.read(fd, 4096) + text = data.decode(errors="replace") + if "RING" in text: + ring_count += text.count("RING") + print(f"[RECV] Ring {ring_count}") + except BlockingIOError: + time.sleep(0.3) + + print("[RECV] Answering...") + answer_result = send_cmd("ATA", 3) + print(f"[RECV] ATA result: {answer_result.strip()}") + + if "CONNECT" not in answer_result: + drain() + os.write(fd, b"ATA\r") + wait_result = wait_for("CONNECT", 10) + print(f"[RECV] Wait result: {wait_result.strip()}") + + print("[RECV] Connected! Sending CED tone...") + time.sleep(0.5) + + # Send CED tone (2100Hz for 2.6-4 seconds) + ced_result = send_cmd("AT+FTS=8", 4) # FTS=8 sends 2100Hz + print(f"[RECV] CED result: {ced_result.strip()}") + + # Wait 75ms then send DIS + time.sleep(0.1) + + print("[RECV] Sending DIS frame...") + + # Start HDLC transmit + os.write(fd, b"AT+FTH=3\r") + fth_result = wait_for("CONNECT", 5) + print(f"[RECV] FTH=3: {fth_result.strip()}") + + if "CONNECT" in fth_result: + # DIS frame: preamble+flags+DCS+data+RTC + # Address: 0xFF (broadcast), Control: 0x80 (DIS from called) + # DIS data: minimum capabilities + dis_data = bytes([ + 0xFF, 0x13, 0x80, # Address + Control (DIS from called) + 0x00, 0x50, 0x1F, # DIS parameters + ]) + # Add FCS (we'll let modem calculate) + os.write(fd, dis_data) + os.write(fd, b"\x00\x00\x00") # Padding + time.sleep(0.5) + + # Wait for OK + dis_ok = wait_for("OK", 5) + print(f"[RECV] DIS sent: {dis_ok.strip()}") + + print("[RECV] Waiting for DCS...") + drain() + os.write(fd, b"AT+FRH=3\r") + dcs_result = wait_for("OK", 10) + print(f"[RECV] DCS received: {len(dcs_result)} bytes") + + print("[RECV] Waiting for TCF...") + drain() + os.write(fd, b"AT+FRM=96\r") # Receive at 14400 bps + tcf_result = wait_for("CONNECT", 5) + print(f"[RECV] TCF: {tcf_result.strip()}") + + # Send CFR + print("[RECV] Sending CFR...") + drain() + os.write(fd, b"AT+FTH=3\r") + wait_for("CONNECT", 5) + cfr_data = bytes([0xFF, 0x13, 0x21]) # CFR frame + os.write(fd, cfr_data) + os.write(fd, b"\x00\x00\x00") + time.sleep(0.5) + wait_for("OK", 5) + print("[RECV] CFR sent") + + print("[RECV] Waiting for page data...") + # Receive page + drain() + os.write(fd, b"AT+FRM=96\r") + page_result = wait_for("OK", 60) + print(f"[RECV] Page received!") + + # Send MCF + print("[RECV] Sending MCF...") + drain() + os.write(fd, b"AT+FTH=3\r") + wait_for("CONNECT", 5) + mcf_data = bytes([0xFF, 0x13, 0x31]) # MCF frame + os.write(fd, mcf_data) + os.write(fd, b"\x00\x00\x00") + time.sleep(0.5) + wait_for("OK", 5) + print("[RECV] MCF sent") + + # Wait for DCN or hangup + print("[RECV] Waiting for DCN...") + drain() + os.write(fd, b"AT+FRH=3\r") + dcn_result = wait_for("OK", 10) + print(f"[RECV] Session ended: {dcn_result[:50]}") + + os.close(fd) + print("[RECV] Done!") + +if __name__ == "__main__": + main() \ No newline at end of file