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 let mut out = Vec::new();
195 rgb_to_container_to_writer(format, rgb, width, height, &mut out)?;
196 Ok(out)
197}
198
199pub 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#[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#[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#[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#[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#[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#[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
347pub 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 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 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 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}