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