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.
This commit is contained in:
+294
-7
@@ -1,7 +1,61 @@
|
||||
use crate::document::convert::Page;
|
||||
use crate::error::{FaxError, Result};
|
||||
|
||||
pub struct T4Codec;
|
||||
|
||||
/// EOL marker: 11 zero bits followed by a 1 bit.
|
||||
const EOL_BIT_LEN: u8 = 12;
|
||||
const EOL_MASK: u64 = (1u64 << EOL_BIT_LEN) - 1; // 12 bits: 0xFFF
|
||||
|
||||
/// Find the bit offset of the first valid EOL marker (000000000001) in the data.
|
||||
/// Returns None if no EOL is found.
|
||||
fn find_first_eol_bit(data: &[u8]) -> Option<usize> {
|
||||
if data.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Use a sliding 12-bit window across the bitstream
|
||||
let mut window: u64 = 0;
|
||||
let mut bits_in_window = 0u8;
|
||||
|
||||
for (byte_idx, &byte) in data.iter().enumerate() {
|
||||
for bit_idx in (0..8u8).rev() {
|
||||
let bit = ((byte >> bit_idx) & 1) as u64;
|
||||
window = ((window << 1) | bit) & EOL_MASK;
|
||||
if bits_in_window < EOL_BIT_LEN {
|
||||
bits_in_window += 1;
|
||||
}
|
||||
if bits_in_window == EOL_BIT_LEN && (window & EOL_MASK) == 1 {
|
||||
let bit_pos = byte_idx * 8 + (7 - bit_idx as usize);
|
||||
return Some(bit_pos - (EOL_BIT_LEN as usize - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract bytes from `data` starting at `bit_offset`, producing a byte-aligned buffer.
|
||||
fn extract_from_bit(data: &[u8], bit_offset: usize) -> Vec<u8> {
|
||||
if bit_offset >= data.len() * 8 {
|
||||
return Vec::new();
|
||||
}
|
||||
let start_byte = bit_offset / 8;
|
||||
let start_bit = (bit_offset % 8) as u8;
|
||||
|
||||
if start_bit == 0 {
|
||||
return data[start_byte..].to_vec();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(data.len() - start_byte);
|
||||
for i in start_byte..data.len() - 1 {
|
||||
let b = (data[i] << start_bit) | (data[i + 1] >> (8 - start_bit));
|
||||
out.push(b);
|
||||
}
|
||||
// Last byte: shift left, lower bits become zero
|
||||
let last = data[data.len() - 1] << start_bit;
|
||||
out.push(last);
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct T4Page {
|
||||
pub data: Vec<u8>,
|
||||
@@ -18,6 +72,50 @@ pub enum T4Encoding {
|
||||
}
|
||||
|
||||
impl T4Codec {
|
||||
/// Decode T.4 Group 3 MH data into a Page, determining height from the data.
|
||||
/// Automatically strips leading non-MH bytes (modem overhead bytes) by scanning
|
||||
/// for the first valid EOL marker.
|
||||
pub fn decode_g3_page(data: &[u8], width_pels: u32) -> Result<Page> {
|
||||
// Strip non-MH bytes before the first EOL marker
|
||||
let clean_data = match find_first_eol_bit(data) {
|
||||
Some(bit_offset) => extract_from_bit(data, bit_offset),
|
||||
None => data.to_vec(),
|
||||
};
|
||||
|
||||
let bytes_per_row = width_pels.div_ceil(8) as usize;
|
||||
let mut pixels = Vec::new();
|
||||
|
||||
let mut has_content = false;
|
||||
let mut line_count = 0u32;
|
||||
let result = fax::decoder::decode_g3(clean_data.iter().copied(), |transitions: &[u16]| {
|
||||
has_content = true;
|
||||
line_count += 1;
|
||||
if line_count <= 3 {
|
||||
tracing::info!("G3 line {}: {} transitions, first few: {:?}", line_count, transitions.len(), &transitions[..transitions.len().min(10)]);
|
||||
}
|
||||
let start = pixels.len();
|
||||
pixels.resize(start + bytes_per_row, 0);
|
||||
if !transitions.is_empty() {
|
||||
Self::transitions_to_pixels(transitions, width_pels, &mut pixels[start..]);
|
||||
}
|
||||
});
|
||||
|
||||
if result.is_some() || has_content {
|
||||
let rows = pixels.len() / bytes_per_row;
|
||||
tracing::info!("G3 decode done: {} lines, {} rows, result={:?}", line_count, rows, result);
|
||||
if rows == 0 {
|
||||
return Err(FaxError::t4("Group 3 decode returned empty page"));
|
||||
}
|
||||
Ok(Page {
|
||||
pixels,
|
||||
width_pels,
|
||||
rows: rows as u32,
|
||||
})
|
||||
} else {
|
||||
Err(FaxError::t4("Group 3 decode returned None"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode T.4/T.6 data into raw 1-bit pixel data (MSB-first, row-major).
|
||||
/// `transitions_to_pixels` converts run-length transitions to pixel data.
|
||||
fn transitions_to_pixels(
|
||||
@@ -59,14 +157,19 @@ impl T4Codec {
|
||||
}
|
||||
|
||||
pub fn decode(data: &[u8], width_pels: u32, rows: u32, encoding: T4Encoding) -> Result<Vec<u8>> {
|
||||
let bytes_per_row = ((width_pels + 7) / 8) as usize;
|
||||
let bytes_per_row = width_pels.div_ceil(8) as usize;
|
||||
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
|
||||
let data: Vec<u8> = data.to_vec();
|
||||
|
||||
let data_owned = data.to_vec();
|
||||
|
||||
match encoding {
|
||||
T4Encoding::Group3MH | T4Encoding::Group3MR => {
|
||||
let clean_data = match find_first_eol_bit(data) {
|
||||
Some(bit_offset) => extract_from_bit(data, bit_offset),
|
||||
None => data.to_vec(),
|
||||
};
|
||||
let mut row_idx = 0u32;
|
||||
let result = fax::decoder::decode_g3(data.into_iter(), |transitions: &[u16]| {
|
||||
let result = fax::decoder::decode_g3(clean_data.into_iter(), |transitions: &[u16]| {
|
||||
if (row_idx as usize) < pixels.len() / bytes_per_row {
|
||||
let start = row_idx as usize * bytes_per_row;
|
||||
let end = (start + bytes_per_row).min(pixels.len());
|
||||
@@ -75,15 +178,16 @@ impl T4Codec {
|
||||
}
|
||||
row_idx += 1;
|
||||
});
|
||||
match result {
|
||||
Some(()) => Ok(pixels),
|
||||
None => Err(FaxError::t4("Group 3 decode returned None")),
|
||||
if result.is_some() || row_idx > 0 {
|
||||
Ok(pixels)
|
||||
} else {
|
||||
Err(FaxError::t4("Group 3 decode returned None"))
|
||||
}
|
||||
}
|
||||
T4Encoding::Group4MMR => {
|
||||
let mut row_idx = 0u32;
|
||||
let result = fax::decoder::decode_g4(
|
||||
data.into_iter(),
|
||||
data_owned.into_iter(),
|
||||
width_pels as u16,
|
||||
None,
|
||||
|transitions: &[u16]| {
|
||||
@@ -104,3 +208,186 @@ impl T4Codec {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fax::encoder::mh::MhEncoder;
|
||||
|
||||
#[test]
|
||||
fn test_find_first_eol_at_start() {
|
||||
let data = vec![0x00, 0x10, 0xFF, 0xFF];
|
||||
assert_eq!(find_first_eol_bit(&data), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_first_eol_with_offset() {
|
||||
let data = vec![0xFF, 0x00, 0x10, 0xFF];
|
||||
assert_eq!(find_first_eol_bit(&data), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_first_eol_none() {
|
||||
let data = vec![0xFF, 0xFF, 0xFF];
|
||||
assert_eq!(find_first_eol_bit(&data), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_first_eol_short_data() {
|
||||
assert_eq!(find_first_eol_bit(&[0x00]), None);
|
||||
assert_eq!(find_first_eol_bit(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_bit_aligned() {
|
||||
let data = vec![0xAA, 0xBB, 0xCC];
|
||||
let result = extract_from_bit(&data, 8);
|
||||
assert_eq!(result, vec![0xBB, 0xCC]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_bit_unaligned() {
|
||||
let data = vec![0b00000000, 0b00010001, 0xFF];
|
||||
// Start at bit 12 (byte 1, bit 4):
|
||||
// byte 1 bits remaining: 0001 (bits 12-15)
|
||||
// byte 2 bits: 11111111 (bits 16-23)
|
||||
// As bytes: [00011111, 11110000] = [0x1F, 0xF0]
|
||||
let result = extract_from_bit(&data, 12);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0], 0x1F);
|
||||
assert_eq!(result[1], 0xF0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encoder_roundtrip() {
|
||||
// Create a simple 2-row, 8-pixel wide image: alternating white/black
|
||||
let width = 8u32;
|
||||
let rows = 2u32;
|
||||
let bytes_per_row = 1usize;
|
||||
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
|
||||
// Row 0: 10101010 (black, white, black, white, black, white, black, white)
|
||||
pixels[0] = 0b10101010;
|
||||
// Row 1: 01010101
|
||||
pixels[1] = 0b01010101;
|
||||
|
||||
let encoded = MhEncoder::encode(&pixels, width, rows).unwrap();
|
||||
// Should be non-empty
|
||||
assert!(!encoded.is_empty());
|
||||
// Should start with EOL (0x00 0x10)
|
||||
assert_eq!(encoded[0], 0x00);
|
||||
assert!(encoded[1] >= 0x10 && encoded[1] < 0x20);
|
||||
|
||||
// Decode back
|
||||
let decoded = T4Codec::decode(&encoded, width, rows, T4Encoding::Group3MH).unwrap();
|
||||
assert_eq!(decoded.len(), pixels.len());
|
||||
assert_eq!(decoded, pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_g3_page_with_garbage_prefix() {
|
||||
let width = 8u32;
|
||||
let rows = 1u32;
|
||||
let bytes_per_row = 1usize;
|
||||
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
|
||||
pixels[0] = 0xFF;
|
||||
|
||||
let encoded = MhEncoder::encode(&pixels, width, rows).unwrap();
|
||||
|
||||
// Prepend garbage (simulating modem overhead)
|
||||
let garbage: Vec<u8> = vec![0x0d, 0xc2, 0x10, 0x5c, 0x40].into_iter().cycle().take(25).collect();
|
||||
let mut with_garbage = garbage.clone();
|
||||
with_garbage.extend_from_slice(&encoded);
|
||||
|
||||
let page = T4Codec::decode_g3_page(&with_garbage, width).unwrap();
|
||||
assert_eq!(page.width_pels, width);
|
||||
assert_eq!(page.rows, rows);
|
||||
assert_eq!(&page.pixels, &pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_garbage_decodes_cleanly() {
|
||||
let width = 100u32;
|
||||
let rows = 10u32;
|
||||
let bytes_per_row = ((width + 7) / 8) as usize;
|
||||
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
|
||||
// Fill with checkerboard pattern
|
||||
for y in 0..rows {
|
||||
for x in 0..width {
|
||||
let byte_idx = (x / 8) as usize;
|
||||
let bit_idx = 7 - (x % 8);
|
||||
if (x + y) % 2 == 0 {
|
||||
pixels[(y as usize) * bytes_per_row + byte_idx] |= 1 << bit_idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let encoded = MhEncoder::encode(&pixels, width, rows).unwrap();
|
||||
|
||||
// Prepend modem-like garbage
|
||||
let garbage: Vec<u8> = vec![0x0d, 0xc2, 0x10, 0x5c, 0x40].into_iter().cycle().take(100).collect();
|
||||
let mut with_garbage = garbage;
|
||||
with_garbage.extend_from_slice(&encoded);
|
||||
|
||||
let decoded = T4Codec::decode_g3_page(&with_garbage, width).unwrap();
|
||||
assert_eq!(decoded.rows, rows);
|
||||
assert_eq!(decoded.pixels, pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_g3_page_no_rtc() {
|
||||
// Encode a page, then strip the trailing RTC (6 EOLs = 72 bits).
|
||||
// decode_g3_page should still succeed because the line data is valid
|
||||
// even without the end-of-document RTC marker.
|
||||
let width = 100u32;
|
||||
let rows = 5u32;
|
||||
let bytes_per_row = ((width + 7) / 8) as usize;
|
||||
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
|
||||
for y in 0..rows {
|
||||
for x in 0..width {
|
||||
let byte_idx = (x / 8) as usize;
|
||||
let bit_idx = 7 - (x % 8);
|
||||
pixels[(y as usize) * bytes_per_row + byte_idx] |= 1 << bit_idx;
|
||||
}
|
||||
}
|
||||
|
||||
let encoded = MhEncoder::encode(&pixels, width, rows).unwrap();
|
||||
|
||||
// Convert to bitstring and strip the last 6 EOLs (72 bits)
|
||||
let bits: String = encoded.iter().map(|b| format!("{:08b}", b)).collect();
|
||||
let eol = "000000000001";
|
||||
|
||||
// Find all EOL positions
|
||||
let mut pos = 0;
|
||||
let mut eol_pos = Vec::new();
|
||||
while let Some(p) = bits[pos..].find(eol) {
|
||||
eol_pos.push(pos + p);
|
||||
pos += p + 12;
|
||||
}
|
||||
assert!(eol_pos.len() >= 7, "need at least 7 EOLs (1 leading + rows + 6 RTC)");
|
||||
|
||||
// The RTC is the last 6 consecutive EOLs with exactly 12-bit spacing
|
||||
// Check last 7 EOLs: the last 6 should be consecutive, preceded by
|
||||
// the last line's EOL (which won't have 12-bit spacing to the RTC).
|
||||
let rtc_start = eol_pos[eol_pos.len() - 6];
|
||||
let truncated_bits = &bits[..rtc_start];
|
||||
let truncated_bytes = (truncated_bits.len() + 7) / 8;
|
||||
|
||||
let mut truncated = vec![0u8; truncated_bytes];
|
||||
for (i, bit) in truncated_bits.chars().enumerate() {
|
||||
if bit == '1' {
|
||||
truncated[i / 8] |= 1 << (7 - (i % 8));
|
||||
}
|
||||
}
|
||||
|
||||
let decoded = T4Codec::decode_g3_page(&truncated, width).unwrap();
|
||||
assert_eq!(decoded.rows, rows);
|
||||
assert_eq!(decoded.width_pels, width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_g3_page_invalid_data_still_fails() {
|
||||
// All-zero data has no EOL marker (needs a '1' bit), so decode should fail
|
||||
let result = T4Codec::decode_g3_page(&[0x00; 100], 100);
|
||||
assert!(result.is_err(), "expected error, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user