bbox_core/
formats.rs

1// This code was adapted from https://github.com/maplibre/martin
2// which has partially adapted it from https://github.com/maplibre/mbtileserver-rs
3// project originally written by Kaveh Karimi and licensed under MIT/Apache-2.0
4
5use std::fmt::Display;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum Format {
9    Gif,
10    Jpeg,
11    Json,
12    Mvt,
13    Png,
14    Webp,
15}
16
17impl Format {
18    #[must_use]
19    pub fn from_suffix(value: &str) -> Option<Self> {
20        Some(match value.to_ascii_lowercase().as_str() {
21            "gif" => Self::Gif,
22            "jpg" | "jpeg" => Self::Jpeg,
23            "json" => Self::Json,
24            "pbf" | "mvt" => Self::Mvt,
25            "png" => Self::Png,
26            "webp" => Self::Webp,
27            _ => None?,
28        })
29    }
30
31    pub fn from_content_type(mime: &str) -> Option<Self> {
32        Some(match mime {
33            "image/gif" => Self::Gif,
34            "image/jpeg" => Self::Jpeg,
35            "application/json" => Self::Json,
36            "application/x-protobuf" => Self::Mvt,
37            "image/png" => Self::Png,
38            "image/webp" => Self::Webp,
39            _ => None?,
40        })
41    }
42
43    pub fn file_suffix(&self) -> &str {
44        match *self {
45            Self::Gif => "gif",
46            Self::Jpeg => "jpg",
47            Self::Json => "json",
48            Self::Mvt => "pbf",
49            Self::Png => "png",
50            Self::Webp => "webp",
51        }
52    }
53
54    #[must_use]
55    pub fn content_type(&self) -> &str {
56        match *self {
57            Self::Gif => "image/gif",
58            Self::Jpeg => "image/jpeg",
59            Self::Json => "application/json",
60            Self::Mvt => "application/x-protobuf",
61            Self::Png => "image/png", // TODO: support for "image/png; mode=8bit"!
62            Self::Webp => "image/webp",
63        }
64    }
65
66    #[must_use]
67    pub fn is_detectable(&self) -> bool {
68        match *self {
69            Self::Png | Self::Jpeg | Self::Gif | Self::Webp => true,
70            // TODO: Json can be detected, but currently we only detect it
71            //       when it's not compressed, so to avoid a warning, keeping it as false for now.
72            //       Once we can detect it inside a compressed data, change it to true.
73            Self::Mvt | Self::Json => false,
74        }
75    }
76}
77
78impl Display for Format {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match *self {
81            Self::Gif => write!(f, "gif"),
82            Self::Jpeg => write!(f, "jpeg"),
83            Self::Json => write!(f, "json"),
84            Self::Mvt => write!(f, "mvt"),
85            Self::Png => write!(f, "png"),
86            Self::Webp => write!(f, "webp"),
87        }
88    }
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Encoding {
93    /// Data is not compressed, but it can be
94    Uncompressed = 0b0000_0000,
95    /// Some formats like JPEG and PNG are already compressed
96    Internal = 0b0000_0001,
97    Gzip = 0b0000_0010,
98    Zlib = 0b0000_0100,
99    Brotli = 0b0000_1000,
100    Zstd = 0b0001_0000,
101}
102
103impl Encoding {
104    #[must_use]
105    pub fn parse(value: &str) -> Option<Self> {
106        Some(match value.to_ascii_lowercase().as_str() {
107            "none" => Self::Uncompressed,
108            "gzip" => Self::Gzip,
109            "zlib" => Self::Zlib,
110            "brotli" => Self::Brotli,
111            "zstd" => Self::Zstd,
112            _ => None?,
113        })
114    }
115
116    #[must_use]
117    pub fn content_encoding(&self) -> Option<&str> {
118        match *self {
119            Self::Uncompressed | Self::Internal => None,
120            Self::Gzip => Some("gzip"),
121            Self::Zlib => Some("deflate"),
122            Self::Brotli => Some("br"),
123            Self::Zstd => Some("zstd"),
124        }
125    }
126
127    #[must_use]
128    pub fn is_encoded(&self) -> bool {
129        match *self {
130            Self::Uncompressed | Self::Internal => false,
131            Self::Gzip | Self::Zlib | Self::Brotli | Self::Zstd => true,
132        }
133    }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub struct TileInfo {
138    pub format: Format,
139    pub encoding: Encoding,
140}
141
142impl TileInfo {
143    #[must_use]
144    pub fn new(format: Format, encoding: Encoding) -> Self {
145        Self { format, encoding }
146    }
147
148    /// Try to figure out the format and encoding of the raw tile data
149    #[must_use]
150    #[allow(clippy::enum_glob_use)]
151    pub fn detect(value: &[u8]) -> Option<Self> {
152        use Encoding::*;
153        use Format::*;
154
155        // TODO: Make detection slower but more accurate:
156        //  - uncompress gzip/zlib/... and run detection again. If detection fails, assume MVT
157        //  - detect json inside a compressed data
158        //  - json should be fully parsed
159        //  - possibly keep the current `detect()` available as a fast path for those who may need it
160        Some(match value {
161            // Compressed prefixes assume MVT content
162            v if v.starts_with(b"\x1f\x8b") => Self::new(Mvt, Gzip),
163            v if v.starts_with(b"\x78\x9c") => Self::new(Mvt, Zlib),
164            v if v.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => Self::new(Png, Internal),
165            v if v.starts_with(b"\x47\x49\x46\x38\x39\x61") => Self::new(Gif, Internal),
166            v if v.starts_with(b"\xFF\xD8\xFF") => Self::new(Jpeg, Internal),
167            v if v.starts_with(b"RIFF") && v.len() > 8 && v[8..].starts_with(b"WEBP") => {
168                Self::new(Webp, Internal)
169            }
170            v if v.starts_with(b"{") => Self::new(Json, Uncompressed),
171            _ => None?,
172        })
173    }
174
175    #[must_use]
176    pub fn encoding(self, encoding: Encoding) -> Self {
177        Self { encoding, ..self }
178    }
179}
180
181impl From<Format> for TileInfo {
182    fn from(format: Format) -> Self {
183        Self::new(
184            format,
185            match format {
186                Format::Png | Format::Jpeg | Format::Webp | Format::Gif => Encoding::Internal,
187                Format::Mvt | Format::Json => Encoding::Uncompressed,
188            },
189        )
190    }
191}
192
193impl Display for TileInfo {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        write!(f, "{}", self.format.content_type())?;
196        if let Some(encoding) = self.encoding.content_encoding() {
197            write!(f, "; encoding={encoding}")?;
198        } else if self.encoding != Encoding::Uncompressed {
199            write!(f, "; uncompressed")?;
200        }
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use std::fs::read;
208
209    use Encoding::{Internal, Uncompressed};
210    use Format::{Jpeg, Json, Png, Webp};
211
212    use super::*;
213
214    fn detect(path: &str) -> Option<TileInfo> {
215        TileInfo::detect(&read(path).unwrap())
216    }
217
218    #[allow(clippy::unnecessary_wraps)]
219    fn info(format: Format, encoding: Encoding) -> Option<TileInfo> {
220        Some(TileInfo::new(format, encoding))
221    }
222
223    #[test]
224    fn test_data_format_png() {
225        assert_eq!(detect("./fixtures/world.png"), info(Png, Internal));
226    }
227
228    #[test]
229    fn test_data_format_jpg() {
230        assert_eq!(detect("./fixtures/world.jpg"), info(Jpeg, Internal));
231    }
232
233    #[test]
234    fn test_data_format_webp() {
235        assert_eq!(detect("./fixtures/dc.webp"), info(Webp, Internal));
236        assert_eq!(TileInfo::detect(br#"RIFF"#), None);
237    }
238
239    #[test]
240    fn test_data_format_json() {
241        assert_eq!(
242            TileInfo::detect(br#"{"foo":"bar"}"#),
243            info(Json, Uncompressed)
244        );
245    }
246}