craft-tiff: YCbCr to RGB and uncompressed/PackBits YCbCr decoding (#971)

PhotoCraft's YCbCr conversion (ReferenceBlackWhite, YCbCrCoefficients,
libtiff-compatible green) and FilmCraft's decoding of uncompressed and
PackBits subsampled chunky YCbCr images, with PackBits as its own module.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 19:43:41 -05:00
1 parent 08882273d1
commit 603d43da85
5 files changed
+531

No files matched your search

+4
View File
@@ -7,6 +7,8 @@
//! maker-note IFDs ([`makernote`]).
//! - [`image::ImageInfo`] describes the pixel layout of an IFD (strips or tiles, planar config,
//! compression, predictor) and enumerates its [`image::Chunk`]s.
//! - [`ycbcr`] converts YCbCr samples to RGB (ReferenceBlackWhite, YCbCrCoefficients) and decodes
//! uncompressed and PackBits subsampled YCbCr images; [`packbits`] decompresses PackBits.
//! - [`writer::TiffWriter`] writes IFD trees (with strips/tiles, `SubIFDs` and pointer IFDs) in
//! either byte order, classic or BigTIFF.
//!
@@ -22,9 +24,11 @@
pub mod image;
pub mod makernote;
pub mod packbits;
mod reader;
pub mod tags;
pub mod writer;
pub mod ycbcr;
pub use reader::{DirectoryEntries, EntrySpan, ParseOptions, decode as decode_value, directory_entries, entry_span, parse_ifd_at};
pub use writer::{IfdBuilder, ImageData, TiffWriter};
+46
View File
@@ -0,0 +1,46 @@
//! PackBits (TIFF 6.0 §9) decompression.
/// Appends the bytes `src` decompresses to onto `out`, stopping once `out` holds `limit` bytes or
/// at a run that the data ends inside. A header of −128 is a no-op.
pub fn decode(src: &[u8], out: &mut Vec<u8>, limit: usize) {
let mut p = 0;
while out.len() < limit {
let Some(&n) = src.get(p) else { return };
let n = n as i8;
p += 1;
if n >= 0 {
let len = n as usize + 1;
let Some(lit) = p.checked_add(len).and_then(|end| src.get(p..end)) else { return };
out.extend_from_slice(lit.get(..len.min(limit - out.len())).unwrap_or(lit));
p += len;
} else if n != -128 {
let Some(&b) = src.get(p) else { return };
let len = (1 - isize::from(n)) as usize;
out.extend(std::iter::repeat_n(b, len.min(limit - out.len())));
p += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runs_literals_and_noops() {
let mut out = Vec::new();
// A literal of 2, a no-op, a run of 3, then a literal cut short by the end of the data.
decode(&[1, 10, 11, 0x80, 0xFE, 7, 4, 1], &mut out, 100);
assert_eq!(out, [10, 11, 7, 7, 7]);
let mut capped = Vec::new();
decode(&[0xFE, 7, 0xFE, 8], &mut capped, 4);
assert_eq!(capped, [7, 7, 7, 8]);
// The longest run, 128 bytes from two, appended to what is there.
let mut out = vec![1];
decode(&[0x81, 9], &mut out, 1000);
assert_eq!((out.len(), out[1], out[128]), (129, 9, 9));
let mut out = Vec::new();
decode(&[0xFF], &mut out, 10);
assert!(out.is_empty());
}
}
+271
View File
@@ -0,0 +1,271 @@
//! YCbCr images (PhotometricInterpretation 6, TIFF 6.0 §20–21): the conversion to RGB, and the
//! decoding of uncompressed and PackBits chunky 8-bit data units of any subsampling.
//!
//! [`YCbCr`] maps stored Y, Cb, Cr codes to RGB: ReferenceBlackWhite scales the codes and
//! YCbCrCoefficients gives the luma weights; missing or unusable tags fall back to the TIFF
//! defaults (BT.601 weights, full-range luma, chroma centred on the middle code). JPEG-compressed
//! data (compression 7) uses the JPEG (JFIF) conversion and ignores both tags, as libtiff does
//! when it asks the JPEG library for RGB. Use it on samples another decoder produced.
//!
//! [`decode_rgb8`] decodes a whole image whose data this crate can read itself: one strip-based,
//! chunky, 8-bit, uncompressed or PackBits YCbCr image, the data units expanded (chroma repeated
//! across each unit) and converted.
//!
//! # Green: a compatibility choice
//!
//! Red and blue follow the specification (TIFF 6.0 p. 91: `R = Cr·(2 − 2·LumaRed) + Y`,
//! `B = Cb·(2 − 2·LumaBlue) + Y`). Green follows libtiff (`TIFFYCbCrToRGBInit`), which rewrites
//! the specification's `G = (Y − LumaBlue·B − LumaRed·R) / LumaGreen` as
//! `G = Y − (LumaRed·(2 − 2·LumaRed) / LumaGreen)·Cr − (LumaBlue·(2 − 2·LumaBlue) / LumaGreen)·Cb`,
//! adding Y unscaled. The two agree whenever the weights sum to 1, as every standard set does
//! (BT.601, BT.709). For other weights the specification's form tints neutral grays; libtiff's
//! keeps them neutral and matches libtiff-based tools.
use crate::{Ifd, TiffError, packbits, tags};
/// YCbCrCoefficients.
pub const YCBCR_COEFFICIENTS: u16 = 529;
/// ReferenceBlackWhite.
pub const REFERENCE_BLACK_WHITE: u16 = 532;
/// BT.601 luma weights: the TIFF default for YCbCrCoefficients.
pub const BT601: [f64; 3] = [0.299, 0.587, 0.114];
/// How one image's YCbCr codes map to RGB.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct YCbCr {
/// Luma weights of red, green and blue.
pub luma: [f64; 3],
/// Black and white codes of Y, Cb and Cr.
pub reference_black_white: [f64; 6],
}
impl YCbCr {
/// TIFF's defaults for samples whose largest code is `max` (255 or 65535): BT.601 weights,
/// full-range luma, chroma centred on the middle code. Also the JPEG (JFIF) conversion.
pub fn defaults(max: u32) -> Self {
let m = f64::from(max);
let mid = (m + 1.0) / 2.0;
YCbCr { luma: BT601, reference_black_white: [0.0, m, mid, m, mid, m] }
}
/// The conversion `ifd` asks for, for samples whose largest code is `max`. Weights must each
/// lie strictly between 0 and 1 and every black/white pair must differ; otherwise that tag's
/// default is used. JPEG-compressed images (compression 7) always use the defaults.
pub fn from_ifd(ifd: &Ifd, max: u32) -> Self {
let defaults = Self::defaults(max);
if ifd.u16(tags::COMPRESSION) == Some(tags::compression::JPEG) {
return defaults;
}
let luma = ifd
.f64s(YCBCR_COEFFICIENTS)
.and_then(|v| <[f64; 3]>::try_from(v.get(..3)?).ok())
.filter(|l| l.iter().all(|c| c.is_finite() && *c > 0.0 && *c < 1.0))
.unwrap_or(defaults.luma);
let reference_black_white = ifd
.f64s(REFERENCE_BLACK_WHITE)
.and_then(|v| <[f64; 6]>::try_from(v.get(..6)?).ok())
.filter(|r| r.iter().all(|v| v.is_finite()) && r.as_chunks::<2>().0.iter().all(|[black, white]| (white - black).abs() > f64::EPSILON))
.unwrap_or(defaults.reference_black_white);
YCbCr { luma, reference_black_white }
}
/// Lookup tables for samples whose largest code is `max`, so a pixel costs a few additions.
pub fn tables(&self, max: u32) -> Tables {
let m = f64::from(max);
let [lr, lg, lb] = self.luma;
let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = self.reference_black_white;
// TIFF 6.0: chroma codes span ±(max + 1) / 2 − 1 around the black code; with the default
// ReferenceBlackWhite that is the code minus the middle code.
let half = (m + 1.0) / 2.0 - 1.0;
let codes = || (0..=max).map(f64::from);
let y = codes().map(|v| (v - y_black) * m / (y_white - y_black)).collect();
let cb: Vec<f64> = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect();
let cr: Vec<f64> = codes().map(|v| (v - cr_black) * half / (cr_white - cr_black)).collect();
let (kr, kb) = (2.0 - 2.0 * lr, 2.0 - 2.0 * lb);
Tables {
max: m,
y,
cr_r: cr.iter().map(|v| kr * v).collect(),
cr_g: cr.iter().map(|v| -lr * kr * v / lg).collect(),
cb_g: cb.iter().map(|v| -lb * kb * v / lg).collect(),
cb_b: cb.iter().map(|v| kb * v).collect(),
}
}
/// Converts interleaved 8-bit pixels of `channels` samples, the first three Y, Cb, Cr, to R,
/// G, B in place; other samples (alpha) are kept. A trailing partial pixel is left as it is.
/// The work is per pixel, so a caller may convert bands of whole pixels in parallel.
pub fn convert_u8(&self, data: &mut [u8], channels: usize) {
if channels < 3 {
return;
}
let t = self.tables(255);
for px in data.chunks_exact_mut(channels) {
if let [y, cb, cr, ..] = px {
let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr));
(*y, *cb, *cr) = (r as u8, g as u8, b as u8);
}
}
}
/// [`YCbCr::convert_u8`] for 16-bit samples.
pub fn convert_u16(&self, data: &mut [u16], channels: usize) {
if channels < 3 {
return;
}
let t = self.tables(65535);
for px in data.chunks_exact_mut(channels) {
if let [y, cb, cr, ..] = px {
let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr));
(*y, *cb, *cr) = (r as u16, g as u16, b as u16);
}
}
}
}
/// Per-code contributions of one [`YCbCr`] conversion.
#[derive(Clone, Debug, PartialEq)]
pub struct Tables {
max: f64,
/// Scaled luma of each Y code.
y: Vec<f64>,
/// Red and green contributions of each Cr code.
cr_r: Vec<f64>,
cr_g: Vec<f64>,
/// Green and blue contributions of each Cb code.
cb_g: Vec<f64>,
cb_b: Vec<f64>,
}
impl Tables {
/// R, G and B of one pixel, rounded and clamped to the code range. Codes past the range read
/// as 0.
pub fn rgb(&self, y: usize, cb: usize, cr: usize) -> [u32; 3] {
let at = |t: &[f64], i: usize| t.get(i).copied().unwrap_or(0.0);
let yv = at(&self.y, y);
// Green adds Y unscaled, as libtiff does (see "Green: a compatibility choice").
let r = yv + at(&self.cr_r, cr);
let g = yv + at(&self.cr_g, cr) + at(&self.cb_g, cb);
let b = yv + at(&self.cb_b, cb);
[r, g, b].map(|v| v.round().clamp(0.0, self.max) as u32)
}
}
/// The YCbCrSubSampling of `ifd` (default 2 × 2), when it is valid: each factor 1, 2 or 4.
pub fn subsampling(ifd: &Ifd) -> Option<(u32, u32)> {
match ifd.u64s(tags::YCBCR_SUBSAMPLING).as_deref() {
None => Some((2, 2)),
Some([h, v, ..]) if [1, 2, 4].contains(h) && [1, 2, 4].contains(v) => Some((*h as u32, *v as u32)),
Some(_) => None,
}
}
/// Bytes of the data units of a `width` × `height` image at `(h, v)` subsampling: the image
/// rounded up to whole units, each `h × v` luma samples then Cb and Cr (TIFF 6.0 §21).
pub fn units_len(width: u32, height: u32, (h, v): (u32, u32)) -> Option<usize> {
let units = u64::from(width.div_ceil(h.max(1))).checked_mul(u64::from(height.div_ceil(v.max(1))))?;
usize::try_from(units.checked_mul(u64::from(h) * u64::from(v) + 2)?).ok()
}
/// Expands 8-bit data units to interleaved Y, Cb, Cr pixels, `width × height × 3` bytes, top row
/// first: each unit's chroma is repeated across its luma samples. `None` when `units` is shorter
/// than [`units_len`].
pub fn expand_units(units: &[u8], width: u32, height: u32, (h, v): (u32, u32)) -> Option<Vec<u8>> {
let (w, ht, sh, sv) = (width as usize, height as usize, h.max(1) as usize, v.max(1) as usize);
let need = units_len(width, height, (h, v))?;
let units = units.get(..need)?;
let units_x = w.div_ceil(sh);
let mut out = vec![0u8; w.checked_mul(ht)?.checked_mul(3)?];
for (i, unit) in units.chunks_exact(sh * sv + 2).enumerate() {
let (ux, uy) = (i % units_x, i / units_x);
let (luma, chroma) = unit.split_at(sh * sv);
let [cb, cr] = <[u8; 2]>::try_from(chroma).ok()?;
for (k, &y) in luma.iter().enumerate() {
let (x, row) = (ux * sh + k % sh, uy * sv + k / sh);
if x < w && row < ht {
let at = (row * w + x) * 3;
if let Some(px) = out.get_mut(at..at + 3) {
px.copy_from_slice(&[y, cb, cr]);
}
}
}
}
Some(out)
}
/// Decodes the YCbCr image of `ifd` in `data` (the whole file) to RGB, `width × height × 3`
/// bytes, top row first. The image must be strip-based, chunky, three 8-bit samples a pixel and
/// uncompressed or PackBits; anything else is [`TiffError::Invalid`]. More than `max_pixels`
/// pixels is [`TiffError::Limit`], and a file too small to hold the image is refused before the data units are allocated.
pub fn decode_rgb8(data: &[u8], ifd: &Ifd, max_pixels: u64) -> crate::Result<Vec<u8>> {
// TiffError has no "unsupported" variant; adding one would break exhaustive matches downstream.
let unsupported = |what: &str| TiffError::Invalid(format!("unsupported YCbCr image with {what}"));
let invalid = |what: &str| TiffError::Invalid(format!("YCbCr image: {what}"));
if ifd.u16(tags::PHOTOMETRIC) != Some(tags::photometric::YCBCR) {
return Err(invalid("PhotometricInterpretation is not YCbCr"));
}
let width = ifd.u32(tags::IMAGE_WIDTH).ok_or(TiffError::MissingTag(tags::IMAGE_WIDTH))?;
let height = ifd.u32(tags::IMAGE_LENGTH).ok_or(TiffError::MissingTag(tags::IMAGE_LENGTH))?;
if width == 0 || height == 0 {
return Err(invalid("empty image"));
}
if u64::from(width) * u64::from(height) > max_pixels {
return Err(TiffError::Limit("pixel count"));
}
if ifd.u64(tags::SAMPLES_PER_PIXEL).unwrap_or(1) != 3 {
return Err(unsupported("other than three samples per pixel"));
}
if ifd.u64s(tags::BITS_PER_SAMPLE).unwrap_or_else(|| vec![1]).iter().any(|&b| b != 8) {
return Err(unsupported("other than 8 bits per sample"));
}
if ifd.u64(tags::PLANAR_CONFIGURATION).unwrap_or(1) != 1 {
return Err(unsupported("separate planes"));
}
if ifd.contains(tags::TILE_OFFSETS) {
return Err(unsupported("tiles"));
}
let packed = match ifd.u16(tags::COMPRESSION).unwrap_or(tags::compression::NONE) {
tags::compression::NONE => false,
tags::compression::PACKBITS => true,
c => return Err(unsupported(&format!("compression {c}"))),
};
let sub = subsampling(ifd).ok_or_else(|| invalid("invalid YCbCrSubSampling"))?;
let need = units_len(width, height, sub).ok_or(TiffError::Limit("pixel count"))?;
// Uncompressed units are the file's bytes; PackBits expands at most 64 to 1 (two bytes, 128 out).
if need > data.len().saturating_mul(if packed { 64 } else { 1 }) {
return Err(TiffError::Truncated(data.len() as u64));
}
let offsets = ifd.u64s(tags::STRIP_OFFSETS).ok_or(TiffError::MissingTag(tags::STRIP_OFFSETS))?;
let counts = ifd.u64s(tags::STRIP_BYTE_COUNTS).ok_or(TiffError::MissingTag(tags::STRIP_BYTE_COUNTS))?;
// Fallible: `need` can be hundreds of MiB for a small PackBits file.
let mut units: Vec<u8> = Vec::new();
units.try_reserve_exact(need).map_err(|_| TiffError::Limit("memory"))?;
let mut strip_bytes = 0usize;
for (&off, &len) in offsets.iter().zip(&counts) {
if units.len() >= need {
break;
}
let strip = usize::try_from(off)
.ok()
.zip(usize::try_from(len).ok())
.and_then(|(o, l)| data.get(o..o.checked_add(l)?))
.ok_or(TiffError::Truncated(off))?;
// The strips of one image do not overlap, so together they cannot exceed the file.
strip_bytes = strip_bytes.saturating_add(strip.len());
if strip_bytes > data.len() {
return Err(invalid("strips overlap"));
}
if packed {
packbits::decode(strip, &mut units, need);
} else {
units.extend_from_slice(strip.get(..need - units.len()).unwrap_or(strip));
}
}
if units.len() < need {
return Err(TiffError::Truncated(data.len() as u64));
}
let mut rgb = expand_units(&units, width, height, sub).ok_or_else(|| invalid("data units"))?;
YCbCr::from_ifd(ifd, 255).convert_u8(&mut rgb, 3);
Ok(rgb)
}
+1
View File
@@ -1,3 +1,4 @@
//! The integration tests, one module per file, linked into one test binary.
mod roundtrip;
mod ycbcr;
+209
View File
@@ -0,0 +1,209 @@
//! YCbCr: PhotoCraft's conversion tests and FilmCraft's decoding cases, on files this crate writes.
use craft_tiff::ycbcr::{self, BT601, REFERENCE_BLACK_WHITE, YCBCR_COEFFICIENTS, YCbCr};
use craft_tiff::{ByteOrder, IfdBuilder, ImageData, Tiff, TiffError, TiffWriter, Value, tags};
fn convert(c: &YCbCr, px: [u8; 3]) -> [u8; 3] {
let mut d = px.to_vec();
c.convert_u8(&mut d, 3);
[d[0], d[1], d[2]]
}
#[test]
fn jfif_conversion_matches_known_colours() {
let c = YCbCr::defaults(255);
assert_eq!(convert(&c, [0, 128, 128]), [0, 0, 0]);
assert_eq!(convert(&c, [255, 128, 128]), [255, 255, 255]);
assert_eq!(convert(&c, [128, 128, 128]), [128, 128, 128]);
// Pure red, green and blue as JFIF encodes them.
assert_eq!(convert(&c, [76, 85, 255]), [254, 0, 0]);
assert_eq!(convert(&c, [150, 44, 21]), [0, 255, 1]);
assert_eq!(convert(&c, [29, 255, 107]), [0, 0, 254]);
}
#[test]
fn sixteen_bit_and_extra_samples() {
let c = YCbCr::defaults(65535);
let mut d = [65535u16, 32768, 32768, 1234];
c.convert_u16(&mut d, 4);
assert_eq!(d, [65535, 65535, 65535, 1234]);
}
#[test]
fn studio_range_reference_black_white() {
// Y 16–235, chroma 128 ± 112 (ITU-R BT.601 studio range).
let c = YCbCr { luma: BT601, reference_black_white: [16.0, 235.0, 128.0, 240.0, 128.0, 240.0] };
assert_eq!(convert(&c, [16, 128, 128]), [0, 0, 0]);
assert_eq!(convert(&c, [235, 128, 128]), [255, 255, 255]);
}
#[test]
fn weights_that_do_not_sum_to_one_keep_grays_neutral() {
let c = YCbCr { luma: [0.4, 0.4, 0.4], reference_black_white: YCbCr::defaults(255).reference_black_white };
for y in [0u8, 64, 128, 200, 255] {
assert_eq!(convert(&c, [y, 128, 128]), [y, y, y]);
}
}
#[test]
fn odd_buffers_do_not_panic() {
let c = YCbCr::defaults(255);
let mut d = vec![1u8, 2];
c.convert_u8(&mut d, 3);
c.convert_u8(&mut d, 0);
c.convert_u8(&mut d, 2);
let mut w = vec![1u16];
c.convert_u16(&mut w, 0);
assert_eq!((d, w), (vec![1, 2], vec![1]));
}
fn ifd_with(entries: &[(u16, Value)]) -> craft_tiff::Ifd {
// A TIFF directory cannot be empty: give it a size.
let mut b = IfdBuilder::new().with(tags::IMAGE_WIDTH, Value::Long(vec![1]));
for (t, v) in entries {
b.set(*t, v.clone());
}
let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap();
Tiff::parse(&bytes).unwrap().ifds.remove(0)
}
#[test]
fn tags_are_read_and_bad_ones_fall_back() {
let d = YCbCr::defaults(255);
assert_eq!(YCbCr::from_ifd(&ifd_with(&[]), 255), d);
let rbw = Value::Rational(vec![(16, 1), (235, 1), (128, 1), (240, 1), (128, 1), (240, 1)]);
let c = YCbCr::from_ifd(
&ifd_with(&[(YCBCR_COEFFICIENTS, Value::Rational(vec![(2126, 10000), (7152, 10000), (722, 10000)])), (REFERENCE_BLACK_WHITE, rbw.clone())]),
255,
);
assert_eq!((c.luma, c.reference_black_white), ([0.2126, 0.7152, 0.0722], [16.0, 235.0, 128.0, 240.0, 128.0, 240.0]));
// JPEG-compressed data ignores both tags.
assert_eq!(YCbCr::from_ifd(&ifd_with(&[(tags::COMPRESSION, Value::Short(vec![7])), (REFERENCE_BLACK_WHITE, rbw)]), 255), d);
// A weight of 0 or 1, too few values, or a black equal to its white: that tag's default.
let bad = ifd_with(&[
(YCBCR_COEFFICIENTS, Value::Rational(vec![(0, 1), (1, 2), (1, 2)])),
(REFERENCE_BLACK_WHITE, Value::Rational(vec![(16, 1), (16, 1), (128, 1), (240, 1), (128, 1), (240, 1)])),
]);
assert_eq!(YCbCr::from_ifd(&bad, 255), d);
assert_eq!(YCbCr::from_ifd(&ifd_with(&[(YCBCR_COEFFICIENTS, Value::Rational(vec![(1, 2)]))]), 255).luma, BT601);
}
/// A YCbCr TIFF of `w` × `h` at `sub` with these data units, in `strips` strips, uncompressed or
/// PackBits (one literal packet per 128 bytes).
fn file(w: u32, h: u32, sub: (u16, u16), units: &[u8], packbits: bool, extra: &[(u16, Value)]) -> Vec<u8> {
let data = if packbits {
units.chunks(128).flat_map(|c| std::iter::once((c.len() - 1) as u8).chain(c.iter().copied())).collect()
} else {
units.to_vec()
};
let mut b = IfdBuilder::new();
b.set(tags::IMAGE_WIDTH, Value::Long(vec![w]))
.set(tags::IMAGE_LENGTH, Value::Long(vec![h]))
.set(tags::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]))
.set(tags::COMPRESSION, Value::Short(vec![if packbits { 32773 } else { 1 }]))
.set(tags::PHOTOMETRIC, Value::Short(vec![6]))
.set(tags::SAMPLES_PER_PIXEL, Value::Short(vec![3]))
.set(tags::YCBCR_SUBSAMPLING, Value::Short(vec![sub.0, sub.1]))
.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] });
for (t, v) in extra {
b.set(*t, v.clone());
}
TiffWriter::new(ByteOrder::Big, false).write(&[b]).unwrap()
}
fn decode(bytes: &[u8]) -> craft_tiff::Result<Vec<u8>> {
ycbcr::decode_rgb8(bytes, &Tiff::parse(bytes).unwrap().ifds[0], 1 << 28)
}
#[test]
fn data_units_expand_with_their_chroma() {
// 3 × 2 at 2 × 1: two units a row, the second half outside the image.
let units = [10, 20, 128, 128, 30, 40, 128, 128, 50, 60, 128, 128, 70, 80, 128, 128];
let px = ycbcr::expand_units(&units, 3, 2, (2, 1)).unwrap();
assert_eq!(px, [10, 128, 128, 20, 128, 128, 30, 128, 128, 50, 128, 128, 60, 128, 128, 70, 128, 128]);
assert_eq!(ycbcr::units_len(3, 2, (2, 1)), Some(16));
assert_eq!(ycbcr::units_len(5, 5, (4, 2)), Some(2 * 3 * 10));
assert_eq!(ycbcr::expand_units(&units[..15], 3, 2, (2, 1)), None);
// 2 × 2 at 2 × 2: four luma samples (row by row), one chroma pair for all of them.
assert_eq!(ycbcr::expand_units(&[1, 2, 3, 4, 9, 8], 2, 2, (2, 2)).unwrap(), [1, 9, 8, 2, 9, 8, 3, 9, 8, 4, 9, 8]);
}
#[test]
fn subsampled_files_decode_uncompressed_and_packbits() {
// 4 × 2 at 2 × 2: grey, white, black and red units (red as JFIF codes it, one shared chroma).
let units = [128, 128, 128, 128, 128, 128, 255, 255, 255, 255, 128, 128];
for packbits in [false, true] {
let rgb = decode(&file(4, 2, (2, 2), &units, packbits, &[])).unwrap();
assert_eq!(rgb, [128, 128, 128, 128, 128, 128, 255, 255, 255, 255, 255, 255].repeat(2), "packbits {packbits}");
}
let red = decode(&file(1, 1, (1, 1), &[76, 85, 255], false, &[])).unwrap();
assert_eq!(red, [254, 0, 0]);
// The file's ReferenceBlackWhite applies.
let studio = Value::Rational(vec![(16, 1), (235, 1), (128, 1), (240, 1), (128, 1), (240, 1)]);
assert_eq!(decode(&file(1, 1, (1, 1), &[235, 128, 128], false, &[(REFERENCE_BLACK_WHITE, studio)])).unwrap(), [255, 255, 255]);
// No subsampling tag means 2 × 2.
assert_eq!(ycbcr::subsampling(&ifd_with(&[])), Some((2, 2)));
}
#[test]
fn what_it_does_not_decode_is_an_error() {
let units = [128u8; 6];
let ok = file(2, 2, (2, 2), &units, false, &[]);
assert!(decode(&ok).is_ok());
let with = |extra: &[(u16, Value)]| decode(&file(2, 2, (2, 2), &units, false, extra));
for (tag, v) in [
(tags::PHOTOMETRIC, Value::Short(vec![2])),
(tags::SAMPLES_PER_PIXEL, Value::Short(vec![4])),
(tags::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])),
(tags::PLANAR_CONFIGURATION, Value::Short(vec![2])),
(tags::COMPRESSION, Value::Short(vec![5])),
(tags::YCBCR_SUBSAMPLING, Value::Short(vec![3, 1])),
(tags::TILE_OFFSETS, Value::Long(vec![8])),
] {
assert!(matches!(with(&[(tag, v.clone())]), Err(TiffError::Invalid(_))), "{tag} {v:?}");
}
}
#[test]
fn limits_and_hostile_files() {
let units = [128u8; 6];
let ok = file(2, 2, (2, 2), &units, false, &[]);
let ifd = Tiff::parse(&ok).unwrap().ifds.remove(0);
assert!(ycbcr::decode_rgb8(&ok, &ifd, 4).is_ok());
assert_eq!(ycbcr::decode_rgb8(&ok, &ifd, 3), Err(TiffError::Limit("pixel count")));
// A small file claiming a large image is refused before the data units are allocated.
for packbits in [false, true] {
let big = file(16384, 16384, (1, 1), &units, packbits, &[]);
assert!(matches!(decode(&big), Err(TiffError::Truncated(_))), "packbits {packbits}");
}
// Data shorter than the image, and a strip outside the file.
assert!(matches!(decode(&file(4, 4, (2, 2), &units, false, &[])), Err(TiffError::Truncated(_))));
let mut moved = Tiff::parse(&ok).unwrap().ifds.remove(0);
for e in &mut moved.entries {
if e.tag == tags::STRIP_OFFSETS {
e.value = Value::Long(vec![u32::MAX]);
}
}
assert_eq!(ycbcr::decode_rgb8(&ok, &moved, 1 << 20), Err(TiffError::Truncated(u64::from(u32::MAX))));
let mut far = ok.clone();
// Every truncation and byte change either decodes or errors.
for n in 0..ok.len() {
if let Ok(t) = Tiff::parse(&ok[..n])
&& let Some(ifd) = t.ifds.first()
{
let _ = ycbcr::decode_rgb8(&ok[..n], ifd, 1 << 20);
}
}
for i in 0..ok.len() {
for v in [0u8, 1, 0x80, 0xFF] {
far.clone_from(&ok);
far[i] = v;
if let Ok(t) = Tiff::parse(&far)
&& let Some(ifd) = t.ifds.first()
&& let Ok(rgb) = ycbcr::decode_rgb8(&far, ifd, 1 << 20)
{
assert_eq!(rgb.len() as u64, u64::from(ifd.u32(tags::IMAGE_WIDTH).unwrap()) * u64::from(ifd.u32(tags::IMAGE_LENGTH).unwrap()) * 3);
}
}
}
}