Skip to main content

terrain_codec/heightmap/
container.rs

1//! PNG / WebP / AVIF container helpers for heightmap RGB bytes.
2//!
3//! Each container is gated on its own cargo feature so callers only pay
4//! the compile-time cost of what they actually use:
5//!
6//! | Feature | Provides            | Backend                            |
7//! |---------|---------------------|------------------------------------|
8//! | `png`   | [`rgb_to_png`]      | `image/png`                        |
9//! | `webp`  | [`rgb_to_webp`]     | `image/webp` (lossless)            |
10//! | `avif`  | [`rgb_to_avif`]     | `image/avif` (ravif, encode-only)  |
11//!
12//! [`decode_image`] auto-detects whichever formats are compiled in. WebP
13//! encoding is **lossless** — lossy WebP would need `libwebp` which is
14//! out of scope here.
15//!
16//! For runtime-chosen container format use the [`ContainerFormat`] enum
17//! and the dispatching [`rgb_to_container`]; calling with a format whose
18//! feature wasn't enabled returns [`ContainerError::Unsupported`] rather
19//! than failing to compile.
20
21use std::fmt;
22use std::io::Cursor;
23use std::str::FromStr;
24
25#[cfg(feature = "avif")]
26use image::codecs::avif::AvifEncoder;
27#[cfg(feature = "png")]
28use image::codecs::png::PngEncoder;
29#[cfg(feature = "webp")]
30use image::codecs::webp::WebPEncoder;
31use image::{ExtendedColorType, ImageEncoder, ImageReader};
32
33/// Re-export of [`image::ImageError`] for callers that don't want to
34/// pull in the `image` crate directly.
35pub type ImageError = image::ImageError;
36
37/// A decoded image returned by [`decode_image`].
38#[derive(Debug, Clone)]
39pub struct DecodedImage {
40    /// Flat row-major RGB bytes (3 bytes per pixel).
41    pub rgb: Vec<u8>,
42    /// Image width in pixels.
43    pub width: u32,
44    /// Image height in pixels.
45    pub height: u32,
46}
47
48/// Identifies one of the supported image container formats for the
49/// runtime-dispatched [`rgb_to_container`] entry point.
50///
51/// All three variants are always present in the enum so callers can
52/// parse user-supplied format names regardless of which cargo features
53/// were enabled at compile time. Encoding into a format whose feature is
54/// not enabled returns [`ContainerError::Unsupported`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum ContainerFormat {
57    /// PNG.
58    Png,
59    /// Lossless WebP.
60    Webp,
61    /// AVIF (encode-only).
62    Avif,
63}
64
65impl ContainerFormat {
66    /// All variants, in declaration order.
67    pub const ALL: [Self; 3] = [Self::Png, Self::Webp, Self::Avif];
68
69    /// Canonical lowercase name (`"png"` / `"webp"` / `"avif"`).
70    pub const fn name(self) -> &'static str {
71        match self {
72            Self::Png => "png",
73            Self::Webp => "webp",
74            Self::Avif => "avif",
75        }
76    }
77
78    /// IANA MIME type for the format.
79    pub const fn mime_type(self) -> &'static str {
80        match self {
81            Self::Png => "image/png",
82            Self::Webp => "image/webp",
83            Self::Avif => "image/avif",
84        }
85    }
86
87    /// Whether the encoder for this format was compiled in at build time.
88    pub const fn is_enabled(self) -> bool {
89        match self {
90            Self::Png => cfg!(feature = "png"),
91            Self::Webp => cfg!(feature = "webp"),
92            Self::Avif => cfg!(feature = "avif"),
93        }
94    }
95}
96
97impl fmt::Display for ContainerFormat {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.name())
100    }
101}
102
103/// Error returned by [`ContainerFormat::from_str`] for an unrecognised name.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ParseContainerFormatError {
106    /// The input string that failed to parse.
107    pub input: String,
108}
109
110impl fmt::Display for ParseContainerFormatError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "unknown container format `{}` (expected one of: png, webp, avif)",
115            self.input
116        )
117    }
118}
119
120impl std::error::Error for ParseContainerFormatError {}
121
122impl FromStr for ContainerFormat {
123    type Err = ParseContainerFormatError;
124
125    /// Parses case-insensitively. Accepts the canonical lowercase names
126    /// as well as the `image/<name>` MIME shorthand.
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        match s.to_ascii_lowercase().as_str() {
129            "png" | "image/png" => Ok(Self::Png),
130            "webp" | "image/webp" => Ok(Self::Webp),
131            "avif" | "image/avif" => Ok(Self::Avif),
132            _ => Err(ParseContainerFormatError {
133                input: s.to_string(),
134            }),
135        }
136    }
137}
138
139/// Error returned by [`rgb_to_container`].
140#[derive(Debug)]
141pub enum ContainerError {
142    /// The underlying `image` encoder failed.
143    Image(ImageError),
144    /// The requested format's cargo feature was not enabled at build time.
145    Unsupported(ContainerFormat),
146}
147
148impl fmt::Display for ContainerError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::Image(e) => write!(f, "container encoding failed: {e}"),
152            Self::Unsupported(fmt_) => write!(
153                f,
154                "container format `{fmt_}` is not supported in this build — enable the `{fmt_}` cargo feature"
155            ),
156        }
157    }
158}
159
160impl std::error::Error for ContainerError {
161    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
162        match self {
163            Self::Image(e) => Some(e),
164            Self::Unsupported(_) => None,
165        }
166    }
167}
168
169impl From<ImageError> for ContainerError {
170    fn from(value: ImageError) -> Self {
171        Self::Image(value)
172    }
173}
174
175/// Wrap raw `width × height × 3` RGB bytes in the chosen container format.
176///
177/// This is the runtime-dispatched counterpart of the per-format
178/// [`rgb_to_png`] / [`rgb_to_webp`] / [`rgb_to_avif`] functions. Useful
179/// when the format is determined at runtime (CLI flag, query param,
180/// `Accept` header).
181///
182/// Returns [`ContainerError::Unsupported`] when the requested format's
183/// cargo feature was not enabled.
184///
185/// # Panics
186///
187/// Panics if `rgb.len() != (width * height * 3) as usize`.
188pub fn rgb_to_container(
189    format: ContainerFormat,
190    rgb: &[u8],
191    width: u32,
192    height: u32,
193) -> Result<Vec<u8>, ContainerError> {
194    match format {
195        ContainerFormat::Png => {
196            #[cfg(feature = "png")]
197            {
198                Ok(rgb_to_png(rgb, width, height)?)
199            }
200            #[cfg(not(feature = "png"))]
201            {
202                let _ = (rgb, width, height);
203                Err(ContainerError::Unsupported(ContainerFormat::Png))
204            }
205        }
206        ContainerFormat::Webp => {
207            #[cfg(feature = "webp")]
208            {
209                Ok(rgb_to_webp(rgb, width, height)?)
210            }
211            #[cfg(not(feature = "webp"))]
212            {
213                let _ = (rgb, width, height);
214                Err(ContainerError::Unsupported(ContainerFormat::Webp))
215            }
216        }
217        ContainerFormat::Avif => {
218            #[cfg(feature = "avif")]
219            {
220                Ok(rgb_to_avif(rgb, width, height)?)
221            }
222            #[cfg(not(feature = "avif"))]
223            {
224                let _ = (rgb, width, height);
225                Err(ContainerError::Unsupported(ContainerFormat::Avif))
226            }
227        }
228    }
229}
230
231/// Wrap raw `width × height × 3` RGB bytes in a PNG container.
232///
233/// Available behind the `png` cargo feature.
234///
235/// # Errors
236///
237/// Returns [`ImageError`] if the underlying encoder fails (very rare for
238/// valid RGB inputs — typically only OOM).
239///
240/// # Panics
241///
242/// Panics if `rgb.len() != (width * height * 3) as usize`.
243#[cfg(feature = "png")]
244pub fn rgb_to_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ImageError> {
245    assert_rgb_len(rgb, width, height);
246    let mut out = Vec::with_capacity(rgb.len());
247    PngEncoder::new(&mut out).write_image(rgb, width, height, ExtendedColorType::Rgb8)?;
248    Ok(out)
249}
250
251/// Wrap raw `width × height × 3` RGB bytes in a lossless WebP container.
252///
253/// Available behind the `webp` cargo feature.
254///
255/// # Errors
256///
257/// Returns [`ImageError`] on encode failure.
258///
259/// # Panics
260///
261/// Panics if `rgb.len() != (width * height * 3) as usize`.
262#[cfg(feature = "webp")]
263pub fn rgb_to_webp(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ImageError> {
264    assert_rgb_len(rgb, width, height);
265    let mut out = Vec::with_capacity(rgb.len() / 2);
266    WebPEncoder::new_lossless(&mut out).write_image(rgb, width, height, ExtendedColorType::Rgb8)?;
267    Ok(out)
268}
269
270/// Wrap raw `width × height × 3` RGB bytes in an AVIF container.
271///
272/// Available behind the `avif` cargo feature, which pulls in the pure-Rust
273/// [`ravif`](https://docs.rs/ravif) encoder.
274///
275/// **Encode-only:** [`decode_image`] cannot decode AVIF without the
276/// system `libdav1d` library. If you need to decode AVIF, enable
277/// `image/avif-native` in your own dependency declaration and provide
278/// libdav1d at link time.
279///
280/// # Errors
281///
282/// Returns [`ImageError`] on encode failure.
283///
284/// # Panics
285///
286/// Panics if `rgb.len() != (width * height * 3) as usize`.
287#[cfg(feature = "avif")]
288pub fn rgb_to_avif(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ImageError> {
289    assert_rgb_len(rgb, width, height);
290    let mut out = Vec::with_capacity(rgb.len() / 4);
291    AvifEncoder::new(&mut out).write_image(rgb, width, height, ExtendedColorType::Rgb8)?;
292    Ok(out)
293}
294
295/// Decode container bytes to raw RGB. The format is auto-detected from
296/// the bytes' header.
297///
298/// Only formats whose cargo features are enabled will be recognised —
299/// e.g. with just `png` on, this can decode PNG but not WebP. AVIF
300/// decoding additionally requires `image/avif-native` (libdav1d) which
301/// is not enabled by our `avif` feature.
302///
303/// Pixels with alpha are dropped (the `image` crate decodes to RGBA
304/// internally and we keep only the RGB channels).
305///
306/// # Errors
307///
308/// Returns [`ImageError`] if the bytes are not in a recognised format
309/// or the decoder fails.
310pub fn decode_image(bytes: &[u8]) -> Result<DecodedImage, ImageError> {
311    let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?;
312    let img = reader.decode()?;
313    let width = img.width();
314    let height = img.height();
315    let rgb = img.into_rgb8().into_raw();
316    Ok(DecodedImage { rgb, width, height })
317}
318
319#[track_caller]
320fn assert_rgb_len(rgb: &[u8], width: u32, height: u32) {
321    let expected = (width as usize) * (height as usize) * 3;
322    assert_eq!(
323        rgb.len(),
324        expected,
325        "rgb length mismatch: expected {expected}, got {}",
326        rgb.len()
327    );
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::heightmap::{HeightmapFormat, decode, encode};
334
335    fn sample_rgb(width: u32, height: u32) -> Vec<u8> {
336        let elevations: Vec<f32> = (0..(width * height) as usize)
337            .map(|i| i as f32 * 10.0)
338            .collect();
339        encode(HeightmapFormat::Terrarium, &elevations, width, height)
340    }
341
342    #[test]
343    fn container_format_round_trips_through_from_str() {
344        for fmt in ContainerFormat::ALL {
345            let parsed: ContainerFormat = fmt.to_string().parse().unwrap();
346            assert_eq!(parsed, fmt);
347            // MIME alias also works.
348            let mime: ContainerFormat = fmt.mime_type().parse().unwrap();
349            assert_eq!(mime, fmt);
350        }
351        assert!("bogus".parse::<ContainerFormat>().is_err());
352    }
353
354    #[test]
355    fn is_enabled_reflects_features() {
356        assert_eq!(ContainerFormat::Png.is_enabled(), cfg!(feature = "png"));
357        assert_eq!(ContainerFormat::Webp.is_enabled(), cfg!(feature = "webp"));
358        assert_eq!(ContainerFormat::Avif.is_enabled(), cfg!(feature = "avif"));
359    }
360
361    #[test]
362    fn dispatch_returns_unsupported_for_disabled_features() {
363        let rgb = sample_rgb(4, 4);
364        for fmt in ContainerFormat::ALL {
365            let result = rgb_to_container(fmt, &rgb, 4, 4);
366            match (fmt.is_enabled(), &result) {
367                (true, Ok(_)) => {}
368                (false, Err(ContainerError::Unsupported(f))) => assert_eq!(*f, fmt),
369                other => panic!(
370                    "unexpected combination: enabled={:?} {other:?}",
371                    fmt.is_enabled()
372                ),
373            }
374        }
375    }
376
377    #[cfg(feature = "png")]
378    #[test]
379    fn png_roundtrip_through_codec() {
380        let width = 8u32;
381        let height = 8u32;
382        let elevations: Vec<f32> = (0..(width * height) as usize)
383            .map(|i| i as f32 * 10.0)
384            .collect();
385
386        for fmt in [
387            HeightmapFormat::Terrarium,
388            HeightmapFormat::Mapbox,
389            HeightmapFormat::Gsi,
390        ] {
391            let rgb = encode(fmt, &elevations, width, height);
392            let png = rgb_to_png(&rgb, width, height).unwrap();
393            assert_eq!(
394                &png[..8],
395                b"\x89PNG\r\n\x1a\n",
396                "{fmt} should produce PNG magic"
397            );
398            let DecodedImage {
399                rgb: rgb_back,
400                width: w2,
401                height: h2,
402            } = decode_image(&png).unwrap();
403            assert_eq!((w2, h2), (width, height));
404            assert_eq!(rgb_back, rgb);
405            let elev_back = decode(fmt, &rgb_back, width, height);
406            for (a, b) in elevations.iter().zip(&elev_back) {
407                assert!((a - b).abs() < 0.5, "{fmt}: {a} → {b}");
408            }
409        }
410    }
411
412    #[cfg(feature = "avif")]
413    #[test]
414    fn avif_encodes_to_valid_container() {
415        let rgb = sample_rgb(8, 8);
416        let avif = rgb_to_avif(&rgb, 8, 8).unwrap();
417        // AVIF files have an `ftypavif` brand in the first ISO BMFF box.
418        assert!(
419            avif.windows(8).any(|w| w == b"ftypavif"),
420            "expected AVIF brand in output"
421        );
422    }
423
424    #[cfg(all(feature = "webp", feature = "png"))]
425    #[test]
426    fn webp_roundtrip_through_codec() {
427        let rgb = sample_rgb(8, 8);
428        let webp = rgb_to_webp(&rgb, 8, 8).unwrap();
429        // WebP files start with "RIFF" .... "WEBP".
430        assert_eq!(&webp[..4], b"RIFF");
431        assert_eq!(&webp[8..12], b"WEBP");
432        let decoded = decode_image(&webp).unwrap();
433        assert_eq!((decoded.width, decoded.height), (8, 8));
434        assert_eq!(decoded.rgb, rgb);
435    }
436}