diff --git a/src/document/mod.rs b/src/document/mod.rs index 1972e38..5248295 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -1,5 +1,7 @@ pub mod convert; pub mod tiff; +pub mod pdf; pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page}; pub use tiff::TiffFaxWriter; +pub use pdf::pdf_to_fax_document; diff --git a/src/document/pdf.rs b/src/document/pdf.rs new file mode 100644 index 0000000..89159b4 --- /dev/null +++ b/src/document/pdf.rs @@ -0,0 +1,98 @@ +use crate::error::{FaxError, Result}; +use crate::document::convert::{DocumentFormat, FaxDocument, Page}; +use crate::config::FaxResolution; +use std::path::Path; +use std::process::Command; + +/// Convert PDF to FaxDocument using Ghostscript. +pub fn pdf_to_fax_document(pdf_path: &Path, resolution: FaxResolution) -> Result { + let dpi_arg = match resolution { + FaxResolution::Standard => "204x98", + FaxResolution::Fine => "204x196", + FaxResolution::SuperFine => "204x392", + }; + + let tiff_path = pdf_path.with_extension("fax.tif"); + tracing::info!("Converting PDF {} to TIFF {}", pdf_path.display(), tiff_path.display()); + + let output = Command::new("gs") + .arg("-dBATCH") + .arg("-dNOPAUSE") + .arg("-q") + .arg("-sDEVICE=tiffgray") + .arg(format!("-r{}", dpi_arg)) + .arg(format!("-sOutputFile={}", tiff_path.display())) + .arg(pdf_path) + .output() + .map_err(|e| FaxError::document(format!("Failed to run ghostscript: {}", e)))?; + + tracing::info!("Ghostscript exit status: {:?}", output.status); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + tracing::error!("Ghostscript stderr: {}", stderr); + tracing::error!("Ghostscript stdout: {}", stdout); + return Err(FaxError::document(format!( + "Ghostscript failed: exit code {:?}", + output.status.code() + ))); + } + + let tiff_data = std::fs::read(&tiff_path)?; + let doc = tiff_to_fax_document(&tiff_data)?; + + std::fs::remove_file(&tiff_path).ok(); + + Ok(doc) +} + +/// Read TIFF-F and convert to FaxDocument with 1-bit pixel data. +pub fn tiff_to_fax_document(tiff_data: &[u8]) -> Result { + use tiff::decoder::Decoder; + use std::io::Cursor; + + let mut cursor = Cursor::new(tiff_data); + 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 bytes_per_row = ((width + 7) / 8) as usize; + let mut pixels = vec![0u8; bytes_per_row * height as usize]; + + let mut result = decoder.read_image() + .map_err(|e| FaxError::document(format!("Failed to read TIFF image: {}", e)))?; + + // Convert to 1-bit packed format + let buffer = result.as_buffer(0); + let img_data = buffer.as_bytes(); + + // 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; + } + } + } + + let page = Page { + pixels, + width_pels: width, + rows: height, + }; + + Ok(FaxDocument { + pages: vec![page], + format: DocumentFormat::Tiff, + }) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 81a6420..d263d35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use tracing::info; use telfax::config::FaxConfig; +use telfax::config::FaxResolution; use telfax::error::Result; use telfax::modem::detect::detect_modem; use telfax::modem::ModemDriver; @@ -32,11 +33,14 @@ enum Commands { Send { /// Recipient phone number recipient: String, - /// Path to document file (PNG/JPG/TIFF) + /// Path to document file (PDF or image) file: String, /// Serial device #[arg(short, long, default_value = "/dev/cu.usbmodem00000021")] device: String, + /// Resolution (standard/fine/superfine) + #[arg(short, long, default_value = "fine")] + resolution: String, }, /// Interactively test the modem Test { @@ -44,6 +48,17 @@ enum Commands { #[arg(default_value = "/dev/cu.usbmodem00000021")] device: String, }, + /// Convert PDF to TIFF-F fax format (dry test) + Convert { + /// Input PDF file path + input: String, + /// Output TIFF file path + #[arg(short, long)] + output: Option, + /// Resolution (standard/fine/superfine) + #[arg(short, long, default_value = "fine")] + resolution: String, + }, } #[tokio::main] @@ -60,8 +75,9 @@ 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 } => cmd_send(&device, &recipient, &file), + Commands::Send { recipient, file, device, resolution } => cmd_send(&device, &recipient, &file, &resolution), Commands::Test { device } => cmd_test(&device), + Commands::Convert { input, output, resolution } => cmd_convert(&input, output.as_deref(), &resolution), } } @@ -103,15 +119,36 @@ 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) -> Result<()> { +fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &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)?; + // Determine resolution + let fax_resolution = match resolution.to_lowercase().as_str() { + "standard" | "std" => FaxResolution::Standard, + "fine" => FaxResolution::Fine, + "superfine" | "super" => FaxResolution::SuperFine, + _ => FaxResolution::Fine, + }; + + // Load document - handle PDF or image + let path = std::path::Path::new(file); + let doc = if file.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(file) + .map_err(|e| telfax::error::FaxError::Other(format!("Failed to read file: {}", e)))?; + telfax::document::document_from_image(&data)? + }; + let page = &doc.pages[0]; + info!("Document: {}x{} pixels, {} bytes", page.width_pels, page.rows, page.pixels.len()); + + // TODO: Encode to T.4 MH before sending + // For now, we send raw pixel data (modem will need T.4 encoded data) + info!("WARNING: T.4 encoding not fully implemented - sending raw pixels"); let mut sender = telfax::fax::class1::Class1Send::new(&mut driver); sender.send_fax(recipient, &page.pixels, page.width_pels, page.rows)?; @@ -159,3 +196,71 @@ fn cmd_test(device: &str) -> Result<()> { println!("\nDone."); 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, + "fine" => FaxResolution::Fine, + "superfine" | "super" => FaxResolution::SuperFine, + _ => FaxResolution::Fine, + }; + + let input_path = std::path::Path::new(input); + let output_path = output.map(|s| std::path::Path::new(s)); + + println!("=== Converting {} to Fax Format ===", input); + println!("Resolution: {}", resolution); + + if input.to_lowercase().ends_with(".pdf") { + let doc = telfax::document::pdf_to_fax_document(input_path, fax_resolution)?; + + println!("Converted successfully:"); + println!(" Pages: {}", doc.pages.len()); + + for (i, page) in doc.pages.iter().enumerate() { + println!(" Page {}: {}x{} pixels, {} bytes", + i + 1, page.width_pels, page.rows, page.pixels.len()); + + let bytes_per_row = ((page.width_pels + 7) / 8) as usize; + println!(" Bytes per row: {}", bytes_per_row); + println!(" Data rows: {}", page.pixels.len() / bytes_per_row); + } + + // Write TIFF output if specified + if let Some(out_path) = output_path { + telfax::document::tiff::TiffFaxWriter::write_fax_tiff(out_path, &doc.pages)?; + println!(" Output written to: {}", out_path.display()); + } + + println!("\n=== Conversion Test Passed ==="); + } else if input.to_lowercase().ends_with(".tif") || input.to_lowercase().ends_with(".tiff") { + let tiff_data = std::fs::read(input_path)?; + let doc = telfax::document::pdf::tiff_to_fax_document(&tiff_data)?; + + println!("TIFF parsed successfully:"); + println!(" Pages: {}", doc.pages.len()); + + for (i, page) in doc.pages.iter().enumerate() { + println!(" Page {}: {}x{} pixels, {} bytes", + i + 1, page.width_pels, page.rows, page.pixels.len()); + } + + println!("\n=== TIFF Parse Test Passed ==="); + } else { + // Try as image + let data = std::fs::read(input_path)?; + let doc = telfax::document::document_from_image(&data)?; + + println!("Image converted successfully:"); + println!(" Pages: {}", doc.pages.len()); + + for (i, page) in doc.pages.iter().enumerate() { + println!(" Page {}: {}x{} pixels, {} bytes", + i + 1, page.width_pels, page.rows, page.pixels.len()); + } + + println!("\n=== Image Conversion Test Passed ==="); + } + + Ok(()) +} diff --git a/test/output.tif b/test/output.tif new file mode 100644 index 0000000..2397864 Binary files /dev/null and b/test/output.tif differ diff --git a/test/output_fax.tif b/test/output_fax.tif new file mode 100644 index 0000000..ca4d400 Binary files /dev/null and b/test/output_fax.tif differ diff --git a/test/test_conversion.py b/test/test_conversion.py new file mode 100644 index 0000000..b122b9b --- /dev/null +++ b/test/test_conversion.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +import subprocess +import os + +# Test PDF to TIFF-F conversion +pdf_path = "/Users/accusys/telfax/test/testfax.pdf" +tiff_path = "/Users/accusys/telfax/test/testfax_converted.tif" + +print("=== Testing PDF → TIFF-F Conversion ===") + +# Run ghostscript +result = subprocess.run([ + "gs", "-dBATCH", "-dNOPAUSE", "-q", + "-sDEVICE=tiffg3", + "-r204x196", # Fine resolution + "-sOutputFile", tiff_path, + pdf_path +], capture_output=True, text=True) + +if result.returncode != 0: + print(f"Ghostscript failed: {result.stderr}") + exit(1) + +# Check output +if os.path.exists(tiff_path): + size = os.path.getsize(tiff_path) + print(f"✓ TIFF-F created: {tiff_path}") + print(f" Size: {size} bytes") + + # Get TIFF info + import struct + + with open(tiff_path, 'rb') as f: + header = f.read(8) + if header[:2] == b'II': + print(" Byte order: Little-endian") + elif header[:2] == b'MM': + print(" Byte order: Big-endian") + + # Read IFD offset + if header[:2] == b'II': + ifd_offset = struct.unpack('I', header[4:8])[0] + + f.seek(ifd_offset) + num_entries = struct.unpack('H', f.read(2))[0] + print(f" IFD entries: {num_entries}") + + # Read key tags + for i in range(num_entries): + tag = struct.unpack('H', f.read(2))[0] + type_ = struct.unpack('H', f.read(2))[0] + count = struct.unpack('I', f.read(4))[0] + value_offset = f.read(4) + + # Tags of interest: 256=width, 257=height, 259=compression, 262=photometric + if tag == 256: # Width + val = struct.unpack('I', value_offset)[0] + print(f" Width: {val} pixels") + elif tag == 257: # Height + val = struct.unpack('I', value_offset)[0] + print(f" Height: {val} lines") + elif tag == 259: # Compression + val = struct.unpack('H', value_offset[:2])[0] + comp_names = {1: "None", 3: "Group 3 MH", 4: "Group 4 MMR"} + print(f" Compression: {comp_names.get(val, val)}") + elif tag == 262: # PhotometricInterpretation + val = struct.unpack('H', value_offset[:2])[0] + photo_names = {0: "WhiteIsZero", 1: "BlackIsZero"} + print(f" Photometric: {photo_names.get(val, val)}") +else: + print("✗ TIFF-F not created") + exit(1) + +print("\n=== Conversion Test Passed ===") \ No newline at end of file diff --git a/test/test_tiff_convert.rs b/test/test_tiff_convert.rs new file mode 100644 index 0000000..dfda59f --- /dev/null +++ b/test/test_tiff_convert.rs @@ -0,0 +1,33 @@ +use telfax::document::pdf::{pdf_to_fax_document, tiff_to_fax_document}; +use telfax::config::FaxResolution; +use std::path::Path; + +fn main() { + let tiff_path = "/Users/accusys/telfax/test/testfax.tif"; + let tiff_data = std::fs::read(tiff_path).expect("Failed to read TIFF"); + + println!("=== Testing TIFF → FaxDocument conversion ==="); + println!("Input TIFF: {} bytes", tiff_data.len()); + + let doc = tiff_to_fax_document(&tiff_data).expect("Conversion failed"); + + println!("Converted successfully:"); + println!(" Pages: {}", doc.pages.len()); + for (i, page) in doc.pages.iter().enumerate() { + println!(" Page {}: {}x{} pixels, {} bytes", + i + 1, page.width_pels, page.rows, page.pixels.len()); + } + + // Verify pixel data - check some sample values + let page = &doc.pages[0]; + let bytes_per_row = ((page.width_pels + 7) / 8) as usize; + + println!(" Bytes per row: {}", bytes_per_row); + println!(" Expected rows: {}", page.rows); + println!(" Actual data rows: {}", page.pixels.len() / bytes_per_row); + + // Sample first few bytes of first row + println!(" First row bytes (hex): {:02x?}", &page.pixels[0..8.min(bytes_per_row)]); + + println!("\n=== Test Passed ==="); +} \ No newline at end of file diff --git a/test/testfax.pdf b/test/testfax.pdf new file mode 100644 index 0000000..4b49546 Binary files /dev/null and b/test/testfax.pdf differ diff --git a/test/testfax.ps b/test/testfax.ps new file mode 100644 index 0000000..54f5287 --- /dev/null +++ b/test/testfax.ps @@ -0,0 +1,41 @@ +%!PS-Adobe-3.0 +%%Pages: 1 +%%PageOrder: Ascend +%%BoundingBox: 0 0 612 792 +%%EndComments + +%%BeginProlog +/Dict 2 dict def +Dict begin +/PageSize [612 792] def +end + +%%EndProlog + +%%Page: 1 1 +10 750 moveto +/Fnt1 12/Times-Roman findfont definefont pop +/Fnt1 12 selectfont +(Telfax Test Document - Page 1) show + +10 730 moveto +(This is a test fax transmission from telfax Rust server.) show + +10 700 moveto +(Testing US Robotics 56K FAX USB Modem) show + +10 680 moveto +(Date: 2026-07-01) show + +10 600 moveto +/BoxTimes-Roman 14 selectfont +(TEST FAX CONTENT) show + +10 550 moveto +(This document tests the Class 1 fax protocol implementation.) show + +showpage +%%PageTrailer + +%%Trailer +%%EOF diff --git a/test/testfax.tif b/test/testfax.tif new file mode 100644 index 0000000..3080107 Binary files /dev/null and b/test/testfax.tif differ diff --git a/test/testfax_gray.tif b/test/testfax_gray.tif new file mode 100644 index 0000000..587d7df Binary files /dev/null and b/test/testfax_gray.tif differ