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