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.
365 lines
12 KiB
Rust
365 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
use std::{fs::File, path};
|
|
|
|
use tiff::decoder::{Decoder, DecodingResult};
|
|
use tiff::tags::{ByteOrder, Type};
|
|
use tiff::ColorType;
|
|
|
|
use libtest_mimic::{Arguments, Failed, Trial};
|
|
use walkdir::WalkDir;
|
|
|
|
const REFERENCE_DIR: &str = "tests/libtiffpic";
|
|
|
|
// `find test/libtiffpic -type f | sort`
|
|
//
|
|
// Each entry maps a file path to its expected behavior:
|
|
// None = not a TIFF file, skip entirely
|
|
// Some(0) = TIFF with unsupported features, decode-only smoke test
|
|
// Some(nonzero) = TIFF with full CRC32 hash verification of decoded output
|
|
const FILES: &[(&str, Option<u32>)] = &[
|
|
("tests/libtiffpic/caspian.tif", Some(0x4ea8d4e4)),
|
|
("tests/libtiffpic/cramps.tif", Some(0xdfa2fba4)),
|
|
("tests/libtiffpic/cramps-tile.tif", Some(0xdfa2fba4)),
|
|
("tests/libtiffpic/CVS/Entries", None),
|
|
("tests/libtiffpic/CVS/Repository", None),
|
|
("tests/libtiffpic/CVS/Root", None),
|
|
("tests/libtiffpic/depth/CVS/Entries", None),
|
|
("tests/libtiffpic/depth/CVS/Repository", None),
|
|
("tests/libtiffpic/depth/CVS/Root", None),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-02.tif",
|
|
Some(0x1c90f515),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-04.tif",
|
|
Some(0x0f4813cb),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-06.tif",
|
|
Some(0xf0c0a901),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-08.tif",
|
|
Some(0xe797b15b),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-10.tif",
|
|
Some(0xdee442ec),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-12.tif",
|
|
Some(0x4810c4e9),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-14.tif",
|
|
Some(0x7e03cf03),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-16.tif",
|
|
Some(0x8e0add32),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-24.tif",
|
|
Some(0x5cf29313),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-minisblack-32.tif",
|
|
Some(0x80025eee),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-palette-02.tif",
|
|
Some(0xaeb21cff),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-palette-04.tif",
|
|
Some(0x339d428b),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-palette-08.tif",
|
|
Some(0xb6b203e9),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-palette-16.tif",
|
|
Some(0x4e02308f),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-02.tif",
|
|
Some(0x8f16cf35),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-04.tif",
|
|
Some(0x95e97f94),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-08.tif",
|
|
Some(0x98134e7c),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-10.tif",
|
|
Some(0xa2ad8f4d),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-12.tif",
|
|
Some(0xfad187fc),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-14.tif",
|
|
Some(0x93fdc170),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-16.tif",
|
|
Some(0x25e1066b),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-24.tif",
|
|
Some(0x25cdf743),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-contig-32.tif",
|
|
Some(0x195dde9c),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-02.tif",
|
|
Some(0xcc0281ca),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-04.tif",
|
|
Some(0x867134b2),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-08.tif",
|
|
Some(0x2341af83),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-10.tif",
|
|
Some(0x79211047),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-12.tif",
|
|
Some(0x14a0ff72),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-14.tif",
|
|
Some(0xc077389d),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-16.tif",
|
|
Some(0xd0849282),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-24.tif",
|
|
Some(0x433d333b),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-rgb-planar-32.tif",
|
|
Some(0xd0780b23),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-separated-contig-08.tif",
|
|
Some(0x97fb03a6),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-separated-contig-16.tif",
|
|
Some(0xb6ee0135),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-separated-planar-08.tif",
|
|
Some(0x502e93fa),
|
|
),
|
|
(
|
|
"tests/libtiffpic/depth/flower-separated-planar-16.tif",
|
|
Some(0x50e580d9),
|
|
),
|
|
("tests/libtiffpic/depth/genimages", None),
|
|
("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(0xc2f30c6f)),
|
|
// No support for direct CCITT Group 3 1D format
|
|
("tests/libtiffpic/g3test.g3", None),
|
|
("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)),
|
|
("tests/libtiffpic/jim___dg.tif", Some(0x8c6a5e8f)),
|
|
("tests/libtiffpic/jim___gg.tif", Some(0x8c6a5e8f)),
|
|
("tests/libtiffpic/ladoga.tif", Some(0x9b118511)),
|
|
("tests/libtiffpic/off_l16.tif", Some(0xd434a2d9)),
|
|
("tests/libtiffpic/off_luv24.tif", Some(0x2bee71a2)),
|
|
("tests/libtiffpic/off_luv32.tif", Some(0xffaa1e2e)),
|
|
("tests/libtiffpic/oxford.tif", Some(0x355d2e3e)),
|
|
("tests/libtiffpic/pc260001.tif", Some(0xdb56b753)),
|
|
("tests/libtiffpic/quad-jpeg.tif", Some(0xcba22475)),
|
|
("tests/libtiffpic/quad-lzw.tif", Some(0)), // invalid LZW stream
|
|
("tests/libtiffpic/quad-tile.tif", Some(0x6055f9ee)),
|
|
("tests/libtiffpic/README", None),
|
|
("tests/libtiffpic/smallliz.tif", Some(0)), // unsupported YCbCr chroma subsampling
|
|
("tests/libtiffpic/strike.tif", Some(0x016b0f6a)),
|
|
("tests/libtiffpic/text.tif", Some(0)), // unsupported compression
|
|
("tests/libtiffpic/ycbcr-cat.tif", Some(0)), // unsupported YCbCr chroma subsampling
|
|
("tests/libtiffpic/zackthecat.tif", Some(0)), // unsupported YCbCr chroma subsampling
|
|
];
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
if args.iter().any(|a| a == "--print-hashes") {
|
|
print_reference_hashes();
|
|
return;
|
|
}
|
|
|
|
let mut candidates: HashMap<_, _> = FILES.iter().copied().collect();
|
|
let mut trials = Vec::new();
|
|
|
|
for entry in WalkDir::new(REFERENCE_DIR) {
|
|
let entry = entry.unwrap();
|
|
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
|
|
let Some(path) = entry.path().to_str() else {
|
|
continue;
|
|
};
|
|
let path = path.replace(std::path::MAIN_SEPARATOR, "/"); // always use UNIX path separators
|
|
|
|
// Mark this path as consumed in either case by removing it.
|
|
let Some(expected_hash) = candidates.remove(path.as_str()) else {
|
|
panic!("File {} not in reference list", path);
|
|
};
|
|
|
|
// None = non-image file, skip
|
|
let Some(expected_hash) = expected_hash else {
|
|
continue;
|
|
};
|
|
|
|
let path = path.to_string();
|
|
trials.push(Trial::test(
|
|
path.to_string(),
|
|
move || -> Result<(), Failed> { decode_and_verify(&path, expected_hash) },
|
|
));
|
|
}
|
|
|
|
if !candidates.is_empty() {
|
|
for (path, _) in candidates {
|
|
eprintln!("Reference file {} not found in directory walk", path);
|
|
}
|
|
|
|
panic!("Some reference files were not found");
|
|
}
|
|
|
|
let args = Arguments::from_args();
|
|
libtest_mimic::run(&args, trials).exit();
|
|
}
|
|
|
|
/// Map DecodingResult variant to a TIFF Type for byte-order normalization.
|
|
fn sample_type_of(result: &DecodingResult) -> Type {
|
|
match result {
|
|
DecodingResult::U8(_) => Type::BYTE,
|
|
DecodingResult::I8(_) => Type::SBYTE,
|
|
DecodingResult::U16(_) => Type::SHORT,
|
|
DecodingResult::I16(_) => Type::SSHORT,
|
|
DecodingResult::U32(_) => Type::LONG,
|
|
DecodingResult::I32(_) => Type::SLONG,
|
|
DecodingResult::F32(_) => Type::FLOAT,
|
|
DecodingResult::U64(_) => Type::LONG8,
|
|
DecodingResult::I64(_) => Type::SLONG8,
|
|
DecodingResult::F64(_) => Type::DOUBLE,
|
|
DecodingResult::F16(_) => Type::SHORT, // f16 is 2 bytes like SHORT
|
|
}
|
|
}
|
|
|
|
/// Hash color type into the hasher (discriminant + fields).
|
|
fn hash_color_type(hasher: &mut crc32fast::Hasher, color_type: ColorType) {
|
|
match color_type {
|
|
ColorType::Gray(n) => hasher.update(&[0, n]),
|
|
ColorType::RGB(n) => hasher.update(&[1, n]),
|
|
ColorType::Palette(n) => hasher.update(&[2, n]),
|
|
ColorType::GrayA(n) => hasher.update(&[3, n]),
|
|
ColorType::RGBA(n) => hasher.update(&[4, n]),
|
|
ColorType::CMYK(n) => hasher.update(&[5, n]),
|
|
ColorType::CMYKA(n) => hasher.update(&[6, n]),
|
|
ColorType::YCbCr(n) => hasher.update(&[7, n]),
|
|
ColorType::Lab(n) => hasher.update(&[8, n]),
|
|
ColorType::Multiband {
|
|
bit_depth,
|
|
num_samples,
|
|
} => {
|
|
hasher.update(&[9, bit_depth]);
|
|
hasher.update(&num_samples.to_le_bytes());
|
|
}
|
|
other => panic!("compute_tiff_hash: unhandled ColorType {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Compute a CRC32 hash of the full decoded TIFF output using read_image_to_buffer.
|
|
///
|
|
/// Hashes per page: page index, dimensions, color type, TIFF sample type tag,
|
|
/// and decoded sample data normalized to little-endian byte order.
|
|
fn compute_hash(path: &std::path::Path) -> Result<u32, tiff::TiffError> {
|
|
let img_file = File::open(path)?;
|
|
let mut decoder = Decoder::open(img_file)?;
|
|
let mut hasher = crc32fast::Hasher::new();
|
|
let mut buffer = DecodingResult::U8(vec![]);
|
|
|
|
let mut page = 0u32;
|
|
while decoder.more_images() {
|
|
decoder.next_image()?;
|
|
let (width, height) = decoder.dimensions()?;
|
|
let color_type = decoder.colortype()?;
|
|
let _layout = decoder.read_image_to_buffer(&mut buffer)?;
|
|
|
|
hasher.update(&page.to_le_bytes());
|
|
hasher.update(&width.to_le_bytes());
|
|
hasher.update(&height.to_le_bytes());
|
|
hash_color_type(&mut hasher, color_type);
|
|
|
|
let sample_type = sample_type_of(&buffer);
|
|
hasher.update(&sample_type.to_u16().to_le_bytes());
|
|
|
|
let mut buf = buffer.as_buffer(0);
|
|
let bytes = buf.as_bytes_mut();
|
|
ByteOrder::native().convert(sample_type, bytes, ByteOrder::LittleEndian);
|
|
hasher.update(bytes);
|
|
|
|
page += 1;
|
|
}
|
|
|
|
hasher.update(&page.to_le_bytes());
|
|
Ok(hasher.finalize())
|
|
}
|
|
|
|
fn decode_and_verify(path: &str, expected_hash: u32) -> Result<(), Failed> {
|
|
let full_path = path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path);
|
|
|
|
if expected_hash == 0 {
|
|
// Smoke test: try to decode, tolerate errors (unsupported formats)
|
|
let _ = compute_hash(&full_path);
|
|
return Ok(());
|
|
}
|
|
|
|
let actual_hash = compute_hash(&full_path)
|
|
.map_err(|e| Failed::from(format!("Decoding failed for {path}: {e}")))?;
|
|
|
|
if actual_hash != expected_hash {
|
|
return Err(Failed::from(format!(
|
|
"{path}: expected hash 0x{expected_hash:08x} but got 0x{actual_hash:08x}"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Print reference hashes for all decodable files (use --print-hashes).
|
|
fn print_reference_hashes() {
|
|
for &(path, expected) in FILES {
|
|
if expected.is_none() {
|
|
continue;
|
|
}
|
|
let full_path = path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path);
|
|
match compute_hash(&full_path) {
|
|
Ok(hash) => eprintln!("(\"{path}\", Some(0x{hash:08x})),"),
|
|
Err(e) => eprintln!("// {path}: FAILED: {e}"),
|
|
}
|
|
}
|
|
}
|