From 4e185b16dc2467cf0fe88b69ff66ad80b34d3053 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 00:26:24 +0000 Subject: [PATCH] craft-exr: bounded part listing, flat decoding and writing (feature decode) PhotoCraft's exr-crate wrapper (preview check, per-part and total size limits, file-size plausibility, part ranking, one-part decode) and EffectCraft's streaming channel filter, as one reader that returns a neutral model: part headers with text attributes, and the kept channels in their stored type. --- Cargo.lock | 67 ++++ README.md | 3 +- crates/exr/Cargo.toml | 10 +- crates/exr/src/decode.rs | 546 +++++++++++++++++++++++++++++++++ crates/exr/src/decode_tests.rs | 200 ++++++++++++ crates/exr/src/lib.rs | 9 +- 6 files changed, 832 insertions(+), 3 deletions(-) create mode 100644 crates/exr/src/decode.rs create mode 100644 crates/exr/src/decode_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ebce5f5a..73b217f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1" + [[package]] name = "bitflags" version = "1.3.2" @@ -1034,7 +1040,10 @@ dependencies = [ name = "craft-exr" version = "0.1.0" dependencies = [ + "exr", + "half", "serde_json", + "thiserror 2.0.21", ] [[package]] @@ -2385,6 +2394,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "multiversion", + "smallvec", + "zune-inflate", +] + [[package]] name = "extended" version = "0.1.0" @@ -3968,6 +3992,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.190" @@ -4182,6 +4212,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -6631,6 +6683,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempfile" version = "3.27.0" @@ -8459,6 +8517,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/README.md b/README.md index be5e2936..b86874df 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = std::result::Result; + +fn malformed(e: impl std::fmt::Display) -> Error { + Error::Malformed(e.to_string()) +} + +/// An `exr`-crate error, its `NotSupported` kind kept apart from damaged data. +fn map_exr(e: exr::error::Error) -> Error { + match e { + exr::error::Error::NotSupported(message) => Error::Unsupported(message.to_string()), + e => malformed(e), + } +} + +/// Caps on what a file may ask the reader to allocate. `#[non_exhaustive]`: start from +/// [`Limits::default`] and set the fields you change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Largest data window width and height of a part. + pub max_width: u32, + pub max_height: u32, + /// Largest data window of a part, in pixels. + pub max_pixels: u64, + /// Most bytes of samples all parts together may declare, counting 4 bytes a sample. A decode + /// allocates at most this for the channels it keeps. + pub max_bytes: u64, + /// The most a compressed byte is taken to expand to (DWA parts get 4 times as much). A file + /// declaring more samples than its size times this, plus 1 MiB, is refused before reading. + pub max_ratio: u64, +} + +impl Default for Limits { + fn default() -> Self { + let max_bytes = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 }; + Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_bytes, max_ratio: 1 << 16 } + } +} + +/// How a channel's samples are stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SampleType { + F16, + F32, + U32, +} + +impl SampleType { + fn of(t: ExrSample) -> Self { + match t { + ExrSample::F16 => SampleType::F16, + ExrSample::F32 => SampleType::F32, + ExrSample::U32 => SampleType::U32, + } + } +} + +/// A channel as the header declares it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelInfo { + /// The name as stored (`diffuse.R`); the part name is not prefixed. + pub name: String, + pub sample: SampleType, + /// Horizontal and vertical sampling (1, 1 for a full-resolution channel). + pub sampling: (usize, usize), +} + +/// A window: position and size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Window { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Header facts of one part. +#[derive(Debug, Clone, PartialEq)] +pub struct PartInfo { + /// Position in the file. + pub index: usize, + /// The part's name (`name` attribute), the prefix renderers give its channels. + pub name: Option, + /// The `view` attribute of a stereo part. + pub view: Option, + pub data_window: Window, + pub display_window: Window, + pub deep: bool, + pub tiled: bool, + /// The compression's name as the `exr` crate spells it (`ZIP16`, `PIZ`, `DWAA(None)`, …). + pub compression: String, + pub channels: Vec, + /// Every text attribute of the part and of the shared header (`cryptomatte/…/manifest`, + /// `owner`, …), name and value, sorted by name. + pub text_attributes: Vec<(String, String)>, +} + +impl PartInfo { + /// The value of the text attribute called `name`. + pub fn text(&self, name: &str) -> Option<&str> { + self.text_attributes.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) + } + + /// How much the part looks like a colour picture: 4 RGBA, 3 RGB, 2 YA, 1 Y (or a lone + /// channel), 0 anything else. + pub fn color_rank(&self) -> usize { + color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels()) + } +} + +/// Refuses headers whose `preview` attributes declare more pixels than their value holds. +/// +/// The `exr` crate sizes a preview's buffer from its declared dimensions (up to 4 MiB, zero +/// filled) before it finds the attribute too short, and in its non-pedantic mode then skips it +/// 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<()> { + if b.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01][..]) { + return Ok(()); + } + let Some(flags) = b.get(4..8).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes) else { return Ok(()) }; + let multipart = flags & 0x1000 != 0; + let cstr = |at: usize| -> Option<(&[u8], usize)> { + let rest = b.get(at..)?; + let n = rest.iter().position(|&c| c == 0)?; + Some((rest.get(..n)?, at + n + 1)) + }; + let u32_at = |at: usize| b.get(at..at.checked_add(4)?).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes); + let mut at = 8; + loop { + // One header's attributes: name, type, size, value; an empty name ends the header. + loop { + let Some((name, next)) = cstr(at) else { return Ok(()) }; + at = next; + if name.is_empty() { + break; + } + let Some((kind, next)) = cstr(at) else { return Ok(()) }; + let Some(size) = u32_at(next).and_then(|n| i32::try_from(n).ok()).and_then(|n| usize::try_from(n).ok()) else { return Ok(()) }; + let value = next + 4; + let end = match value.checked_add(size) { + Some(end) if end <= b.len() => end, + _ => return Ok(()), + }; + if kind == b"preview" { + let declared = u32_at(value) + .zip(u32_at(value + 4)) + .and_then(|(w, h)| u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)) + .filter(|_| size >= 8); + if declared.is_none_or(|n| n > (size - 8) as u64) { + return Err(malformed("a preview attribute declares more pixels than it holds")); + } + } + at = end; + } + // A multi-part header list ends with an empty header (a lone NUL). + if !multipart || b.get(at) == Some(&0) { + return Ok(()); + } + } +} + +fn window_size(size: exr::math::Vec2) -> (u32, u32) { + (u32::try_from(size.0).unwrap_or(u32::MAX), u32::try_from(size.1).unwrap_or(u32::MAX)) +} + +/// Reads the headers and checks every part's declared size, alone and together, before any pixel +/// allocation. Headers are read without the crate's validation, which refuses deep parts. +fn read_meta(bytes: &[u8], limits: &Limits) -> Result { + check_previews(bytes)?; + let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(map_exr)?; + let (mut total, mut dwa) = (0u64, 0u64); + for header in meta.headers.iter() { + let (w, h) = window_size(header.layer_size); + if w > limits.max_width || h > limits.max_height { + return Err(Error::LimitExceeded(format!("a {w}x{h} part exceeds {}x{}", limits.max_width, limits.max_height))); + } + let pixels = u64::from(w) * u64::from(h); + if pixels > limits.max_pixels { + return Err(Error::LimitExceeded(format!("a part of {pixels} pixels exceeds {}", limits.max_pixels))); + } + let bytes = pixels.saturating_mul(header.channels.list.len().max(1) as u64 * 4); + total = total.saturating_add(bytes); + if matches!(header.compression, ExrCompression::DWAA(_) | ExrCompression::DWAB(_)) { + dwa = dwa.saturating_add(bytes); + } + } + if total > limits.max_bytes { + return Err(Error::LimitExceeded(format!("{total} bytes across {} parts exceed {}", meta.headers.len(), limits.max_bytes))); + } + // The crate allocates a flat part's samples before reading its chunks, so the file must be + // able to hold them. DWA's run-length channels are zlib-packed on top and reach about + // 132000:1, so DWA parts are counted at a quarter. Deep parts size their samples from the + // file's own counts. + if !meta.requirements.has_deep_data { + let declared = total.saturating_sub(dwa).saturating_add(dwa / 4); + let capacity = (bytes.len() as u64).saturating_mul(limits.max_ratio).saturating_add(1 << 20); + if declared > capacity { + return Err(Error::LimitExceeded(format!("a {} byte file cannot hold {declared} bytes of samples", bytes.len()))); + } + } + Ok(meta) +} + +fn text_attributes(header: &Header) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = header + .shared_attributes + .other + .iter() + .chain(header.own_attributes.other.iter()) + .filter_map(|(k, v)| match v { + AttributeValue::Text(t) => Some((k.to_string(), t.to_string())), + _ => None, + }) + .collect(); + out.sort(); + out.dedup_by(|a, b| a.0 == b.0); + out +} + +fn part_info(index: usize, header: &Header) -> PartInfo { + let (w, h) = window_size(header.layer_size); + let display = &header.shared_attributes.display_window; + let (dw, dh) = window_size(display.size); + PartInfo { + index, + name: header.own_attributes.layer_name.as_ref().map(|n| n.to_string()), + view: header.own_attributes.view_name.as_ref().map(|v| v.to_string()), + data_window: Window { x: header.own_attributes.layer_position.0, y: header.own_attributes.layer_position.1, width: w, height: h }, + display_window: Window { x: display.position.0, y: display.position.1, width: dw, height: dh }, + deep: header.deep, + tiled: matches!(header.blocks, exr::meta::BlockDescription::Tiles(_)), + compression: format!("{:?}", header.compression), + channels: header + .channels + .list + .iter() + .map(|c| ChannelInfo { name: c.name.to_string(), sample: SampleType::of(c.sample_type), sampling: (c.sampling.0, c.sampling.1) }) + .collect(), + text_attributes: text_attributes(header), + } +} + +/// Lists every part of the file from its headers, after checking the declared sizes. +pub fn parts(bytes: &[u8], limits: &Limits) -> Result> { + let meta = read_meta(bytes, limits)?; + Ok(meta.headers.iter().enumerate().map(|(i, h)| part_info(i, h)).collect()) +} + +/// The part a plain picture decode shows: the highest [`PartInfo::color_rank`], the earliest on a +/// tie. `None` when no part has colour channels. +pub fn pick_part(parts: &[PartInfo]) -> Option { + let mut best: Option<(usize, usize)> = None; + for p in parts { + let rank = p.color_rank(); + if rank > 0 && best.is_none_or(|(_, r)| rank > r) { + best = Some((p.index, rank)); + } + } + best.map(|(i, _)| i) +} + +/// The samples of a decoded channel, in their stored type. +#[derive(Debug, Clone, PartialEq)] +pub enum Samples { + F16(Vec), + F32(Vec), + U32(Vec), +} + +impl Samples { + pub fn len(&self) -> usize { + match self { + Samples::F16(v) => v.len(), + Samples::F32(v) => v.len(), + Samples::U32(v) => v.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Sample `i` as `f32` (U32 IDs exact up to 2^24). 0 past the end. + pub fn get_f32(&self, i: usize) -> f32 { + match self { + Samples::F16(v) => v.get(i).map_or(0.0, |s| s.to_f32()), + Samples::F32(v) => v.get(i).copied().unwrap_or(0.0), + Samples::U32(v) => v.get(i).map_or(0.0, |&s| s as f32), + } + } + + /// All samples as `f32`. + pub fn to_f32(&self) -> Vec { + match self { + Samples::F16(v) => v.iter().map(|s| s.to_f32()).collect(), + Samples::F32(v) => v.clone(), + Samples::U32(v) => v.iter().map(|&s| s as f32).collect(), + } + } +} + +/// A decoded channel. +#[derive(Debug, Clone, PartialEq)] +pub struct Channel { + /// The name as stored. + pub name: String, + /// `width × height` samples of the part's data window, top row first. + pub samples: Samples, +} + +/// A decoded flat part: its header facts and the channels the filter kept, in file order. +#[derive(Debug, Clone, PartialEq)] +pub struct Part { + pub info: PartInfo, + pub channels: Vec, +} + +impl Part { + /// The kept channel called `name`. + pub fn channel(&self, name: &str) -> Option<&Channel> { + self.channels.iter().find(|c| c.name == name) + } +} + +/// Places a data-window plane in a display window: samples outside the display window are cut, +/// the display window's pixels the data window does not cover are `fill`. `None` when the plane +/// is not `data.width × data.height` samples or the display window has more than `max_pixels` +/// pixels. +pub fn to_display_window(plane: &[f32], data: Window, display: Window, fill: f32, max_pixels: u64) -> Option> { + let (w, h) = (data.width as usize, data.height as usize); + if plane.len() != w.checked_mul(h)? || u64::from(display.width) * u64::from(display.height) > max_pixels { + return None; + } + let (dw, dh) = (display.width as usize, display.height as usize); + let mut out = vec![fill; dw.checked_mul(dh)?]; + let (ox, oy) = (i64::from(data.x) - i64::from(display.x), i64::from(data.y) - i64::from(display.y)); + for (y, row) in plane.chunks_exact(w.max(1)).enumerate() { + let Some(ty) = usize::try_from(y as i64 + oy).ok().filter(|ty| *ty < dh) else { continue }; + let skip = usize::try_from(-ox).unwrap_or(0).min(w); + let tx = usize::try_from(ox.max(0)).unwrap_or(usize::MAX); + let take = (w - skip).min(dw.saturating_sub(tx)); + if take == 0 { + continue; + } + let at = ty * dw + tx; + if let (Some(dst), Some(src)) = (out.get_mut(at..at + take), row.get(skip..skip + take)) { + dst.copy_from_slice(src); + } + } + Some(out) +} + +/// Which channels of which part a decode keeps. +struct Select<'k> { + index: usize, + keep: &'k dyn Fn(&str) -> bool, + seen: std::cell::Cell, +} + +/// One part's kept channels while it is decoded: each line goes straight into its channel's plane. +struct SelectReader { + /// Per channel of the part: its plane, `None` when not kept. + planes: Vec>, + width: usize, + height: usize, +} + +impl<'s> ReadChannels<'s> for Select<'_> { + type Reader = SelectReader; + + fn create_channels_reader(&'s self, header: &Header) -> exr::error::Result { + let position = self.seen.get(); + self.seen.set(position.saturating_add(1)); + let (width, height) = (header.layer_size.0, header.layer_size.1); + if position != self.index { + // Another part: keep nothing, so its chunks are skipped unread. + return Ok(SelectReader { planes: header.channels.list.iter().map(|_| None).collect(), width, height }); + } + let n = width.checked_mul(height).ok_or(exr::error::Error::Invalid("part size".into()))?; + let mut planes = Vec::with_capacity(header.channels.list.len()); + for c in header.channels.list.iter() { + if !(self.keep)(&c.name.to_string()) { + planes.push(None); + continue; + } + if c.sampling.0 != 1 || c.sampling.1 != 1 { + return Err(exr::error::Error::NotSupported("subsampled channels".into())); + } + planes.push(Some(match c.sample_type { + ExrSample::F16 => Samples::F16(vec![f16::ZERO; n]), + ExrSample::F32 => Samples::F32(vec![0.0; n]), + ExrSample::U32 => Samples::U32(vec![0; n]), + })); + } + Ok(SelectReader { planes, width, height }) + } +} + +impl ChannelsReader for SelectReader { + type Channels = Vec>; + + fn filter_block(&self, tile: TileCoordinates) -> bool { + tile.is_largest_resolution_level() && self.planes.iter().any(Option::is_some) + } + + fn read_block(&mut self, header: &Header, block: UncompressedBlock) -> exr::error::UnitResult { + for line in block.lines(&header.channels) { + let Some(Some(plane)) = self.planes.get_mut(line.location.channel) else { continue }; + let (x, y, count) = (line.location.position.0, line.location.position.1, line.location.sample_count); + if y >= self.height || x.checked_add(count).is_none_or(|end| end > self.width) { + continue; + } + let at = y * self.width + x; + match plane { + Samples::F16(v) => line.read_samples_into_slice(v.get_mut(at..at + count).unwrap_or_default())?, + Samples::F32(v) => line.read_samples_into_slice(v.get_mut(at..at + count).unwrap_or_default())?, + Samples::U32(v) => line.read_samples_into_slice(v.get_mut(at..at + count).unwrap_or_default())?, + } + } + Ok(()) + } + + fn into_channels(self) -> Vec> { + self.planes + } +} + +/// Decodes the channels of flat part `index` that `keep` accepts (by the name as stored), in +/// their stored type. A file with deep parts, or a kept channel that is subsampled, is +/// [`Error::Unsupported`]. +pub fn decode_part(bytes: &[u8], index: usize, keep: &dyn Fn(&str) -> bool, limits: &Limits) -> Result { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(Error::Unsupported("deep data (use the deep reader)".into())); + } + let header = meta.headers.get(index).ok_or_else(|| malformed(format!("part {index} of {}", meta.headers.len())))?; + if matches!(header.compression, ExrCompression::HTJ2K32 | ExrCompression::HTJ2K256) { + return Err(Error::Unsupported("HTJ2K compression".into())); + } + let info = part_info(index, header); + let select = Select { index, keep, seen: std::cell::Cell::new(0) }; + let image = ReadImage::new(ReadAllLayers { read_channels: select }, |_| {}).from_buffered(Cursor::new(bytes)).map_err(map_exr)?; + let planes = image.layer_data.into_iter().nth(index).map(|l| l.channel_data).ok_or_else(|| malformed("the part was not read"))?; + let channels = info.channels.iter().zip(planes).filter_map(|(c, p)| Some(Channel { name: c.name.clone(), samples: p? })).collect(); + Ok(Part { info, channels }) +} + +/// Compression for [`write`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Compression { + None, + Rle, + Zip1, + #[default] + Zip16, + Piz, +} + +/// Writes one scan-line part of `width × height` with these channels (each `width × height` +/// samples, top row first; the order given does not matter, the file stores them sorted). +pub fn write(width: u32, height: u32, channels: &[Channel], compression: Compression) -> Result> { + let enc = |m: &str| Error::Encode(m.into()); + if width == 0 || height == 0 || channels.is_empty() { + return Err(enc("empty picture")); + } + let n = (width as usize).checked_mul(height as usize).ok_or_else(|| enc("picture too large"))?; + if channels.iter().any(|c| c.samples.len() != n) { + return Err(enc("a channel has the wrong number of samples")); + } + let mut list = Vec::with_capacity(channels.len()); + for c in channels { + let name = exr::meta::attribute::Text::new_or_none(c.name.as_str()).ok_or_else(|| enc("a channel name is not valid OpenEXR text"))?; + let samples = match &c.samples { + Samples::F16(v) => FlatSamples::F16(v.clone()), + Samples::F32(v) => FlatSamples::F32(v.clone()), + Samples::U32(v) => FlatSamples::U32(v.clone()), + }; + list.push(AnyChannel::new(name, samples)); + } + let compression = match compression { + Compression::None => ExrCompression::Uncompressed, + Compression::Rle => ExrCompression::RLE, + Compression::Zip1 => ExrCompression::ZIP1, + Compression::Zip16 => ExrCompression::ZIP16, + Compression::Piz => ExrCompression::PIZ, + }; + let encoding = Encoding { compression, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }; + let layer = Layer::new((width as usize, height as usize), LayerAttributes::default(), encoding, AnyChannels::sort(list.into_iter().collect())); + let mut out = Cursor::new(Vec::new()); + exr::image::Image::from_layer(layer).write().to_buffered(&mut out).map_err(|e| Error::Encode(e.to_string()))?; + Ok(out.into_inner()) +} diff --git a/crates/exr/src/decode_tests.rs b/crates/exr/src/decode_tests.rs new file mode 100644 index 00000000..69d8d07a --- /dev/null +++ b/crates/exr/src/decode_tests.rs @@ -0,0 +1,200 @@ +//! The preview check is PhotoCraft's test; the rest builds files with the `exr` crate. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use exr::meta::attribute::{Preview, Text}; +use exr::prelude::{ + AnyChannel, AnyChannels, Blocks, Encoding, FlatSamples, ImageAttributes, IntegerBounds, Layer, LayerAttributes, LineOrder, WritableImage, +}; + +use super::decode::*; + +fn ramp(n: usize, k: f32) -> Vec { + (0..n).map(|i| i as f32 * k).collect() +} + +/// A layer of `w × h` with these channels of f32 ramps, scan lines or 4 × 4 tiles. +fn layer( + name: Option<&str>, + w: usize, + h: usize, + channels: &[&str], + tiles: bool, + compression: exr::prelude::Compression, +) -> Layer> { + let mut attrs = name.map_or_else(LayerAttributes::default, LayerAttributes::named); + attrs.other.insert(Text::from("cryptomatte/abc1234/name"), exr::meta::attribute::AttributeValue::Text(Text::from("CryptoObject"))); + let list = channels.iter().enumerate().map(|(i, n)| AnyChannel::new(*n, FlatSamples::F32(ramp(w * h, i as f32 + 1.0)))).collect(); + let blocks = if tiles { Blocks::Tiles(exr::math::Vec2(4, 4)) } else { Blocks::ScanLines }; + Layer::new((w, h), attrs, Encoding { compression, blocks, line_order: LineOrder::Increasing }, AnyChannels::sort(list)) +} + +fn file(layers: Vec>>, display: (usize, usize)) -> Vec { + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), display)), layers); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).unwrap(); + out.into_inner() +} + +fn all(_: &str) -> bool { + true +} + +#[test] +fn every_compression_scan_lines_and_tiles_decode_exactly() { + use exr::prelude::Compression::*; + for compression in [Uncompressed, RLE, ZIP1, ZIP16, PIZ] { + for tiles in [false, true] { + let bytes = file(vec![layer(None, 9, 7, &["R", "G", "B"], tiles, compression)], (9, 7)); + let part = decode_part(&bytes, 0, &all, &Limits::default()).unwrap(); + assert_eq!((part.info.tiled, part.info.data_window.width, part.info.data_window.height), (tiles, 9, 7)); + // Each channel kept its own ramp, whatever order the file stores them in. + for (name, k) in [("R", 1.0), ("G", 2.0), ("B", 3.0)] { + assert_eq!(part.channel(name).unwrap().samples, Samples::F32(ramp(63, k)), "{compression:?} tiles {tiles} {name}"); + } + } + } + // Lossy ones decode too (values not exact). + for compression in [PXR24, B44, B44A, DWAA(None), DWAB(None)] { + let bytes = file(vec![layer(None, 16, 16, &["R"], false, compression)], (16, 16)); + assert_eq!(decode_part(&bytes, 0, &all, &Limits::default()).unwrap().channels[0].samples.len(), 256, "{compression:?}"); + } +} + +#[test] +fn multi_part_files_list_and_decode_one_part() { + let bytes = file( + vec![ + layer(Some("depth"), 4, 2, &["Z"], false, exr::prelude::Compression::ZIP1), + layer(Some("beauty"), 4, 2, &["A", "B", "G", "R"], true, exr::prelude::Compression::PIZ), + ], + (4, 2), + ); + let parts = parts(&bytes, &Limits::default()).unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!((parts[0].name.as_deref(), parts[1].name.as_deref(), parts[1].tiled), (Some("depth"), Some("beauty"), true)); + assert_eq!(parts[0].channels, [ChannelInfo { name: "Z".into(), sample: SampleType::F32, sampling: (1, 1) }]); + assert_eq!((parts[0].color_rank(), parts[1].color_rank(), parts[1].compression.as_str()), (1, 4, "PIZ")); + assert_eq!(parts[1].text("cryptomatte/abc1234/name"), Some("CryptoObject")); + assert_eq!(pick_part(&parts), Some(1)); + // Only the kept channels of the chosen part come back. + let part = decode_part(&bytes, 1, &|n| n == "R" || n == "A", &Limits::default()).unwrap(); + assert_eq!(part.channels.iter().map(|c| c.name.as_str()).collect::>(), ["A", "R"]); + let z = decode_part(&bytes, 0, &all, &Limits::default()).unwrap(); + assert_eq!(z.channels[0].samples.to_f32(), ramp(8, 1.0)); + assert!(matches!(decode_part(&bytes, 2, &all, &Limits::default()), Err(Error::Malformed(_)))); + assert_eq!(pick_part(&[]), None); +} + +#[test] +fn written_files_read_back() { + let h: Vec = (0..6).map(|i| f16::from_f32(i as f32 / 4.0)).collect(); + let channels = vec![ + Channel { name: "Y".into(), samples: Samples::F16(h.clone()) }, + Channel { name: "A".into(), samples: Samples::F32(ramp(6, 0.5)) }, + Channel { name: "id".into(), samples: Samples::U32((0..6).collect()) }, + ]; + for c in [Compression::None, Compression::Rle, Compression::Zip1, Compression::Zip16, Compression::Piz] { + let bytes = write(3, 2, &channels, c).unwrap(); + let part = decode_part(&bytes, 0, &all, &Limits::default()).unwrap(); + assert_eq!(part.channel("Y").unwrap().samples, Samples::F16(h.clone())); + assert_eq!(part.channel("A").unwrap().samples, Samples::F32(ramp(6, 0.5))); + assert_eq!(part.channel("id").unwrap().samples, Samples::U32((0..6).collect())); + assert_eq!(part.channel("id").unwrap().samples.get_f32(5), 5.0); + } + assert!(matches!(write(0, 2, &channels, Compression::None), Err(Error::Encode(_)))); + assert!(matches!(write(2, 2, &channels, Compression::None), Err(Error::Encode(_)))); + assert!(matches!(write(3, 2, &[], Compression::None), Err(Error::Encode(_)))); +} + +#[test] +fn display_window_placement() { + let data = Window { x: 1, y: -1, width: 2, height: 2 }; + let display = Window { x: 0, y: 0, width: 3, height: 2 }; + // Row -1 is cut; row 0 lands at display row 0 from column 1. + assert_eq!(to_display_window(&[1.0, 2.0, 3.0, 4.0], data, display, 9.0, 100).unwrap(), [9.0, 3.0, 4.0, 9.0, 9.0, 9.0]); + assert_eq!(to_display_window(&[1.0], data, display, 0.0, 100), None); + assert_eq!(to_display_window(&[1.0, 2.0, 3.0, 4.0], data, display, 0.0, 5), None); + let far = Window { x: -10, y: 0, width: 2, height: 2 }; + assert_eq!(to_display_window(&[1.0, 2.0, 3.0, 4.0], far, display, 0.0, 100).unwrap(), [0.0; 6]); +} + +/// A 2x2 single-part file whose layer carries a 2x1 preview. +fn with_preview() -> Vec { + let mut attrs = LayerAttributes::named("rgb"); + attrs.preview = Some(Preview { size: exr::math::Vec2(2, 1), pixel_data: vec![7; 8] }); + let list = ["R", "G", "B"].into_iter().map(|n| AnyChannel::new(n, FlatSamples::F32(vec![0.5; 4]))).collect(); + let layer = Layer::new( + (2, 2), + attrs, + Encoding { compression: exr::prelude::Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list), + ); + file(vec![layer], (2, 2)) +} + +#[test] +fn a_preview_declaring_more_pixels_than_it_holds_is_refused_before_the_crate_reads_it() { + let good = with_preview(); + assert_eq!(decode_part(&good, 0, &all, &Limits::default()).unwrap().channels.len(), 3); + // Patch the preview to 1024x1024 (4 MiB) while its value still holds 8 bytes. + let tag = b"preview\0preview\0"; + let at = good.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len() + 4; + let mut bad = good.clone(); + bad[at..at + 4].copy_from_slice(&1024u32.to_le_bytes()); + bad[at + 4..at + 8].copy_from_slice(&1024u32.to_le_bytes()); + for r in [parts(&bad, &Limits::default()).map(|_| ()), decode_part(&bad, 0, &all, &Limits::default()).map(|_| ())] { + assert!(matches!(&r, Err(Error::Malformed(m)) if m.contains("preview")), "{r:?}"); + } + // A header cut off mid-attribute is left to the crate to report. + assert!(parts(&good[..at], &Limits::default()).is_err()); +} + +#[test] +fn limits_are_checked_from_the_headers() { + let bytes = file(vec![layer(None, 8, 4, &["R", "G"], false, exr::prelude::Compression::ZIP16)], (8, 4)); + let ok = |l: Limits| parts(&bytes, &l).is_ok(); + let d = Limits::default(); + assert!(ok(Limits { max_width: 8, max_height: 4, max_pixels: 32, max_bytes: 8 * 4 * 2 * 4, ..d })); + assert!(matches!(parts(&bytes, &Limits { max_width: 7, ..d }), Err(Error::LimitExceeded(_)))); + assert!(matches!(parts(&bytes, &Limits { max_height: 3, ..d }), Err(Error::LimitExceeded(_)))); + assert!(matches!(parts(&bytes, &Limits { max_pixels: 31, ..d }), Err(Error::LimitExceeded(_)))); + assert!(matches!(decode_part(&bytes, 0, &all, &Limits { max_bytes: 255, ..d }), Err(Error::LimitExceeded(_)))); + // A file too small for what it declares: 256 bytes of samples against a ratio of 0 and no slack + // is over; the 1 MiB slack always admits small pictures. + assert!(ok(Limits { max_ratio: 0, ..d })); + // A header claiming a huge data window over a few bytes of file is refused by the ratio. + let big = file(vec![layer(None, 4096, 4096, &["R"], false, exr::prelude::Compression::ZIP16)], (4096, 4096)); + assert!(ok_big(&big)); + assert!(matches!(parts(&big, &Limits { max_ratio: 1, ..d }), Err(Error::LimitExceeded(_)))); +} + +fn ok_big(b: &[u8]) -> bool { + parts(b, &Limits::default()).is_ok() +} + +#[test] +fn hostile_files_never_panic() { + let good = file( + vec![ + layer(Some("a"), 5, 3, &["R", "G", "B"], false, exr::prelude::Compression::ZIP1), + layer(Some("b"), 5, 3, &["Y"], true, exr::prelude::Compression::RLE), + ], + (5, 3), + ); + let limits = Limits { max_bytes: 1 << 24, ..Limits::default() }; + for n in 0..good.len() { + let _ = parts(&good[..n], &limits); + let _ = decode_part(&good[..n], 1, &all, &limits); + } + for i in 0..good.len() { + for v in [0u8, 1, 0x7F, 0xFF] { + let mut b = good.clone(); + b[i] = v; + if let Ok(p) = decode_part(&b, 0, &all, &limits) { + let n = p.info.data_window.width as usize * p.info.data_window.height as usize; + assert!(p.channels.iter().all(|c| c.samples.len() == n)); + } + } + } + assert!(parts(b"not exr", &limits).is_err()); +} diff --git a/crates/exr/src/lib.rs b/crates/exr/src/lib.rs index 8075f251..c9e64236 100644 --- a/crates/exr/src/lib.rs +++ b/crates/exr/src/lib.rs @@ -10,7 +10,14 @@ //! - [`cryptomatte`]: Cryptomatte (specification 1.2) IDs, metadata keys, manifests and rank //! channel names. //! -//! Nothing here reads pixels or depends on a decoder: the functions take names and attribute text. +//! Those take names and attribute text and depend on no decoder. With the `decode` feature: +//! +//! - [`decode`]: bounded part listing, flat decoding (scan lines and tiles, every compression the +//! `exr` crate reads) and writing, on top of the `exr` crate. pub mod channels; pub mod cryptomatte; +#[cfg(feature = "decode")] +pub mod decode; +#[cfg(all(test, feature = "decode"))] +mod decode_tests;