1use 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
33pub type ImageError = image::ImageError;
36
37#[derive(Debug, Clone)]
39pub struct DecodedImage {
40 pub rgb: Vec<u8>,
42 pub width: u32,
44 pub height: u32,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum ContainerFormat {
57 Png,
59 Webp,
61 Avif,
63}
64
65impl ContainerFormat {
66 pub const ALL: [Self; 3] = [Self::Png, Self::Webp, Self::Avif];
68
69 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ParseContainerFormatError {
106 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 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#[derive(Debug)]
141pub enum ContainerError {
142 Image(ImageError),
144 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
175pub 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#[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#[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#[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
295pub 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 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 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 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}