Skip to main content

geopackage_core/
tiles.rs

1//! Tile pyramids: the user data table template (spec clause 2.2.8, Annex C),
2//! the tile matrix model, and the consistency rules a conforming pyramid
3//! satisfies.
4//!
5//! A tile pyramid is a `gpkg_contents` row with `data_type = 'tiles'`, one
6//! `gpkg_tile_matrix_set` row that fixes the pyramid's bounding box and spatial
7//! reference system, one `gpkg_tile_matrix` row per zoom level, and a user
8//! table that holds the tiles themselves. The table definition SQL for the two
9//! catalogue tables is in [`crate::ddl`]. Each pyramid has its own user table,
10//! so this module builds it.
11//!
12//! [`TileMatrixSet`] and [`TileMatrix`] hold no table name. They describe the
13//! shape of a pyramid, and the container decides which table it belongs to.
14//! [`TileMatrixSet::validate`] checks a pyramid against Requirement 144 and
15//! Requirements 45 to 53.
16//! Nothing here reads or writes a database.
17
18use crate::Error;
19use crate::ident::quote;
20
21/// Relative tolerance for the tile matrix extent identity (Requirement 45).
22///
23/// The requirement states an equality between the matrix set's width and
24/// `matrix_width * tile_width * pixel_x_size`. A pixel size is almost always
25/// derived by division, so an exact comparison would reject files that differ
26/// only in the last bits. A relative tolerance of `1e-9` is far above that
27/// rounding and far below any real disagreement, which is off by whole tiles.
28const EXTENT_TOLERANCE: f64 = 1e-9;
29
30/// Half the side of the web mercator quad, in metres.
31///
32/// The quad is the extent every XYZ basemap is tiled over, and the
33/// `EPSG:3857` extent that [`TileMatrixSet::web_mercator_quad`] builds.
34pub const WEB_MERCATOR_HALF_SPAN: f64 = 20_037_508.342_789_244;
35
36/// The tile payload column of a tile pyramid user data table.
37///
38/// The spec fixes this name. It is also the `gpkg_extensions.column_name`
39/// value that the tile extensions (`gpkg_webp`, `gpkg_zoom_other`) register
40/// against.
41pub const TILE_DATA_COLUMN: &str = "tile_data";
42
43/// Registered extension name for zoom levels that do not step by factors of
44/// two (Annex F.6).
45pub const ZOOM_OTHER_EXTENSION_NAME: &str = "gpkg_zoom_other";
46/// `gpkg_extensions.definition` value for [`ZOOM_OTHER_EXTENSION_NAME`].
47pub const ZOOM_OTHER_EXTENSION_DEFINITION: &str =
48    "http://www.geopackage.org/spec140/#extension_zoom_other_intervals";
49/// Registered extension name for WebP tile payloads (Annex F.7).
50pub const WEBP_EXTENSION_NAME: &str = "gpkg_webp";
51/// `gpkg_extensions.definition` value for [`WEBP_EXTENSION_NAME`].
52pub const WEBP_EXTENSION_DEFINITION: &str =
53    "http://www.geopackage.org/spec140/#extension_tiles_webp";
54/// `gpkg_extensions.scope` value both tile extensions use.
55pub const TILE_EXTENSION_SCOPE: &str = "read-write";
56
57/// Returns `true` if these zoom levels form the spec's default ladder, in
58/// which each level's pixel size is exactly half that of the level below it.
59///
60/// Any other pyramid is legal, but only with `gpkg_zoom_other` registered
61/// against its table. The comparison uses the same relative tolerance as
62/// Requirement 45 because a halved pixel size is usually derived rather than
63/// written down. A pyramid of fewer than two levels has no interval that could
64/// differ from a factor of two, so it counts as a ladder.
65pub fn is_power_of_two_ladder(matrices: &[TileMatrix]) -> bool {
66    let mut sorted: Vec<&TileMatrix> = matrices.iter().collect();
67    sorted.sort_unstable_by_key(|matrix| matrix.zoom_level);
68    sorted.windows(2).all(|pair| {
69        let [lower, upper] = pair else {
70            return true;
71        };
72        [
73            (lower.pixel_x_size, upper.pixel_x_size),
74            (lower.pixel_y_size, upper.pixel_y_size),
75        ]
76        .iter()
77        .all(|(lower, upper)| {
78            let halved = lower / 2.0;
79            (halved - upper).abs() <= EXTENT_TOLERANCE * halved.abs().max(upper.abs())
80        })
81    })
82}
83
84/// Returns the `CREATE TABLE` statement for a tile pyramid user data table.
85///
86/// The statement takes the form Annex C gives (Requirement 54): an
87/// `INTEGER PRIMARY KEY` acting as the rowid alias, the zoom/column/row index,
88/// the payload, and the uniqueness constraint over the three index columns.
89///
90/// You cannot configure the column names. A feature table names its geometry
91/// column in `gpkg_geometry_columns`, but the spec fixes a tile table's shape,
92/// and every reader assumes it.
93///
94/// # Errors
95///
96/// [`Error::InvalidIdentifier`] if `table` cannot be quoted.
97pub fn create_tile_table_sql(table: &str) -> Result<String, Error> {
98    Ok(format!(
99        "CREATE TABLE {} (\
100         id INTEGER PRIMARY KEY AUTOINCREMENT, \
101         zoom_level INTEGER NOT NULL, \
102         tile_column INTEGER NOT NULL, \
103         tile_row INTEGER NOT NULL, \
104         tile_data BLOB NOT NULL, \
105         UNIQUE (zoom_level, tile_column, tile_row))",
106        quote(table)?
107    ))
108}
109
110/// A `gpkg_tile_matrix_set` row: the exact extent of a tile pyramid, and the
111/// spatial reference system its coordinates are in.
112///
113/// The extent is exact rather than informative (Requirement 144). Every tile's
114/// own bounding box is calculated from it, and `(min_x, max_y)` is the
115/// upper-left corner of tile `(0, 0)` at every zoom level. Tiles may be sparse
116/// within the extent, so it may be larger than the tiles actually present.
117#[derive(Debug, Clone, Copy, PartialEq)]
118pub struct TileMatrixSet {
119    /// Spatial reference system of the extent, and of the pyramid.
120    pub srs_id: i32,
121    /// Minimum x (west edge).
122    pub min_x: f64,
123    /// Minimum y (south edge).
124    pub min_y: f64,
125    /// Maximum x (east edge).
126    pub max_x: f64,
127    /// Maximum y (north edge).
128    pub max_y: f64,
129}
130
131/// A `gpkg_tile_matrix` row: one zoom level of a pyramid.
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct TileMatrix {
134    /// Zoom level, ascending with resolution.
135    pub zoom_level: i64,
136    /// Number of tile columns at this zoom level.
137    pub matrix_width: i64,
138    /// Number of tile rows at this zoom level.
139    pub matrix_height: i64,
140    /// Tile width in pixels.
141    pub tile_width: i64,
142    /// Tile height in pixels.
143    pub tile_height: i64,
144    /// Ground units per pixel in x.
145    pub pixel_x_size: f64,
146    /// Ground units per pixel in y.
147    pub pixel_y_size: f64,
148}
149
150/// A tile pyramid that does not satisfy the spec's consistency rules.
151///
152/// Returned by [`TileMatrixSet::validate`], which the container layer runs
153/// before writing a pyramid. Reading never validates, so a file written by
154/// someone else is reported as it is.
155#[derive(Debug, thiserror::Error)]
156#[non_exhaustive]
157pub enum TileError {
158    /// The matrix set extent is not a well-ordered, finite box.
159    #[error(
160        "tile matrix set extent [{min_x}, {min_y}, {max_x}, {max_y}] is not a finite box with min < max on both axes"
161    )]
162    InvalidExtent {
163        /// Minimum x of the offending extent.
164        min_x: f64,
165        /// Minimum y.
166        min_y: f64,
167        /// Maximum x.
168        max_x: f64,
169        /// Maximum y.
170        max_y: f64,
171    },
172    /// A negative `zoom_level` (Requirement 46).
173    #[error("zoom level {zoom_level} is negative")]
174    NegativeZoomLevel {
175        /// The offending zoom level.
176        zoom_level: i64,
177    },
178    /// A `matrix_width`, `matrix_height`, `tile_width` or `tile_height` that is
179    /// not greater than zero (Requirements 47 to 50).
180    #[error("zoom level {zoom_level} has {field} {value}, which must be greater than 0")]
181    NonPositiveDimension {
182        /// Zoom level of the offending tile matrix.
183        zoom_level: i64,
184        /// Which column: `matrix_width`, `matrix_height`, `tile_width` or
185        /// `tile_height`.
186        field: &'static str,
187        /// The offending value.
188        value: i64,
189    },
190    /// A `pixel_x_size` or `pixel_y_size` that is not greater than zero
191    /// (Requirements 51 and 52).
192    #[error("zoom level {zoom_level} has {field} {value}, which must be greater than 0")]
193    NonPositivePixelSize {
194        /// Zoom level of the offending tile matrix.
195        zoom_level: i64,
196        /// Which column: `pixel_x_size` or `pixel_y_size`.
197        field: &'static str,
198        /// The offending value.
199        value: f64,
200    },
201    /// Pixel sizes that do not decrease as zoom level increases
202    /// (Requirement 53).
203    #[error(
204        "zoom level {zoom_level} has {field} {value}, which is not smaller than the {previous_value} of zoom level {previous_zoom_level}"
205    )]
206    PixelSizeNotDescending {
207        /// Zoom level of the offending tile matrix.
208        zoom_level: i64,
209        /// The next lower zoom level present.
210        previous_zoom_level: i64,
211        /// Which column: `pixel_x_size` or `pixel_y_size`.
212        field: &'static str,
213        /// The offending value.
214        value: f64,
215        /// The value at `previous_zoom_level`.
216        previous_value: f64,
217    },
218    /// A zoom level whose tile grid does not span the matrix set extent
219    /// (Requirement 45).
220    #[error(
221        "zoom level {zoom_level} spans {actual} in {axis}, but the tile matrix set extent is {expected}"
222    )]
223    ExtentMismatch {
224        /// Zoom level of the offending tile matrix.
225        zoom_level: i64,
226        /// Which axis: `x` or `y`.
227        axis: &'static str,
228        /// The extent implied by the matrix set.
229        expected: f64,
230        /// The extent the tile grid spans.
231        actual: f64,
232    },
233    /// Two tile matrices for the same zoom level (the `gpkg_tile_matrix`
234    /// primary key is `(table_name, zoom_level)`).
235    #[error("zoom level {zoom_level} appears more than once")]
236    DuplicateZoomLevel {
237        /// The repeated zoom level.
238        zoom_level: i64,
239    },
240    /// A tile index outside the grid its zoom level declares.
241    #[error(
242        "tile ({column}, {row}) is outside the {matrix_width} by {matrix_height} grid of zoom level {zoom_level}"
243    )]
244    CoordOutsideMatrix {
245        /// Zoom level the tile was addressed at.
246        zoom_level: i64,
247        /// The offending column index.
248        column: i64,
249        /// The offending row index.
250        row: i64,
251        /// Columns the zoom level has.
252        matrix_width: i64,
253        /// Rows the zoom level has.
254        matrix_height: i64,
255    },
256    /// An XYZ conversion requested on a pyramid whose grid is not the standard
257    /// web mercator quad, where GeoPackage and XYZ indices do not coincide.
258    #[error(
259        "zoom level {zoom_level} is not addressed as an XYZ grid: that needs a web mercator quad extent and a 2^zoom square matrix"
260    )]
261    NotAnXyzGrid {
262        /// Zoom level the conversion was requested for.
263        zoom_level: i64,
264    },
265    /// A tile payload whose header could not be read.
266    #[error("tile payload is not a readable image: {reason}")]
267    UnreadablePayload {
268        /// The header reader's error message.
269        reason: String,
270    },
271    /// A coverage tile payload that does not conform to an encoding requirement
272    /// of the tiled gridded coverage extension (OGC 17-066r2).
273    ///
274    /// The checks in [`crate::coverage`] read the header of a payload and not
275    /// its samples, so they do not check Requirement 21.
276    #[error(
277        "coverage tile payload breaks tiled gridded coverage Requirement {requirement}: {detail}"
278    )]
279    CoverageProfileViolation {
280        /// The requirement number, in the numbering of OGC 17-066r2.
281        requirement: u8,
282        /// What the payload declares, and what the requirement specifies.
283        detail: String,
284    },
285    /// A tile payload whose pixel dimensions are not the ones its zoom level
286    /// declares.
287    #[error(
288        "tile is {actual_width} by {actual_height} pixels, but zoom level {zoom_level} declares {expected_width} by {expected_height}"
289    )]
290    PayloadSizeMismatch {
291        /// Zoom level the tile was written at.
292        zoom_level: i64,
293        /// `tile_width` of that zoom level.
294        expected_width: i64,
295        /// `tile_height` of that zoom level.
296        expected_height: i64,
297        /// Width the payload's header declares.
298        actual_width: i64,
299        /// Height the payload's header declares.
300        actual_height: i64,
301    },
302    /// A zoom ladder whose range is empty, negative, or too wide for the grid
303    /// to stay representable.
304    #[error(
305        "zoom range {min_zoom} to {max_zoom} is not usable: it must be non-negative, ascending, and narrow enough for the doubled grid to fit an i64"
306    )]
307    InvalidZoomRange {
308        /// Lowest zoom level requested.
309        min_zoom: i64,
310        /// Highest zoom level requested.
311        max_zoom: i64,
312    },
313}
314
315/// The encoding of a tile payload, as its first bytes declare it.
316///
317/// The base tiles requirement class allows PNG (Requirement 36) and JPEG
318/// (Requirement 37), mixed freely within one table. WebP is allowed under the
319/// `gpkg_webp` extension, and TIFF only inside the tiled gridded coverage
320/// extension. The container, not the format, therefore decides whether a
321/// given payload may be written. This enum
322/// only says what the bytes are.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324#[non_exhaustive]
325pub enum TileFormat {
326    /// `image/png`.
327    Png,
328    /// `image/jpeg`.
329    Jpeg,
330    /// WebP, which needs the `gpkg_webp` extension registered.
331    Webp,
332    /// TIFF, which appears only in tiled gridded coverages.
333    Tiff,
334    /// An image this crate recognises but no tile table may hold.
335    Other,
336}
337
338impl TileFormat {
339    /// Returns whether the base tiles requirement class permits this encoding
340    /// with no extension registered.
341    pub fn is_core(self) -> bool {
342        matches!(self, Self::Png | Self::Jpeg)
343    }
344
345    /// Returns the MIME type the spec names for this encoding, if it names one.
346    pub fn mime_type(self) -> Option<&'static str> {
347        match self {
348            Self::Png => Some("image/png"),
349            Self::Jpeg => Some("image/jpeg"),
350            Self::Webp => Some("image/webp"),
351            Self::Tiff => Some("image/tiff"),
352            Self::Other => None,
353        }
354    }
355}
356
357/// What a tile payload's header declares: its encoding and its pixel size.
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub struct TilePayload {
360    /// The encoding identified from the payload's magic bytes.
361    pub format: TileFormat,
362    /// Width in pixels.
363    pub width: i64,
364    /// Height in pixels.
365    pub height: i64,
366}
367
368/// Reads the encoding and pixel dimensions from a tile payload's header.
369///
370/// Only the header is read. No pixel data is decoded, nothing is copied, and
371/// the slice is borrowed throughout. The size is returned with the format
372/// because magic-byte sniffing cannot catch a tile whose dimensions disagree
373/// with its zoom level's `tile_width`/`tile_height`
374/// ([`TileMatrix::check_payload`]).
375///
376/// The dimensions of a TIFF come from [`crate::coverage`], which reads the
377/// first IFD for the coverage profile check. The crate has one parser for each
378/// format, because two readers of the same bytes can disagree on a malformed
379/// payload.
380///
381/// # Errors
382///
383/// [`TileError::UnreadablePayload`] when the bytes are not a recognisable
384/// image, or are truncated before the header ends.
385pub fn probe(bytes: &[u8]) -> Result<TilePayload, TileError> {
386    let unreadable = |error: imagesize::ImageError| TileError::UnreadablePayload {
387        reason: error.to_string(),
388    };
389    let format = match imagesize::image_type(bytes).map_err(unreadable)? {
390        imagesize::ImageType::Png => TileFormat::Png,
391        imagesize::ImageType::Jpeg => TileFormat::Jpeg,
392        imagesize::ImageType::Webp => TileFormat::Webp,
393        imagesize::ImageType::Tiff => TileFormat::Tiff,
394        _ => TileFormat::Other,
395    };
396    if format == TileFormat::Tiff {
397        let (width, height) = crate::coverage::dimensions(bytes)?;
398        return Ok(TilePayload {
399            format,
400            width,
401            height,
402        });
403    }
404    let size = imagesize::blob_size(bytes).map_err(unreadable)?;
405    let (Ok(width), Ok(height)) = (i64::try_from(size.width), i64::try_from(size.height)) else {
406        return Err(TileError::UnreadablePayload {
407            reason: format!("header declares {} by {} pixels", size.width, size.height),
408        });
409    };
410    Ok(TilePayload {
411        format,
412        width,
413        height,
414    })
415}
416
417/// A tile's address within a pyramid.
418///
419/// Rows count downwards from the **top** of the tile matrix set, so `(0, 0)`
420/// is the tile whose upper-left corner is the matrix set's `(min_x, max_y)`.
421/// This matches WMTS and XYZ, and is the opposite of TMS, which counts rows up
422/// from the bottom. [`TileMatrix::flip_row`] converts between the two.
423///
424/// The indices are relative to the tile matrix set's own extent, not to a
425/// global grid, so they equal XYZ indices only for a pyramid over the standard
426/// web mercator quad. [`TileMatrixSet::xyz_to_tile`] checks this instead of
427/// assuming it.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
429pub struct TileCoord {
430    /// Zoom level, matching a `gpkg_tile_matrix` row.
431    pub zoom_level: i64,
432    /// Column index, counting east from `min_x`.
433    pub column: i64,
434    /// Row index, counting south from `max_y`.
435    pub row: i64,
436}
437
438impl TileCoord {
439    /// Creates a tile address from its zoom level, column and row.
440    pub fn new(zoom_level: i64, column: i64, row: i64) -> Self {
441        Self {
442            zoom_level,
443            column,
444            row,
445        }
446    }
447}
448
449/// The ground extent of a single tile.
450///
451/// The fields are named rather than held in an array because this crate's
452/// other envelope convention ([`crate::gpb::Envelope`]) orders its four values
453/// differently, and a bare `[f64; 4]` would make the two easy to mix up.
454#[derive(Debug, Clone, Copy, PartialEq)]
455pub struct TileBounds {
456    /// Minimum x (west edge).
457    pub min_x: f64,
458    /// Minimum y (south edge).
459    pub min_y: f64,
460    /// Maximum x (east edge).
461    pub max_x: f64,
462    /// Maximum y (north edge).
463    pub max_y: f64,
464}
465
466/// A rectangle of tile indices within one zoom level, inclusive at both ends.
467///
468/// The container turns a bounding-box query into one range predicate over
469/// this rather than a tile-by-tile lookup.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub struct TileRange {
472    /// Westmost column.
473    pub min_column: i64,
474    /// Eastmost column.
475    pub max_column: i64,
476    /// Northmost row.
477    pub min_row: i64,
478    /// Southmost row.
479    pub max_row: i64,
480}
481
482/// The shape of a power-of-two zoom ladder, the spec's default arrangement, in
483/// which each level doubles the grid of the one below it.
484///
485/// The grid at the lowest zoom level defaults to a single tile, the web
486/// mercator convention. By convention, a geographic (EPSG:4326) pyramid starts
487/// two tiles wide and one tall, which you can set with [`Self::base_grid`].
488/// Tiles default to 256 pixels square.
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub struct ZoomLadder {
491    min_zoom: i64,
492    max_zoom: i64,
493    base_matrix_width: i64,
494    base_matrix_height: i64,
495    tile_width: i64,
496    tile_height: i64,
497}
498
499impl ZoomLadder {
500    /// Creates a ladder over the inclusive zoom range, with one 256-pixel tile
501    /// at `min_zoom`.
502    pub fn new(min_zoom: i64, max_zoom: i64) -> Self {
503        Self {
504            min_zoom,
505            max_zoom,
506            base_matrix_width: 1,
507            base_matrix_height: 1,
508            tile_width: 256,
509            tile_height: 256,
510        }
511    }
512
513    /// Sets the grid at `min_zoom`, in tiles (default `1` by `1`).
514    #[must_use]
515    pub fn base_grid(mut self, columns: i64, rows: i64) -> Self {
516        self.base_matrix_width = columns;
517        self.base_matrix_height = rows;
518        self
519    }
520
521    /// Sets the tile size in pixels (default `256` by `256`).
522    #[must_use]
523    pub fn tile_size(mut self, width: i64, height: i64) -> Self {
524        self.tile_width = width;
525        self.tile_height = height;
526        self
527    }
528}
529
530impl TileMatrixSet {
531    /// Creates a matrix set from its spatial reference system and its four
532    /// edges.
533    pub fn new(srs_id: i32, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
534        Self {
535            srs_id,
536            min_x,
537            min_y,
538            max_x,
539            max_y,
540        }
541    }
542
543    /// Returns the width of the extent in ground units.
544    pub fn width(&self) -> f64 {
545        self.max_x - self.min_x
546    }
547
548    /// Returns the height of the extent in ground units.
549    pub fn height(&self) -> f64 {
550        self.max_y - self.min_y
551    }
552
553    /// Checks a whole pyramid: the extent against Requirement 144, then every
554    /// tile matrix against Requirements 45 to 53, in ascending zoom order.
555    ///
556    /// `matrices` may be in any order and may be empty. A pyramid with no zoom
557    /// levels declared breaks no rule, because the spec requires a
558    /// `gpkg_tile_matrix` row only for a level that contains tiles.
559    ///
560    /// # Errors
561    ///
562    /// Returns the first [`TileError`] the pyramid violates, checking the
563    /// lowest zoom level first.
564    pub fn validate(&self, matrices: &[TileMatrix]) -> Result<(), TileError> {
565        self.validate_extent()?;
566        // Sorted rather than compared pairwise in place: Requirement 53 is
567        // stated over the zoom-ascending sequence, and a caller assembling a
568        // ladder has no reason to hand it over in order. Tens of levels at
569        // most, and this runs once per pyramid, not once per tile.
570        let mut sorted: Vec<&TileMatrix> = matrices.iter().collect();
571        sorted.sort_unstable_by_key(|matrix| matrix.zoom_level);
572        let mut previous: Option<&TileMatrix> = None;
573        for matrix in sorted {
574            matrix.validate()?;
575            self.validate_span(matrix)?;
576            if let Some(previous) = previous {
577                previous.validate_precedes(matrix)?;
578            }
579            previous = Some(matrix);
580        }
581        Ok(())
582    }
583
584    /// Checks Requirement 144. The extent is exact, so it must be a finite box
585    /// with positive width and height for any tile bound to be calculable from
586    /// it.
587    fn validate_extent(&self) -> Result<(), TileError> {
588        let ordered = self.min_x < self.max_x && self.min_y < self.max_y;
589        let finite = self.min_x.is_finite()
590            && self.min_y.is_finite()
591            && self.max_x.is_finite()
592            && self.max_y.is_finite();
593        if ordered && finite {
594            Ok(())
595        } else {
596            Err(TileError::InvalidExtent {
597                min_x: self.min_x,
598                min_y: self.min_y,
599                max_x: self.max_x,
600                max_y: self.max_y,
601            })
602        }
603    }
604
605    /// Returns the ground extent of one tile.
606    ///
607    /// Each edge is calculated once from its index, so two adjacent tiles
608    /// share an edge exactly. The outer edges of the grid are the edges of the
609    /// extent.
610    ///
611    /// The column and row are not bounds-checked. A column or row outside the
612    /// grid still has a calculable box, and a caller walking outwards from a
613    /// tile may need it. Use [`TileMatrix::check_contains`] to check them.
614    pub fn tile_bounds(&self, matrix: &TileMatrix, column: i64, row: i64) -> TileBounds {
615        TileBounds {
616            min_x: self.column_edge(matrix, column),
617            min_y: self.row_edge(matrix, row.saturating_add(1)),
618            max_x: self.column_edge(matrix, column.saturating_add(1)),
619            max_y: self.row_edge(matrix, row),
620        }
621    }
622
623    /// Returns the x of the west edge of `column`, which is the east edge of
624    /// the column before it.
625    fn column_edge(&self, matrix: &TileMatrix, column: i64) -> f64 {
626        if column == 0 {
627            self.min_x
628        } else if column == matrix.matrix_width {
629            self.max_x
630        } else {
631            self.min_x + column as f64 * matrix.tile_span_x()
632        }
633    }
634
635    /// Returns the y of the north edge of `row`, which is the south edge of
636    /// the row before it. Rows count south from the top edge, so the row
637    /// index is subtracted from `max_y`.
638    fn row_edge(&self, matrix: &TileMatrix, row: i64) -> f64 {
639        if row == 0 {
640            self.max_y
641        } else if row == matrix.matrix_height {
642            self.min_y
643        } else {
644            self.max_y - row as f64 * matrix.tile_span_y()
645        }
646    }
647
648    /// Returns the tile containing a position, or `None` for a position
649    /// outside the extent or with a NaN coordinate.
650    ///
651    /// Every edge is inclusive. A position on an edge that two tiles share
652    /// belongs to the tile east or south of it. A position on `max_x` belongs
653    /// to the last column rather than to one past it, and a position on `min_y`
654    /// belongs to the last row.
655    ///
656    /// For a grid that spans the extent (Requirement 45), the bounds that
657    /// [`Self::tile_bounds`] returns for the tile contain the position.
658    ///
659    /// Also returns `None` for a grid with no columns or no rows, which
660    /// contains no tile for any position. Requirements 47 and 48 do not permit
661    /// that grid, and [`TileMatrixSet::validate`] rejects it, but a
662    /// [`TileMatrix`] read from another writer's file has not been through
663    /// either check.
664    pub fn tile_at(&self, matrix: &TileMatrix, x: f64, y: f64) -> Option<(i64, i64)> {
665        if matrix.matrix_width <= 0 || matrix.matrix_height <= 0 {
666            return None;
667        }
668        // `contains` is false for NaN, which a pair of `<` tests would let
669        // through.
670        if !(self.min_x..=self.max_x).contains(&x) || !(self.min_y..=self.max_y).contains(&y) {
671            return None;
672        }
673        // A search over the edges of `tile_bounds` rather than a division,
674        // which rounds differently, so the tile always contains the position.
675        // It also finds the right tile where several tiles thinner than the
676        // coordinates' resolution share one edge value. The last column whose
677        // west edge is at or before `x`, and the last row whose north edge is
678        // at or above `y`: on a shared edge, the tile east or south of it.
679        let column = first_index(matrix.matrix_width, |c| self.column_edge(matrix, c + 1) > x)
680            .min(matrix.matrix_width - 1);
681        let row = first_index(matrix.matrix_height, |r| self.row_edge(matrix, r + 1) < y)
682            .min(matrix.matrix_height - 1);
683        Some((column, row))
684    }
685
686    /// Returns the tiles a bounding box touches at one zoom level, clamped to
687    /// the grid, or `None` when the box misses the extent entirely or contains
688    /// no point: a minimum above its maximum, or a NaN edge.
689    ///
690    /// The range is every tile whose [`Self::tile_bounds`] share a point with
691    /// the box, edges included. A box whose edge lies on an edge that two
692    /// tiles share therefore selects the tile on each side of it.
693    pub fn tile_range(
694        &self,
695        matrix: &TileMatrix,
696        min_x: f64,
697        min_y: f64,
698        max_x: f64,
699        max_y: f64,
700    ) -> Option<TileRange> {
701        if [min_x, min_y, max_x, max_y]
702            .iter()
703            .any(|edge| edge.is_nan())
704            || min_x > max_x
705            || min_y > max_y
706        {
707            return None;
708        }
709        if max_x < self.min_x || min_x > self.max_x || max_y < self.min_y || min_y > self.max_y {
710            return None;
711        }
712        // Clamping into the extent first means the divisions below cannot run
713        // away on a box that covers the world.
714        let (west, east) = (min_x.max(self.min_x), max_x.min(self.max_x));
715        let (south, north) = (min_y.max(self.min_y), max_y.min(self.max_y));
716        if matrix.matrix_width <= 0 || matrix.matrix_height <= 0 {
717            return None;
718        }
719        // Searches over the edges of `tile_bounds`, as in `tile_at`. A tile
720        // touches the box when its far edge reaches the box's near edge on
721        // both sides of each axis. Rows count southwards, so the north edge
722        // of the box gives the lowest row.
723        let (width, height) = (matrix.matrix_width, matrix.matrix_height);
724        let min_column = first_index(width, |c| self.column_edge(matrix, c + 1) >= west);
725        let max_column = first_index(width, |c| self.column_edge(matrix, c) > east) - 1;
726        let min_row = first_index(height, |r| self.row_edge(matrix, r + 1) <= north);
727        let max_row = first_index(height, |r| self.row_edge(matrix, r) < south) - 1;
728        if min_column > max_column || min_row > max_row {
729            return None;
730        }
731        Some(TileRange {
732            min_column,
733            max_column,
734            min_row,
735            max_row,
736        })
737    }
738
739    /// Returns the tile matrices of a power-of-two ladder over this extent.
740    ///
741    /// Pixel sizes are derived from the extent rather than supplied, so
742    /// Requirement 45 holds by construction. Each level's grid spans the matrix
743    /// set exactly.
744    ///
745    /// # Errors
746    ///
747    /// [`TileError::InvalidExtent`] for an extent no tile can be measured
748    /// against, [`TileError::NonPositiveDimension`] for a non-positive base
749    /// grid or tile size, and [`TileError::InvalidZoomRange`] for a range that
750    /// is empty, negative, or wide enough to overflow the grid.
751    pub fn ladder(&self, ladder: ZoomLadder) -> Result<Vec<TileMatrix>, TileError> {
752        self.validate_extent()?;
753        if ladder.min_zoom < 0 || ladder.max_zoom < ladder.min_zoom {
754            return Err(TileError::InvalidZoomRange {
755                min_zoom: ladder.min_zoom,
756                max_zoom: ladder.max_zoom,
757            });
758        }
759        for (field, value) in [
760            ("matrix_width", ladder.base_matrix_width),
761            ("matrix_height", ladder.base_matrix_height),
762            ("tile_width", ladder.tile_width),
763            ("tile_height", ladder.tile_height),
764        ] {
765            if value <= 0 {
766                return Err(TileError::NonPositiveDimension {
767                    zoom_level: ladder.min_zoom,
768                    field,
769                    value,
770                });
771            }
772        }
773
774        let levels = usize::try_from(ladder.max_zoom - ladder.min_zoom + 1).unwrap_or(0);
775        let mut matrices = Vec::with_capacity(levels);
776        let (mut matrix_width, mut matrix_height) =
777            (ladder.base_matrix_width, ladder.base_matrix_height);
778        for zoom_level in ladder.min_zoom..=ladder.max_zoom {
779            matrices.push(TileMatrix {
780                zoom_level,
781                matrix_width,
782                matrix_height,
783                tile_width: ladder.tile_width,
784                tile_height: ladder.tile_height,
785                pixel_x_size: self.width() / (matrix_width as f64 * ladder.tile_width as f64),
786                pixel_y_size: self.height() / (matrix_height as f64 * ladder.tile_height as f64),
787            });
788            let overflow = || TileError::InvalidZoomRange {
789                min_zoom: ladder.min_zoom,
790                max_zoom: ladder.max_zoom,
791            };
792            matrix_width = matrix_width.checked_mul(2).ok_or_else(overflow)?;
793            matrix_height = matrix_height.checked_mul(2).ok_or_else(overflow)?;
794        }
795        Ok(matrices)
796    }
797
798    /// Returns the extent of the standard web mercator quad (EPSG:3857), the
799    /// tiling scheme every XYZ basemap uses.
800    pub fn web_mercator_quad() -> Self {
801        Self::new(
802            3857,
803            -WEB_MERCATOR_HALF_SPAN,
804            -WEB_MERCATOR_HALF_SPAN,
805            WEB_MERCATOR_HALF_SPAN,
806            WEB_MERCATOR_HALF_SPAN,
807        )
808    }
809
810    /// Returns `true` if this extent is the standard web mercator quad.
811    ///
812    /// This compares `srs_id` against 3857 literally. A file that registers web
813    /// mercator under some other `srs_id`, which the format allows, returns
814    /// `false`. Recognising that file would mean resolving CRS definitions,
815    /// which this crate does not do.
816    pub fn is_web_mercator_quad(&self) -> bool {
817        self.srs_id == 3857
818            && [
819                (self.min_x, -WEB_MERCATOR_HALF_SPAN),
820                (self.min_y, -WEB_MERCATOR_HALF_SPAN),
821                (self.max_x, WEB_MERCATOR_HALF_SPAN),
822                (self.max_y, WEB_MERCATOR_HALF_SPAN),
823            ]
824            .iter()
825            .all(|(actual, expected)| {
826                (actual - expected).abs() <= EXTENT_TOLERANCE * expected.abs()
827            })
828    }
829
830    /// Returns `true` if tiles at this zoom level have the same indices an
831    /// XYZ service would give them.
832    ///
833    /// That requires the standard web mercator quad, tiled as a `2^zoom`
834    /// square. Tile pixel dimensions play no part because they set resolution,
835    /// not addressing, so a 512-pixel quad grid is still XYZ-addressed.
836    pub fn matches_xyz_grid(&self, matrix: &TileMatrix) -> bool {
837        self.is_web_mercator_quad() && matrix.is_quad_grid()
838    }
839
840    /// Reads an XYZ `z/x/y` address as a tile of this pyramid.
841    ///
842    /// # Errors
843    ///
844    /// [`TileError::NotAnXyzGrid`] when this pyramid does not address tiles the
845    /// way XYZ does, rather than returning indices that silently point
846    /// somewhere else, and [`TileError::CoordOutsideMatrix`] for an address
847    /// outside the grid.
848    pub fn xyz_to_tile(
849        &self,
850        matrix: &TileMatrix,
851        z: i64,
852        x: i64,
853        y: i64,
854    ) -> Result<TileCoord, TileError> {
855        if matrix.zoom_level != z || !self.matches_xyz_grid(matrix) {
856            return Err(TileError::NotAnXyzGrid { zoom_level: z });
857        }
858        matrix.check_contains(x, y)?;
859        Ok(TileCoord::new(z, x, y))
860    }
861
862    /// Returns the XYZ `z/x/y` address of one of this pyramid's tiles.
863    ///
864    /// # Errors
865    ///
866    /// As [`Self::xyz_to_tile`].
867    pub fn tile_to_xyz(
868        &self,
869        matrix: &TileMatrix,
870        coord: TileCoord,
871    ) -> Result<(i64, i64, i64), TileError> {
872        if matrix.zoom_level != coord.zoom_level || !self.matches_xyz_grid(matrix) {
873            return Err(TileError::NotAnXyzGrid {
874                zoom_level: coord.zoom_level,
875            });
876        }
877        matrix.check_contains(coord.column, coord.row)?;
878        Ok((coord.zoom_level, coord.column, coord.row))
879    }
880
881    /// Checks Requirement 45: the tile grid at each zoom level spans the matrix
882    /// set extent exactly, within [`EXTENT_TOLERANCE`].
883    fn validate_span(&self, matrix: &TileMatrix) -> Result<(), TileError> {
884        for (axis, expected, actual) in [
885            ("x", self.width(), matrix.span_x()),
886            ("y", self.height(), matrix.span_y()),
887        ] {
888            if (expected - actual).abs() > EXTENT_TOLERANCE * expected.abs().max(actual.abs()) {
889                return Err(TileError::ExtentMismatch {
890                    zoom_level: matrix.zoom_level,
891                    axis,
892                    expected,
893                    actual,
894                });
895            }
896        }
897        Ok(())
898    }
899}
900
901/// Returns the first index in `0..count` at which `predicate` holds, or
902/// `count` if it holds nowhere, for a predicate that is false and then true
903/// along the range. The edges of a grid that spans its extent give such
904/// predicates; a grid from another writer's file that does not still ends the
905/// search after `log2(count)` steps.
906fn first_index(count: i64, predicate: impl Fn(i64) -> bool) -> i64 {
907    let (mut low, mut high) = (0, count);
908    while low < high {
909        let middle = low + (high - low) / 2;
910        if predicate(middle) {
911            high = middle;
912        } else {
913            low = middle + 1;
914        }
915    }
916    low
917}
918
919impl TileMatrix {
920    /// Creates a tile matrix from its zoom level, grid size, tile size and
921    /// pixel sizes.
922    pub fn new(
923        zoom_level: i64,
924        matrix_width: i64,
925        matrix_height: i64,
926        tile_width: i64,
927        tile_height: i64,
928        pixel_x_size: f64,
929        pixel_y_size: f64,
930    ) -> Self {
931        Self {
932            zoom_level,
933            matrix_width,
934            matrix_height,
935            tile_width,
936            tile_height,
937            pixel_x_size,
938            pixel_y_size,
939        }
940    }
941
942    /// Returns the ground units one tile spans in x.
943    pub fn tile_span_x(&self) -> f64 {
944        self.tile_width as f64 * self.pixel_x_size
945    }
946
947    /// Returns the ground units one tile spans in y.
948    pub fn tile_span_y(&self) -> f64 {
949        self.tile_height as f64 * self.pixel_y_size
950    }
951
952    /// Returns `true` if a column and row fall inside this zoom level's grid.
953    pub fn contains(&self, column: i64, row: i64) -> bool {
954        (0..self.matrix_width).contains(&column) && (0..self.matrix_height).contains(&row)
955    }
956
957    /// Like [`Self::contains`], but returns a typed error.
958    ///
959    /// # Errors
960    ///
961    /// [`TileError::CoordOutsideMatrix`] for an index outside the grid.
962    pub fn check_contains(&self, column: i64, row: i64) -> Result<(), TileError> {
963        if self.contains(column, row) {
964            Ok(())
965        } else {
966            Err(TileError::CoordOutsideMatrix {
967                zoom_level: self.zoom_level,
968                column,
969                row,
970                matrix_width: self.matrix_width,
971                matrix_height: self.matrix_height,
972            })
973        }
974    }
975
976    /// Checks a payload's pixel dimensions against the tile size this zoom
977    /// level declares.
978    ///
979    /// # Errors
980    ///
981    /// [`TileError::PayloadSizeMismatch`] when they disagree.
982    pub fn check_payload(&self, payload: &TilePayload) -> Result<(), TileError> {
983        if payload.width == self.tile_width && payload.height == self.tile_height {
984            Ok(())
985        } else {
986            Err(TileError::PayloadSizeMismatch {
987                zoom_level: self.zoom_level,
988                expected_width: self.tile_width,
989                expected_height: self.tile_height,
990                actual_width: payload.width,
991                actual_height: payload.height,
992            })
993        }
994    }
995
996    /// Converts a row index between the GeoPackage sense, counting south from
997    /// the top, and the TMS sense, counting north from the bottom.
998    ///
999    /// The conversion is its own inverse, so one function covers both
1000    /// directions.
1001    pub fn flip_row(&self, row: i64) -> i64 {
1002        self.matrix_height - 1 - row
1003    }
1004
1005    /// Returns `true` if this zoom level tiles its extent as a `2^zoom`
1006    /// square, the grid an XYZ service assumes.
1007    fn is_quad_grid(&self) -> bool {
1008        u32::try_from(self.zoom_level)
1009            .ok()
1010            .filter(|zoom| *zoom < 63)
1011            .and_then(|zoom| 1_i64.checked_shl(zoom))
1012            .is_some_and(|side| self.matrix_width == side && self.matrix_height == side)
1013    }
1014
1015    /// Returns the ground units this zoom level's tile grid spans in x.
1016    pub fn span_x(&self) -> f64 {
1017        self.matrix_width as f64 * self.tile_width as f64 * self.pixel_x_size
1018    }
1019
1020    /// Returns the ground units this zoom level's tile grid spans in y.
1021    pub fn span_y(&self) -> f64 {
1022        self.matrix_height as f64 * self.tile_height as f64 * self.pixel_y_size
1023    }
1024
1025    /// Checks Requirements 46 to 52: a non-negative zoom level, positive grid
1026    /// and tile dimensions, and positive pixel sizes.
1027    ///
1028    /// # Errors
1029    ///
1030    /// The first rule this tile matrix violates.
1031    pub fn validate(&self) -> Result<(), TileError> {
1032        if self.zoom_level < 0 {
1033            return Err(TileError::NegativeZoomLevel {
1034                zoom_level: self.zoom_level,
1035            });
1036        }
1037        for (field, value) in [
1038            ("matrix_width", self.matrix_width),
1039            ("matrix_height", self.matrix_height),
1040            ("tile_width", self.tile_width),
1041            ("tile_height", self.tile_height),
1042        ] {
1043            if value <= 0 {
1044                return Err(TileError::NonPositiveDimension {
1045                    zoom_level: self.zoom_level,
1046                    field,
1047                    value,
1048                });
1049            }
1050        }
1051        for (field, value) in [
1052            ("pixel_x_size", self.pixel_x_size),
1053            ("pixel_y_size", self.pixel_y_size),
1054        ] {
1055            // Finiteness first: a NaN compares false against every bound, so
1056            // testing `value <= 0.0` alone would let one through.
1057            if !value.is_finite() || value <= 0.0 {
1058                return Err(TileError::NonPositivePixelSize {
1059                    zoom_level: self.zoom_level,
1060                    field,
1061                    value,
1062                });
1063            }
1064        }
1065        Ok(())
1066    }
1067
1068    /// Checks Requirement 53: pixel sizes decrease as zoom level increases.
1069    ///
1070    /// The requirement is read strictly. Two zoom levels of identical
1071    /// resolution are not a pyramid, and the OGC test suite compares them as
1072    /// decreasing. `self` is the lower zoom level.
1073    fn validate_precedes(&self, next: &TileMatrix) -> Result<(), TileError> {
1074        if self.zoom_level == next.zoom_level {
1075            return Err(TileError::DuplicateZoomLevel {
1076                zoom_level: self.zoom_level,
1077            });
1078        }
1079        for (field, value, previous_value) in [
1080            ("pixel_x_size", next.pixel_x_size, self.pixel_x_size),
1081            ("pixel_y_size", next.pixel_y_size, self.pixel_y_size),
1082        ] {
1083            if value >= previous_value {
1084                return Err(TileError::PixelSizeNotDescending {
1085                    zoom_level: next.zoom_level,
1086                    previous_zoom_level: self.zoom_level,
1087                    field,
1088                    value,
1089                    previous_value,
1090                });
1091            }
1092        }
1093        Ok(())
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    /// A two-level pyramid over a 256-unit square: 1x1 tiles of 256 pixels at
1102    /// zoom 0, 2x2 at zoom 1.
1103    fn square_pyramid() -> (TileMatrixSet, Vec<TileMatrix>) {
1104        let set = TileMatrixSet::new(4326, 0.0, 0.0, 256.0, 256.0);
1105        let matrices = vec![
1106            TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
1107            TileMatrix::new(1, 2, 2, 256, 256, 0.5, 0.5),
1108        ];
1109        (set, matrices)
1110    }
1111
1112    #[test]
1113    fn valid_pyramid_passes() {
1114        let (set, matrices) = square_pyramid();
1115        set.validate(&matrices).unwrap();
1116    }
1117
1118    #[test]
1119    fn unordered_input_is_validated_in_zoom_order() {
1120        let (set, mut matrices) = square_pyramid();
1121        matrices.reverse();
1122        set.validate(&matrices).unwrap();
1123    }
1124
1125    #[test]
1126    fn empty_pyramid_breaks_no_rule() {
1127        let (set, _) = square_pyramid();
1128        set.validate(&[]).unwrap();
1129    }
1130
1131    #[test]
1132    fn requirement_144_extent_must_be_a_well_ordered_box() {
1133        let inverted = TileMatrixSet::new(4326, 256.0, 0.0, 0.0, 256.0);
1134        assert!(matches!(
1135            inverted.validate(&[]),
1136            Err(TileError::InvalidExtent { .. })
1137        ));
1138        let degenerate = TileMatrixSet::new(4326, 0.0, 0.0, 0.0, 256.0);
1139        assert!(matches!(
1140            degenerate.validate(&[]),
1141            Err(TileError::InvalidExtent { .. })
1142        ));
1143        let infinite = TileMatrixSet::new(4326, 0.0, 0.0, f64::INFINITY, 256.0);
1144        assert!(matches!(
1145            infinite.validate(&[]),
1146            Err(TileError::InvalidExtent { .. })
1147        ));
1148    }
1149
1150    #[test]
1151    fn requirement_45_tile_grid_spans_the_extent() {
1152        let (set, _) = square_pyramid();
1153        // Half the columns needed to cover 256 units at one unit per pixel.
1154        let short = TileMatrix::new(1, 1, 2, 256, 256, 0.5, 0.5);
1155        assert!(matches!(
1156            set.validate(&[short]),
1157            Err(TileError::ExtentMismatch { axis: "x", .. })
1158        ));
1159    }
1160
1161    #[test]
1162    fn requirement_45_tolerates_rounding_in_a_derived_pixel_size() {
1163        let set = TileMatrixSet::new(
1164            3857,
1165            -20_037_508.34,
1166            -20_037_508.34,
1167            20_037_508.34,
1168            20_037_508.34,
1169        );
1170        // The pixel size a caller would compute by division, which does not
1171        // multiply back to the extent exactly.
1172        let pixel = set.width() / 256.0;
1173        set.validate(&[TileMatrix::new(0, 1, 1, 256, 256, pixel, pixel)])
1174            .unwrap();
1175    }
1176
1177    #[test]
1178    fn requirements_46_to_50_reject_non_positive_dimensions() {
1179        let (set, _) = square_pyramid();
1180        assert!(matches!(
1181            set.validate(&[TileMatrix::new(-1, 1, 1, 256, 256, 1.0, 1.0)]),
1182            Err(TileError::NegativeZoomLevel { zoom_level: -1 })
1183        ));
1184        for matrix in [
1185            TileMatrix::new(0, 0, 1, 256, 256, 1.0, 1.0),
1186            TileMatrix::new(0, 1, 0, 256, 256, 1.0, 1.0),
1187            TileMatrix::new(0, 1, 1, 0, 256, 1.0, 1.0),
1188            TileMatrix::new(0, 1, 1, 256, 0, 1.0, 1.0),
1189        ] {
1190            assert!(matches!(
1191                set.validate(&[matrix]),
1192                Err(TileError::NonPositiveDimension { .. })
1193            ));
1194        }
1195    }
1196
1197    #[test]
1198    fn requirements_51_and_52_reject_non_positive_pixel_sizes() {
1199        let (set, _) = square_pyramid();
1200        for matrix in [
1201            TileMatrix::new(0, 1, 1, 256, 256, 0.0, 1.0),
1202            TileMatrix::new(0, 1, 1, 256, 256, 1.0, -1.0),
1203            TileMatrix::new(0, 1, 1, 256, 256, f64::NAN, 1.0),
1204            TileMatrix::new(0, 1, 1, 256, 256, 1.0, f64::INFINITY),
1205        ] {
1206            assert!(matches!(
1207                set.validate(&[matrix]),
1208                Err(TileError::NonPositivePixelSize { .. })
1209            ));
1210        }
1211    }
1212
1213    #[test]
1214    fn requirement_53_pixel_sizes_descend_with_zoom() {
1215        let set = TileMatrixSet::new(4326, 0.0, 0.0, 256.0, 256.0);
1216        // Zoom 1 at the same resolution as zoom 0, which still spans the
1217        // extent but is not a pyramid.
1218        let flat = [
1219            TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
1220            TileMatrix::new(1, 1, 1, 256, 256, 1.0, 1.0),
1221        ];
1222        assert!(matches!(
1223            set.validate(&flat),
1224            Err(TileError::PixelSizeNotDescending { zoom_level: 1, .. })
1225        ));
1226    }
1227
1228    #[test]
1229    fn duplicate_zoom_levels_are_rejected() {
1230        let (set, _) = square_pyramid();
1231        let duplicated = [
1232            TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
1233            TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
1234        ];
1235        assert!(matches!(
1236            set.validate(&duplicated),
1237            Err(TileError::DuplicateZoomLevel { zoom_level: 0 })
1238        ));
1239    }
1240
1241    /// A PNG header (signature and IHDR) with a zeroed CRC, which a header
1242    /// probe does not check.
1243    fn png_bytes(width: u32, height: u32) -> Vec<u8> {
1244        let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
1245        bytes.extend_from_slice(&13_u32.to_be_bytes());
1246        bytes.extend_from_slice(b"IHDR");
1247        bytes.extend_from_slice(&width.to_be_bytes());
1248        bytes.extend_from_slice(&height.to_be_bytes());
1249        // Bit depth, colour type, compression, filter, interlace, then CRC.
1250        bytes.extend_from_slice(&[8, 6, 0, 0, 0, 0, 0, 0, 0]);
1251        bytes
1252    }
1253
1254    /// A JPEG start-of-image followed by a baseline SOF0 frame header.
1255    fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
1256        let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
1257        bytes.extend_from_slice(&height.to_be_bytes());
1258        bytes.extend_from_slice(&width.to_be_bytes());
1259        bytes.extend_from_slice(&[0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
1260        bytes
1261    }
1262
1263    /// A WebP container in the extended (`VP8X`) form, whose dimensions are
1264    /// stored as 24-bit little-endian values one less than the real size.
1265    fn webp_bytes(width: u32, height: u32) -> Vec<u8> {
1266        let mut bytes = Vec::from(*b"RIFF");
1267        bytes.extend_from_slice(&0_u32.to_le_bytes());
1268        bytes.extend_from_slice(b"WEBP");
1269        bytes.extend_from_slice(b"VP8X");
1270        bytes.extend_from_slice(&10_u32.to_le_bytes());
1271        bytes.extend_from_slice(&[0, 0, 0, 0]);
1272        bytes.extend_from_slice(&(width - 1).to_le_bytes()[..3]);
1273        bytes.extend_from_slice(&(height - 1).to_le_bytes()[..3]);
1274        bytes
1275    }
1276
1277    #[test]
1278    fn probe_reads_format_and_size() {
1279        assert_eq!(
1280            probe(&png_bytes(256, 256)).unwrap(),
1281            TilePayload {
1282                format: TileFormat::Png,
1283                width: 256,
1284                height: 256,
1285            }
1286        );
1287        assert_eq!(
1288            probe(&jpeg_bytes(512, 128)).unwrap(),
1289            TilePayload {
1290                format: TileFormat::Jpeg,
1291                width: 512,
1292                height: 128,
1293            }
1294        );
1295        assert_eq!(
1296            probe(&webp_bytes(256, 256)).unwrap(),
1297            TilePayload {
1298                format: TileFormat::Webp,
1299                width: 256,
1300                height: 256,
1301            }
1302        );
1303    }
1304
1305    #[test]
1306    fn probe_rejects_what_is_not_a_readable_image() {
1307        let png = png_bytes(256, 256);
1308        for bytes in [b"not an image at all".as_slice(), &[], &png[..12]] {
1309            assert!(matches!(
1310                probe(bytes),
1311                Err(TileError::UnreadablePayload { .. })
1312            ));
1313        }
1314    }
1315
1316    #[test]
1317    fn payload_size_is_checked_against_its_zoom_level() {
1318        let (_, matrices) = square_pyramid();
1319        let zoom0 = matrices[0];
1320        zoom0
1321            .check_payload(&probe(&png_bytes(256, 256)).unwrap())
1322            .unwrap();
1323        assert!(matches!(
1324            zoom0.check_payload(&probe(&png_bytes(512, 256)).unwrap()),
1325            Err(TileError::PayloadSizeMismatch {
1326                expected_width: 256,
1327                actual_width: 512,
1328                ..
1329            })
1330        ));
1331    }
1332
1333    #[test]
1334    fn only_png_and_jpeg_need_no_extension() {
1335        assert!(TileFormat::Png.is_core());
1336        assert!(TileFormat::Jpeg.is_core());
1337        assert!(!TileFormat::Webp.is_core());
1338        assert!(!TileFormat::Tiff.is_core());
1339        assert_eq!(TileFormat::Png.mime_type(), Some("image/png"));
1340        assert_eq!(TileFormat::Other.mime_type(), None);
1341    }
1342
1343    #[test]
1344    fn tile_bounds_count_rows_from_the_top() {
1345        let (set, matrices) = square_pyramid();
1346        let zoom1 = matrices[1];
1347        assert_eq!(
1348            set.tile_bounds(&zoom1, 0, 0),
1349            TileBounds {
1350                min_x: 0.0,
1351                min_y: 128.0,
1352                max_x: 128.0,
1353                max_y: 256.0,
1354            },
1355            "row 0 is the northern row"
1356        );
1357        assert_eq!(
1358            set.tile_bounds(&zoom1, 1, 1),
1359            TileBounds {
1360                min_x: 128.0,
1361                min_y: 0.0,
1362                max_x: 256.0,
1363                max_y: 128.0,
1364            }
1365        );
1366    }
1367
1368    #[test]
1369    fn tile_at_is_inclusive_at_the_edges() {
1370        let (set, matrices) = square_pyramid();
1371        let zoom1 = matrices[1];
1372        assert_eq!(set.tile_at(&zoom1, 0.0, 256.0), Some((0, 0)));
1373        assert_eq!(
1374            set.tile_at(&zoom1, 256.0, 0.0),
1375            Some((1, 1)),
1376            "the far corner belongs to the last tile, not to one past it"
1377        );
1378        assert_eq!(set.tile_at(&zoom1, 200.0, 200.0), Some((1, 0)));
1379        assert_eq!(set.tile_at(&zoom1, -0.5, 100.0), None);
1380    }
1381
1382    #[test]
1383    fn a_grid_with_no_tiles_contains_no_position() {
1384        // Requirements 47 and 48 forbid this grid and `validate` rejects it,
1385        // but a tile matrix from the file of another writer has not been
1386        // through either check, and a public method must not panic on it. The
1387        // `tile_payload` fuzz target found this case.
1388        let (set, _) = square_pyramid();
1389        for (width, height) in [(0, 1), (1, 0), (-1, 1), (1, i64::MIN)] {
1390            let degenerate = TileMatrix::new(0, width, height, 256, 256, 1.0, 1.0);
1391            assert_eq!(set.tile_at(&degenerate, 1.0, 1.0), None);
1392            assert_eq!(
1393                set.tile_range(&degenerate, 0.0, 0.0, 256.0, 256.0),
1394                None,
1395                "a range is a pair of tile_at calls, and gives the same answer"
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn tile_range_clamps_to_the_grid() {
1402        let (set, matrices) = square_pyramid();
1403        let zoom1 = matrices[1];
1404        assert_eq!(
1405            set.tile_range(&zoom1, -1000.0, -1000.0, 1000.0, 1000.0),
1406            Some(TileRange {
1407                min_column: 0,
1408                max_column: 1,
1409                min_row: 0,
1410                max_row: 1,
1411            })
1412        );
1413        assert_eq!(
1414            set.tile_range(&zoom1, 1.0, 200.0, 2.0, 210.0),
1415            Some(TileRange {
1416                min_column: 0,
1417                max_column: 0,
1418                min_row: 0,
1419                max_row: 0,
1420            })
1421        );
1422        assert_eq!(set.tile_range(&zoom1, 300.0, 300.0, 400.0, 400.0), None);
1423        assert_eq!(set.tile_range(&zoom1, 100.0, 100.0, 0.0, 0.0), None);
1424    }
1425
1426    /// Three columns of a third over `[-1, 0]`: the last column's east edge
1427    /// is `max_x`, which `-1 + 2/3 + 1/3` does not reach.
1428    #[test]
1429    fn the_outer_tile_edges_are_the_extent() {
1430        let set = TileMatrixSet::new(0, -1.0, 0.0, 0.0, 1.0);
1431        let matrices = set
1432            .ladder(ZoomLadder::new(0, 0).base_grid(3, 3).tile_size(1, 1))
1433            .unwrap();
1434        let matrix = matrices[0];
1435        let last = set.tile_bounds(&matrix, 2, 2);
1436        assert_eq!((last.max_x, last.min_y), (set.max_x, set.min_y));
1437        for index in 0..2 {
1438            assert_eq!(
1439                set.tile_bounds(&matrix, index, 0).max_x,
1440                set.tile_bounds(&matrix, index + 1, 0).min_x,
1441                "adjacent columns share their edge"
1442            );
1443            assert_eq!(
1444                set.tile_bounds(&matrix, 0, index).min_y,
1445                set.tile_bounds(&matrix, 0, index + 1).max_y,
1446                "adjacent rows share their edge"
1447            );
1448        }
1449        assert_eq!(set.tile_at(&matrix, -1e-17, 0.5), Some((2, 1)));
1450    }
1451
1452    /// Four rows a quarter of an `f64` step high at 1e9 share rounded edge
1453    /// values. A box reaching the top edge touches every row whose bounds
1454    /// reach it, and `tile_at` returns a row whose bounds contain the
1455    /// position.
1456    #[test]
1457    fn rows_thinner_than_the_resolution_are_all_found() {
1458        let set = TileMatrixSet::new(0, 0.0, 999_999_999.999_999_9, 1.0, 1e9);
1459        let matrix = set
1460            .ladder(ZoomLadder::new(0, 0).base_grid(1, 4).tile_size(1, 1))
1461            .unwrap()[0];
1462        let range = set.tile_range(&matrix, 0.0, 0.0, 0.0, 1e9).unwrap();
1463        assert_eq!((range.min_row, range.max_row), (0, 3));
1464        for y in [set.min_y, 1e9] {
1465            let (column, row) = set.tile_at(&matrix, 0.5, y).unwrap();
1466            let bounds = set.tile_bounds(&matrix, column, row);
1467            assert!(bounds.min_y <= y && y <= bounds.max_y, "{y} in row {row}");
1468        }
1469    }
1470
1471    #[test]
1472    fn a_box_selects_both_tiles_of_a_shared_edge_it_lies_on() {
1473        let (set, matrices) = square_pyramid();
1474        let zoom1 = matrices[1];
1475        // Zoom 1 has edges at 128 on both axes.
1476        let on_edge =
1477            |min_x, min_y, max_x, max_y| set.tile_range(&zoom1, min_x, min_y, max_x, max_y);
1478        let both = Some(TileRange {
1479            min_column: 0,
1480            max_column: 1,
1481            min_row: 0,
1482            max_row: 1,
1483        });
1484        assert_eq!(
1485            on_edge(128.0, 128.0, 128.0, 128.0),
1486            both,
1487            "a point on the corner"
1488        );
1489        assert_eq!(
1490            on_edge(128.0, 128.0, 200.0, 200.0),
1491            both,
1492            "west and south edges"
1493        );
1494        assert_eq!(
1495            on_edge(50.0, 50.0, 128.0, 128.0),
1496            both,
1497            "east and north edges"
1498        );
1499    }
1500
1501    #[test]
1502    fn a_nan_position_or_edge_selects_no_tile() {
1503        let (set, matrices) = square_pyramid();
1504        let zoom1 = matrices[1];
1505        assert_eq!(set.tile_at(&zoom1, f64::NAN, 1.0), None);
1506        assert_eq!(set.tile_at(&zoom1, 1.0, f64::NAN), None);
1507        for edge in 0..4 {
1508            let mut bbox = [0.0, 0.0, 256.0, 256.0];
1509            bbox[edge] = f64::NAN;
1510            let [min_x, min_y, max_x, max_y] = bbox;
1511            assert_eq!(set.tile_range(&zoom1, min_x, min_y, max_x, max_y), None);
1512        }
1513    }
1514
1515    #[test]
1516    fn flip_row_converts_tms_both_ways() {
1517        let (_, matrices) = square_pyramid();
1518        let zoom1 = matrices[1];
1519        assert_eq!(zoom1.flip_row(0), 1);
1520        assert_eq!(zoom1.flip_row(zoom1.flip_row(0)), 0);
1521    }
1522
1523    #[test]
1524    fn xyz_indices_hold_only_on_the_quad() {
1525        let quad = TileMatrixSet::web_mercator_quad();
1526        let matrices = quad.ladder(ZoomLadder::new(0, 2)).unwrap();
1527        let zoom2 = matrices[2];
1528        assert_eq!(
1529            quad.xyz_to_tile(&zoom2, 2, 3, 1).unwrap(),
1530            TileCoord::new(2, 3, 1)
1531        );
1532        assert_eq!(
1533            quad.tile_to_xyz(&zoom2, TileCoord::new(2, 3, 1)).unwrap(),
1534            (2, 3, 1)
1535        );
1536        assert!(matches!(
1537            quad.xyz_to_tile(&zoom2, 2, 4, 0),
1538            Err(TileError::CoordOutsideMatrix { .. })
1539        ));
1540
1541        // A geographic pyramid is two tiles wide at zoom 0, so its indices are
1542        // its own and the conversion errors rather than mis-addressing.
1543        let geographic = TileMatrixSet::new(4326, -180.0, -90.0, 180.0, 90.0);
1544        let geographic_matrices = geographic
1545            .ladder(ZoomLadder::new(0, 1).base_grid(2, 1))
1546            .unwrap();
1547        assert!(matches!(
1548            geographic.xyz_to_tile(&geographic_matrices[0], 0, 0, 0),
1549            Err(TileError::NotAnXyzGrid { .. })
1550        ));
1551    }
1552
1553    #[test]
1554    fn ladder_levels_span_the_extent() {
1555        let quad = TileMatrixSet::web_mercator_quad();
1556        let matrices = quad.ladder(ZoomLadder::new(0, 5)).unwrap();
1557        assert_eq!(matrices.len(), 6);
1558        assert_eq!(matrices[5].matrix_width, 32);
1559        assert_eq!(matrices[5].matrix_height, 32);
1560        assert_eq!(matrices[5].tile_width, 256);
1561        quad.validate(&matrices).unwrap();
1562    }
1563
1564    #[test]
1565    fn ladder_rejects_unusable_shapes() {
1566        let quad = TileMatrixSet::web_mercator_quad();
1567        for ladder in [
1568            ZoomLadder::new(3, 1),
1569            ZoomLadder::new(-1, 1),
1570            ZoomLadder::new(0, 70),
1571        ] {
1572            assert!(matches!(
1573                quad.ladder(ladder),
1574                Err(TileError::InvalidZoomRange { .. })
1575            ));
1576        }
1577        assert!(matches!(
1578            quad.ladder(ZoomLadder::new(0, 1).tile_size(0, 256)),
1579            Err(TileError::NonPositiveDimension { .. })
1580        ));
1581    }
1582
1583    #[test]
1584    fn tile_table_ddl() {
1585        assert_eq!(
1586            create_tile_table_sql("basemap").unwrap(),
1587            "CREATE TABLE \"basemap\" (\
1588             id INTEGER PRIMARY KEY AUTOINCREMENT, \
1589             zoom_level INTEGER NOT NULL, \
1590             tile_column INTEGER NOT NULL, \
1591             tile_row INTEGER NOT NULL, \
1592             tile_data BLOB NOT NULL, \
1593             UNIQUE (zoom_level, tile_column, tile_row))"
1594        );
1595    }
1596
1597    #[test]
1598    fn quotes_awkward_identifiers() {
1599        assert!(
1600            create_tile_table_sql("we\"ird")
1601                .unwrap()
1602                .starts_with("CREATE TABLE \"we\"\"ird\" (")
1603        );
1604        create_tile_table_sql("").unwrap_err();
1605    }
1606}