martin_tile_utils/
lib.rs

1#![doc = include_str!("../README.md")]
2
3// This code was partially adapted from https://github.com/maplibre/mbtileserver-rs
4// project originally written by Kaveh Karimi and licensed under MIT OR Apache-2.0
5
6use std::f64::consts::PI;
7use std::fmt::{Display, Formatter, Result};
8
9/// circumference of the earth in meters
10pub const EARTH_CIRCUMFERENCE: f64 = 40_075_016.685_578_5;
11/// circumference of the earth in degrees
12pub const EARTH_CIRCUMFERENCE_DEGREES: u32 = 360;
13
14/// radius of the earth in meters
15pub const EARTH_RADIUS: f64 = EARTH_CIRCUMFERENCE / 2.0 / PI;
16
17pub const MAX_ZOOM: u8 = 30;
18
19mod decoders;
20pub use decoders::*;
21mod rectangle;
22pub use rectangle::{TileRect, append_rect};
23
24#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
25pub struct TileCoord {
26    pub z: u8,
27    pub x: u32,
28    pub y: u32,
29}
30
31pub type TileData = Vec<u8>;
32pub type Tile = (TileCoord, Option<TileData>);
33
34impl Display for TileCoord {
35    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
36        if f.alternate() {
37            write!(f, "{}/{}/{}", self.z, self.x, self.y)
38        } else {
39            write!(f, "{},{},{}", self.z, self.x, self.y)
40        }
41    }
42}
43
44impl TileCoord {
45    /// Checks provided coordinates for validity
46    /// before constructing [`TileCoord`] instance.
47    ///
48    /// Check [`Self::new_unchecked`] if you are sure that your inputs are possible.
49    #[must_use]
50    pub fn new_checked(z: u8, x: u32, y: u32) -> Option<TileCoord> {
51        Self::is_possible_on_zoom_level(z, x, y).then_some(Self { z, x, y })
52    }
53
54    /// Constructs [`TileCoord`] instance from arguments without checking that the tiles can exist.
55    ///
56    /// Check [`Self::new_checked`] if you are unsure if your inputs are possible.
57    #[must_use]
58    pub fn new_unchecked(z: u8, x: u32, y: u32) -> TileCoord {
59        Self { z, x, y }
60    }
61
62    /// Checks that zoom `z` is plausibily small and `x`/`y` is possible on said zoom level
63    #[must_use]
64    pub fn is_possible_on_zoom_level(z: u8, x: u32, y: u32) -> bool {
65        if z > MAX_ZOOM {
66            return false;
67        }
68
69        let side_len = 1_u32 << z;
70        x < side_len && y < side_len
71    }
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum Format {
76    Gif,
77    Jpeg,
78    Json,
79    Mvt,
80    Png,
81    Webp,
82    Avif,
83}
84
85impl Format {
86    #[must_use]
87    pub fn parse(value: &str) -> Option<Self> {
88        Some(match value.to_ascii_lowercase().as_str() {
89            "gif" => Self::Gif,
90            "jpg" | "jpeg" => Self::Jpeg,
91            "json" => Self::Json,
92            "pbf" | "mvt" => Self::Mvt,
93            "png" => Self::Png,
94            "webp" => Self::Webp,
95            "avif" => Self::Avif,
96            _ => None?,
97        })
98    }
99
100    /// Get the `format` value as it should be stored in the `MBTiles` metadata table
101    #[must_use]
102    pub fn metadata_format_value(self) -> &'static str {
103        match self {
104            Self::Gif => "gif",
105            Self::Jpeg => "jpeg",
106            Self::Json => "json",
107            // QGIS uses `pbf` instead of `mvt` for some reason
108            Self::Mvt => "pbf",
109            Self::Png => "png",
110            Self::Webp => "webp",
111            Self::Avif => "avif",
112        }
113    }
114
115    #[must_use]
116    pub fn content_type(&self) -> &str {
117        match *self {
118            Self::Gif => "image/gif",
119            Self::Jpeg => "image/jpeg",
120            Self::Json => "application/json",
121            Self::Mvt => "application/x-protobuf",
122            Self::Png => "image/png",
123            Self::Webp => "image/webp",
124            Self::Avif => "image/avif",
125        }
126    }
127
128    #[must_use]
129    pub fn is_detectable(self) -> bool {
130        match self {
131            Self::Png | Self::Jpeg | Self::Gif | Self::Webp | Self::Avif => true,
132            // TODO: Json can be detected, but currently we only detect it
133            //       when it's not compressed, so to avoid a warning, keeping it as false for now.
134            //       Once we can detect it inside a compressed data, change it to true.
135            Self::Mvt | Self::Json => false,
136        }
137    }
138}
139
140impl Display for Format {
141    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
142        f.write_str(match *self {
143            Self::Gif => "gif",
144            Self::Jpeg => "jpeg",
145            Self::Json => "json",
146            Self::Mvt => "mvt",
147            Self::Png => "png",
148            Self::Webp => "webp",
149            Self::Avif => "avif",
150        })
151    }
152}
153
154#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
155pub enum Encoding {
156    /// Data is not compressed, but it can be
157    Uncompressed = 0b0000_0000,
158    /// Some formats like JPEG and PNG are already compressed
159    Internal = 0b0000_0001,
160    Gzip = 0b0000_0010,
161    Zlib = 0b0000_0100,
162    Brotli = 0b0000_1000,
163    Zstd = 0b0001_0000,
164}
165
166impl Encoding {
167    #[must_use]
168    pub fn parse(value: &str) -> Option<Self> {
169        Some(match value.to_ascii_lowercase().as_str() {
170            "none" => Self::Uncompressed,
171            "gzip" => Self::Gzip,
172            "zlib" => Self::Zlib,
173            "brotli" => Self::Brotli,
174            "zstd" => Self::Zstd,
175            _ => None?,
176        })
177    }
178
179    #[must_use]
180    pub fn content_encoding(&self) -> Option<&str> {
181        match *self {
182            Self::Uncompressed | Self::Internal => None,
183            Self::Gzip => Some("gzip"),
184            Self::Zlib => Some("deflate"),
185            Self::Brotli => Some("br"),
186            Self::Zstd => Some("zstd"),
187        }
188    }
189
190    #[must_use]
191    pub fn is_encoded(self) -> bool {
192        match self {
193            Self::Uncompressed | Self::Internal => false,
194            Self::Gzip | Self::Zlib | Self::Brotli | Self::Zstd => true,
195        }
196    }
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub struct TileInfo {
201    pub format: Format,
202    pub encoding: Encoding,
203}
204
205impl TileInfo {
206    #[must_use]
207    pub fn new(format: Format, encoding: Encoding) -> Self {
208        Self { format, encoding }
209    }
210
211    /// Try to figure out the format and encoding of the raw tile data
212    #[must_use]
213    pub fn detect(value: &[u8]) -> Option<Self> {
214        // TODO: Make detection slower but more accurate:
215        //  - uncompress gzip/zlib/... and run detection again. If detection fails, assume MVT
216        //  - detect json inside a compressed data
217        //  - json should be fully parsed
218        //  - possibly keep the current `detect()` available as a fast path for those who may need it
219        Some(match value {
220            // Compressed prefixes assume MVT content
221            v if v.starts_with(b"\x1f\x8b") => Self::new(Format::Mvt, Encoding::Gzip),
222            v if v.starts_with(b"\x78\x9c") => Self::new(Format::Mvt, Encoding::Zlib),
223            v if v.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => {
224                Self::new(Format::Png, Encoding::Internal)
225            }
226            v if v.starts_with(b"\x47\x49\x46\x38\x39\x61") => {
227                Self::new(Format::Gif, Encoding::Internal)
228            }
229            v if v.starts_with(b"\xFF\xD8\xFF") => Self::new(Format::Jpeg, Encoding::Internal),
230            v if v.starts_with(b"RIFF") && v.len() > 8 && v[8..].starts_with(b"WEBP") => {
231                Self::new(Format::Webp, Encoding::Internal)
232            }
233            v if v.starts_with(b"{") => Self::new(Format::Json, Encoding::Uncompressed),
234            _ => None?,
235        })
236    }
237
238    #[must_use]
239    pub fn encoding(self, encoding: Encoding) -> Self {
240        Self { encoding, ..self }
241    }
242}
243
244impl From<Format> for TileInfo {
245    fn from(format: Format) -> Self {
246        Self::new(
247            format,
248            match format {
249                Format::Png | Format::Jpeg | Format::Webp | Format::Gif | Format::Avif => {
250                    Encoding::Internal
251                }
252                Format::Mvt | Format::Json => Encoding::Uncompressed,
253            },
254        )
255    }
256}
257
258impl Display for TileInfo {
259    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
260        write!(f, "{}", self.format.content_type())?;
261        if let Some(encoding) = self.encoding.content_encoding() {
262            write!(f, "; encoding={encoding}")?;
263        } else if self.encoding != Encoding::Uncompressed {
264            f.write_str("; uncompressed")?;
265        }
266        Ok(())
267    }
268}
269
270/// Convert longitude and latitude to a tile (x,y) coordinates for a given zoom
271#[must_use]
272#[expect(clippy::cast_possible_truncation)]
273#[expect(clippy::cast_sign_loss)]
274pub fn tile_index(lng: f64, lat: f64, zoom: u8) -> (u32, u32) {
275    let tile_size = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
276    let (x, y) = wgs84_to_webmercator(lng, lat);
277    let col = (((x - (EARTH_CIRCUMFERENCE * -0.5)).abs() / tile_size) as u32).min((1 << zoom) - 1);
278    let row = ((((EARTH_CIRCUMFERENCE * 0.5) - y).abs() / tile_size) as u32).min((1 << zoom) - 1);
279    (col, row)
280}
281
282/// Convert min/max XYZ tile coordinates to a bounding box values.
283///
284/// The result is `[min_lng, min_lat, max_lng, max_lat]`
285///
286/// # Panics
287/// Panics if `zoom` is greater than [`MAX_ZOOM`].
288#[must_use]
289pub fn xyz_to_bbox(zoom: u8, min_x: u32, min_y: u32, max_x: u32, max_y: u32) -> [f64; 4] {
290    assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
291
292    let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
293
294    let left_down_bbox = tile_bbox(min_x, max_y, tile_length);
295    let right_top_bbox = tile_bbox(max_x, min_y, tile_length);
296
297    let (min_lng, min_lat) = webmercator_to_wgs84(left_down_bbox[0], left_down_bbox[1]);
298    let (max_lng, max_lat) = webmercator_to_wgs84(right_top_bbox[2], right_top_bbox[3]);
299    [min_lng, min_lat, max_lng, max_lat]
300}
301
302#[expect(clippy::cast_lossless)]
303fn tile_bbox(x: u32, y: u32, tile_length: f64) -> [f64; 4] {
304    let min_x = EARTH_CIRCUMFERENCE * -0.5 + x as f64 * tile_length;
305    let max_y = EARTH_CIRCUMFERENCE * 0.5 - y as f64 * tile_length;
306
307    [min_x, max_y - tile_length, min_x + tile_length, max_y]
308}
309
310/// Convert bounding box to a tile box `(min_x, min_y, max_x, max_y)` for a given zoom
311#[must_use]
312pub fn bbox_to_xyz(left: f64, bottom: f64, right: f64, top: f64, zoom: u8) -> (u32, u32, u32, u32) {
313    let (min_col, min_row) = tile_index(left, top, zoom);
314    let (max_col, max_row) = tile_index(right, bottom, zoom);
315    (min_col, min_row, max_col, max_row)
316}
317
318/// Compute precision of a zoom level, i.e. how many decimal digits of the longitude and latitude are relevant
319#[must_use]
320#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
321pub fn get_zoom_precision(zoom: u8) -> usize {
322    assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
323    let lng_delta = webmercator_to_wgs84(EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom), 0.0).0;
324    let log = lng_delta.log10() - 0.5;
325    if log > 0.0 { 0 } else { -log.ceil() as usize }
326}
327
328/// transform [`WebMercator`](https://epsg.io/3857) to [WGS84](https://epsg.io/4326)
329// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
330#[must_use]
331pub fn webmercator_to_wgs84(x: f64, y: f64) -> (f64, f64) {
332    let lng = (x / EARTH_RADIUS).to_degrees();
333    let lat = f64::atan(f64::sinh(y / EARTH_RADIUS)).to_degrees();
334    (lng, lat)
335}
336
337/// transform [WGS84](https://epsg.io/4326) to [`WebMercator`](https://epsg.io/3857)
338// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
339#[must_use]
340pub fn wgs84_to_webmercator(lon: f64, lat: f64) -> (f64, f64) {
341    let x = lon * PI / 180.0 * EARTH_RADIUS;
342
343    let y_sin = lat.to_radians().sin();
344    let y = EARTH_RADIUS / 2.0 * ((1.0 + y_sin) / (1.0 - y_sin)).ln();
345
346    (x, y)
347}
348
349#[cfg(test)]
350mod tests {
351    #![expect(clippy::unreadable_literal)]
352
353    use std::fs::read;
354
355    use Encoding::{Internal, Uncompressed};
356    use Format::{Jpeg, Json, Png, Webp};
357    use approx::assert_relative_eq;
358    use rstest::rstest;
359
360    use super::*;
361
362    fn detect(path: &str) -> Option<TileInfo> {
363        TileInfo::detect(&read(path).unwrap())
364    }
365
366    #[expect(clippy::unnecessary_wraps)]
367    fn info(format: Format, encoding: Encoding) -> Option<TileInfo> {
368        Some(TileInfo::new(format, encoding))
369    }
370
371    #[test]
372    fn test_data_format_png() {
373        assert_eq!(detect("./fixtures/world.png"), info(Png, Internal));
374    }
375
376    #[test]
377    fn test_data_format_jpg() {
378        assert_eq!(detect("./fixtures/world.jpg"), info(Jpeg, Internal));
379    }
380
381    #[test]
382    fn test_data_format_webp() {
383        assert_eq!(detect("./fixtures/dc.webp"), info(Webp, Internal));
384        assert_eq!(TileInfo::detect(br"RIFF"), None);
385    }
386
387    #[test]
388    fn test_data_format_json() {
389        assert_eq!(
390            TileInfo::detect(br#"{"foo":"bar"}"#),
391            info(Json, Uncompressed)
392        );
393    }
394
395    #[rstest]
396    #[case(-180.0, 85.0511, 0, (0,0))]
397    #[case(-180.0, 85.0511, 1, (0,0))]
398    #[case(-180.0, 85.0511, 2, (0,0))]
399    #[case(0.0, 0.0, 0, (0,0))]
400    #[case(0.0, 0.0, 1, (1,1))]
401    #[case(0.0, 0.0, 2, (2,2))]
402    #[case(0.0, 1.0, 0, (0,0))]
403    #[case(0.0, 1.0, 1, (1,0))]
404    #[case(0.0, 1.0, 2, (2,1))]
405    fn test_tile_colrow(
406        #[case] lng: f64,
407        #[case] lat: f64,
408        #[case] zoom: u8,
409        #[case] expected: (u32, u32),
410    ) {
411        assert_eq!(
412            expected,
413            tile_index(lng, lat, zoom),
414            "{lng},{lat}@z{zoom} should be {expected:?}"
415        );
416    }
417
418    #[rstest]
419    // you could easily get test cases from maptiler: https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#4/-118.82/71.02
420    #[case(0, 0, 0, 0, 0, [-180.0,-85.0511287798066,180.0,85.0511287798066])]
421    #[case(1, 0, 0, 0, 0, [-180.0,0.0,0.0,85.0511287798066])]
422    #[case(5, 1, 1, 2, 2, [-168.75,81.09321385260837,-146.25,83.97925949886205])]
423    #[case(5, 1, 3, 2, 5, [-168.75,74.01954331150226,-146.25,81.09321385260837])]
424    fn test_xyz_to_bbox(
425        #[case] zoom: u8,
426        #[case] min_x: u32,
427        #[case] min_y: u32,
428        #[case] max_x: u32,
429        #[case] max_y: u32,
430        #[case] expected: [f64; 4],
431    ) {
432        let bbox = xyz_to_bbox(zoom, min_x, min_y, max_x, max_y);
433        assert_relative_eq!(bbox[0], expected[0], epsilon = f64::EPSILON * 2.0);
434        assert_relative_eq!(bbox[1], expected[1], epsilon = f64::EPSILON * 2.0);
435        assert_relative_eq!(bbox[2], expected[2], epsilon = f64::EPSILON * 2.0);
436        assert_relative_eq!(bbox[3], expected[3], epsilon = f64::EPSILON * 2.0);
437    }
438
439    #[rstest]
440    #[case(0, (0, 0, 0, 0))]
441    #[case(1, (0, 1, 0, 1))]
442    #[case(2, (0, 3, 0, 3))]
443    #[case(3, (0, 7, 0, 7))]
444    #[case(4, (0, 14, 1, 15))]
445    #[case(5, (0, 29, 2, 31))]
446    #[case(6, (0, 58, 5, 63))]
447    #[case(7, (0, 116, 11, 126))]
448    #[case(8, (0, 233, 23, 253))]
449    #[case(9, (0, 466, 47, 507))]
450    #[case(10, (1, 933, 94, 1014))]
451    #[case(11, (3, 1866, 188, 2029))]
452    #[case(12, (6, 3732, 377, 4059))]
453    #[case(13, (12, 7465, 755, 8119))]
454    #[case(14, (25, 14931, 1510, 16239))]
455    #[case(15, (51, 29863, 3020, 32479))]
456    #[case(16, (102, 59727, 6041, 64958))]
457    #[case(17, (204, 119455, 12083, 129917))]
458    #[case(18, (409, 238911, 24166, 259834))]
459    #[case(19, (819, 477823, 48332, 519669))]
460    #[case(20, (1638, 955647, 96665, 1039339))]
461    #[case(21, (3276, 1911295, 193331, 2078678))]
462    #[case(22, (6553, 3822590, 386662, 4157356))]
463    #[case(23, (13107, 7645181, 773324, 8314713))]
464    #[case(24, (26214, 15290363, 1546649, 16629427))]
465    #[case(25, (52428, 30580726, 3093299, 33258855))]
466    #[case(26, (104857, 61161453, 6186598, 66517711))]
467    #[case(27, (209715, 122322907, 12373196, 133035423))]
468    #[case(28, (419430, 244645814, 24746393, 266070846))]
469    #[case(29, (838860, 489291628, 49492787, 532141692))]
470    #[case(30, (1677721, 978583256, 98985574, 1064283385))]
471    fn test_box_to_xyz(#[case] zoom: u8, #[case] expected_xyz: (u32, u32, u32, u32)) {
472        let actual_xyz = bbox_to_xyz(
473            -179.43749999999955,
474            -84.76987877980656,
475            -146.8124999999996,
476            -81.37446385260833,
477            zoom,
478        );
479        assert_eq!(
480            actual_xyz, expected_xyz,
481            "zoom {zoom} does not have te right xyz"
482        );
483    }
484
485    #[rstest]
486    // test data via https://epsg.io/transform#s_srs=4326&t_srs=3857
487    #[case((0.0,0.0), (0.0,0.0))]
488    #[case((30.0,0.0), (3339584.723798207,0.0))]
489    #[case((-30.0,0.0), (-3339584.723798207,0.0))]
490    #[case((0.0,30.0), (0.0,3503549.8435043753))]
491    #[case((0.0,-30.0), (0.0,-3503549.8435043753))]
492    #[case((38.897957,-77.036560), (4330100.766138651, -13872207.775755845))] // white house
493    #[case((-180.0,-85.0), (-20037508.342789244, -19971868.880408566))]
494    #[case((180.0,85.0), (20037508.342789244, 19971868.880408566))]
495    #[case((0.026949458523585632,0.08084834874097367), (3000.0, 9000.0))]
496    fn test_coordinate_syste_conversion(
497        #[case] wgs84: (f64, f64),
498        #[case] webmercator: (f64, f64),
499    ) {
500        // epsg produces the expected values with f32 precision, grrr..
501        let epsilon = f64::from(f32::EPSILON);
502
503        let actual_wgs84 = webmercator_to_wgs84(webmercator.0, webmercator.1);
504        assert_relative_eq!(actual_wgs84.0, wgs84.0, epsilon = epsilon);
505        assert_relative_eq!(actual_wgs84.1, wgs84.1, epsilon = epsilon);
506
507        let actual_webmercator = wgs84_to_webmercator(wgs84.0, wgs84.1);
508        assert_relative_eq!(actual_webmercator.0, webmercator.0, epsilon = epsilon);
509        assert_relative_eq!(actual_webmercator.1, webmercator.1, epsilon = epsilon);
510    }
511
512    #[rstest]
513    #[case(0..11, 0)]
514    #[case(11..14, 1)]
515    #[case(14..17, 2)]
516    #[case(17..21, 3)]
517    #[case(21..24, 4)]
518    #[case(24..27, 5)]
519    #[case(27..30, 6)]
520    fn test_get_zoom_precision(
521        #[case] zoom: std::ops::Range<u8>,
522        #[case] expected_precision: usize,
523    ) {
524        for z in zoom {
525            let actual_precision = get_zoom_precision(z);
526            assert_eq!(
527                actual_precision, expected_precision,
528                "Zoom level {z} should have precision {expected_precision}, but was {actual_precision}"
529            );
530        }
531    }
532
533    #[test]
534    fn test_tile_coord_zoom_range() {
535        for z in 0..=MAX_ZOOM {
536            assert!(TileCoord::is_possible_on_zoom_level(z, 0, 0));
537            assert_eq!(
538                TileCoord::new_checked(z, 0, 0),
539                Some(TileCoord { z, x: 0, y: 0 })
540            );
541        }
542        assert!(!TileCoord::is_possible_on_zoom_level(MAX_ZOOM + 1, 0, 0));
543        assert_eq!(TileCoord::new_checked(MAX_ZOOM + 1, 0, 0), None);
544    }
545
546    #[test]
547    fn test_tile_coord_new_checked_xy_for_zoom() {
548        assert!(TileCoord::is_possible_on_zoom_level(5, 0, 0));
549        assert_eq!(
550            TileCoord::new_checked(5, 0, 0),
551            Some(TileCoord { z: 5, x: 0, y: 0 })
552        );
553        assert!(TileCoord::is_possible_on_zoom_level(5, 31, 31));
554        assert_eq!(
555            TileCoord::new_checked(5, 31, 31),
556            Some(TileCoord { z: 5, x: 31, y: 31 })
557        );
558        assert!(!TileCoord::is_possible_on_zoom_level(5, 31, 32));
559        assert_eq!(TileCoord::new_checked(5, 31, 32), None);
560        assert!(!TileCoord::is_possible_on_zoom_level(5, 32, 31));
561        assert_eq!(TileCoord::new_checked(5, 32, 31), None);
562    }
563
564    #[test]
565    /// Any (u8, u32, u32) values can be put inside [`TileCoord`], of course, but some
566    /// functions may panic at runtime (e.g. [`mbtiles::invert_y_value`]) if they are impossible,
567    /// so let's not do that.
568    fn test_tile_coord_new_unchecked() {
569        assert_eq!(
570            TileCoord::new_unchecked(u8::MAX, u32::MAX, u32::MAX),
571            TileCoord {
572                z: u8::MAX,
573                x: u32::MAX,
574                y: u32::MAX
575            }
576        );
577    }
578
579    #[test]
580    fn xyz_format() {
581        let xyz = TileCoord { z: 1, x: 2, y: 3 };
582        assert_eq!(format!("{xyz}"), "1,2,3");
583        assert_eq!(format!("{xyz:#}"), "1/2/3");
584    }
585}