Files
telfax/src/document/pdf.rs
T
Warren 55bca92691 V1.0: Class 1 fax — real-world 4-page send to external number confirmed
Core features:
- Class 1 T.30 protocol: full send/receive implementation
- HDLC: DLE-stuffing, FCS strip, USR5637 bit-reversal handling
- T.4 MH encoder/decoder (1728px A4 standard)
- Document pipeline: PDF (Ghostscript), PNG, TIFF input
- Width clamping: US Letter 1734px → 1728px fax standard
- Cover page: CJK rasterization (TW/CN/JP/EN), TIFF + HTML output
- OCR verification: Tesseract 5 with eng+chi_tra, CJK space-tolerant
- API server (axum): health, send, jobs, cover, retry, cancel
- Background worker: auto-poll queue, speed fallback, retry policy
- Modem detection, pool management

Real-world test results (2026-07-23):
- V90 → 25153038: 4 pages, V.17 12000 bps, 2:33 ✅
- USR5637 → 25153038: 4 pages, V.17 12000 bps, 2:26 ✅
- Both faxes confirmed received on remote machine

Tested: loopback (100% pixel match), multi-page, all input formats,
cover pages, OCR verify, API endpoints, worker processing.
13 unit tests pass, 0 new clippy warnings.
2026-07-24 18:47:15 +08:00

135 lines
4.6 KiB
Rust

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 mut pages = Vec::new();
loop {
let (width, height) = decoder.dimensions()
.map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?;
let bytes_per_row = width.div_ceil(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;
}
}
}
// Clamp to A4 fax width (1728 pels). US Letter at 204 DPI = 1734.
let fax_width = width.min(1728);
// If wider than A4, re-encode pixels at the clamped width
let (pixels, width_pels) = if width > 1728 {
let bytes_per_row = fax_width.div_ceil(8) as usize;
let mut out = vec![0u8; bytes_per_row * height as usize];
for y in 0..height {
for x in 0..fax_width {
// img_data is 8-bit grayscale, one byte per pixel
let src_pixel = img_data[(y * width + x) as usize];
if src_pixel < 128 {
let dst_byte = y as usize * bytes_per_row + (x as usize / 8);
let dst_bit = 7 - (x % 8);
out[dst_byte] |= 1 << dst_bit;
}
}
}
(out, fax_width)
} else {
(pixels, width)
};
pages.push(Page {
pixels,
width_pels,
rows: height,
});
// Try to advance to next page
match decoder.next_image() {
Ok(_) => continue,
Err(_) => break,
}
}
if pages.is_empty() {
return Err(FaxError::document("TIFF contains no pages"));
}
Ok(FaxDocument {
pages,
format: DocumentFormat::Tiff,
})
}