Files
telfax/src/fax/t4.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

394 lines
14 KiB
Rust

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>,
pub width_pels: u32,
pub rows: u32,
pub encoding: T4Encoding,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum T4Encoding {
Group3MH,
Group3MR,
Group4MMR,
}
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(
transitions: &[u16],
width_pels: u32,
row_buf: &mut [u8],
) {
row_buf.fill(0);
let mut is_black = false;
let mut prev = 0u32;
for &t in transitions {
let t = t as u32;
if t > width_pels {
break;
}
if is_black {
for p in prev..t.min(width_pels) {
let byte_idx = (p / 8) as usize;
let bit_idx = 7 - (p % 8);
if byte_idx < row_buf.len() {
row_buf[byte_idx] |= 1 << bit_idx;
}
}
}
prev = t;
is_black = !is_black;
}
// If ends on black, fill to end
if is_black {
for p in prev..width_pels {
let byte_idx = (p / 8) as usize;
let bit_idx = 7 - (p % 8);
if byte_idx < row_buf.len() {
row_buf[byte_idx] |= 1 << bit_idx;
}
}
}
}
pub fn decode(data: &[u8], width_pels: u32, rows: u32, encoding: T4Encoding) -> Result<Vec<u8>> {
let bytes_per_row = width_pels.div_ceil(8) as usize;
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
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(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());
let row_buf = &mut pixels[start..end];
Self::transitions_to_pixels(transitions, width_pels, row_buf);
}
row_idx += 1;
});
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_owned.into_iter(),
width_pels as u16,
None,
|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());
let row_buf = &mut pixels[start..end];
Self::transitions_to_pixels(transitions, width_pels, row_buf);
}
row_idx += 1;
},
);
match result {
Some(()) => Ok(pixels),
None => Err(FaxError::t4("Group 4 decode returned None")),
}
}
}
}
}
#[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);
}
}