diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 1b4d9cc..e3da8f3 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -85,6 +85,8 @@ pub(crate) struct Image { pub chunk_offsets: Vec, pub chunk_bytes: Vec, pub chroma_subsampling: (u16, u16), + /// FillOrder: 1 = MSB-to-LSB (default), 2 = LSB-to-MSB (common in fax images) + pub fill_order: u16, /// The color map (palette) for `PhotometricInterpretation::RGBPalette`. /// Contains `3 * 2^BitsPerSample` u16 entries: R values, then G values, then B values. pub color_map: Option>, @@ -173,6 +175,7 @@ impl Image { chunk_offsets: Vec::new(), chunk_bytes: Vec::new(), chroma_subsampling: (2, 2), + fill_order: 1, color_map: None, decompression_to_host_endian: false, } @@ -333,6 +336,12 @@ impl Image { (2, 2) }; + let fill_order = tag_reader + .find_tag(Tag::FillOrder)? + .map(Value::into_u16) + .transpose()? + .unwrap_or(1); + let planes = match planar_config { PlanarConfiguration::Chunky => 1, PlanarConfiguration::Planar => samples, @@ -510,6 +519,7 @@ impl Image { chunk_offsets, chunk_bytes, chroma_subsampling, + fill_order, color_map, decompression_to_host_endian: matches!( compression_method, @@ -681,6 +691,7 @@ impl Image { #[cfg_attr(not(feature = "jpeg"), allow(unused_variables))] jpeg_tables: Option<&[u8]>, dimensions: (u32, u32), samples: u16, + #[cfg_attr(not(feature = "fax"), allow(unused_variables))] fill_order: u16, ) -> TiffResult> { Ok(match compression_method { CompressionMethod::None => Box::new(reader), @@ -774,10 +785,18 @@ impl Image { Box::new(Cursor::new(data)) } #[cfg(feature = "fax")] + CompressionMethod::Fax3 => Box::new(super::stream::Group3Reader::new( + dimensions, + reader, + compressed_length, + fill_order, + )?), + #[cfg(feature = "fax")] CompressionMethod::Fax4 => Box::new(super::stream::Group4Reader::new( dimensions, reader, compressed_length, + fill_order, )?), #[cfg(feature = "webp")] CompressionMethod::WebP => Box::new(super::stream::WebPReader::new( @@ -1144,6 +1163,7 @@ impl Image { self.jpeg_tables.as_deref().map(|a| &**a), chunk_dims, self.samples, + self.fill_order, )?; // Extended bit depths (9-15, 17-31): packed N-bit samples must be unpacked to diff --git a/src/decoder/stream.rs b/src/decoder/stream.rs index cf67d5d..9fb5e4e 100644 --- a/src/decoder/stream.rs +++ b/src/decoder/stream.rs @@ -256,9 +256,24 @@ impl Read for PackBitsReader { } } +/// Iterator adapter that reverses bits in each byte for FillOrder=2 (LSB-to-MSB). +#[cfg(feature = "fax")] +type FaxBytes = + core::iter::Map>>, fn(io::Result) -> io::Result>; + +#[cfg(feature = "fax")] +fn fax_byte_iter(reader: R, len: u64, fill_order: u16) -> FaxBytes { + let map_fn: fn(io::Result) -> io::Result = if fill_order == 2 { + |r| r.map(u8::reverse_bits) + } else { + |r| r + }; + io::BufReader::new(reader.take(len)).bytes().map(map_fn) +} + #[cfg(feature = "fax")] pub struct Group4Reader { - decoder: fax34::decoder::Group4Decoder>>>, + decoder: fax34::decoder::Group4Decoder>, line_buf: io::Cursor>, height: u32, width: u16, @@ -271,13 +286,14 @@ impl Group4Reader { dimensions: (u32, u32), reader: R, compressed_length: u64, + fill_order: u16, ) -> crate::TiffResult { let width = u16::try_from(dimensions.0)?; let height = dimensions.1; Ok(Self { decoder: fax34::decoder::Group4Decoder::new( - io::BufReader::new(reader.take(compressed_length)).bytes(), + fax_byte_iter(reader, compressed_length, fill_order), width, )?, line_buf: io::Cursor::new(Vec::with_capacity(width.into())), @@ -356,6 +372,141 @@ impl Read for Group4Reader { } } +/// Owning byte reader for Group 3 decoding: the compressed data is consumed +/// into an iterator so the reader doesn't borrow the buffer. +#[cfg(feature = "fax")] +type OwnedBitReader = fax34::ByteReader< + core::iter::Map, fn(u8) -> Result>, +>; + +/// Decodes CCITT Group 3 (T.4) 1D compressed data line by line. +/// +/// The fax crate's `Group3Decoder` has a bug where `new()` doesn't consume the +/// initial EOL marker, causing its lookup tables to destructively consume bits +/// from the EOL when trying to decode run-length codes. We work around this by +/// using the crate's lower-level `white::decode` / `black::decode` functions +/// and handling EOL markers ourselves. +#[cfg(feature = "fax")] +pub struct Group3Reader { + reader: OwnedBitReader, + line_buf: io::Cursor>, + height: u32, + width: u16, + y: u32, +} + +#[cfg(feature = "fax")] +impl Group3Reader { + pub fn new( + dimensions: (u32, u32), + reader: R, + compressed_length: u64, + fill_order: u16, + ) -> crate::TiffResult { + let width = u16::try_from(dimensions.0)?; + let height = dimensions.1; + + // Buffer all compressed data and apply FillOrder bit reversal + let mut compressed = vec![0u8; compressed_length as usize]; + reader.take(compressed_length).read_exact(&mut compressed)?; + if fill_order == 2 { + for b in &mut compressed { + *b = b.reverse_bits(); + } + } + + // Create an owning bit reader (Vec consumed by into_iter, no borrow issues) + let map_fn: fn(u8) -> Result = Ok; + let mut bit_reader = fax34::ByteReader::new(compressed.into_iter().map(map_fn)).unwrap(); + + // Skip fill bits + initial EOL marker + Self::skip_eol(&mut bit_reader); + + Ok(Self { + reader: bit_reader, + line_buf: io::Cursor::new(Vec::with_capacity(width.into())), + width, + height, + y: 0, + }) + } + + /// Skip zero fill bits followed by the terminating '1' bit of an EOL code. + fn skip_eol(reader: &mut impl fax34::BitReader) { + while reader.peek(1) == Some(0) { + let _ = reader.consume(1); + } + if reader.peek(1) == Some(1) { + let _ = reader.consume(1); + } + } + + /// Decode a full run-length value (terminal + optional makeup codes). + fn with_markup( + decoder: fn(&mut R) -> Option, + reader: &mut R, + ) -> Option { + let mut sum = 0u16; + while let Some(n) = decoder(reader) { + sum += n; + if n < 64 { + return Some(sum); + } + } + None + } +} + +#[cfg(feature = "fax")] +impl Read for Group3Reader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.line_buf.position() as usize == self.line_buf.get_ref().len() + && self.y < self.height + { + // Decode one line of alternating white/black run-lengths + let mut transitions = Vec::new(); + let mut a0 = 0u16; + let mut is_white = true; + loop { + let run = if is_white { + Self::with_markup(fax34::maps::white::decode, &mut self.reader) + } else { + Self::with_markup(fax34::maps::black::decode, &mut self.reader) + }; + match run { + Some(len) => { + a0 += len; + transitions.push(a0); + is_white = !is_white; + } + None => break, + } + } + + self.y += 1; + + // Convert transitions to packed bilevel output + let bytes_per_line = usize::from(self.width).div_ceil(8); + let buffer = self.line_buf.get_mut(); + buffer.clear(); + buffer.resize(bytes_per_line, 0u8); + + let pels = fax34::decoder::pels(&transitions, self.width); + for (i, c) in pels.enumerate() { + if c == fax34::Color::Black { + buffer[i / 8] |= 1 << (7 - (i % 8)); + } + } + self.line_buf.set_position(0); + + // Skip fill bits + line-ending EOL + Self::skip_eol(&mut self.reader); + } + + self.line_buf.read(buf) + } +} + #[cfg(feature = "webp")] pub struct WebPReader { inner: Cursor>, diff --git a/tests/decode_libtiffpic.rs b/tests/decode_libtiffpic.rs index 37324fb..288e0ee 100644 --- a/tests/decode_libtiffpic.rs +++ b/tests/decode_libtiffpic.rs @@ -174,10 +174,10 @@ const FILES: &[(&str, Option)] = &[ ("tests/libtiffpic/depth/README.txt", None), ("tests/libtiffpic/depth/summary.txt", None), ("tests/libtiffpic/dscf0013.tif", Some(0)), // unsupported YCbCr chroma subsampling - ("tests/libtiffpic/fax2d.tif", Some(0)), // unsupported Fax3 compression + ("tests/libtiffpic/fax2d.tif", Some(0xc2f30c6f)), // No support for direct CCITT Group 3 1D format ("tests/libtiffpic/g3test.g3", None), - ("tests/libtiffpic/g3test.tif", Some(0)), // unsupported Fax3 compression + ("tests/libtiffpic/g3test.tif", Some(0x304b606b)), ("tests/libtiffpic/jello.tif", Some(0xb03dbda9)), ("tests/libtiffpic/jim___ah.tif", Some(0x81a27b7f)), ("tests/libtiffpic/jim___cg.tif", Some(0xfd31747c)),