diff --git a/Cargo.lock b/Cargo.lock index 9fcbb791..dbc89abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,7 @@ name = "craft-exr" version = "0.1.0" dependencies = [ "exr", + "flate2", "half", "serde_json", "thiserror 2.0.21", diff --git a/crates/exr/Cargo.toml b/crates/exr/Cargo.toml index 0c0989ba..c2776352 100644 --- a/crates/exr/Cargo.toml +++ b/crates/exr/Cargo.toml @@ -13,10 +13,12 @@ workspace = true [features] # Reading and writing pixels through the `exr` crate (BSD-3-Clause). The naming and Cryptomatte # modules need none of it. -decode = ["dep:exr", "dep:half", "dep:thiserror"] +decode = ["dep:exr", "dep:half", "dep:thiserror", "dep:flate2"] [dependencies] serde_json = { workspace = true } exr = { version = "1.73", default-features = false, optional = true } half = { version = "2", optional = true } thiserror = { workspace = true, optional = true } +# zlib for deep ZIPS chunks, which the `exr` crate does not read. +flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true } diff --git a/crates/exr/src/cryptomatte_samples.rs b/crates/exr/src/cryptomatte_samples.rs new file mode 100644 index 00000000..799e5e9a --- /dev/null +++ b/crates/exr/src/cryptomatte_samples.rs @@ -0,0 +1,192 @@ +//! Cryptomatte layers and their samples in a file (feature `decode`), on top of +//! [`crate::cryptomatte`] for the naming and [`crate::decode`] for the pixels. + +use crate::cryptomatte::{attribute_name, keys, layer_streams, parse_manifest, split_stream}; +use crate::decode::{Error, Limits, PartInfo, Result, decode_part, parts}; + +/// One Cryptomatte layer of a file: header facts and the parsed manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct Layer { + /// The part that holds it. + pub part: usize, + /// The `cryptomatte//name` value (`CryptoObject`, …). + pub name: String, + /// The 7-hex-digit metadata key. + pub key: String, + /// The `conversion` attribute (`uint32_to_float32`), when given. + pub conversion: Option, + /// The embedded manifest, (name, hash) sorted by name; empty without one. + pub manifest: Vec<(String, u32)>, + /// The `manif_file` attribute: a sidecar manifest's file name, when given. + pub sidecar_manifest: Option, + /// The rank channel sets, in index order (`crypto_asset00`, `crypto_asset01`, …). + pub streams: Vec, +} + +/// The Cryptomatte layers of the parts, sorted by part, then name. A layer whose channels are +/// not in its part is left out. +pub fn layers_of(parts: &[PartInfo]) -> Vec { + let mut out = Vec::new(); + for p in parts { + let names: Vec<&str> = p.channels.iter().map(|c| c.name.as_str()).collect(); + // Many file-controlled keys can name one layer: find its streams once. + let mut streams_for: std::collections::HashMap> = std::collections::HashMap::new(); + for key in keys(p.text_attributes.iter().map(|(k, _)| k.as_str())) { + let Some(name) = p.text(&attribute_name(&key, "name")) else { continue }; + let streams = streams_for.entry(name.to_string()).or_insert_with(|| layer_streams(names.iter().copied(), name)).clone(); + if streams.is_empty() { + continue; + } + out.push(Layer { + part: p.index, + name: name.to_string(), + conversion: p.text(&attribute_name(&key, "conversion")).map(str::to_string), + manifest: p.text(&attribute_name(&key, "manifest")).map(parse_manifest).unwrap_or_default(), + sidecar_manifest: p.text(&attribute_name(&key, "manif_file")).map(str::to_string), + key, + streams, + }); + } + } + out.sort_by(|a, b| (a.part, &a.name).cmp(&(b.part, &b.name))); + out +} + +/// Every Cryptomatte layer of a file, from its headers. +pub fn layers(bytes: &[u8], limits: &Limits) -> Result> { + Ok(layers_of(&parts(bytes, limits)?)) +} + +/// The samples of one layer: per pixel of the part's data window, its (ID, coverage) pairs with +/// coverage above zero and a finite ID, sorted by descending coverage. +#[derive(Debug, Clone, PartialEq)] +pub struct Ranks { + pub width: u32, + pub height: u32, + pub pixels: Vec>, +} + +/// Decodes the samples of `layer` (from [`layers`]). Only its rank channels are decoded. +pub fn decode(bytes: &[u8], layer: &Layer, limits: &Limits) -> Result { + let all = parts(bytes, limits)?; + let info = all.get(layer.part).ok_or_else(|| Error::Malformed("the Cryptomatte part is missing".into()))?; + let (w, h) = (info.data_window.width, info.data_window.height); + // Each pixel also gets a pair list: its header and up to one (ID, coverage) pair per rank, + // on top of four f32 channels per set. + let ranks = layer.streams.len().saturating_mul(2); + let per_pixel = (layer.streams.len().max(1) as u64) + .saturating_mul(16) + .saturating_add(std::mem::size_of::>() as u64) + .saturating_add((ranks as u64).saturating_mul(std::mem::size_of::<(f32, f32)>() as u64)); + limits.check_bytes(w, h, per_pixel)?; + let wanted = |name: &str| split_stream(name).is_some_and(|s| layer.streams.contains(&s.stream)); + let part = decode_part(bytes, layer.part, &wanted, limits)?; + let npx = w as usize * h as usize; + // Two (ID, coverage) ranks per set: rank `slot × 2 + stream.rank`. + let mut ids: Vec> = vec![Vec::new(); ranks]; + let mut coverages: Vec> = vec![Vec::new(); ranks]; + for c in &part.channels { + let Some(stream) = split_stream(&c.name) else { continue }; + let Some(slot) = layer.streams.iter().position(|s| *s == stream.stream) else { continue }; + let values = c.samples.to_f32(); + if values.len() != npx { + return Err(Error::Malformed("a Cryptomatte channel is smaller than its layer".into())); + } + let bucket = if stream.coverage { &mut coverages } else { &mut ids }; + if let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) { + *dst = values; + } + } + let mut pixels = vec![Vec::new(); npx]; + for (p, out) in pixels.iter_mut().enumerate() { + for (id, cov) in ids.iter().zip(&coverages) { + let (id, cov) = (id.get(p).copied().unwrap_or(0.0), cov.get(p).copied().unwrap_or(0.0)); + if cov > 0.0 && id.is_finite() { + out.push((id, cov)); + } + } + out.sort_by(|a, b| b.1.total_cmp(&a.1)); + } + Ok(Ranks { width: w, height: h, pixels }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use exr::meta::attribute::{AttributeValue, Text}; + use exr::prelude::{ + AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, ImageAttributes, IntegerBounds, Layer as ExrLayer, LayerAttributes, + LineOrder, WritableImage, + }; + + use super::*; + use crate::cryptomatte::{id, key}; + + /// A 2 × 1 file with a `CryptoAsset` layer of two rank sets (`crypto_asset00`, `01`) and a + /// beauty RGB, its manifest naming `cube` and `sphere`. + fn file() -> Vec { + let (cube, sphere) = (id("cube"), id("sphere")); + let k = key("CryptoAsset"); + let mut attrs = LayerAttributes::default(); + let mut text = |field: &str, v: String| { + attrs.other.insert(Text::from(attribute_name(&k, field).as_str()), AttributeValue::Text(Text::from(v.as_str()))); + }; + text("name", "CryptoAsset".into()); + text("conversion", "uint32_to_float32".into()); + text("manifest", format!(r#"{{"cube":"{:08x}","sphere":"{:08x}"}}"#, cube.to_bits(), sphere.to_bits())); + let ch = |n: &str, v: [f32; 2]| AnyChannel::new(n, FlatSamples::F32(v.to_vec())); + let list = vec![ + ch("R", [1.0, 1.0]), + ch("G", [1.0, 1.0]), + ch("B", [1.0, 1.0]), + // Pixel 0: cube 0.75, sphere 0.25 (in the second rank); pixel 1: sphere 1. + ch("crypto_asset00.red", [cube, sphere]), + ch("crypto_asset00.green", [0.75, 1.0]), + ch("crypto_asset00.blue", [sphere, 0.0]), + ch("crypto_asset00.alpha", [0.25, 0.0]), + ch("crypto_asset01.R", [0.0, cube]), + ch("crypto_asset01.G", [0.0, 0.0]), + ch("crypto_asset01.B", [f32::NAN, 0.0]), + ch("crypto_asset01.A", [0.5, 0.0]), + ]; + let layer = ExrLayer::new( + (2, 1), + attrs, + Encoding { compression: Compression::ZIP1, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (2, 1))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).unwrap(); + out.into_inner() + } + + #[test] + fn layers_come_from_the_headers() { + let bytes = file(); + let found = layers(&bytes, &Limits::default()).unwrap(); + let [l] = found.as_slice() else { panic!("{found:?}") }; + assert_eq!( + (l.part, l.name.as_str(), l.key.as_str(), l.conversion.as_deref()), + (0, "CryptoAsset", key("CryptoAsset").as_str(), Some("uint32_to_float32")) + ); + assert_eq!(l.streams, ["crypto_asset00", "crypto_asset01"]); + assert_eq!(l.manifest.iter().map(|(n, _)| n.as_str()).collect::>(), ["cube", "sphere"]); + assert_eq!(l.sidecar_manifest, None); + } + + #[test] + fn ranks_are_sorted_by_coverage_and_skip_empty_and_nan_ids() { + let bytes = file(); + let layer = layers(&bytes, &Limits::default()).unwrap().remove(0); + let r = decode(&bytes, &layer, &Limits::default()).unwrap(); + assert_eq!((r.width, r.height), (2, 1)); + assert_eq!(r.pixels[0], [(id("cube"), 0.75), (id("sphere"), 0.25)]); + assert_eq!(r.pixels[1], [(id("sphere"), 1.0)]); + // The per-pixel lists count against the budget. + let tight = Limits { max_bytes: 2 * 64, ..Limits::default() }; + assert!(matches!(decode(&bytes, &layer, &tight), Err(Error::LimitExceeded(_)))); + let missing = Layer { part: 3, ..layer }; + assert!(matches!(decode(&bytes, &missing, &Limits::default()), Err(Error::Malformed(_)))); + } +} diff --git a/crates/exr/src/decode.rs b/crates/exr/src/decode.rs index c5e90682..2b47f42e 100644 --- a/crates/exr/src/decode.rs +++ b/crates/exr/src/decode.rs @@ -89,6 +89,32 @@ impl Default for Limits { } } +impl Limits { + /// Effectively unlimited (still bounded by overflow checks). + pub fn none() -> Self { + Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: u64::MAX, max_bytes: u64::MAX, max_ratio: u64::MAX } + } + + /// Checks a `width × height` buffer of `bytes_per_pixel` against the size limits. + pub(crate) fn check_bytes(&self, width: u32, height: u32, bytes_per_pixel: u64) -> Result<()> { + if width == 0 || height == 0 { + return Err(Error::Malformed(format!("zero-sized picture {width}x{height}"))); + } + if width > self.max_width || height > self.max_height { + return Err(Error::LimitExceeded(format!("{width}x{height} exceeds {}x{}", self.max_width, self.max_height))); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > self.max_pixels { + return Err(Error::LimitExceeded(format!("{pixels} pixels exceed {}", self.max_pixels))); + } + let bytes = pixels.saturating_mul(bytes_per_pixel); + if bytes > self.max_bytes || usize::try_from(bytes).is_err() { + return Err(Error::LimitExceeded(format!("{bytes} bytes exceed {}", self.max_bytes))); + } + Ok(()) + } +} + /// How a channel's samples are stored. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SampleType { @@ -98,7 +124,7 @@ pub enum SampleType { } impl SampleType { - fn of(t: ExrSample) -> Self { + pub(crate) fn of(t: ExrSample) -> Self { match t { ExrSample::F16 => SampleType::F16, ExrSample::F32 => SampleType::F32, @@ -167,7 +193,7 @@ impl PartInfo { /// and goes on: thousands of tiny malformed previews would each cost that allocation. This walks /// the raw headers and checks each preview against its own size. Anything else odd is left to /// the crate to report. -fn check_previews(b: &[u8]) -> Result<()> { +pub(crate) fn check_previews(b: &[u8]) -> Result<()> { if b.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01][..]) { return Ok(()); } diff --git a/crates/exr/src/deep.rs b/crates/exr/src/deep.rs new file mode 100644 index 00000000..b86d497c --- /dev/null +++ b/crates/exr/src/deep.rs @@ -0,0 +1,853 @@ +//! Deep OpenEXR (`deepscanline` / `deeptile`): a variable-length list of samples per pixel, each +//! with its own colour, alpha and depth. The `exr` crate refuses deep headers, so the chunk stream +//! is walked here, per the OpenEXR file layout specification: a deep chunk carries its +//! coordinates, the packed size of the sample-count table, the packed and unpacked sizes of the +//! sample data, then the compressed count table (cumulative within each row of the block) and the +//! compressed sample data (row by row, each row channel by channel). The layout was checked +//! against files written by the OpenEXR library. Deep data permits NONE, RLE, ZIPS and ZIP +//! compression; the first three are read (one scan line per block keeps the count table +//! unambiguous) and ZIP is [`Error::Unsupported`]. +//! +//! [`decode_deep`] returns the samples ([`DeepImage`]); [`flatten`] composites them into a flat +//! picture: samples sorted by Z, front-to-back `over` with premultiplied colour, un-premultiplied +//! into straight alpha (HDR values are kept, not clamped). +//! +//! Every allocation is admitted against [`Limits::max_bytes`] first: the per-pixel sample-count +//! index, each count table with its decompression scratch, and the samples. + +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use exr::meta::attribute::{LevelMode, SampleType as ExrSample}; +use exr::meta::{BlockDescription, MetaData}; +use exr::prelude::Compression; +use half::f16; + +use crate::channels::ColorLayout; +use crate::decode::{Error, Limits, Result, SampleType, check_previews}; + +fn err(e: impl std::fmt::Display) -> Error { + Error::Malformed(e.to_string()) +} + +fn unsup(reason: impl Into) -> Error { + Error::Unsupported(reason.into()) +} + +/// A zeroed buffer of `len` elements, or an error when the allocator refuses it. +fn alloc_zeroed(len: usize) -> Result> { + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| Error::LimitExceeded(format!("cannot allocate {len} elements")))?; + v.resize(len, T::default()); + Ok(v) +} + +/// One channel of a [`DeepImage`]. +#[derive(Debug, Clone, PartialEq)] +pub struct DeepChannel { + /// The channel name as in the file (`R`, `G`, `B`, `A`, `Z`, …). + pub name: String, + /// The sample type as stored; `samples` always holds the value as f32 (half and float + /// exactly, unsigned integers exactly up to 2^24). + pub sample: SampleType, + /// Every sample of the channel, in pixel order; pixel `i`'s are `counts[i]..counts[i + 1]`. + pub samples: Vec, +} + +/// The samples of a deep part. All channels share the sample layout, so a sample is one index +/// into every channel. +#[derive(Debug, Clone, PartialEq)] +pub struct DeepImage { + pub width: u32, + pub height: u32, + /// The stage the picture opens on: the display window size, when the file declares one the + /// data region fits in and the limits allow (each side at most 32768). `None` otherwise: the + /// picture is the data window alone. + pub stage: Option<(u32, u32)>, + /// Where the data window's (0, 0) sits on the stage; `(0, 0)` without a stage. + pub origin: (i32, i32), + /// The sample channels in file order. + pub channels: Vec, + /// Cumulative sample counts, `width × height + 1` entries: pixel `i` holds the samples + /// `counts[i]..counts[i + 1]` of every channel. + pub counts: Vec, +} + +impl DeepImage { + /// A channel by its exact name. + pub fn channel(&self, name: &str) -> Option<&DeepChannel> { + self.channels.iter().find(|c| c.name == name) + } + + /// The samples of one pixel, as indices into every channel's `samples`. + pub fn sample_range(&self, pixel: usize) -> std::ops::Range { + let end = self.counts.get(pixel + 1).copied().unwrap_or(0); + self.counts.get(pixel).copied().unwrap_or(end)..end + } + + /// The total number of samples over all pixels. + pub fn total_samples(&self) -> u64 { + self.counts.last().copied().unwrap_or(0) + } +} + +/// A flattened deep picture. +#[derive(Debug, Clone, PartialEq)] +pub struct Flat { + pub width: u32, + pub height: u32, + pub layout: ColorLayout, + /// `width × height × layout.channels()` straight-alpha samples, top row first. + pub samples: Vec, + /// `Some(origin)` when the picture is the display-window stage with the data placed at `origin`. + pub stage_origin: Option<(i32, i32)>, + /// The most samples one pixel had. + pub max_samples_per_pixel: u32, +} + +/// Reads the samples of the first deep part. `Err(Unsupported)` when the file has none. +pub fn decode_deep(bytes: &[u8], limits: &Limits) -> Result { + check_previews(bytes)?; + let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; + decode_deep_meta(&meta, bytes, limits) +} +// --- checked little-endian reads ------------------------------------------------------------- +// Every length and offset comes from the file, so every read is bounds-checked and a short +// read is a malformed file, never a panic. + +fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8]> { + // Borrow packed payloads from the input. Copying them here allocates before count-table + // admission and needlessly duplicates the table even when the second pass just skips it. + let truncated = || err(format!("truncated file while reading {what}")); + let start = usize::try_from(cur.position()).map_err(|_| truncated())?; + let end = start.checked_add(n).ok_or_else(truncated)?; + let bytes = *cur.get_ref(); + let v = bytes.get(start..end).ok_or_else(truncated)?; + cur.set_position(end as u64); + Ok(v) +} + +fn i32(cur: &mut Cursor<&[u8]>) -> Result { + let b = read_exact(cur, 4, "an i32")?; + Ok(i32::from_le_bytes([ + b.first().copied().unwrap_or(0), + b.get(1).copied().unwrap_or(0), + b.get(2).copied().unwrap_or(0), + b.get(3).copied().unwrap_or(0), + ])) +} + +fn u64(cur: &mut Cursor<&[u8]>) -> Result { + let b = read_exact(cur, 8, "a u64")?; + let a: [u8; 8] = b.try_into().unwrap_or([0; 8]); + Ok(u64::from_le_bytes(a)) +} + +fn seek_to(cur: &mut Cursor<&[u8]>, pos: u64) -> Result<()> { + if pos > cur.get_ref().len() as u64 { + return Err(err(format!("chunk offset {pos} points past the end of the file"))); + } + cur.seek(SeekFrom::Start(pos)).map_err(err)?; + Ok(()) +} + +// --- decompression ---------------------------------------------------------------------------- + +/// Undo the ZIP predictor: each byte was stored as its difference to the previous byte plus +/// 128 (so a zero difference is 0x80), modulo 256. Applies to the sample-count table and the +/// sample data alike. +fn differences_to_samples(buf: &mut [u8]) { + if let Some(&first) = buf.first() { + let mut prev = i16::from(first); + for b in buf.iter_mut().skip(1) { + let s = (prev + i16::from(*b) - 128) as u8; + prev = i16::from(s); + *b = s; + } + } +} + +/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half +/// holding every second byte of the original; weave them back together. The scratch copy is +/// allocated fallibly, so a buffer that passed `max_bytes` but not the allocator is an error. +fn interleave_byte_blocks(buf: &mut [u8]) -> Result<()> { + let half = buf.len().div_ceil(2); + let mut out = alloc_zeroed::(buf.len())?; + let mut k = 0usize; + for i in 0..buf.len() / 2 { + let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied()); + if let (Some(a), Some(b), Some(pair)) = (a, b, out.get_mut(k..k + 2)) { + if let Some(byte) = pair.first_mut() { + *byte = a; + } + if let Some(byte) = pair.get_mut(1) { + *byte = b; + } + } + k += 2; + } + if buf.len() % 2 == 1 && half > 0 { + let last = buf.get(half - 1).copied(); + if let (Some(last), Some(slot)) = (last, out.get_mut(k)) { + *slot = last; + } + } + buf.copy_from_slice(&out); + Ok(()) +} + +/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`, +/// but never reserves past `limit`: callers admit exactly that size against `max_bytes`, and +/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error. +fn reserve_up_to(out: &mut Vec, extra: usize, limit: usize, what: &str) -> Result<()> { + let need = out.len().saturating_add(extra); + if need > limit { + return Err(err(format!("{what}: data expands past {limit} bytes"))); + } + if need > out.capacity() { + let target = out.capacity().saturating_mul(2).max(need).min(limit); + out.try_reserve_exact(target.saturating_sub(out.len())) + .map_err(|_| Error::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?; + } + Ok(()) +} + +/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative +/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate). +fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result> { + let mut out = Vec::new(); + reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?; + let mut i = 0; + while i < src.len() { + let count = src[i] as i8; + i += 1; + let n = if count < 0 { (-(count as i32)) as usize } else { count as usize + 1 }; + if out.len().saturating_add(n) > expected { + return Err(err(format!("{what}: RLE data expands past {expected} bytes"))); + } + reserve_up_to(&mut out, n, expected, what)?; + if count < 0 { + let end = i.saturating_add(n); + let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; + if run.len() != n { + return Err(err(format!("{what}: RLE run points past the compressed data"))); + } + out.extend_from_slice(run); + i = end; + } else { + let b = src.get(i).copied().ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; + i += 1; + out.resize(out.len() + n, b); + } + } + if out.len() != expected { + return Err(err(format!("{what}: RLE data expands to {} bytes, expected {expected}", out.len()))); + } + Ok(out) +} + +/// Decompress `what` to exactly `expected` bytes. Like flat blocks, a payload that already +/// has the uncompressed length is stored raw whatever the declared compression. +fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result> { + if packed.len() == expected { + let mut out = Vec::new(); + reserve_up_to(&mut out, expected, expected, what)?; + out.extend_from_slice(packed); + return Ok(out); + } + match compression { + Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))), + Compression::RLE => { + // Like ZIPS, RLE runs over the predicted, byte-split stream. + let mut out = rle_decode(packed, expected, what)?; + differences_to_samples(&mut out); + interleave_byte_blocks(&mut out)?; + Ok(out) + } + Compression::ZIP1 => { + // Inflate straight into `out`, grown with `reserve_up_to` rather than by + // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are + // in, a one-byte read checks that the stream ends there. + let malformed = || err(format!("{what}: zlib data is malformed")); + let mut zlib = flate2::bufread::ZlibDecoder::new(packed); + let mut out = Vec::new(); + let mut filled = 0; + while filled < expected { + if filled == out.len() { + let grow = out.len().max(1 << 13).min(expected - filled); + reserve_up_to(&mut out, grow, expected, what)?; + out.resize(filled + grow, 0); + } + let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?; + if n == 0 { + break; + } + filled += n; + } + out.truncate(filled); + if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 { + return Err(err(format!("{what}: zlib data expands past {expected} bytes"))); + } + if out.len() != expected { + return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len()))); + } + differences_to_samples(&mut out); + interleave_byte_blocks(&mut out)?; + Ok(out) + } + other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))), + } +} + +// --- the deep chunk stream --------------------------------------------------------------------- + +/// One parsed chunk: the block it covers, plus where to find the compressed sample data in +/// the second pass. Its pixels' sample counts live in the decode's global index, so a chunk +/// holds no per-pixel data. +struct DeepChunk { + /// The block's rows within the data window. + rows: std::ops::Range, + /// The block's columns within the data window (clipped to it for an edge tile). + cols: std::ops::Range, + /// File offset of the chunk start. + pos: u64, + /// Size of the compressed sample data in the file. + packed_data: u64, + /// The decompressed sample-data size the file declares (validated against the table). + unpacked_data: u64, +} + +fn sample_bytes(t: ExrSample) -> usize { + match t { + ExrSample::U32 | ExrSample::F32 => 4, + ExrSample::F16 => 2, + } +} + +/// One stored sample as f32. `b` holds [`sample_bytes`] bytes; a short slice reads as zero. +fn sample_value(t: ExrSample, b: &[u8]) -> f32 { + match t { + ExrSample::F16 => b.try_into().map(|a: [u8; 2]| f16::from_le_bytes(a).to_f32()).unwrap_or(0.0), + ExrSample::F32 => b.try_into().map(f32::from_le_bytes).unwrap_or(0.0), + ExrSample::U32 => b.try_into().map(|a: [u8; 4]| u32::from_le_bytes(a) as f32).unwrap_or(0.0), + } +} + +/// [`decode_deep`] on headers read without validation (deep headers fail it). +fn decode_deep_meta(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Result { + let header_index = meta.headers.iter().position(|h| h.deep).ok_or_else(|| unsup("the file has no deep part"))?; + let header = meta.headers.get(header_index).ok_or_else(|| unsup("the file has no deep part"))?; + let compression = header.compression; + if !matches!(compression, Compression::Uncompressed | Compression::RLE | Compression::ZIP1) { + return Err(unsup(format!("deep data with {compression:?} compression (NONE, RLE and ZIPS are supported)"))); + } + + let dw = header.data_window(); + let (w, h) = (dw.size.0, dw.size.1); + if w == 0 || h == 0 { + return Err(err("the data window is empty")); + } + if w > u32::MAX as usize || h > u32::MAX as usize { + return Err(err("the data window is too large")); + } + let y0 = dw.position.1; + let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?; + limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?; + // The per-pixel bookkeeping is the cumulative sample-count index the result returns + // (`DeepImage::counts`: one u64 per pixel, plus one), live from the first chunk to the end. + // Charge it before anything is allocated; the count tables and the samples are admitted + // against what it leaves. The data-window check above budgets only four bytes a pixel. + let index_len = npixels.checked_add(1).ok_or_else(|| err("the data window is too large"))?; + let index_bytes = (index_len as u64).checked_mul(8).ok_or_else(|| err("the data window is too large"))?; + if index_bytes > limits.max_bytes { + return Err(Error::LimitExceeded(format!( + "the sample-count index of {w}x{h} deep pixels needs {index_bytes} bytes, exceeding max_bytes {}", + limits.max_bytes + ))); + } + let budget = limits.max_bytes - index_bytes; + + // The display window is the full stage the renderer composed onto (a render pass's data + // window is often its crop). Open on the stage when it plausibly holds the data region: + // sane size for the limits, and the data region inside it. Otherwise fall back to the + // data window alone, as before. + let display = header.shared_attributes.display_window; + let display_size = (display.size.0.max(1) as u32, display.size.1.max(1) as u32); + let (stage, origin) = match display_size { + (sw, sh) + if sw <= 1 << 15 + && sh <= 1 << 15 + && limits.check_bytes(sw, sh, 4).is_ok() + && dw.position.0 >= display.position.0 + && dw.position.1 >= display.position.1 + && dw.position.0.saturating_add(w as i32) <= display.position.0.saturating_add(sw as i32) + && dw.position.1.saturating_add(h as i32) <= display.position.1.saturating_add(sh as i32) => + { + (Some((sw, sh)), (dw.position.0 - display.position.0, dw.position.1 - display.position.1)) + } + _ => (None, (0, 0)), + }; + + let exr_types: Vec = header.channels.list.iter().map(|c| c.sample_type).collect(); + let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum(); + let multipart = meta.requirements.has_multiple_layers; + + // The offset tables follow the headers: `chunk_count` u64 per header, in header order. + // Our part's table starts after the tables of all earlier parts. + let mut table_pos = header_end(bytes)?; + for h_ in meta.headers.iter().take(header_index) { + table_pos = table_pos.saturating_add((h_.chunk_count.min(1 << 24) as u64).saturating_mul(8)); + } + let mut cur = Cursor::new(bytes); + seek_to(&mut cur, table_pos)?; + let mut offsets = Vec::with_capacity(header.chunk_count.min(1 << 20)); + for _ in 0..header.chunk_count { + offsets.push(u64(&mut cur)?); + } + let tables_end = cur.position(); + + // Block geometry. + let (tiles_x, tile_w, tile_h, slots) = match &header.blocks { + BlockDescription::ScanLines => (0usize, w, 1usize, h), // one line per block for NONE/RLE/ZIPS + BlockDescription::Tiles(t) => { + if t.level_mode != LevelMode::Singular { + return Err(unsup("deep mip-map or rip-map tiles (single-level tiles are supported)")); + } + let (tw, th) = (t.tile_size.0.max(1), t.tile_size.1.max(1)); + let tx = w.saturating_add(tw - 1) / tw; + let ty = h.saturating_add(th - 1) / th; + (tx, tw, th, tx.checked_mul(ty).ok_or_else(|| err("tile count overflows"))?) + } + }; + + // Each block has exactly one chunk (a duplicate or a missing one is malformed), so a table + // of any other length is refused here, before the per-block bookkeeping is sized by the + // header's geometry: `seen` and `chunks` stay proportional to the offset table in the file. + if offsets.len() != slots { + return Err(err(format!("the offset table lists {} chunks, the data window needs {slots}", offsets.len()))); + } + // Per-pixel sample counts in `counts[p + 1]`, made cumulative after the first pass. + let mut counts = alloc_zeroed::(index_len)?; + let mut seen = vec![false; slots]; + let mut chunks: Vec = Vec::with_capacity(offsets.len()); + + for &off in &offsets { + if off < tables_end || off >= bytes.len() as u64 { + return Err(err(format!("chunk offset {off} is outside the chunk area of the file"))); + } + seek_to(&mut cur, off)?; + if multipart { + let part = i32(&mut cur)?; + if part < 0 || part as usize != header_index { + return Err(err(format!("chunk declares part {part}, expected {header_index}"))); + } + } + // Block coordinates → the block's pixel bounds and its slot. + let (block_rows, block_cols, slot): (std::ops::Range, std::ops::Range, usize) = match &header.blocks { + BlockDescription::ScanLines => { + let y = i32(&mut cur)?; + let row = match y.checked_sub(y0) { + Some(d) if d >= 0 && (d as usize) < h => d as usize, + _ => return Err(err(format!("scan line block y={y} is outside the data window"))), + }; + (row..row + 1, 0..w, row) + } + BlockDescription::Tiles(_) => { + let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?); + if (lx, ly) != (0, 0) { + return Err(err(format!("deep tile at level ({lx},{ly}); only level 0 exists in a single-level file"))); + } + if tx < 0 || ty < 0 || (tx as usize) >= tiles_x || (ty as usize) * tile_h >= h { + return Err(err(format!("tile ({tx},{ty}) is outside the data window"))); + } + let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h); + let rows = (by + tile_h).min(h); + let cols = (bx + tile_w).min(w); + let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize; + (by..rows, bx..cols, slot) + } + }; + let block_len = block_rows.len().saturating_mul(block_cols.len()); + if seen.get(slot).copied().unwrap_or(true) { + return Err(err(format!("two chunks cover block {slot}"))); + } + if let Some(s) = seen.get_mut(slot) { + *s = true; + } + + let packed_table = u64(&mut cur)?; + let packed_data = u64(&mut cur)?; + let unpacked_data = u64(&mut cur)?; + let remaining = (bytes.len() as u64).saturating_sub(cur.position()); + if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { + return Err(err("deep chunk sizes point past the end of the file")); + } + let what = "the sample-count table"; + let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; + let expected_table = block_len.checked_mul(4).ok_or_else(|| err("count table overflows"))?; + // A compressed clipped table also needs its scratch copy. The data-window check only + // budgets four bytes per channel per pixel, insufficient for a one-channel image. + // An equal-sized payload takes decompress's raw path, which allocates no scratch. + // Both are admitted against what the sample-count index leaves of `max_bytes`. + let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; + let clipped_need = (expected_table as u64).saturating_mul(buffers); + if clipped_need > budget { + return Err(Error::LimitExceeded(format!( + "{what} needs {clipped_need} bytes including decompression scratch, on top of the {index_bytes} byte sample-count index, exceeding max_bytes {}", + limits.max_bytes + ))); + } + // An edge tile's table may still be stored at the full tile size (its pixels first, in + // rows of the clipped width, then unused entries), as the OpenEXR library writes it. + // That size comes from the header's tile size alone, which nothing ties to the data + // window, so it is tried only when `max_bytes` has room for the table and for the copy + // that undoes its byte split. Otherwise only the clipped size is read. + let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4)); + let raw = match full_table { + // Scan lines and interior tiles: the full size is the clipped size. + Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?, + Some(full) if (full as u64).saturating_mul(2) <= budget => { + decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))? + } + _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| { + // Four bytes per pixel of the tile, twice: the table and its scratch copy. + let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8); + Error::LimitExceeded(format!( + "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, on top of the {index_bytes} byte sample-count index, exceeding max_bytes {}", + limits.max_bytes + )) + })?, + }; + + // The table is cumulative within each row of the block (a tile restarts at every row); + // each pixel's own count is the difference to its left neighbour's entry. + let mut entries = raw.as_chunks::<4>().0.iter(); + let mut total = 0u64; + for yy in block_rows.clone() { + let mut prev = 0u32; + for xx in block_cols.clone() { + let v = u32::from_le_bytes(*entries.next().ok_or_else(|| err("the count table is short"))?); + if v < prev { + return Err(err("the sample-count table decreases (malformed cumulative counts)")); + } + let p = yy.saturating_mul(w).saturating_add(xx).saturating_add(1); + if let Some(c) = counts.get_mut(p) { + *c = u64::from(v - prev); + } + prev = v; + } + total = total.checked_add(u64::from(prev)).ok_or_else(|| err("the block's sample count overflows"))?; + } + let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?; + if expected_data != unpacked_data { + return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}"))); + } + chunks.push(DeepChunk { rows: block_rows, cols: block_cols, pos: off, packed_data, unpacked_data }); + } + if seen.iter().any(|s| !s) { + let missing = seen.iter().filter(|s| !**s).count(); + return Err(err(format!("{missing} of {slots} chunks are missing from the offset table"))); + } + + // Global cumulative counts in raster order, in place: pixel `p` holds the samples + // `counts[p]..counts[p + 1]`. + let mut acc = 0u64; + for c in counts.iter_mut() { + acc = acc.checked_add(*c).ok_or_else(|| err("the total sample count overflows"))?; + *c = acc; + } + let total_samples = acc; + let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?; + if sample_bytes_total > budget { + return Err(Error::LimitExceeded(format!( + "deep samples need {sample_bytes_total} bytes on top of the {index_bytes} byte sample-count index, exceeding max_bytes {}", + limits.max_bytes + ))); + } + let total_samples = + usize::try_from(total_samples).map_err(|_| Error::LimitExceeded(format!("{total_samples} deep samples do not fit in memory")))?; + + let mut channels: Vec = Vec::with_capacity(header.channels.list.len()); + for c in &header.channels.list { + channels.push(DeepChannel { name: c.name.to_string(), sample: SampleType::of(c.sample_type), samples: alloc_zeroed::(total_samples)? }); + } + + // Second pass: the compressed sample data, placed row by row (a block's channel data is + // contiguous in the file, but a tile's rows interleave with other tiles' in raster order). + for chunk in &chunks { + seek_to(&mut cur, chunk.pos)?; + if multipart { + let _part = i32(&mut cur)?; + } + match &header.blocks { + BlockDescription::ScanLines => { + let _y = i32(&mut cur)?; + } + BlockDescription::Tiles(_) => { + let _coords = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?); + } + } + let packed_table = u64(&mut cur)?; + let _packed_data = u64(&mut cur)?; + let _unpacked_data = u64(&mut cur)?; + // The count table sits between the sizes and the sample data; skip its bytes. + read_exact(&mut cur, packed_table as usize, "the sample-count table")?; + let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?; + let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; + // Row by row (a scan line block is one row), and within a row channel by channel, as + // flat tiles are laid out: each pixel's samples are contiguous within its channel. A + // block row is a run of consecutive pixels in raster order, so its samples are the + // contiguous range `counts[first]..counts[last + 1]` of every output channel. + let mut in_off = 0usize; + for yy in chunk.rows.clone() { + let first = yy.saturating_mul(w).saturating_add(chunk.cols.start); + let end = yy.saturating_mul(w).saturating_add(chunk.cols.end); + let index = |p: usize| counts.get(p).copied().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| err("pixel index out of range")); + let (row_from, row_to) = (index(first)?, index(end)?); + let row_total = row_to.checked_sub(row_from).ok_or_else(|| err("the sample-count index decreases"))?; + for (ci, t) in exr_types.iter().enumerate() { + let sb = sample_bytes(*t); + let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?; + let stop = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?; + let src = raw.get(in_off..stop).ok_or_else(|| err("the sample data is short"))?; + in_off = stop; + let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?; + let dst = out.samples.get_mut(row_from..row_to).ok_or_else(|| err("the sample data is short"))?; + for (slot, b) in dst.iter_mut().zip(src.chunks_exact(sb)) { + *slot = sample_value(*t, b); + } + } + } + } + + Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts }) +} + +/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`] +/// consumes exactly the version field and the headers, so re-reading with a fresh cursor +/// and taking its end position yields it. +fn header_end(bytes: &[u8]) -> Result { + let mut cur = Cursor::new(bytes); + MetaData::read_from_buffered(&mut cur, false).map_err(err)?; + Ok(cur.position()) +} + +/// Composite the deep samples into a flat image: per pixel, samples sorted by Z (stable; the +/// file is assumed messy), front-to-back `over` with premultiplied colour, un-premultiplied +/// into straight alpha at the end. Without any alpha channel the front-most sample wins, +/// opaque. Z, ZBack and other deep channels influence the flat result only through the +/// ordering; volume samples (with ZBack) are approximated as point samples. +pub fn flatten(deep: &DeepImage, limits: &Limits) -> Result { + let npx = deep.width as usize * deep.height as usize; + let has = |n: &str| deep.channel(n).is_some(); + let rgb = has("R") && has("G") && has("B"); + let gray = !rgb && (has("Y") || deep.channels.len() == 1); + let layout = if rgb || gray { + if has("A") || has("AR") || has("AG") || has("AB") { + if rgb { ColorLayout::Rgba } else { ColorLayout::YA } + } else if rgb { + ColorLayout::Rgb + } else { + ColorLayout::Y + } + } else { + return Err(unsup("no R/G/B or Y channels in the deep part")); + }; + let nc = layout.channels(); + // Admit the flattened output at its true size: nc f32 samples per pixel. The deep decode + // admitted the data window at four bytes per pixel, before the flattened channel layout + // was known, so re-check here with the actual layout and f32 sample size. + limits.check_bytes(deep.width, deep.height, nc as u64 * 4)?; + let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) }; + let (r, g, b) = (samples("R"), samples("G"), samples("B")); + let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB")); + let (a, z) = (samples("A"), samples("Z")); + let y = samples("Y"); + let lum = if y.is_empty() { deep.channels.first().map(|c| c.samples.as_slice()).unwrap_or(&[]) } else { y }; + + let mut out = alloc_zeroed::(npx * nc)?; + let mut order: Vec = Vec::new(); + let mut max_samples = 0u32; + for p in 0..npx { + let range = deep.sample_range(p); + let (s0, n) = (range.start as usize, (range.end - range.start) as usize); + max_samples = max_samples.max(u32::try_from(n).unwrap_or(u32::MAX)); + if n == 0 { + continue; // no samples: transparent black + } + order.clear(); + order.extend(0u32..n as u32); + if !z.is_empty() { + // Stable sort by Z; a NaN depth (invalid) sorts last, deterministically. + order.sort_by(|&i, &j| { + let zi = z.get(s0 + i as usize).copied().unwrap_or(f32::NAN); + let zj = z.get(s0 + j as usize).copied().unwrap_or(f32::NAN); + zi.total_cmp(&zj) + }); + } + let at = |ch: &[f32], i: u32| ch.get(s0 + i as usize).copied().unwrap_or(0.0); + // Per-colour-channel alpha: AR/AG/AB when present, else A, else opaque. + let alpha = |i: u32| -> [f32; 3] { + let s = at(a, i); + [ + if !a.is_empty() && ar.is_empty() { + s + } else if !ar.is_empty() { + at(ar, i) + } else { + 1.0 + }, + if !a.is_empty() && ag.is_empty() { + s + } else if !ag.is_empty() { + at(ag, i) + } else { + 1.0 + }, + if !a.is_empty() && ab.is_empty() { + s + } else if !ab.is_empty() { + at(ab, i) + } else { + 1.0 + }, + ] + }; + let mut cacc = [0f32; 3]; // premultiplied colour + let mut aacc = [0f32; 3]; // accumulated alpha + for &i in &order { + let ai = alpha(i); + let src = if rgb { [at(r, i), at(g, i), at(b, i)] } else { [at(lum, i); 3] }; + let mut covered = true; + for k in 0..3 { + let t = (1.0 - aacc[k]).max(0.0); + cacc[k] += src[k] * t; + aacc[k] += t * ai[k]; + covered &= aacc[k] >= 1.0; + } + if covered { + break; // everything behind is hidden + } + } + let a_out = if a.is_empty() && ar.is_empty() && ag.is_empty() && ab.is_empty() { 1.0 } else { (aacc[0] + aacc[1] + aacc[2]) / 3.0 }; + let a_clamped = a_out.clamp(0.0, 1.0); + // Un-premultiply into straight alpha (the deep colours are premultiplied). + let straight = |c: f32| if a_clamped > 1e-6 { c / a_clamped } else { 0.0 }; + let base = p * nc; + // Written by layout: RGB(A), gray(+A) — a fixed RGBA quadruple would put a luminance + // copy into a GrayA image's alpha slot. + // A fixed array plus a length: this runs once per pixel, so no allocation. + let (vals, len) = match (rgb, nc) { + (true, 4) => ([straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), a_clamped], 4), + (true, _) => ([straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), 0.0], 3), + (false, 2) => ([straight(cacc[0]), a_clamped, 0.0, 0.0], 2), + (false, _) => ([straight(cacc[0]), 0.0, 0.0, 0.0], 1), + }; + for (k, v) in vals.into_iter().take(len).enumerate() { + if let Some(slot) = out.get_mut(base + k) { + *slot = v; + } + } + } + // Open on the full display-window stage when the file declares one: the renderer's data + // window is its crop, and the composition lives on the whole stage. The composed region + // pads to the stage at its offset, transparent elsewhere. + match deep.stage { + Some((sw, sh)) => { + // The stage can be larger than the data window: admit it at its real size first. + limits.check_bytes(sw, sh, nc as u64 * 4)?; + let mut padded = alloc_zeroed::(sw as usize * sh as usize * nc)?; + let row_len = deep.width as usize * nc; + let dx = deep.origin.0.max(0) as usize; + for row in 0..deep.height as usize { + let src = row * row_len; + let dst = (row + deep.origin.1.max(0) as usize) * sw as usize + dx; + if let (Some(d), Some(r)) = (padded.get_mut(dst * nc..dst * nc + row_len), out.get(src..src + row_len)) { + d.copy_from_slice(r); + } + } + Ok(Flat { width: sw, height: sh, layout, samples: padded, stage_origin: Some(deep.origin), max_samples_per_pixel: max_samples }) + } + None => Ok(Flat { width: deep.width, height: deep.height, layout, samples: out, stage_origin: None, max_samples_per_pixel: max_samples }), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use super::*; + use std::io::Write as _; + + #[test] + fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() { + let bytes = [1u8, 2, 3, 4]; + let mut cur = Cursor::new(bytes.as_slice()); + let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes"); + assert_eq!(prefix, &[1, 2, 3]); + assert_eq!(prefix.as_ptr(), bytes.as_ptr()); + assert_eq!(cur.position(), 3); + assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(Error::Malformed(_)))); + assert_eq!(cur.position(), 3); + assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(Error::Malformed(_)))); + cur.set_position(u64::MAX); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(Error::Malformed(_)))); + } + + /// What the writer stores before compressing: the byte split, then the +128 predictor. + fn predicted(raw: &[u8]) -> Vec { + let half = raw.len().div_ceil(2); + let mut out = vec![0u8; raw.len()]; + for (i, &v) in raw.iter().enumerate() { + out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v; + } + let mut prev = 0i16; + for (i, b) in out.iter_mut().enumerate() { + let v = i16::from(*b); + if i > 0 { + *b = (v - prev + 128) as u8; + } + prev = v; + } + out + } + + /// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count + /// table, with the copy that undoes its byte split), so decompression must not reserve + /// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table + /// 4 MiB, so the "admitted" table and its copy took up to three times the table size. + #[test] + fn decompressed_buffers_reserve_no_more_than_expected() { + for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] { + let raw = vec![0u8; n]; + let p = predicted(&raw); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&p).expect("write to a Vec"); + let zips = z.finish().expect("finish a Vec"); + assert!(decompress(Compression::ZIP1, &zips[..zips.len() - 1], n, "the table").is_err()); + // RLE: the first byte as a literal, then runs of up to 128 copies of the next. + let mut rle = vec![0xFF, p[0]]; + for run in p[1..].chunks(128) { + rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]); + } + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + let out = decompress(compression, packed, n, "the table").expect("decompresses"); + assert_eq!(out, raw, "{compression:?}, {n} bytes"); + assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity()); + } + // One byte short, the same data expands past the expected size and is refused. + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes"); + } + } + } + + /// A size past the limit is malformed data, and a reservation the allocator refuses is an + /// error rather than an abort. + #[test] + fn reservations_past_the_limit_or_the_allocator_are_errors() { + let mut v = Vec::new(); + assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(Error::Malformed(_)))); + assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(Error::LimitExceeded(_)))); + assert_eq!(v.capacity(), 0); + } +} diff --git a/crates/exr/src/deep_tests.rs b/crates/exr/src/deep_tests.rs new file mode 100644 index 00000000..cb172180 --- /dev/null +++ b/crates/exr/src/deep_tests.rs @@ -0,0 +1,1015 @@ +//! Deep EXR, ported from PhotoCraft's tests: a synthetic generator builds deep chunks per the +//! OpenEXR file layout specification (NONE, RLE and ZIPS × scan lines and tiles), the oracle +//! tests check the compositing against hand-computed values, and malformed inputs must error, +//! never panic. PhotoCraft's check against the OpenEXR project's deep test images stays with its +//! corpus. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write as _; + +use crate::channels::ColorLayout; +use crate::decode::{Error as CodecError, Limits, SampleType, f16}; +use crate::deep::{Flat, decode_deep as decode_deep_exr, flatten}; + +/// What PhotoCraft's tests read off a flattened picture. +enum Warn { + DeepFlattened, + DisplayWindowStage { origin: (i32, i32) }, +} + +struct Img { + flat: Flat, + warnings: Vec, +} + +impl Img { + fn layout(&self) -> ColorLayout { + self.flat.layout + } + fn sample_type(&self) -> SampleType { + SampleType::F32 + } + fn to_f32_samples(&self) -> Option> { + Some(self.flat.samples.clone()) + } + fn width(&self) -> u32 { + self.flat.width + } + fn height(&self) -> u32 { + self.flat.height + } +} + +/// The flat decode of a deep file, as PhotoCraft's picture decode does it: the part sizes checked +/// first, then the deep samples read and flattened. +fn decode_with(bytes: &[u8], limits: Limits) -> Result { + crate::decode::parts(bytes, &limits)?; + let flat = flatten(&decode_deep_exr(bytes, &limits)?, &limits)?; + let mut warnings = vec![Warn::DeepFlattened]; + if let Some(origin) = flat.stage_origin { + warnings.push(Warn::DisplayWindowStage { origin }); + } + Ok(Img { flat, warnings }) +} + +fn decode(bytes: &[u8]) -> Result { + decode_with(bytes, Limits::default()) +} + +// --------------------------------------------------------------------------------------------- +// A minimal deep-EXR writer, mirroring what the reference files show on disk: +// version 2 | deep bit (and the tile bit for tiles), one header with the mandatory +// attributes in alphabetical order, a chunk offset table, then deep chunks of +// [y | tile coords][u64 packed table][u64 packed data][u64 unpacked data][table][data]. +// The table holds u32 sample counts, cumulative within each row of the block; the sample +// data is grouped per row, then per channel, in pixel order (the layout the OpenEXR library +// writes). NONE stores raw; RLE and ZIPS separate even/odd bytes, apply the +128 predictor, +// then RLE packs runs and ZIPS deflates. + +fn attr(out: &mut Vec, name: &str, ty: &str, payload: &[u8]) { + out.extend_from_slice(name.as_bytes()); + out.push(0); + out.extend_from_slice(ty.as_bytes()); + out.push(0); + out.extend_from_slice(&(payload.len() as i32).to_le_bytes()); + out.extend_from_slice(payload); +} + +fn samples_to_differences(buf: &mut [u8]) { + if let Some(&first) = buf.first() { + let mut prev = i16::from(first); + for b in buf.iter_mut().skip(1) { + let v = i16::from(*b); + *b = (v - prev + 128) as u8; + prev = v; + } + } +} + +fn separate_bytes_fragments(buf: &mut [u8]) { + let copy = buf.to_vec(); + let half = copy.len().div_ceil(2); + for i in 0..copy.len() { + buf[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = copy[i]; + } +} + +fn rle_bytes(src: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < src.len() { + let mut run = 1; + while i + run < src.len() && run < 128 && src[i + run] == src[i] { + run += 1; + } + // OpenEXR tokens: `n >= 0` repeats the next byte n + 1 times, `n < 0` copies -n bytes. + if run > 1 { + out.push((run - 1) as u8); + out.push(src[i]); + i += run; + } else { + let start = i; + i += 1; + while i < src.len() && (i - start) < 127 && src[i] != src[i - 1] { + i += 1; + } + let n = i - start; + out.push((-(n as i32)) as u8); + out.extend_from_slice(&src[start..start + n]); + } + } + out +} + +fn compress(kind: u8, raw: &[u8]) -> Vec { + match kind { + 1 => { + // Like ZIPS, RLE packs the byte-split, predicted stream. + let mut b = raw.to_vec(); + separate_bytes_fragments(&mut b); + samples_to_differences(&mut b); + let c = rle_bytes(&b); + if c.len() < raw.len() { c } else { raw.to_vec() } + } + 2 => { + let mut b = raw.to_vec(); + separate_bytes_fragments(&mut b); + samples_to_differences(&mut b); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + // Writing to a Vec never fails; the encoder errors surface in `finish`. + #[allow(clippy::unwrap_used)] + z.write_all(&b).unwrap(); + #[allow(clippy::unwrap_used)] + let c = z.finish().unwrap(); + if c.len() < raw.len() { c } else { raw.to_vec() } + } + _ => raw.to_vec(), + } +} + +/// One channel: a name (the caller must pass channels in alphabetical order, as chlist +/// requires) and the sample type as in the chlist attribute (0 = u32, 1 = half, 2 = float). +struct Chan { + name: &'static str, + ty: i32, +} + +/// `counts[p]` samples per pixel; `sample(p, ci)` returns channel `ci`'s values for pixel +/// `p` (its length must be `counts[p]`). `tile` switches between deepscanline and deeptile. +#[allow(clippy::too_many_arguments)] +fn gen_deep( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + gen_deep_tables(w, h, channels, compression, tile, counts, sample, false) +} + +/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile +/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its +/// clipped width), then zeros. +#[allow(clippy::too_many_arguments)] +fn gen_deep_tables( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, + full_tile_tables: bool, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); + // Single-part deep files (deepscanline and deeptile alike) carry only the deep-data + // flag; the tiling lives in the `tiles` attribute and the `type` string. + out.extend_from_slice(&(2u32 | 0x800).to_le_bytes()); + + let mut chlist = Vec::new(); + for c in channels { + chlist.extend_from_slice(c.name.as_bytes()); + chlist.push(0); + chlist.extend_from_slice(&c.ty.to_le_bytes()); + chlist.push(0); // pLinear + chlist.extend_from_slice(&[0u8; 3]); + chlist.extend_from_slice(&1i32.to_le_bytes()); + chlist.extend_from_slice(&1i32.to_le_bytes()); + } + chlist.push(0); + attr(&mut out, "channels", "chlist", &chlist); + attr(&mut out, "compression", "compression", &[compression]); + // box2i is inclusive: the data window of a w×h image at the origin ends at (w-1, h-1). + let dw: Vec = [0i32, 0, w as i32 - 1, h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "dataWindow", "box2i", &dw); + attr(&mut out, "displayWindow", "box2i", &dw); + attr(&mut out, "lineOrder", "lineOrder", &[0]); + attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes()); + attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat()); + attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes()); + if let Some((tw, th)) = tile { + let mut td = Vec::new(); + td.extend_from_slice(&(tw as u32).to_le_bytes()); + td.extend_from_slice(&(th as u32).to_le_bytes()); + td.push(0); // one level, round down + attr(&mut out, "tiles", "tiledesc", &td); + } + attr(&mut out, "type", "string", if tile.is_some() { b"deeptile" } else { b"deepscanline" }); + attr(&mut out, "version", "int", &1i32.to_le_bytes()); + out.push(0); // end of the header + + // The blocks: scan lines (one line each) or the level-0 tiles. + struct Block { + pixels: Vec, + /// Pixels per row: the count table restarts and the sample data is grouped per row. + row_w: usize, + } + let blocks: Vec = match tile { + None => (0..h).map(|y| Block { pixels: (y * w..y * w + w).collect(), row_w: w }).collect(), + Some((tw, th)) => { + let tx = w.div_ceil(tw); + let ty = h.div_ceil(th); + let mut v = Vec::new(); + for by in 0..ty { + for bx in 0..tx { + let mut px = Vec::new(); + for yy in (by * th)..((by * th + th).min(h)) { + for xx in (bx * tw)..((bx * tw + tw).min(w)) { + px.push(yy * w + xx); + } + } + v.push(Block { pixels: px, row_w: (bx * tw + tw).min(w) - bx * tw }); + } + } + v + } + }; + + let table_len = |b: &Block| b.pixels.len() * 4; + let data_len = |b: &Block| -> usize { + let samples: u64 = b.pixels.iter().map(|&p| counts[p] as u64).sum(); + channels.iter().map(|c| samples as usize * if c.ty == 1 { 2 } else { 4 }).sum() + }; + + // Build every chunk, remember the offsets, then write the offset table first. + let mut chunks: Vec<(usize, Vec)> = Vec::new(); // (block index, framed chunk) + for (bi, b) in blocks.iter().enumerate() { + let mut c = Vec::new(); + match tile { + None => { + let y = (bi % h) as i32; + c.extend_from_slice(&y.to_le_bytes()); + } + Some((tw, _)) => { + let tx = w.div_ceil(tw); + let (bx, by) = (bi % tx, bi / tx); + c.extend_from_slice(&(bx as i32).to_le_bytes()); + c.extend_from_slice(&(by as i32).to_le_bytes()); + c.extend_from_slice(&0i32.to_le_bytes()); + c.extend_from_slice(&0i32.to_le_bytes()); + } + } + let mut table = Vec::with_capacity(b.pixels.len() * 4); + for row in b.pixels.chunks(b.row_w) { + let mut acc = 0u32; + for &p in row { + acc += counts[p]; + table.extend_from_slice(&acc.to_le_bytes()); + } + } + assert_eq!(table.len(), table_len(b)); + if let (true, Some((tw, th))) = (full_tile_tables, tile) { + table.resize(tw * th * 4, 0); + } + let mut data = Vec::new(); + for row in b.pixels.chunks(b.row_w) { + for (ci, ch) in channels.iter().enumerate() { + for &p in row { + for v in sample(p, ci) { + match ch.ty { + 1 => data.extend_from_slice(&f16::from_f32(v).to_le_bytes()), + 0 => data.extend_from_slice(&(v as u32).to_le_bytes()), + _ => data.extend_from_slice(&v.to_le_bytes()), + } + } + } + } + } + let ct = compress(compression, &table); + let cd = compress(compression, &data); + c.extend_from_slice(&(ct.len() as u64).to_le_bytes()); + c.extend_from_slice(&(cd.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&ct); + c.extend_from_slice(&cd); + assert_eq!(data.len(), data_len(b)); + chunks.push((bi, c)); + } + let mut pos = out.len() + chunks.len() * 8; + let mut table = Vec::new(); + for (_, c) in &chunks { + table.extend_from_slice(&(pos as u64).to_le_bytes()); + pos += c.len(); + } + out.extend_from_slice(&table); + for (_, c) in chunks { + out.extend_from_slice(&c); + } + out +} + +/// RGBA float channels in chlist (alphabetical) order, no Z (everything composites). +const RGBA_NO_Z: [Chan; 4] = [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }]; + +/// RGBA(Z) float channels in chlist (alphabetical) order. +const RGBAZ: [Chan; 5] = + [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }]; + +fn sample_rgba(p: usize, ci: usize) -> Vec { + // One sample per pixel: a small checkerboard-ish pattern with varying depth. Deep + // colours are premultiplied, so the stored RGB values include the 0.5 alpha. + let v = [0.25f32, 0.5, 0.75, 1.0][p % 4]; + match ci { + 0 => vec![0.5], // A + 1 => vec![0.125 * v], // B = straight 0.25v, premultiplied + 2 => vec![0.25 * v], // G = straight 0.5v, premultiplied + 3 => vec![0.5 * v], // R = straight v, premultiplied + _ => vec![10.0 + p as f32], // Z + } +} + +const COUNTS_ONE: &[u32] = &[1; 16]; // for a 4x4 image + +fn rgba_flat(bytes: &[u8]) -> Vec { + let img = decode(bytes).expect("decodes"); + let s = img.to_f32_samples().expect("f32"); + assert_eq!(img.layout(), ColorLayout::Rgba); + assert_eq!(img.sample_type(), SampleType::F32); + assert!(img.warnings.iter().any(|w| matches!(w, Warn::DeepFlattened))); + s +} + +// --------------------------------------------------------------------------------------------- +// Oracles + +/// One sample per pixel: the flat image is the stored colour un-premultiplied. +#[test] +fn single_sample_matches_flat() { + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + let px = rgba_flat(&bytes); + for p in 0..16 { + let v = [0.25f32, 0.5, 0.75, 1.0][p % 4]; + let (r, g, b, a) = (px[p * 4], px[p * 4 + 1], px[p * 4 + 2], px[p * 4 + 3]); + assert!((r - v).abs() < 1e-6, "r at {p}: {r}"); + assert!((g - 0.5 * v).abs() < 1e-6, "g at {p}: {g}"); + assert!((b - 0.25 * v).abs() < 1e-6, "b at {p}: {b}"); + assert!((a - 0.5).abs() < 1e-6, "a at {p}: {a}"); + } +} + +/// Two samples stored back-to-front: the front (smaller Z) covers the back. Hand-computed: +/// front a=0.5, premultiplied colour (0.5, 0.25, 0); back a=0.25, premultiplied (0.25, 0.25, 0.25). +/// Over (front first): a = 0.5 + (1−0.5)·0.25 = 0.625; c = (0.5 + 0.5·0.25, 0.25 + 0.5·0.25, +/// 0 + 0.5·0.25) = (0.625, 0.375, 0.125); straight = c / a = (1.0, 0.6, 0.2). +#[test] +fn z_order_composites_front_over_back() { + // pixel 0: samples [(z=9, back), (z=1, front)] — the file order is reversed on purpose. + let counts = vec![2u32; 16]; + let sample = |p: usize, ci: usize| -> Vec { + if p != 0 { + let v = sample_rgba(p, ci); + return vec![v[0], v[0]]; + } + match ci { + 0 => vec![0.25, 0.5], // A: back first + 1 => vec![0.25, 0.0], // B + 2 => vec![0.25, 0.25], // G + 3 => vec![0.25, 0.5], // R + _ => vec![9.0, 1.0], // Z + } + }; + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let px = rgba_flat(&bytes); + let a = 0.5f32 + 0.5 * 0.25; + assert!((px[3] - a).abs() < 1e-6, "alpha {}", px[3]); + assert!((px[0] - 0.625 / a).abs() < 1e-6, "r {}", px[0]); + assert!((px[1] - 0.375 / a).abs() < 1e-6, "g {}", px[1]); + assert!((px[2] - 0.125 / a).abs() < 1e-6, "b {}", px[2]); +} + +/// A fully opaque front sample hides everything behind it. +#[test] +fn opaque_front_hides_back() { + let counts = vec![3u32; 16]; + let sample = |p: usize, ci: usize| -> Vec { + if p != 1 { + let v = sample_rgba(p, ci); + return vec![v[0], v[0], v[0]]; + } + match ci { + 0 => vec![1.0, 0.9, 1.0], // front opaque, middle partial, back opaque + 1 => vec![0.0, 1.0, 1.0], + 2 => vec![1.0, 1.0, 1.0], + 3 => vec![1.0, 0.0, 0.0], + _ => vec![1.0, 5.0, 9.0], + } + }; + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let px = rgba_flat(&bytes); + let (r, g, b, a) = (px[4], px[5], px[6], px[7]); + assert!((r - 1.0).abs() < 1e-6 && (g - 1.0).abs() < 1e-6 && (b - 0.0).abs() < 1e-6, "colour ({r},{g},{b})"); + assert!((a - 1.0).abs() < 1e-6, "alpha {a}"); +} + +/// Deep EXR is HDR: colour above 1.0 survives the composite (it used to be clamped), and a +/// tiled file whose edge tiles are narrower than the tile size decodes like the scan lines. +#[test] +fn hdr_colour_is_kept_and_edge_tiles_match_scanlines() { + let (w, h) = (7, 5); + let counts: Vec = (0..w * h).map(|p| (p as u32 * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { + (0..(p as u32 * 3) % 4).map(|k| [1.0, 0.25 * k as f32, 0.5, 2.0 + p as f32 / 8.0, 10.0 - k as f32][ci]).collect() + }; + for compression in [0u8, 1, 2] { + let scan = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, None, &counts, &sample)); + let tiled = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, Some((3, 2)), &counts, &sample)); + assert_eq!(scan, tiled, "compression {compression}"); + // Pixel 1 has 3 opaque samples; the front one (k = 2, Z = 8) has R = 2.125. + assert!((scan[4] - 2.125).abs() < 1e-5, "compression {compression}: R {}", scan[4]); + } +} + +/// Pixels without samples are transparent black, and the structured counts say so. +#[test] +fn empty_pixels_are_transparent() { + let mut counts = vec![0u32; 16]; + counts[5] = 1; + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &|p, ci| { + if p == 5 { vec![[1.0f32, 0.5, 0.5, 0.5, 1.0][ci]] } else { Vec::new() } + }); + let px = rgba_flat(&bytes); + for p in 0..16 { + let base = p * 4; + if p == 5 { + assert!((px[base + 3] - 1.0).abs() < 1e-6); + } else { + assert!(px[base..base + 4].iter().all(|v| *v == 0.0), "pixel {p} not empty"); + } + } + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + assert_eq!(deep.total_samples(), 1); + assert_eq!(deep.sample_range(5).end, 1); +} + +/// RLE, ZIPS and NONE decode to identical pixels and identical structured samples. +#[test] +fn rle_and_zips_match_uncompressed() { + let counts: Vec = (0..16).map(|p| if p % 3 == 0 { 2 } else { 1 }).collect(); + let sample = |p: usize, ci: usize| -> Vec { + if counts[p] == 2 { + match ci { + 0 => vec![0.75, 0.25], + 1 => vec![0.1, 0.2], + 2 => vec![0.3, 0.4], + 3 => vec![0.5, 0.6], + _ => vec![7.0, 3.0], + } + } else { + sample_rgba(p, ci) + } + }; + let base = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let px = rgba_flat(&base); + let deep_base = decode_deep_exr(&base, &Limits::none()).expect("deep"); + for kind in [1u8, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, kind, None, &counts, &sample); + let other = rgba_flat(&bytes); + for (i, (a, b)) in px.iter().zip(other.iter()).enumerate() { + assert!((a - b).abs() < 1e-6, "compression {kind}, sample {i}: {a} vs {b}"); + } + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + assert_eq!(deep.counts, deep_base.counts); + for (a, b) in deep.channels.iter().zip(deep_base.channels.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.samples, b.samples, "channel {}", a.name); + } + } +} + +/// Deep tiles decode to the same pixels as deep scanlines of the same data. +#[test] +fn tiles_match_scanlines() { + let counts = vec![1u32, 0, 2, 1, 1, 1, 0, 1, 2, 1, 1, 0, 1, 1, 1, 1]; + let sample = |p: usize, ci: usize| -> Vec { + if counts[p] == 2 { + match ci { + 0 => vec![0.5, 0.5], + 1 => vec![0.2, 0.1], + 2 => vec![0.3, 0.3], + 3 => vec![0.4, 0.4], + _ => vec![2.0, 8.0], + } + } else if counts[p] > 0 { + vec![[0.5, 0.2, 0.4, 0.7, 4.0][ci]] + } else { + Vec::new() + } + }; + let sl = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let tiled = gen_deep(4, 4, &RGBAZ, 0, Some((2, 2)), &counts, &sample); + let a = rgba_flat(&sl); + let b = rgba_flat(&tiled); + for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() { + assert!((x - y).abs() < 1e-6, "tile sample {i}: {x} vs {y}"); + } +} + +/// Half-float channels come back as F16 samples with exact values. +#[test] +fn half_channels_round_trip() { + let channels = + [Chan { name: "A", ty: 1 }, Chan { name: "B", ty: 1 }, Chan { name: "G", ty: 1 }, Chan { name: "R", ty: 1 }, Chan { name: "Z", ty: 1 }]; + let bytes = gen_deep(4, 4, &channels, 2, None, COUNTS_ONE, &|p, _ci| { + let v = f16::from_f32(0.5).to_f32(); + vec![v * (p as f32 + 1.0) / 4.0] + }); + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + for c in &deep.channels { + assert_eq!(c.sample, SampleType::F16, "{}", c.name); + } + let a = deep.channel("A").expect("A"); + assert_eq!(a.samples.len(), 16); +} + +/// Without an alpha channel the front-most sample wins, opaque; luminance maps to gray. +#[test] +fn without_alpha_front_sample_is_opaque() { + let channels = [Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }]; + let counts = vec![2u32; 16]; + let bytes = gen_deep(4, 4, &channels, 0, None, &counts, &|p, ci| { + if p % 2 == 0 { + match ci { + 0 => vec![0.9, 0.1], + 1 => vec![0.2, 0.8], + 2 => vec![0.4, 0.6], + _ => vec![2.0, 6.0], + } + } else { + vec![[0.0, 0.0, 1.0, 1.0][ci], [0.0, 0.0, 1.0, 9.0][ci]] + } + }); + let img = decode(&bytes).expect("decodes"); + assert_eq!(img.layout(), ColorLayout::Rgb); + let px = img.to_f32_samples().expect("f32"); + // Even pixels: the front sample (z=2) is (r 0.4, g 0.2, b 0.9). + assert!((px[0] - 0.4).abs() < 1e-6 && (px[1] - 0.2).abs() < 1e-6 && (px[2] - 0.9).abs() < 1e-6); +} + +/// Grayscale deep files (Y + Z) flatten to opaque gray, Y + A + Z keeps its alpha: the alpha +/// must land in the GrayA alpha slot, not a luminance copy (that was the bug). +#[test] +fn grayscale_deep_keeps_its_alpha() { + let channels = [Chan { name: "A", ty: 2 }, Chan { name: "Y", ty: 2 }, Chan { name: "Z", ty: 2 }]; + let bytes = gen_deep(4, 4, &channels, 0, None, COUNTS_ONE, &|p, ci| { + match ci { + 0 => vec![0.5], // A + 1 => vec![0.5], // Y, premultiplied: straight 1.0 × 0.5 + _ => vec![p as f32], // Z + } + }); + let img = decode(&bytes).expect("decodes"); + assert_eq!(img.layout(), ColorLayout::YA); + let px = img.to_f32_samples().expect("f32"); + for (p, s) in px.chunks(2).enumerate() { + let (y, a) = (s[0], s[1]); + assert!((a - 0.5).abs() < 1e-6, "pixel {p}: alpha {a}, want 0.5"); + assert!((y - 1.0).abs() < 1e-6, "pixel {p}: luminance {y}, want the un-premultiplied 1.0"); + } + + // Without an alpha channel the same file is opaque gray. + let channels = [Chan { name: "Y", ty: 2 }, Chan { name: "Z", ty: 2 }]; + let bytes = gen_deep(4, 4, &channels, 0, None, COUNTS_ONE, &|p, ci| match ci { + 0 => vec![0.4], + _ => vec![p as f32], + }); + let img = decode(&bytes).expect("decodes"); + assert_eq!(img.layout(), ColorLayout::Y); + let px = img.to_f32_samples().expect("f32"); + for (p, v) in px.iter().enumerate() { + assert!((v - 0.4).abs() < 1e-6, "pixel {p}: {v}, want 0.4"); + } +} + +/// decode_deep returns the counts and channels in file order. +#[test] +fn decode_deep_structure() { + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + assert_eq!((deep.width, deep.height), (4, 4)); + assert_eq!(deep.channels.iter().map(|c| c.name.as_str()).collect::>(), ["A", "B", "G", "R", "Z"]); + assert_eq!(deep.counts.len(), 17); + assert_eq!(deep.counts[0], 0); + assert_eq!(deep.total_samples(), 16); + for p in 0..16 { + assert_eq!(deep.sample_range(p).end - deep.sample_range(p).start, 1); + } + assert_eq!(deep.channel("Z").expect("Z").samples[5], 15.0); +} + +// --------------------------------------------------------------------------------------------- +// Hostile input: every damaged file errors, nothing panics. + +fn truncate_sweep(bytes: &[u8]) { + // Cut at every offset (cap the count for speed) — a cut file must error, not panic. + let step = (bytes.len() / 96).max(1); + let mut i = step; + while i < bytes.len() { + let cut = &bytes[..i]; + let _ = decode_deep_exr(cut, &Limits::none()); + let _ = decode(cut); + i += step; + } +} + +#[test] +fn truncated_files_error_never_panic() { + for kind in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, kind, None, COUNTS_ONE, &sample_rgba); + truncate_sweep(&bytes); + } + let tiled = gen_deep(4, 4, &RGBAZ, 1, Some((2, 2)), COUNTS_ONE, &sample_rgba); + truncate_sweep(&tiled); +} + +#[test] +fn hostile_sizes_and_offsets_error() { + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + // A tiny sample-data budget rejects the file. + let tight = Limits { max_bytes: 64, ..Limits::none() }; + assert!(matches!(decode_deep_exr(&bytes, &tight), Err(CodecError::LimitExceeded(_)))); + // A non-deep file is unsupported for decode_deep. + let flat = crate::decode::write( + 2, + 2, + &[crate::decode::Channel { name: "R".into(), samples: crate::decode::Samples::F32(vec![0.5; 4]) }], + crate::decode::Compression::None, + ) + .expect("encode"); + assert!(matches!(decode_deep_exr(&flat, &Limits::none()), Err(CodecError::Unsupported(_)))); + // The last chunk's y coordinate destroyed: out of the data window. + let mut broken = bytes.clone(); + let header_end = header_end_of(&broken); + let last_off = u64::from_le_bytes(broken[header_end + 24..header_end + 32].try_into().expect("8")) as usize; + broken[last_off..last_off + 4].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(decode_deep_exr(&broken, &Limits::none()).is_err()); + assert!(decode(&broken).is_err()); + // Two chunks claiming the same block: duplicate offsets. + let mut dup = bytes.clone(); + let header_end = header_end_of(&dup); + let first: [u8; 8] = dup[header_end..header_end + 8].try_into().expect("8"); + dup[header_end + 8..header_end + 16].copy_from_slice(&first); + assert!(matches!(decode_deep_exr(&dup, &Limits::none()), Err(CodecError::Malformed(_)))); + // An offset pointing past the file. + let mut far = bytes.clone(); + far[header_end..header_end + 8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed(_)))); +} + +/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes +/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4 +/// image, its table stored at that size, makes a compressed file of tens of KB whose table +/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a +/// budget smaller than both the table must be refused before it is allocated; with room for +/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of +/// ordinary tiles, still decode under a small budget. +#[test] +fn deep_tile_count_table_is_admitted_against_max_bytes() { + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + let tight = budget(1 << 20); + let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + for compression in [0u8, 1, 2] { + let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true); + assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &tight); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}", + got.map(|d| d.total_samples()) + ); + let flat = decode_with(&big, tight); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width())); + // The same tile with its table stored at the clipped size fits the small budget. + let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba); + assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}"); + if compression != 0 { + assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len()); + } + // The table and its scratch copy are admitted together, 8 MiB, on top of the 4x4 + // image's sample-count index (17 u64). + let index = 17 * 8; + assert_eq!(decode_deep_exr(&big, &budget(2 * table + index)).expect("room for the table and its copy"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &budget(2 * table + index - 1)); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: one byte short gave {:?}", + got.map(|d| d.total_samples()) + ); + } + // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones. + let counts: Vec = (0..35).map(|p| (p * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true); + let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample); + assert_eq!( + decode_deep_exr(&full, &tight).expect("full-size edge tables"), + decode_deep_exr(&clipped, &tight).expect("clipped edge tables"), + "compression {compression}" + ); + } +} + +/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side, +/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits, +/// where its full size passes the check and is only tried as the data arrives. Each tile is +/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over +/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's +/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for +/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint +/// channels decode after full-size tables like float ones. +#[test] +fn deep_tile_table_admission_edges() { + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + let side = (1 << 30) - 2; + for compression in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba); + for limits in [Limits::none(), Limits::default(), budget(1 << 20)] { + assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}"); + } + } + let counts: Vec = (0..48).map(|p| (p % 3) as u32).collect(); + let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c }); + let [a, b, g, r, z] = RGBAZ; + let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }]; + for channels in [&RGBAZ[..], &half[..], &with_id[..]] { + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true); + let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample); + let what = format!("{} channels, compression {compression}", channels.len()); + let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables"); + assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}"); + } + } +} + +/// A one-channel data-window check admits only four bytes per pixel, but a compressed count +/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback; +/// the existing multichannel cases have enough pixel allowance to hide this gap. +#[test] +fn clipped_count_tables_include_scratch_in_the_budget() { + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; 64]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + for tile in [None, Some((64, 1)), Some((128, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + // The table and any scratch, on top of the sample-count index (65 u64). + let index = 65 * 8; + let required = index + if compression == 0 { 256 } else { 512 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}"); + assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}"); + let flat = decode_with(&bytes, budget(required - 1)); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); + if compression == 0 { + assert_eq!(decode_deep_exr(&bytes, &budget(index + 511)).expect("raw table needs no scratch"), want, "{what}"); + } + } + } +} + +/// The per-pixel bookkeeping of a deep decode (the cumulative sample-count index that +/// [`crate::deep::DeepImage::counts`] returns, eight bytes a pixel plus one entry) is +/// charged against `max_bytes`, and the count tables are admitted on top of it. A one-channel +/// image without samples passed the data-window check (four bytes a pixel) and decoded while +/// holding 24 bytes a pixel of bookkeeping: a 4096x4096 file of 65-300 KB held 384 MiB under a +/// 64 or 128 MiB budget. Under that four-byte budget it is now refused; it decodes with room +/// for the index plus the largest count table and its scratch copy, and not one byte less. +#[test] +fn deep_bookkeeping_is_charged_against_max_bytes() { + let (w, h) = (256usize, 256usize); + let n = w * h; + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; n]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + let index = (n as u64 + 1) * 8; + for tile in [None, Some((w, h))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(w, h, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(want.counts.len(), n + 1, "{what}"); + let pixel_budget = budget(4 * n as u64); + let got = decode_deep_exr(&bytes, &pixel_budget); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "{what}: a four-byte-a-pixel budget gave {:?}", got.map(|d| d.counts.len())); + let flat = decode_with(&bytes, pixel_budget); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}: the flat decode gave {:?}", flat.map(|i| i.width())); + // One block's count table (a scan line, or the single tile), with the scratch copy + // that undoes the byte split when it is stored compressed. + let table = 4 * if tile.is_some() { n } else { w } as u64; + let required = index + table * if compression == 0 { 1 } else { 2 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("index and table fit exactly"), want, "{what}"); + let got = decode_deep_exr(&bytes, &budget(required - 1)); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "{what}: one byte short gave {:?}", got.map(|d| d.counts.len())); + } + } +} + +/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's +/// compression tag, decides whether a scratch buffer is needed. +#[test] +fn raw_count_tables_need_no_scratch_even_with_compression() { + let channels = [Chan { name: "Y", ty: 2 }]; + // The four-byte table on top of the one-pixel sample-count index (two u64). + let limits = Limits { max_bytes: 16 + 4, ..Limits::none() }; + for tile in [None, Some((1, 1)), Some((2, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new()); + let header_end = header_end_of(&bytes); + let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize; + let table_size = chunk + if tile.is_some() { 16 } else { 4 }; + let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size")); + assert_eq!(packed, 4, "the four-byte count table is stored raw"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}"); + } + } +} + +/// Walk the attributes like the format prescribes: the offset table starts after the +/// header's terminating zero byte. +fn header_end_of(bytes: &[u8]) -> usize { + let mut p = 8usize; // magic + version + loop { + let Some(nul) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute name") }; + if nul == 0 { + return p + 1; // the empty name terminates the header + } + p += nul + 1; + let Some(t) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute type") }; + p += t + 1; + let size = i32::from_le_bytes(bytes[p..p + 4].try_into().expect("size")) as usize; + p += 4 + size; + } +} + +#[test] +fn inconsistent_count_table_errors() { + // The declared unpacked size disagrees with the count table. + let mut bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + // Find the first chunk: the offset table follows the header (find the "version" attr end). + let header_end = header_end_of(&bytes); + let first_chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("8")) as usize; + // Scanline chunk: y (4), three u64s (24); bump the unpacked size by one. + let at = first_chunk + 4 + 16; + let v = u64::from_le_bytes(bytes[at..at + 8].try_into().expect("8")); + bytes[at..at + 8].copy_from_slice(&(v + 1).to_le_bytes()); + assert!(matches!(decode_deep_exr(&bytes, &Limits::none()), Err(CodecError::Malformed(_)))); +} + +/// `gen_deep` for a cropped render: the data window sits at `(dwx, dwy)` inside a larger +/// display window of `sw × sh` (the stage the renderer composed onto). Scanline chunk Y +/// coordinates are absolute display rows, so they carry the data-window offset. +#[allow(clippy::too_many_arguments)] +fn gen_deep_cropped( + w: usize, + h: usize, + dwx: i32, + dwy: i32, + sw: usize, + sh: usize, + channels: &[Chan], + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); + out.extend_from_slice(&(2u32 | 0x800).to_le_bytes()); + + let mut chlist = Vec::new(); + for c in channels { + chlist.extend_from_slice(c.name.as_bytes()); + chlist.push(0); + chlist.extend_from_slice(&c.ty.to_le_bytes()); + chlist.push(0); + chlist.extend_from_slice(&[0u8; 3]); + chlist.extend_from_slice(&1i32.to_le_bytes()); + chlist.extend_from_slice(&1i32.to_le_bytes()); + } + chlist.push(0); + attr(&mut out, "channels", "chlist", &chlist); + attr(&mut out, "compression", "compression", &[0u8]); + // box2i is inclusive: the windows end at position + size - 1. + let dw: Vec = [dwx, dwy, dwx + w as i32 - 1, dwy + h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "dataWindow", "box2i", &dw); + let disp: Vec = [0i32, 0, sw as i32 - 1, sh as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "displayWindow", "box2i", &disp); + attr(&mut out, "lineOrder", "lineOrder", &[0]); + attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes()); + attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat()); + attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes()); + attr(&mut out, "type", "string", b"deepscanline"); + attr(&mut out, "version", "int", &1i32.to_le_bytes()); + out.push(0); + + // One scanline block per data row; the pixel indexing stays data-relative. + let mut chunks: Vec> = Vec::new(); + for y in 0..h { + let pixels: Vec = (y * w..y * w + w).collect(); + let mut c = Vec::new(); + c.extend_from_slice(&(dwy + y as i32).to_le_bytes()); + let mut table = Vec::with_capacity(pixels.len() * 4); + let mut acc = 0u32; + for &p in &pixels { + acc += counts[p]; + table.extend_from_slice(&acc.to_le_bytes()); + } + let mut data = Vec::new(); + for (ci, _) in channels.iter().enumerate() { + for &p in &pixels { + for v in sample(p, ci) { + data.extend_from_slice(&v.to_le_bytes()); + } + } + } + c.extend_from_slice(&(table.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&table); + c.extend_from_slice(&data); + chunks.push(c); + } + let mut pos = out.len() + chunks.len() * 8; + let mut table = Vec::new(); + for c in &chunks { + table.extend_from_slice(&(pos as u64).to_le_bytes()); + pos += c.len(); + } + out.extend_from_slice(&table); + for c in chunks { + out.extend_from_slice(&c); + } + out +} + +/// A cropped render opens on the full display-window stage: MY27-style renders carry a +/// `display_window` of the full frame (e.g. 4000×3000) while the deep data window is the +/// render pass's crop inside it. The image must open at stage size with the data at its +/// crop offset, transparent elsewhere — not cropped to the data window. +#[test] +fn cropped_deep_data_opens_on_the_display_stage() { + let (w, h, dwx, dwy, sw, sh) = (24usize, 16usize, 8i32, 6i32, 48usize, 32usize); + let counts = vec![1u32; w * h]; + let sample = |p: usize, ci: usize| -> Vec { + // chlist order (RGBA_NO_Z): A, B, G, R. + let x = p % w; + let y = p / w; + match ci { + 0 => vec![1.0], // A + 1 => vec![0.5], // B + 2 => vec![y as f32 / h as f32], // G + _ => vec![x as f32 / w as f32], // R + } + }; + let bytes = gen_deep_cropped(w, h, dwx, dwy, sw, sh, &RGBA_NO_Z, &counts, &sample); + let img = decode(&bytes).expect("cropped deep EXR decodes"); + assert_eq!((img.width(), img.height()), (sw as u32, sh as u32), "opens on the display window stage"); + assert!(img.warnings.iter().any(|w| matches!(w, Warn::DisplayWindowStage { origin } if *origin == (dwx, dwy)))); + + // The data sits at its crop offset: the first data pixel's green channel carries its + // data-row fraction, everything outside the data region stays transparent black. + let s = img.to_f32_samples().expect("f32"); + let nc = s.len() / (sw * sh); + let px = |x: usize, y: usize| -> Vec { s[y * sw * nc + x * nc..y * sw * nc + x * nc + nc].to_vec() }; + let first = px(dwx as usize, dwy as usize); + assert!( + (first[0] - 0.0).abs() < 1e-5 && (first[1] - 0.0).abs() < 1e-5 && (first[2] - 0.5).abs() < 1e-5 && (first[3] - 1.0).abs() < 1e-5, + "first data pixel at the offset: {first:?}" + ); + let last = px(dwx as usize + w - 1, dwy as usize + h - 1); + assert!( + (last[0] - (w - 1) as f32 / w as f32).abs() < 1e-5 && (last[1] - (h - 1) as f32 / h as f32).abs() < 1e-5, + "last data pixel keeps its coordinates: {last:?}" + ); + let outside = px(0, 0); + assert_eq!(outside, vec![0.0, 0.0, 0.0, 0.0], "outside the data region: transparent"); +} diff --git a/crates/exr/src/lib.rs b/crates/exr/src/lib.rs index c9e64236..b28cfa03 100644 --- a/crates/exr/src/lib.rs +++ b/crates/exr/src/lib.rs @@ -14,10 +14,18 @@ //! //! - [`decode`]: bounded part listing, flat decoding (scan lines and tiles, every compression the //! `exr` crate reads) and writing, on top of the `exr` crate. +//! - [`deep`]: deep scan lines and tiles (NONE, RLE, ZIPS) and their flattening. +//! - [`cryptomatte_samples`]: a file's Cryptomatte layers and their per-pixel (ID, coverage) ranks. pub mod channels; pub mod cryptomatte; #[cfg(feature = "decode")] +pub mod cryptomatte_samples; +#[cfg(feature = "decode")] pub mod decode; #[cfg(all(test, feature = "decode"))] mod decode_tests; +#[cfg(feature = "decode")] +pub mod deep; +#[cfg(all(test, feature = "decode"))] +mod deep_tests;