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 { 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 { 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, 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 { // 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> { 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 = 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 = 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); } }