Skip to main content

martin_tile_utils/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4// This code was partially adapted from https://github.com/maplibre/mbtileserver-rs
5// project originally written by Kaveh Karimi and licensed under MIT OR Apache-2.0
6
7use std::f64::consts::PI;
8use std::fmt::{Display, Formatter};
9
10use strum::EnumIter;
11
12/// circumference of the earth in meters
13pub const EARTH_CIRCUMFERENCE: f64 = 40_075_016.685_578_5;
14/// circumference of the earth in degrees
15pub const EARTH_CIRCUMFERENCE_DEGREES: u32 = 360;
16
17/// radius of the earth in meters
18pub const EARTH_RADIUS: f64 = EARTH_CIRCUMFERENCE / 2.0 / PI;
19
20pub const MAX_ZOOM: u8 = 30;
21
22mod decoders;
23pub use decoders::*;
24mod rectangle;
25pub use rectangle::{TileRect, append_rect};
26
27#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
28pub struct TileCoord {
29    pub z: u8,
30    pub x: u32,
31    pub y: u32,
32}
33
34pub type TileData = Vec<u8>;
35pub type Tile = (TileCoord, Option<TileData>);
36
37impl Display for TileCoord {
38    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
39        if f.alternate() {
40            write!(f, "{}/{}/{}", self.z, self.x, self.y)
41        } else {
42            write!(f, "{},{},{}", self.z, self.x, self.y)
43        }
44    }
45}
46
47impl TileCoord {
48    /// Checks provided coordinates for validity
49    /// before constructing [`TileCoord`] instance.
50    ///
51    /// Check [`Self::new_unchecked`] if you are sure that your inputs are possible.
52    #[must_use]
53    pub fn new_checked(z: u8, x: u32, y: u32) -> Option<Self> {
54        Self::is_possible_on_zoom_level(z, x, y).then_some(Self { z, x, y })
55    }
56
57    /// Constructs [`TileCoord`] instance from arguments without checking that the tiles can exist.
58    ///
59    /// Check [`Self::new_checked`] if you are unsure if your inputs are possible.
60    #[must_use]
61    pub const fn new_unchecked(z: u8, x: u32, y: u32) -> Self {
62        Self { z, x, y }
63    }
64
65    /// Checks that zoom `z` is plausibily small and `x`/`y` is possible on said zoom level
66    #[must_use]
67    pub const fn is_possible_on_zoom_level(z: u8, x: u32, y: u32) -> bool {
68        if z > MAX_ZOOM {
69            return false;
70        }
71
72        let side_len = 1_u32 << z;
73        x < side_len && y < side_len
74    }
75}
76
77#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, EnumIter)]
78pub enum Format {
79    Gif,
80    Jpeg,
81    Json,
82    Mvt,
83    Mlt,
84    Png,
85    Webp,
86    Avif,
87    Jxl,
88}
89
90impl Format {
91    /// All image formats.
92    pub const IMAGE_FORMATS: &[Self] = &[
93        Self::Gif,
94        Self::Jpeg,
95        Self::Png,
96        Self::Webp,
97        Self::Avif,
98        Self::Jxl,
99    ];
100
101    #[must_use]
102    pub fn parse(value: &str) -> Option<Self> {
103        Some(match value.to_ascii_lowercase().as_str() {
104            "gif" => Self::Gif,
105            "jpg" | "jpeg" => Self::Jpeg,
106            "json" => Self::Json,
107            "pbf" | "mvt" => Self::Mvt,
108            "mlt" => Self::Mlt,
109            "png" => Self::Png,
110            "webp" => Self::Webp,
111            "avif" => Self::Avif,
112            "jxl" => Self::Jxl,
113            _ => None?,
114        })
115    }
116
117    /// Get the `format` value as it should be stored in the `MBTiles` metadata table
118    #[must_use]
119    pub const fn metadata_format_value(self) -> &'static str {
120        match self {
121            Self::Gif => "gif",
122            Self::Jpeg => "jpeg",
123            Self::Json => "json",
124            // QGIS uses `pbf` instead of `mvt` for some reason
125            Self::Mvt => "pbf",
126            Self::Mlt => "mlt",
127            Self::Png => "png",
128            Self::Webp => "webp",
129            Self::Avif => "avif",
130            Self::Jxl => "jxl",
131        }
132    }
133
134    #[must_use]
135    pub const fn content_type(&self) -> &str {
136        match *self {
137            Self::Gif => "image/gif",
138            Self::Jpeg => "image/jpeg",
139            Self::Json => "application/json",
140            Self::Mvt => "application/x-protobuf",
141            Self::Mlt => "application/vnd.maplibre-tile",
142            Self::Png => "image/png",
143            Self::Webp => "image/webp",
144            Self::Avif => "image/avif",
145            Self::Jxl => "image/jxl",
146        }
147    }
148
149    /// Parse a content type string back to a `Format`.
150    #[must_use]
151    pub fn from_content_type(supertype: &str, subtype: &str) -> Option<Self> {
152        Some(match (supertype, subtype) {
153            ("image", "gif") => Self::Gif,
154            ("image", "jpeg" | "jpg") => Self::Jpeg,
155            ("application", "json") => Self::Json,
156            ("application", "x-protobuf" | "vnd.mapbox-vector-tile") => Self::Mvt,
157            ("application", "vnd.maplibre-vector-tile" | "vnd.maplibre-tile") => Self::Mlt,
158            ("image", "png") => Self::Png,
159            ("image", "webp") => Self::Webp,
160            ("image", "avif") => Self::Avif,
161            ("image", "jxl") => Self::Jxl,
162            _ => None?,
163        })
164    }
165
166    #[must_use]
167    pub const fn is_detectable(self) -> bool {
168        match self {
169            Self::Png
170            | Self::Jpeg
171            | Self::Gif
172            | Self::Webp
173            | Self::Avif
174            | Self::Jxl
175            | Self::Json
176            | Self::Mlt => true,
177            Self::Mvt => false,
178        }
179    }
180}
181
182impl Display for Format {
183    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
184        f.write_str(match *self {
185            Self::Gif => "gif",
186            Self::Jpeg => "jpeg",
187            Self::Json => "json",
188            Self::Mvt => "mvt",
189            Self::Mlt => "mlt",
190            Self::Png => "png",
191            Self::Webp => "webp",
192            Self::Avif => "avif",
193            Self::Jxl => "jxl",
194        })
195    }
196}
197
198#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
199pub enum Encoding {
200    /// Data is not compressed, but it can be
201    Uncompressed = 0b0000_0000,
202    /// Some formats like JPEG and PNG are already compressed
203    Internal = 0b0000_0001,
204    Gzip = 0b0000_0010,
205    Zlib = 0b0000_0100,
206    Brotli = 0b0000_1000,
207    Zstd = 0b0001_0000,
208}
209
210impl Encoding {
211    /// Parse the encoding from common names if they match
212    #[must_use]
213    pub fn parse(value: &str) -> Option<Self> {
214        Some(match value.to_ascii_lowercase().as_str() {
215            "none" | "identity" => Self::Uncompressed,
216            "gzip" => Self::Gzip,
217            "deflate" | "zlib" => Self::Zlib,
218            "br" | "brotli" => Self::Brotli,
219            "zstd" => Self::Zstd,
220            _ => None?,
221        })
222    }
223
224    /// Returns `None` for [`Encoding::Uncompressed`] and [`Encoding::Internal`]:
225    /// absence of the `compression` key in the metadata table means no external encoding.
226    #[must_use]
227    pub const fn compression(self) -> Option<&'static str> {
228        match self {
229            Self::Uncompressed | Self::Internal => None,
230            Self::Gzip => Some("gzip"),
231            Self::Zlib => Some("deflate"),
232            Self::Brotli => Some("br"),
233            Self::Zstd => Some("zstd"),
234        }
235    }
236
237    #[must_use]
238    pub const fn is_encoded(self) -> bool {
239        match self {
240            Self::Uncompressed | Self::Internal => false,
241            Self::Gzip | Self::Zlib | Self::Brotli | Self::Zstd => true,
242        }
243    }
244}
245
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub struct TileInfo {
248    pub format: Format,
249    pub encoding: Encoding,
250}
251
252impl TileInfo {
253    #[must_use]
254    pub const fn new(format: Format, encoding: Encoding) -> Self {
255        Self { format, encoding }
256    }
257
258    /// Try to figure out the format and encoding of the raw tile data
259    #[must_use]
260    pub fn detect(value: &[u8]) -> Self {
261        // Try GZIP decompression
262        if value.starts_with(b"\x1f\x8b") {
263            if let Ok(decompressed) = decode_gzip(value) {
264                let inner_format = Self::detect_vectorish_format(&decompressed);
265                return Self::new(inner_format, Encoding::Gzip);
266            }
267            // If decompression fails or format is unknown, assume MVT
268            return Self::new(Format::Mvt, Encoding::Gzip);
269        }
270
271        // Try Zlib decompression
272        if value.starts_with(b"\x78\x9c") {
273            if let Ok(decompressed) = decode_zlib(value) {
274                let inner_format = Self::detect_vectorish_format(&decompressed);
275                return Self::new(inner_format, Encoding::Zlib);
276            }
277            // If decompression fails or format is unknown, assume MVT
278            return Self::new(Format::Mvt, Encoding::Zlib);
279        }
280        if let Some(raster_format) = Self::detect_raster_formats(value) {
281            Self::new(raster_format, Encoding::Internal)
282        } else {
283            Self::detect_vectorish_format(value).into()
284        }
285    }
286
287    /// Fast-path detection without decompression
288    #[must_use]
289    fn detect_raster_formats(value: &[u8]) -> Option<Format> {
290        match value {
291            v if v.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => Some(Format::Png),
292            v if v.starts_with(b"\x47\x49\x46\x38\x39\x61") => Some(Format::Gif),
293            v if v.starts_with(b"\xFF\xD8\xFF") => Some(Format::Jpeg),
294            v if v.starts_with(b"\xFF\x0A") => Some(Format::Jxl),
295            v if v.starts_with(b"\x00\x00\x00\x0C\x4A\x58\x4C\x20\x0D\x0A\x87\x0A") => {
296                Some(Format::Jxl)
297            }
298            v if v.starts_with(b"RIFF") && v.len() > 8 && v[8..].starts_with(b"WEBP") => {
299                Some(Format::Webp)
300            }
301            _ => None,
302        }
303    }
304
305    /// Detect the format of vector (or json) data after decompression
306    #[must_use]
307    fn detect_vectorish_format(value: &[u8]) -> Format {
308        match value {
309            v if decode_7bit_length_and_tag(v, &[0x1]).is_ok() => Format::Mlt,
310            v if is_valid_json(v) => Format::Json,
311            // If we can't detect the format, we assume MVT.
312            // Reasoning:
313            //- it's the most common format and
314            //- we don't have a detector for it
315            _ => Format::Mvt,
316        }
317    }
318
319    #[must_use]
320    pub const fn encoding(self, encoding: Encoding) -> Self {
321        Self { encoding, ..self }
322    }
323}
324
325impl From<Format> for TileInfo {
326    fn from(format: Format) -> Self {
327        Self::new(
328            format,
329            match format {
330                Format::Mlt
331                | Format::Png
332                | Format::Jpeg
333                | Format::Webp
334                | Format::Gif
335                | Format::Avif
336                | Format::Jxl => Encoding::Internal,
337                Format::Mvt | Format::Json => Encoding::Uncompressed,
338            },
339        )
340    }
341}
342
343impl Display for TileInfo {
344    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
345        write!(f, "{}", self.format.content_type())?;
346        if let Some(encoding) = self.encoding.compression() {
347            write!(f, "; encoding={encoding}")?;
348        } else if self.encoding != Encoding::Uncompressed {
349            f.write_str("; uncompressed")?;
350        }
351        Ok(())
352    }
353}
354
355#[derive(thiserror::Error, Debug, PartialEq, Eq)]
356enum SevenBitDecodingError {
357    /// Expected a tag, but got nothing
358    #[error("Expected a tag, but got nothing")]
359    TruncatedTag,
360    /// The size of the tile is too large to be decoded
361    #[error("The size of the tile is too large to be decoded")]
362    SizeOverflow,
363    /// The size of the tile is lower than the number of bytes for the size and tag
364    #[error("The size of the tile is lower than the number of bytes for the size and tag")]
365    SizeUnderflow,
366    /// Expected a size, but got nothing
367    #[error("Expected a size, but got nothing")]
368    TruncatedSize,
369    /// Expected data according to the size, but got nothing
370    #[error(
371        "Expected {expected} bytes of data in layer according to the size, but got only {actual}"
372    )]
373    TruncatedData { expected: u64, actual: u64 },
374    /// Got unexpected tag
375    #[error("Got tag {0} instead of the expected")]
376    UnexpectedTag(u8),
377}
378
379/// Tries to validate that the tile consists of a valid concatenation of (`size_7_bit`, `one_of_expected_version`, `data`)
380fn decode_7bit_length_and_tag(tile: &[u8], versions: &[u8]) -> Result<(), SevenBitDecodingError> {
381    if tile.is_empty() {
382        return Err(SevenBitDecodingError::TruncatedSize);
383    }
384    let mut tile_iter = tile.iter().peekable();
385    while tile_iter.peek().is_some() {
386        // need to parse size
387        let mut size = 0_u64;
388        let mut header_bit_count = 0_u64;
389        loop {
390            header_bit_count += 1;
391            let Some(b) = tile_iter.next() else {
392                return Err(SevenBitDecodingError::TruncatedSize);
393            };
394            if header_bit_count * 7 + 8 > 64 {
395                return Err(SevenBitDecodingError::SizeOverflow);
396            }
397            // decode size
398            size <<= 7;
399            let seven_bit_mask = !0x80;
400            size |= u64::from(*b & seven_bit_mask);
401            // 0 => no further size
402            if b & 0x80 == 0 {
403                // need to check tag
404                header_bit_count += 1;
405                let Some(tag) = tile_iter.next() else {
406                    return Err(SevenBitDecodingError::TruncatedTag);
407                };
408                if !versions.contains(tag) {
409                    return Err(SevenBitDecodingError::UnexpectedTag(*tag));
410                }
411                // need to check data-length
412                let payload_len = size
413                    .checked_sub(header_bit_count)
414                    .ok_or(SevenBitDecodingError::SizeUnderflow)?;
415                for i in 0..payload_len {
416                    if tile_iter.next().is_none() {
417                        return Err(SevenBitDecodingError::TruncatedData {
418                            expected: payload_len,
419                            actual: i,
420                        });
421                    }
422                }
423                break;
424            }
425        }
426    }
427    Ok(())
428}
429
430/// Detects if the given tile is a valid JSON tile.
431///
432/// The check for a dictionary is used to speed up the validation process.
433fn is_valid_json(tile: &[u8]) -> bool {
434    tile.starts_with(b"{")
435        && tile.ends_with(b"}")
436        && serde_json::from_slice::<serde::de::IgnoredAny>(tile).is_ok()
437}
438
439/// Convert longitude and latitude to a tile (x,y) coordinates for a given zoom
440#[must_use]
441#[expect(clippy::cast_possible_truncation)]
442#[expect(clippy::cast_sign_loss)]
443pub fn tile_index(lng: f64, lat: f64, zoom: u8) -> (u32, u32) {
444    let tile_size = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
445    let (x, y) = wgs84_to_webmercator(lng, lat);
446    let col = ((EARTH_CIRCUMFERENCE.mul_add(0.5, x).abs() / tile_size) as u32).min((1 << zoom) - 1);
447    let row =
448        ((EARTH_CIRCUMFERENCE.mul_add(0.5, -y).abs() / tile_size) as u32).min((1 << zoom) - 1);
449    (col, row)
450}
451
452/// Convert min/max XYZ tile coordinates to a bounding box values.
453///
454/// The result is `[min_lng, min_lat, max_lng, max_lat]`
455///
456/// # Panics
457/// Panics if `zoom` is greater than [`MAX_ZOOM`].
458#[must_use]
459pub fn xyz_to_bbox(zoom: u8, min_x: u32, min_y: u32, max_x: u32, max_y: u32) -> [f64; 4] {
460    assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
461
462    let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
463
464    let left_down_bbox = tile_bbox(min_x, max_y, tile_length);
465    let right_top_bbox = tile_bbox(max_x, min_y, tile_length);
466
467    let (min_lng, min_lat) = webmercator_to_wgs84(left_down_bbox[0], left_down_bbox[1]);
468    let (max_lng, max_lat) = webmercator_to_wgs84(right_top_bbox[2], right_top_bbox[3]);
469    [min_lng, min_lat, max_lng, max_lat]
470}
471
472#[expect(clippy::cast_lossless)]
473#[must_use]
474pub fn tile_bbox(x: u32, y: u32, tile_length: f64) -> [f64; 4] {
475    let min_x = (x as f64).mul_add(tile_length, EARTH_CIRCUMFERENCE * -0.5);
476    let max_y = (y as f64).mul_add(-tile_length, EARTH_CIRCUMFERENCE * 0.5);
477
478    [min_x, max_y - tile_length, min_x + tile_length, max_y]
479}
480
481/// Convert bounding box to a tile box `(min_x, min_y, max_x, max_y)` for a given zoom
482#[must_use]
483pub fn bbox_to_xyz(left: f64, bottom: f64, right: f64, top: f64, zoom: u8) -> (u32, u32, u32, u32) {
484    let (min_col, min_row) = tile_index(left, top, zoom);
485    let (max_col, max_row) = tile_index(right, bottom, zoom);
486    (min_col, min_row, max_col, max_row)
487}
488
489/// Compute precision of a zoom level, i.e. how many decimal digits of the longitude and latitude are relevant
490#[must_use]
491#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
492pub fn get_zoom_precision(zoom: u8) -> usize {
493    assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
494    let lng_delta = webmercator_to_wgs84(EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom), 0.0).0;
495    let log = lng_delta.log10() - 0.5;
496    if log > 0.0 { 0 } else { -log.ceil() as usize }
497}
498
499/// transform [`WebMercator`](https://epsg.io/3857) to [WGS84](https://epsg.io/4326)
500// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
501#[must_use]
502pub fn webmercator_to_wgs84(x: f64, y: f64) -> (f64, f64) {
503    let lng = (x / EARTH_RADIUS).to_degrees();
504    let lat = f64::atan(f64::sinh(y / EARTH_RADIUS)).to_degrees();
505    (lng, lat)
506}
507
508/// transform [WGS84](https://epsg.io/4326) to [`WebMercator`](https://epsg.io/3857)
509// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
510#[must_use]
511pub fn wgs84_to_webmercator(lon: f64, lat: f64) -> (f64, f64) {
512    let x = lon.to_radians() * EARTH_RADIUS;
513
514    let y_sin = lat.to_radians().sin();
515    let y = EARTH_RADIUS / 2.0 * ((1.0 + y_sin) / (1.0 - y_sin)).ln();
516
517    (x, y)
518}
519
520#[cfg(test)]
521mod tests {
522    use approx::assert_relative_eq;
523    use rstest::rstest;
524
525    use super::*;
526
527    #[rstest]
528    #[case::png(
529        include_bytes!("../fixtures/world.png"),
530        TileInfo::new(Format::Png, Encoding::Internal)
531    )]
532    #[case::jpg(
533        include_bytes!("../fixtures/world.jpg"),
534        TileInfo::new(Format::Jpeg, Encoding::Internal)
535    )]
536    #[case::webp(
537        include_bytes!("../fixtures/dc.webp"),
538        TileInfo::new(Format::Webp, Encoding::Internal)
539    )]
540    #[case::jxl_codestream(
541        &[0xFF, 0x0A, 0x00, 0x00],
542        TileInfo::new(Format::Jxl, Encoding::Internal)
543    )]
544    #[case::jxl_container(
545        &[0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A],
546        TileInfo::new(Format::Jxl, Encoding::Internal)
547    )]
548    #[case::json(
549        br#"{"foo":"bar"}"#,
550        TileInfo::new(Format::Json, Encoding::Uncompressed)
551    )]
552    // we have no way of knowing what is an MVT -> we just say it is out of the
553    // fact that it is not something else
554    #[case::invalid_webp_header(b"RIFF", TileInfo::new(Format::Mvt, Encoding::Uncompressed))]
555    fn test_data_format_detect(#[case] data: &[u8], #[case] expected: TileInfo) {
556        assert_eq!(TileInfo::detect(data), expected);
557    }
558
559    /// Test detection of compressed content (JSON, MLT, MVT)
560    #[test]
561    fn compressed_json_gzip() {
562        let json_data = br#"{"type":"FeatureCollection","features":[]}"#;
563        let compressed = encode_gzip(json_data).unwrap();
564        let result = TileInfo::detect(&compressed);
565        assert_eq!(result, TileInfo::new(Format::Json, Encoding::Gzip));
566    }
567
568    #[test]
569    fn compressed_json_zlib() {
570        use std::io::Write as _;
571
572        use flate2::write::ZlibEncoder;
573
574        let json_data = br#"{"type":"FeatureCollection","features":[]}"#;
575        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
576        encoder.write_all(json_data).unwrap();
577        let compressed = encoder.finish().unwrap();
578
579        let result = TileInfo::detect(&compressed);
580        assert_eq!(result, TileInfo::new(Format::Json, Encoding::Zlib));
581    }
582
583    #[test]
584    fn raw_mlt_encoding_internal() {
585        // MLT has internal compression, so raw MLT bytes should be Encoding::Internal
586        // to prevent the serve path from applying heavyweight gzip/brotli on top.
587        let mlt_data = &[0x02, 0x01];
588        let result = TileInfo::detect(mlt_data);
589        assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Internal));
590    }
591
592    #[test]
593    fn compressed_mlt_gzip() {
594        // MLT tile: length=2 (0x02), version=1 (0x01)
595        let mlt_data = &[0x02, 0x01];
596        let compressed = encode_gzip(mlt_data).unwrap();
597        let result = TileInfo::detect(&compressed);
598        assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Gzip));
599    }
600
601    #[test]
602    fn compressed_mlt_zlib() {
603        use std::io::Write as _;
604
605        use flate2::write::ZlibEncoder;
606
607        // MLT tile: length=5 (0x05), version=1 (0x01), plus some data
608        let mlt_data = &[0x05, 0x01, 0xaa, 0xbb, 0xcc];
609        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
610        encoder.write_all(mlt_data).unwrap();
611        let compressed = encoder.finish().unwrap();
612
613        let result = TileInfo::detect(&compressed);
614        assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Zlib));
615    }
616
617    #[test]
618    fn compressed_mvt_gzip_fallback() {
619        // Random data that doesn't match any known format => should be detected as MVT
620        let random_data = &[0x1a, 0x2b, 0x3c, 0x4d];
621        let compressed = encode_gzip(random_data).unwrap();
622        let result = TileInfo::detect(&compressed);
623        assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Gzip));
624    }
625
626    #[test]
627    fn compressed_mvt_zlib_fallback() {
628        use std::io::Write as _;
629
630        use flate2::write::ZlibEncoder;
631
632        // Random data that doesn't match any known format => should be detected as MVT
633        let random_data = &[0xaa, 0xbb, 0xcc, 0xdd];
634        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
635        encoder.write_all(random_data).unwrap();
636        let compressed = encoder.finish().unwrap();
637
638        let result = TileInfo::detect(&compressed);
639        assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Zlib));
640    }
641
642    #[test]
643    fn invalid_json_in_gzip() {
644        // Data that looks like JSON but isn't valid => should fall back to MVT
645        let invalid_json = b"{this is not valid json}";
646        let compressed = encode_gzip(invalid_json).unwrap();
647        let result = TileInfo::detect(&compressed);
648        assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Gzip));
649    }
650
651    #[rstest]
652    #[case::minimal_tile(&[0x02, 0x01], Ok(()))]
653    #[case::one_byte_length(&[0x03, 0x01, 0xaa], Ok(()))]
654    #[case::two_byte_length(&[0x80, 0x04, 0x01, 0xaa], Ok(()))]
655    #[case::multi_byte_length(&[0x80, 0x80, 0x05, 0x01, 0xdd], Ok(()))]
656    #[case::wrong_version(&[0x03, 0x02, 0xaa], Err(SevenBitDecodingError::UnexpectedTag(0x02)))]
657    #[case::empty_input(&[], Err(SevenBitDecodingError::TruncatedSize))]
658    #[case::size_overflow(&[0xFF; 64], Err(SevenBitDecodingError::SizeOverflow))]
659    #[case::size_underflow(&[0x00, 0x01], Err(SevenBitDecodingError::SizeUnderflow))]
660    #[case::unterminated_length(&[0x80], Err(SevenBitDecodingError::TruncatedSize))]
661    #[case::missing_version_byte(&[0x05], Err(SevenBitDecodingError::TruncatedTag))]
662    #[case::wrong_length(&[0x03, 0x01], Err(SevenBitDecodingError::TruncatedData { expected: 1, actual: 0 }))]
663    fn test_decode_7bit_length_and_tag(
664        #[case] tile: &[u8],
665        #[case] expected: Result<(), SevenBitDecodingError>,
666    ) {
667        let allowed_versions = &[0x01_u8];
668        let decoded = decode_7bit_length_and_tag(tile, allowed_versions);
669        assert_eq!(decoded, expected, "can decode one layer correctly");
670
671        if tile.is_empty() {
672            return;
673        }
674        let mut tile_with_two_layers = vec![0x02, 0x01];
675        tile_with_two_layers.extend_from_slice(tile);
676        let decoded = decode_7bit_length_and_tag(&tile_with_two_layers, allowed_versions);
677        assert_eq!(decoded, expected, "can decode two layers correctly");
678    }
679
680    #[rstest]
681    #[case(-180.0, 85.0511, 0, (0,0))]
682    #[case(-180.0, 85.0511, 1, (0,0))]
683    #[case(-180.0, 85.0511, 2, (0,0))]
684    #[case(0.0, 0.0, 0, (0,0))]
685    #[case(0.0, 0.0, 1, (1,1))]
686    #[case(0.0, 0.0, 2, (2,2))]
687    #[case(0.0, 1.0, 0, (0,0))]
688    #[case(0.0, 1.0, 1, (1,0))]
689    #[case(0.0, 1.0, 2, (2,1))]
690    fn test_tile_colrow(
691        #[case] lng: f64,
692        #[case] lat: f64,
693        #[case] zoom: u8,
694        #[case] expected: (u32, u32),
695    ) {
696        assert_eq!(
697            expected,
698            tile_index(lng, lat, zoom),
699            "{lng},{lat}@z{zoom} should be {expected:?}"
700        );
701    }
702
703    #[rstest]
704    // you could easily get test cases from maptiler: https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#4/-118.82/71.02
705    #[case(0, 0, 0, 0, 0, [-180.0,-85.051_128_779_806_6,180.0,85.051_128_779_806_6])]
706    #[case(1, 0, 0, 0, 0, [-180.0,0.0,0.0,85.051_128_779_806_6])]
707    #[case(5, 1, 1, 2, 2, [-168.75,81.093_213_852_608_37,-146.25,83.979_259_498_862_05])]
708    #[case(5, 1, 3, 2, 5, [-168.75,74.019_543_311_502_26,-146.25,81.093_213_852_608_37])]
709    fn test_xyz_to_bbox(
710        #[case] zoom: u8,
711        #[case] min_x: u32,
712        #[case] min_y: u32,
713        #[case] max_x: u32,
714        #[case] max_y: u32,
715        #[case] expected: [f64; 4],
716    ) {
717        let bbox = xyz_to_bbox(zoom, min_x, min_y, max_x, max_y);
718        assert_relative_eq!(bbox[0], expected[0], epsilon = f64::EPSILON * 2.0);
719        assert_relative_eq!(bbox[1], expected[1], epsilon = f64::EPSILON * 2.0);
720        assert_relative_eq!(bbox[2], expected[2], epsilon = f64::EPSILON * 2.0);
721        assert_relative_eq!(bbox[3], expected[3], epsilon = f64::EPSILON * 2.0);
722    }
723
724    #[rstest]
725    #[case(0, 0, 0, [-20_037_508.342_789_25, -20_037_508.342_789_25, 20_037_508.342_789_25, 20_037_508.342_789_25])]
726    #[case(1, 0, 0, [-20_037_508.342_789_25, 0.0, 0.0, 20_037_508.342_789_25])]
727    #[case(1, 1, 1, [0.0, -20_037_508.342_789_25, 20_037_508.342_789_25, 0.0])]
728    #[case(2, 0, 0, [-20_037_508.342_789_25, 10_018_754.171_394_625, -10_018_754.171_394_625, 20_037_508.342_789_25])]
729    #[case(2, 2, 2, [0.0, -10_018_754.171_394_625, 10_018_754.171_394_625, 0.0])]
730    fn test_tile_bbox(
731        #[case] zoom: u8,
732        #[case] x: u32,
733        #[case] y: u32,
734        #[case] expected: [f64; 4],
735    ) {
736        let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
737        let bbox = tile_bbox(x, y, tile_length);
738        assert_relative_eq!(bbox[0], expected[0], epsilon = f64::EPSILON * 2.0);
739        assert_relative_eq!(bbox[1], expected[1], epsilon = f64::EPSILON * 2.0);
740        assert_relative_eq!(bbox[2], expected[2], epsilon = f64::EPSILON * 2.0);
741        assert_relative_eq!(bbox[3], expected[3], epsilon = f64::EPSILON * 2.0);
742        assert_relative_eq!(bbox[2] - bbox[0], tile_length, epsilon = f64::EPSILON * 2.0);
743        assert_relative_eq!(bbox[3] - bbox[1], tile_length, epsilon = f64::EPSILON * 2.0);
744    }
745
746    #[rstest]
747    #[case(0, (0, 0, 0, 0))]
748    #[case(1, (0, 1, 0, 1))]
749    #[case(2, (0, 3, 0, 3))]
750    #[case(3, (0, 7, 0, 7))]
751    #[case(4, (0, 14, 1, 15))]
752    #[case(5, (0, 29, 2, 31))]
753    #[case(6, (0, 58, 5, 63))]
754    #[case(7, (0, 116, 11, 126))]
755    #[case(8, (0, 233, 23, 253))]
756    #[case(9, (0, 466, 47, 507))]
757    #[case(10, (1, 933, 94, 1_014))]
758    #[case(11, (3, 1_866, 188, 2_029))]
759    #[case(12, (6, 3_732, 377, 4_059))]
760    #[case(13, (12, 7_465, 755, 8_119))]
761    #[case(14, (25, 14_931, 1_510, 16_239))]
762    #[case(15, (51, 29_863, 3_020, 32_479))]
763    #[case(16, (102, 59_727, 6_041, 64_958))]
764    #[case(17, (204, 119_455, 12_083, 129_917))]
765    #[case(18, (409, 238_911, 24_166, 259_834))]
766    #[case(19, (819, 477_823, 48_332, 519_669))]
767    #[case(20, (1_638, 955_647, 96_665, 1_039_339))]
768    #[case(21, (3_276, 1_911_295, 193_331, 2_078_678))]
769    #[case(22, (6_553, 3_822_590, 386_662, 4_157_356))]
770    #[case(23, (13_107, 7_645_181, 773_324, 8_314_713))]
771    #[case(24, (26_214, 15_290_363, 1_546_649, 16_629_427))]
772    #[case(25, (52_428, 30_580_726, 3_093_299, 33_258_855))]
773    #[case(26, (104_857, 61_161_453, 6_186_598, 66_517_711))]
774    #[case(27, (209_715, 122_322_907, 12_373_196, 133_035_423))]
775    #[case(28, (419_430, 244_645_814, 24_746_393, 266_070_846))]
776    #[case(29, (838_860, 489_291_628, 49_492_787, 532_141_692))]
777    #[case(30, (1_677_721, 978_583_256, 98_985_574, 1_064_283_385))]
778    fn test_box_to_xyz(#[case] zoom: u8, #[case] expected_xyz: (u32, u32, u32, u32)) {
779        let actual_xyz = bbox_to_xyz(
780            -179.437_499_999_999_55,
781            -84.769_878_779_806_56,
782            -146.812_499_999_999_6,
783            -81.374_463_852_608_33,
784            zoom,
785        );
786        assert_eq!(
787            actual_xyz, expected_xyz,
788            "zoom {zoom} does not have the right xyz"
789        );
790    }
791
792    #[rstest]
793    // test data via https://epsg.io/transform#s_srs=4326&t_srs=3857
794    #[case((0.0,0.0), (0.0,0.0))]
795    #[case((30.0,0.0), (3_339_584.723_798_207,0.0))]
796    #[case((-30.0,0.0), (-3_339_584.723_798_207,0.0))]
797    #[case((0.0,30.0), (0.0,3_503_549.843_504_375_3))]
798    #[case((0.0,-30.0), (0.0,-3_503_549.843_504_375_3))]
799    #[case((38.897_957,-77.036_560), (4_330_100.766_138_651, -13_872_207.775_755_845))] // white house
800    #[case((-180.0,-85.0), (-20_037_508.342_789_244, -19_971_868.880_408_566))]
801    #[case((180.0,85.0), (20_037_508.342_789_244, 19_971_868.880_408_566))]
802    #[case((0.026_949_458_523_585_632,0.080_848_348_740_973_67), (3000.0, 9000.0))]
803    fn test_coordinate_syste_conversion(
804        #[case] wgs84: (f64, f64),
805        #[case] webmercator: (f64, f64),
806    ) {
807        // epsg produces the expected values with f32 precision, grrr..
808        let epsilon = f64::from(f32::EPSILON);
809
810        let actual_wgs84 = webmercator_to_wgs84(webmercator.0, webmercator.1);
811        assert_relative_eq!(actual_wgs84.0, wgs84.0, epsilon = epsilon);
812        assert_relative_eq!(actual_wgs84.1, wgs84.1, epsilon = epsilon);
813
814        let actual_webmercator = wgs84_to_webmercator(wgs84.0, wgs84.1);
815        assert_relative_eq!(actual_webmercator.0, webmercator.0, epsilon = epsilon);
816        assert_relative_eq!(actual_webmercator.1, webmercator.1, epsilon = epsilon);
817    }
818
819    #[rstest]
820    #[case(0..11, 0)]
821    #[case(11..14, 1)]
822    #[case(14..17, 2)]
823    #[case(17..21, 3)]
824    #[case(21..24, 4)]
825    #[case(24..27, 5)]
826    #[case(27..30, 6)]
827    fn test_get_zoom_precision(
828        #[case] zoom: std::ops::Range<u8>,
829        #[case] expected_precision: usize,
830    ) {
831        for z in zoom {
832            let actual_precision = get_zoom_precision(z);
833            assert_eq!(
834                actual_precision, expected_precision,
835                "Zoom level {z} should have precision {expected_precision}, but was {actual_precision}"
836            );
837        }
838    }
839
840    #[test]
841    fn tile_coord_zoom_range() {
842        for z in 0..=MAX_ZOOM {
843            assert!(TileCoord::is_possible_on_zoom_level(z, 0, 0));
844            assert_eq!(
845                TileCoord::new_checked(z, 0, 0),
846                Some(TileCoord { z, x: 0, y: 0 })
847            );
848        }
849        assert!(!TileCoord::is_possible_on_zoom_level(MAX_ZOOM + 1, 0, 0));
850        assert_eq!(TileCoord::new_checked(MAX_ZOOM + 1, 0, 0), None);
851    }
852
853    #[test]
854    fn tile_coord_new_checked_xy_for_zoom() {
855        assert!(TileCoord::is_possible_on_zoom_level(5, 0, 0));
856        assert_eq!(
857            TileCoord::new_checked(5, 0, 0),
858            Some(TileCoord { z: 5, x: 0, y: 0 })
859        );
860        assert!(TileCoord::is_possible_on_zoom_level(5, 31, 31));
861        assert_eq!(
862            TileCoord::new_checked(5, 31, 31),
863            Some(TileCoord { z: 5, x: 31, y: 31 })
864        );
865        assert!(!TileCoord::is_possible_on_zoom_level(5, 31, 32));
866        assert_eq!(TileCoord::new_checked(5, 31, 32), None);
867        assert!(!TileCoord::is_possible_on_zoom_level(5, 32, 31));
868        assert_eq!(TileCoord::new_checked(5, 32, 31), None);
869    }
870
871    #[test]
872    /// Any (u8, u32, u32) values can be put inside [`TileCoord`], of course, but some
873    /// functions may panic at runtime (e.g. [`mbtiles::invert_y_value`]) if they are impossible,
874    /// so let's not do that.
875    fn tile_coord_new_unchecked() {
876        assert_eq!(
877            TileCoord::new_unchecked(u8::MAX, u32::MAX, u32::MAX),
878            TileCoord {
879                z: u8::MAX,
880                x: u32::MAX,
881                y: u32::MAX
882            }
883        );
884    }
885
886    #[test]
887    fn xyz_format() {
888        let xyz = TileCoord { z: 1, x: 2, y: 3 };
889        assert_eq!(format!("{xyz}"), "1,2,3");
890        assert_eq!(format!("{xyz:#}"), "1/2/3");
891    }
892
893    #[rstest]
894    #[case("none", Some(Encoding::Uncompressed))]
895    #[case("identity", Some(Encoding::Uncompressed))]
896    #[case("IDENTITY", Some(Encoding::Uncompressed))]
897    #[case("gzip", Some(Encoding::Gzip))]
898    #[case("GZIP", Some(Encoding::Gzip))]
899    #[case("deflate", Some(Encoding::Zlib))]
900    #[case("zlib", Some(Encoding::Zlib))]
901    #[case("br", Some(Encoding::Brotli))]
902    #[case("brotli", Some(Encoding::Brotli))]
903    #[case("zstd", Some(Encoding::Zstd))]
904    #[case("unknown", None)]
905    #[case("", None)]
906    fn test_encoding_parse(#[case] input: &str, #[case] expected: Option<Encoding>) {
907        assert_eq!(Encoding::parse(input), expected);
908    }
909
910    #[rstest]
911    #[case(Encoding::Uncompressed, None)]
912    #[case(Encoding::Internal, None)]
913    #[case(Encoding::Gzip, Some("gzip"))]
914    #[case(Encoding::Zlib, Some("deflate"))]
915    #[case(Encoding::Brotli, Some("br"))]
916    #[case(Encoding::Zstd, Some("zstd"))]
917    fn test_compression(#[case] encoding: Encoding, #[case] expected: Option<&str>) {
918        assert_eq!(encoding.compression(), expected);
919    }
920}