From 603d43da8586c4c053ad4abfbd92f8cdbabb0ae9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:43:41 -0500 Subject: [PATCH] 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. --- crates/tiff/src/lib.rs | 4 + crates/tiff/src/packbits.rs | 46 ++++++ crates/tiff/src/ycbcr.rs | 271 ++++++++++++++++++++++++++++++++++ crates/tiff/tests/it/main.rs | 1 + crates/tiff/tests/it/ycbcr.rs | 209 ++++++++++++++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 crates/tiff/src/packbits.rs create mode 100644 crates/tiff/src/ycbcr.rs create mode 100644 crates/tiff/tests/it/ycbcr.rs diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index bf8247355..bd095f4f0 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -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}; diff --git a/crates/tiff/src/packbits.rs b/crates/tiff/src/packbits.rs new file mode 100644 index 000000000..e556c2259 --- /dev/null +++ b/crates/tiff/src/packbits.rs @@ -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, 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()); + } +} diff --git a/crates/tiff/src/ycbcr.rs b/crates/tiff/src/ycbcr.rs new file mode 100644 index 000000000..a0b7e7dde --- /dev/null +++ b/crates/tiff/src/ycbcr.rs @@ -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 = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect(); + let cr: Vec = 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, + /// Red and green contributions of each Cr code. + cr_r: Vec, + cr_g: Vec, + /// Green and blue contributions of each Cb code. + cb_g: Vec, + cb_b: Vec, +} + +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 { + 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> { + 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> { + // 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 = 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) +} diff --git a/crates/tiff/tests/it/main.rs b/crates/tiff/tests/it/main.rs index e415ed537..0e1dba9ba 100644 --- a/crates/tiff/tests/it/main.rs +++ b/crates/tiff/tests/it/main.rs @@ -1,3 +1,4 @@ //! The integration tests, one module per file, linked into one test binary. mod roundtrip; +mod ycbcr; diff --git a/crates/tiff/tests/it/ycbcr.rs b/crates/tiff/tests/it/ycbcr.rs new file mode 100644 index 000000000..1c990cb36 --- /dev/null +++ b/crates/tiff/tests/it/ycbcr.rs @@ -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 { + 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> { + 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); + } + } + } +}