55bca92691
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.
135 lines
4.6 KiB
Rust
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,
|
|
})
|
|
} |