Skip to main content

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}