1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
//! Error types for pixel format conversion.
use crate::{ColorModel, PixelDescriptor, TransferFunction};
use core::fmt;
/// Errors that can occur during pixel format negotiation or conversion.
//
// `#[non_exhaustive]` (added 0.2.14): cargo-copter confirmed sealing it broke
// zero of zpc's published reverse-dependents — nobody matches `ConvertError`
// exhaustively — so it shipped as a tolerated 0.2.x break instead of waiting for
// 0.3.0. That in turn let the `Buffer(BufferError)` variant land in the same
// patch (adding a variant to an already-`#[non_exhaustive]` enum is not a
// break). `Buffer` preserves the real `zenpixels::BufferError` cause
// (`StrideTooSmall` / `InvalidDimensions` / …) instead of collapsing every
// buffer-construction failure into `AllocationFailed` (an out-of-memory label);
// the construction sites map via `map_err_at(ConvertError::from)`, which keeps
// both the cause and the `At` location trace.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ConvertError {
/// No supported format could be found for the source descriptor.
NoMatch { source: PixelDescriptor },
/// No conversion path exists between the two formats.
NoPath {
from: PixelDescriptor,
to: PixelDescriptor,
},
/// Source and destination buffer sizes don't match the expected dimensions.
BufferSize { expected: usize, actual: usize },
/// Width is zero or would overflow stride calculations.
InvalidWidth(u32),
/// The supported format list was empty.
EmptyFormatList,
/// Conversion between these transfer functions is not yet supported.
UnsupportedTransfer {
from: TransferFunction,
to: TransferFunction,
},
/// Alpha channel is not fully opaque and [`AlphaPolicy::DiscardIfOpaque`](crate::AlphaPolicy::DiscardIfOpaque) was set.
AlphaNotOpaque,
/// Depth reduction was requested but [`DepthPolicy::Forbid`](crate::DepthPolicy::Forbid) was set.
DepthReductionForbidden,
/// Alpha removal was requested but [`AlphaPolicy::Forbid`](crate::AlphaPolicy::Forbid) was set.
AlphaRemovalForbidden,
/// RGB-to-grayscale conversion requires explicit luma coefficients.
RgbToGray,
/// Buffer allocation failed.
AllocationFailed,
/// A pixel buffer or slice could not be constructed: carries the real
/// [`zenpixels::BufferError`] cause (`StrideTooSmall`, `InvalidDimensions`,
/// …) instead of collapsing it into [`AllocationFailed`](Self::AllocationFailed).
Buffer(zenpixels::BufferError),
/// CMS transform could not be built (invalid ICC profile, unsupported color space, etc.).
CmsError(alloc::string::String),
/// The conversion is HDR (`Pq` / `Hlg`) → SDR but no usable peak
/// luminance was supplied. Raised both when no peak was given at all
/// (the plain [`ConvertPlan::new`](crate::ConvertPlan::new) entry
/// point doesn't take one) and when a supplied `HdrConfig` carries a
/// non-finite or non-positive `source_peak_nits` / `target_peak_nits`
/// (including the unset `HdrConfig::default()` value `0.0`) — a
/// degenerate peak would tone-map every pixel to black. Build the
/// plan via `ConvertPlan::new_with_hdr_peak` (or
/// `ConvertPlan::new_with_hdr_config` for full knob control), and
/// pass the source's MaxCLL — e.g. from
/// `hdr::measure::CllMeasure::measure_max`. All three live behind
/// the `hdr-experimental` Cargo feature (plain code spans here, not
/// intra-doc links, so this page renders link-clean without it).
///
/// Pre-0.2.16 the plain `ConvertPlan::new` silently routed HDR→SDR
/// through the linear intermediate with no tone-mapping, producing
/// semantically wrong pixels. This variant replaces that with a
/// loud refusal.
HdrSourceRequiresPeak {
from: PixelDescriptor,
to: PixelDescriptor,
},
/// The conversion requires a color management plugin but none was provided.
///
/// Returned when one (or both) sides use a non-native color model — CMYK,
/// Lab, XYZ, spot inks, or any future device-dependent space — that
/// `zenpixels-convert` cannot resolve with its built-in kernels. Attach
/// a plugin via
/// [`RowConverter::new_explicit_with_cms`](crate::RowConverter::new_explicit_with_cms)
/// (e.g. `Some(&MoxCms)` under the `cms-moxcms` feature) and the plan
/// will dispatch the full row work to it.
///
/// Distinct from [`NoPath`](Self::NoPath): `NeedsCms` says "a path
/// exists, but requires CMS dispatch"; `NoPath` says "no architecturally
/// possible conversion." Callers that want to route to a CMS should
/// match on `NeedsCms` and re-issue the call with a plugin attached.
///
/// **Pre-0.2.16:** the same descriptors caused a process-aborting
/// `assert_not_cmyk` panic — replaced by this typed variant so the
/// documented `Some(&MoxCms)` escape hatch is actually reachable.
NeedsCms {
from: PixelDescriptor,
to: PixelDescriptor,
},
// CMYK rejection used to be folded into `NoPath { from, to }`. Pre-0.2.16
// the public-API entry points panicked via `assert_not_cmyk` BEFORE the CMS
// chain was consulted, so the documented escape hatch ("attach moxcms for
// CMYK↔RGB") was unreachable. 0.2.16 introduces `NeedsCms { from, to }`:
// the panic becomes a typed `Err`, callers can match the variant and
// re-issue with a plugin, and the moxcms backend dispatches CMYK→RGB
// end-to-end. `NoPath` is retained for genuinely impossible conversions
// (signal-range crossings without a kernel; HLG↔PQ until OOTF threading
// lands). `ConvertError` is `#[non_exhaustive]` so this is additive.
}
impl fmt::Display for ConvertError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoMatch { source } => {
write!(
f,
"no supported format matches source {:?}/{:?}",
source.channel_type(),
source.layout()
)
}
Self::NoPath { from, to } => {
write!(
f,
"no conversion path from {:?}/{:?} to {:?}/{:?}",
from.channel_type(),
from.layout(),
to.channel_type(),
to.layout()
)?;
// CMYK or signal-range crossings would otherwise print two
// identical-looking descriptors with no hint why the conversion
// failed. Name the actual blocker so the caller can route
// intelligently (CMYK → moxcms/CMS pipeline; range crossing →
// explicit rescale stage).
let cmyk_blocked =
from.color_model() == ColorModel::Cmyk || to.color_model() == ColorModel::Cmyk;
if cmyk_blocked {
write!(
f,
" (CMYK is device-dependent and requires an ICC profile; \
zenpixels-convert reinterpreting C/M/Y/K as R/G/B/A would \
silently corrupt both colour and transparency. Use a CMS \
such as moxcms for CMYK↔RGB)"
)?;
}
if from.signal_range != to.signal_range {
write!(
f,
" (signal range {} -> {}: no narrow<->full conversion kernels exist; \
relabeling without rescaling would corrupt pixel values)",
from.signal_range, to.signal_range
)?;
}
Ok(())
}
Self::BufferSize { expected, actual } => {
write!(
f,
"buffer size mismatch: expected {expected} bytes, got {actual}"
)
}
Self::InvalidWidth(w) => write!(f, "invalid width: {w}"),
Self::EmptyFormatList => write!(f, "supported format list is empty"),
Self::UnsupportedTransfer { from, to } => {
write!(f, "unsupported transfer conversion: {from:?} → {to:?}")
}
Self::AlphaNotOpaque => write!(f, "alpha channel is not fully opaque"),
Self::DepthReductionForbidden => write!(f, "depth reduction forbidden by policy"),
Self::AlphaRemovalForbidden => write!(f, "alpha removal forbidden by policy"),
Self::RgbToGray => {
write!(f, "RGB-to-grayscale requires explicit luma coefficients")
}
Self::AllocationFailed => write!(f, "buffer allocation failed"),
Self::Buffer(e) => write!(f, "buffer construction failed: {e}"),
Self::CmsError(msg) => write!(f, "CMS transform failed: {msg}"),
Self::HdrSourceRequiresPeak { from, to } => write!(
f,
"HDR→SDR conversion ({:?} → {:?}) requires positive, finite peak \
luminances; build the plan via ConvertPlan::new_with_hdr_peak (or \
new_with_hdr_config) and pass the source's MaxCLL (e.g. \
CllMeasure::measure_max)",
from.transfer(),
to.transfer(),
),
Self::NeedsCms { from, to } => write!(
f,
"conversion from {} to {} requires a color management plugin: \
call RowConverter::new_explicit_with_cms(_, _, _, Some(&MoxCms)) \
(or another PluggableCms backend) to dispatch the row work \
to a CMS",
from.color_model(),
to.color_model(),
),
}
}
}
impl From<zenpixels::BufferError> for ConvertError {
/// Wrap a buffer-construction failure's real cause into
/// [`ConvertError::Buffer`]. Pair with `map_err_at` at the call sites so the
/// `At` location trace is preserved alongside the classified cause.
fn from(err: zenpixels::BufferError) -> Self {
Self::Buffer(err)
}
}
#[cfg(feature = "std")]
impl std::error::Error for ConvertError {}
#[cfg(test)]
mod tests {
use super::*;
use alloc::format;
#[test]
fn display_no_match() {
let e = ConvertError::NoMatch {
source: PixelDescriptor::RGB8_SRGB,
};
let s = format!("{e}");
assert!(s.contains("no supported format"));
assert!(s.contains("U8"));
assert!(s.contains("Rgb"));
}
#[test]
fn display_no_path() {
let e = ConvertError::NoPath {
from: PixelDescriptor::RGB8_SRGB,
to: PixelDescriptor::GRAY8_SRGB,
};
let s = format!("{e}");
assert!(s.contains("no conversion path"));
}
#[test]
fn display_buffer_size() {
let e = ConvertError::BufferSize {
expected: 1024,
actual: 512,
};
let s = format!("{e}");
assert!(s.contains("1024"));
assert!(s.contains("512"));
}
#[test]
fn display_invalid_width() {
let e = ConvertError::InvalidWidth(0);
assert!(format!("{e}").contains("0"));
}
#[test]
fn display_empty_format_list() {
let s = format!("{}", ConvertError::EmptyFormatList);
assert!(s.contains("empty"));
}
#[test]
fn display_unsupported_transfer() {
let e = ConvertError::UnsupportedTransfer {
from: TransferFunction::Pq,
to: TransferFunction::Hlg,
};
let s = format!("{e}");
assert!(s.contains("Pq"));
assert!(s.contains("Hlg"));
}
#[test]
fn display_alpha_not_opaque() {
assert!(format!("{}", ConvertError::AlphaNotOpaque).contains("opaque"));
}
#[test]
fn display_depth_reduction_forbidden() {
assert!(format!("{}", ConvertError::DepthReductionForbidden).contains("forbidden"));
}
#[test]
fn display_alpha_removal_forbidden() {
assert!(format!("{}", ConvertError::AlphaRemovalForbidden).contains("forbidden"));
}
#[test]
fn display_rgb_to_gray() {
assert!(format!("{}", ConvertError::RgbToGray).contains("luma"));
}
#[test]
fn display_allocation_failed() {
assert!(format!("{}", ConvertError::AllocationFailed).contains("allocation"));
}
#[test]
fn display_cms_error() {
let e = ConvertError::CmsError(alloc::string::String::from("profile mismatch"));
let s = format!("{e}");
assert!(s.contains("CMS transform failed"));
assert!(s.contains("profile mismatch"));
}
#[test]
fn display_needs_cms() {
let e = ConvertError::NeedsCms {
from: PixelDescriptor::CMYK8,
to: PixelDescriptor::RGB8_SRGB,
};
let s = format!("{e}");
assert!(s.contains("color management plugin"), "{s}");
assert!(s.contains("CMYK"), "{s}");
assert!(s.contains("RGB"), "{s}");
}
#[test]
fn error_eq() {
assert_eq!(ConvertError::AlphaNotOpaque, ConvertError::AlphaNotOpaque);
assert_ne!(ConvertError::AlphaNotOpaque, ConvertError::RgbToGray);
}
#[test]
fn error_debug() {
let e = ConvertError::AllocationFailed;
let s = format!("{e:?}");
assert!(s.contains("AllocationFailed"));
}
#[test]
fn error_clone() {
let e = ConvertError::BufferSize {
expected: 100,
actual: 50,
};
let e2 = e.clone();
assert_eq!(e, e2);
}
}