feat: add CCITT Group 3 (Fax3) compression and FillOrder support

Add Group3Reader for decoding CCITT Group 3 (T.4) 1D compressed TIFF
images. The fax crate's Group3Decoder has a bug where new() verifies
but doesn't consume the initial EOL marker, causing its lookup tables
to destructively consume bits from the EOL code. We work around this
by using the crate's lower-level white::decode / black::decode
functions and handling EOL markers ourselves.

Also adds FillOrder tag (266) support: FillOrder=2 (LSB-to-MSB,
common in fax images) is handled by reversing bits in each byte
before decoding. This applies to both Group 3 and Group 4 readers.

Both libtiffpic fax test images (fax2d.tif, g3test.tif) now decode
correctly with verified CRC32 hashes.
This commit is contained in:
Lilith River committed 2026-03-10 22:41:03 -06:00
1 parent 83dbb750df
commit 26301fc3f1
3 files changed
+175 -4

No files matched your search

+20
View File
@@ -85,6 +85,8 @@ pub(crate) struct Image {
pub chunk_offsets: Vec<u64>,
pub chunk_bytes: Vec<u64>,
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<Vec<u16>>,
@@ -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<Box<dyn Read + 'r>> {
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