Add PDF to fax conversion support

- pdf.rs: Ghostscript-based PDF to TIFF-F conversion
- tiff_to_fax_document: Parse TIFF and convert to 1-bit fax format
- CLI: Add 'convert' subcommand for dry testing
- CLI: Add resolution option for send command
- Test files for PDF/TIFF conversion validation
This commit is contained in:
Warren
2026-07-01 17:17:49 +08:00
parent 13438289fe
commit bf1ca90a9c
11 changed files with 361 additions and 6 deletions

View File

@@ -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;

98
src/document/pdf.rs Normal file
View File

@@ -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<FaxDocument> {
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<FaxDocument> {
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,
})
}

View File

@@ -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<String>,
/// 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<String>) -> 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(())
}

BIN
test/output.tif Normal file

Binary file not shown.

BIN
test/output_fax.tif Normal file

Binary file not shown.

76
test/test_conversion.py Normal file
View File

@@ -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]
else:
ifd_offset = struct.unpack('>I', header[4:8])[0]
f.seek(ifd_offset)
num_entries = struct.unpack('<H' if header[:2] == b'II' else '>H', f.read(2))[0]
print(f" IFD entries: {num_entries}")
# Read key tags
for i in range(num_entries):
tag = struct.unpack('<H' if header[:2] == b'II' else '>H', f.read(2))[0]
type_ = struct.unpack('<H' if header[:2] == b'II' else '>H', f.read(2))[0]
count = struct.unpack('<I' if header[:2] == b'II' else '>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' if header[:2] == b'II' else '>I', value_offset)[0]
print(f" Width: {val} pixels")
elif tag == 257: # Height
val = struct.unpack('<I' if header[:2] == b'II' else '>I', value_offset)[0]
print(f" Height: {val} lines")
elif tag == 259: # Compression
val = struct.unpack('<H' if header[:2] == b'II' else '>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' if header[:2] == b'II' else '>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 ===")

33
test/test_tiff_convert.rs Normal file
View File

@@ -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 ===");
}

BIN
test/testfax.pdf Normal file

Binary file not shown.

41
test/testfax.ps Normal file
View File

@@ -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

BIN
test/testfax.tif Normal file

Binary file not shown.

BIN
test/testfax_gray.tif Normal file

Binary file not shown.