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    let mut out = Vec::new();
195    rgb_to_container_to_writer(format, rgb, width, height, &mut out)?;
196    Ok(out)
197}
198
199/// Stream-encode raw `width × height × 3` RGB bytes into the chosen
200/// container format, writing directly to `writer` without an intermediate
201/// `Vec<u8>`. Pair with [`crate::heightmap::encode_to`] for an
202/// allocation-free DEM → image pipeline.
203pub fn rgb_to_container_to_writer<W: std::io::Write>(
204    format: ContainerFormat,
205    rgb: &[u8],
206    width: u32,
207    height: u32,
208    writer: W,
209) -> Result<(), ContainerError> {
210    match format {
211        ContainerFormat::Png => {
212            #[cfg(feature = "png")]
213            {
214                rgb_to_png_to_writer(rgb, width, height, writer)?;
215                Ok(())
216            }
217            #[cfg(not(feature = "png"))]
218            {
219                let _ = (rgb, width, height, writer);
220                Err(ContainerError::Unsupported(ContainerFormat::Png))
221            }
222        }
223        ContainerFormat::Webp => {
224            #[cfg(feature = "webp")]
225            {
226                rgb_to_webp_to_writer(rgb, width, height, writer)?;
227                Ok(())
228            }
229            #[cfg(not(feature = "webp"))]
230            {
231                let _ = (rgb, width, height, writer);
232                Err(ContainerError::Unsupported(ContainerFormat::Webp))
233            }
234        }
235        ContainerFormat::Avif => {
236            #[cfg(feature = "avif")]
237            {
238                rgb_to_avif_to_writer(rgb, width, height, writer)?;
239                Ok(())
240            }
241            #[cfg(not(feature = "avif"))]
242            {
243                let _ = (rgb, width, height, writer);
244                Err(ContainerError::Unsupported(ContainerFormat::Avif))
245            }
246        }
247    }
248}
249
250/// Encode raw `width × height × 3` RGB bytes as PNG directly to a writer.
251///
252/// Available behind the `png` cargo feature. Combine with
253/// [`crate::heightmap::encode_to`] to wrap a DEM tile in PNG with no
254/// intermediate `Vec<u8>`.
255///
256/// # Panics
257///
258/// Panics if `rgb.len() != (width * height * 3) as usize`.
259#[cfg(feature = "png")]
260pub fn rgb_to_png_to_writer<W: std::io::Write>(
261    rgb: &[u8],
262    width: u32,
263    height: u32,
264    writer: W,
265) -> Result<(), ImageError> {
266    assert_rgb_len(rgb, width, height);
267    PngEncoder::new(writer).write_image(rgb, width, height, ExtendedColorType::Rgb8)
268}
269
270/// Wrap raw `width × height × 3` RGB bytes in a PNG container `Vec<u8>`.
271///
272/// Available behind the `png` cargo feature.
273///
274/// # Errors
275///
276/// Returns [`ImageError`] if the underlying encoder fails (very rare for
277/// valid RGB inputs — typically only OOM).
278#[cfg(feature = "png")]
279pub fn rgb_to_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ImageError> {
280    let mut out = Vec::with_capacity(rgb.len());
281    rgb_to_png_to_writer(rgb, width, height, &mut out)?;
282    Ok(out)
283}
284
285/// Encode raw `width × height × 3` RGB bytes as lossless WebP directly to a writer.
286///
287/// Available behind the `webp` cargo feature.
288///
289/// # Panics
290///
291/// Panics if `rgb.len() != (width * height * 3) as usize`.
292#[cfg(feature = "webp")]
293pub fn rgb_to_webp_to_writer<W: std::io::Write>(
294    rgb: &[u8],
295    width: u32,
296    height: u32,
297    writer: W,
298) -> Result<(), ImageError> {
299    assert_rgb_len(rgb, width, height);
300    WebPEncoder::new_lossless(writer).write_image(rgb, width, height, ExtendedColorType::Rgb8)
301}
302
303/// Wrap raw `width × height × 3` RGB bytes in a lossless WebP container `Vec<u8>`.
304///
305/// Available behind the `webp` cargo feature.
306#[cfg(feature = "webp")]
307pub fn rgb_to_webp(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ImageError> {
308    let mut out = Vec::with_capacity(rgb.len() / 2);
309    rgb_to_webp_to_writer(rgb, width, height, &mut out)?;
310    Ok(out)
311}
312
313/// Encode raw `width × height × 3` RGB bytes as AVIF directly to a writer.
314///
315/// Available behind the `avif` cargo feature.
316///
317/// # Panics
318///
319/// Panics if `rgb.len() != (width * height * 3) as usize`.
320#[cfg(feature = "avif")]
321pub fn rgb_to_avif_to_writer<W: std::io::Write>(
322    rgb: &[u8],
323    width: u32,
324    height: u32,
325    writer: W,
326) -> Result<(), ImageError> {
327    assert_rgb_len(rgb, width, height);
328    AvifEncoder::new(writer).write_image(rgb, width, height, ExtendedColorType::Rgb8)
329}
330
331/// Wrap raw `width × height × 3` RGB bytes in an AVIF container `Vec<u8>`.
332///
333/// Available behind the `avif` cargo feature, which pulls in the pure-Rust
334/// [`ravif`](https://docs.rs/ravif) encoder.
335///
336/// **Encode-only:** [`decode_image`] cannot decode AVIF without the
337/// system `libdav1d` library. If you need to decode AVIF, enable
338/// `image/avif-native` in your own dependency declaration and provide
339/// libdav1d at link time.
340#[cfg(feature = "avif")]
341pub fn rgb_to_avif(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ImageError> {
342    let mut out = Vec::with_capacity(rgb.len() / 4);
343    rgb_to_avif_to_writer(rgb, width, height, &mut out)?;
344    Ok(out)
345}
346
347/// Decode container bytes to raw RGB. The format is auto-detected from
348/// the bytes' header.
349///
350/// Only formats whose cargo features are enabled will be recognised —
351/// e.g. with just `png` on, this can decode PNG but not WebP. AVIF
352/// decoding additionally requires `image/avif-native` (libdav1d) which
353/// is not enabled by our `avif` feature.
354///
355/// Pixels with alpha are dropped (the `image` crate decodes to RGBA
356/// internally and we keep only the RGB channels).
357///
358/// # Errors
359///
360/// Returns [`ImageError`] if the bytes are not in a recognised format
361/// or the decoder fails.
362pub fn decode_image(bytes: &[u8]) -> Result<DecodedImage, ImageError> {
363    let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?;
364    let img = reader.decode()?;
365    let width = img.width();
366    let height = img.height();
367    let rgb = img.into_rgb8().into_raw();
368    Ok(DecodedImage { rgb, width, height })
369}
370
371#[track_caller]
372fn assert_rgb_len(rgb: &[u8], width: u32, height: u32) {
373    let expected = (width as usize) * (height as usize) * 3;
374    assert_eq!(
375        rgb.len(),
376        expected,
377        "rgb length mismatch: expected {expected}, got {}",
378        rgb.len()
379    );
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::heightmap::{HeightmapFormat, decode, encode};
386
387    fn sample_rgb(width: u32, height: u32) -> Vec<u8> {
388        let elevations: Vec<f32> = (0..(width * height) as usize)
389            .map(|i| i as f32 * 10.0)
390            .collect();
391        encode(HeightmapFormat::Terrarium, &elevations, width, height)
392    }
393
394    #[test]
395    fn container_format_round_trips_through_from_str() {
396        for fmt in ContainerFormat::ALL {
397            let parsed: ContainerFormat = fmt.to_string().parse().unwrap();
398            assert_eq!(parsed, fmt);
399            // MIME alias also works.
400            let mime: ContainerFormat = fmt.mime_type().parse().unwrap();
401            assert_eq!(mime, fmt);
402        }
403        assert!("bogus".parse::<ContainerFormat>().is_err());
404    }
405
406    #[test]
407    fn is_enabled_reflects_features() {
408        assert_eq!(ContainerFormat::Png.is_enabled(), cfg!(feature = "png"));
409        assert_eq!(ContainerFormat::Webp.is_enabled(), cfg!(feature = "webp"));
410        assert_eq!(ContainerFormat::Avif.is_enabled(), cfg!(feature = "avif"));
411    }
412
413    #[test]
414    fn dispatch_returns_unsupported_for_disabled_features() {
415        let rgb = sample_rgb(4, 4);
416        for fmt in ContainerFormat::ALL {
417            let result = rgb_to_container(fmt, &rgb, 4, 4);
418            match (fmt.is_enabled(), &result) {
419                (true, Ok(_)) => {}
420                (false, Err(ContainerError::Unsupported(f))) => assert_eq!(*f, fmt),
421                other => panic!(
422                    "unexpected combination: enabled={:?} {other:?}",
423                    fmt.is_enabled()
424                ),
425            }
426        }
427    }
428
429    #[cfg(feature = "png")]
430    #[test]
431    fn png_roundtrip_through_codec() {
432        let width = 8u32;
433        let height = 8u32;
434        let elevations: Vec<f32> = (0..(width * height) as usize)
435            .map(|i| i as f32 * 10.0)
436            .collect();
437
438        for fmt in [
439            HeightmapFormat::Terrarium,
440            HeightmapFormat::Mapbox,
441            HeightmapFormat::Gsi,
442        ] {
443            let rgb = encode(fmt, &elevations, width, height);
444            let png = rgb_to_png(&rgb, width, height).unwrap();
445            assert_eq!(
446                &png[..8],
447                b"\x89PNG\r\n\x1a\n",
448                "{fmt} should produce PNG magic"
449            );
450            let DecodedImage {
451                rgb: rgb_back,
452                width: w2,
453                height: h2,
454            } = decode_image(&png).unwrap();
455            assert_eq!((w2, h2), (width, height));
456            assert_eq!(rgb_back, rgb);
457            let elev_back = decode(fmt, &rgb_back, width, height);
458            for (a, b) in elevations.iter().zip(&elev_back) {
459                assert!((a - b).abs() < 0.5, "{fmt}: {a} → {b}");
460            }
461        }
462    }
463
464    #[cfg(feature = "avif")]
465    #[test]
466    fn avif_encodes_to_valid_container() {
467        let rgb = sample_rgb(8, 8);
468        let avif = rgb_to_avif(&rgb, 8, 8).unwrap();
469        // AVIF files have an `ftypavif` brand in the first ISO BMFF box.
470        assert!(
471            avif.windows(8).any(|w| w == b"ftypavif"),
472            "expected AVIF brand in output"
473        );
474    }
475
476    #[cfg(all(feature = "webp", feature = "png"))]
477    #[test]
478    fn webp_roundtrip_through_codec() {
479        let rgb = sample_rgb(8, 8);
480        let webp = rgb_to_webp(&rgb, 8, 8).unwrap();
481        // WebP files start with "RIFF" .... "WEBP".
482        assert_eq!(&webp[..4], b"RIFF");
483        assert_eq!(&webp[8..12], b"WEBP");
484        let decoded = decode_image(&webp).unwrap();
485        assert_eq!((decoded.width, decoded.height), (8, 8));
486        assert_eq!(decoded.rgb, rgb);
487    }
488}