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.
This commit is contained in:
Dylan M. Taylor
2026-11-12 00:26:24 +00:00
parent 4559fe4171
commit 4e185b16dc
6 changed files with 832 additions and 3 deletions
Generated
+67
View File
@@ -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"
+2 -1
View File
@@ -90,7 +90,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-epub`](crates/epub/README.md): EPUB packages: the OCF ZIP container, the package document (metadata, manifest, spine) and the navigation document, written as EPUB 3 (reflowable and fixed layout) and read (EPUB 2 and 3) with bounded entry sizes. Content documents are the caller's.
- [`craft-exr`](crates/exr/src/lib.rs): OpenEXR pieces shared by PhotoCraft and EffectCraft: layer
and channel names, the channels that make a colour picture, and Cryptomatte IDs, metadata keys,
manifests and rank channels. Takes names and attribute text; decoding is not part of it yet.
manifests and rank channels. The optional `decode` feature adds bounded part listing, flat
decoding (scan lines, tiles, every compression of the `exr` crate) and writing.
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
+9 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-exr"
description = "OpenEXR pieces shared by the Craft apps: channel and layer naming, colour channel selection and Cryptomatte (IDs, manifests, rank channels)."
description = "OpenEXR for the Craft apps: channel and layer naming, colour channel selection, Cryptomatte, and (feature `decode`) bounded part listing, flat decoding and writing on top of the exr crate."
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,5 +10,13 @@ repository.workspace = true
[lints]
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"]
[dependencies]
serde_json = { workspace = true }
exr = { version = "1.73", default-features = false, optional = true }
half = { version = "2", optional = true }
thiserror = { workspace = true, optional = true }
+546
View File
@@ -0,0 +1,546 @@
//! Bounded OpenEXR reading and writing on top of the `exr` crate (feature `decode`).
//!
//! - [`parts`] lists every part from the headers alone (size, windows, channels, compression,
//! text attributes), after checking the declared sizes against [`Limits`] and the file's size.
//! - [`pick_part`] chooses the part a plain picture decode shows: the best colour channel group
//! ([`crate::channels::color_channels`]), the earliest part on a tie.
//! - [`decode_part`] decodes the channels of one flat part that a filter keeps, in their stored
//! sample type, over the part's data window. Every compression the `exr` crate reads is read,
//! scan lines and tiles alike (the largest resolution level). Other parts' chunks are skipped
//! unread, and channels the filter drops are never allocated.
//! - [`write`] writes one scan-line part.
//!
//! The `exr` crate sizes buffers from headers before it reads pixels, so everything that reaches
//! it is checked first: preview attributes against their own size, each part's samples against
//! [`Limits`], all parts together against the budget, and the samples against what a file of
//! this size can decompress to.
use std::io::Cursor;
use exr::block::UncompressedBlock;
use exr::block::chunk::TileCoordinates;
use exr::image::read::image::ReadImage;
use exr::image::read::layers::{ChannelsReader, ReadAllLayers, ReadChannels};
use exr::meta::MetaData;
use exr::meta::attribute::{AttributeValue, SampleType as ExrSample};
use exr::meta::header::Header;
use exr::prelude::{
AnyChannel, AnyChannels, Blocks, Compression as ExrCompression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, WritableImage,
};
pub use half::f16;
use crate::channels::color_channels;
/// Why a file could not be read or written.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The file is damaged or not OpenEXR.
#[error("OpenEXR: {0}")]
Malformed(String),
/// A valid file this reader does not cover (deep data in a flat read, HTJ2K, subsampled
/// channels kept by the filter).
#[error("unsupported OpenEXR: {0}")]
Unsupported(String),
/// The file declares more than [`Limits`] allow.
#[error("OpenEXR exceeds a limit: {0}")]
LimitExceeded(String),
/// The picture handed to [`write`] cannot be stored.
#[error("OpenEXR: cannot write: {0}")]
Encode(String),
}
/// Result of this module's functions.
pub type Result<T> = std::result::Result<T, Error>;
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<String>,
/// The `view` attribute of a stereo part.
pub view: Option<String>,
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<ChannelInfo>,
/// 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<usize>) -> (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<MetaData> {
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<Vec<PartInfo>> {
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<usize> {
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<f16>),
F32(Vec<f32>),
U32(Vec<u32>),
}
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<f32> {
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<Channel>,
}
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<Vec<f32>> {
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<usize>,
}
/// 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<Option<Samples>>,
width: usize,
height: usize,
}
impl<'s> ReadChannels<'s> for Select<'_> {
type Reader = SelectReader;
fn create_channels_reader(&'s self, header: &Header) -> exr::error::Result<SelectReader> {
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<Option<Samples>>;
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<Option<Samples>> {
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<Part> {
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<Vec<u8>> {
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())
}
+200
View File
@@ -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<f32> {
(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<AnyChannels<FlatSamples>> {
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<Layer<AnyChannels<FlatSamples>>>, display: (usize, usize)) -> Vec<u8> {
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::<Vec<_>>(), ["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<f16> = (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<u8> {
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());
}
+8 -1
View File
@@ -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;