1use crate::Error;
19use crate::ident::quote;
20
21const EXTENT_TOLERANCE: f64 = 1e-9;
29
30pub const WEB_MERCATOR_HALF_SPAN: f64 = 20_037_508.342_789_244;
35
36pub const TILE_DATA_COLUMN: &str = "tile_data";
42
43pub const ZOOM_OTHER_EXTENSION_NAME: &str = "gpkg_zoom_other";
46pub const ZOOM_OTHER_EXTENSION_DEFINITION: &str =
48 "http://www.geopackage.org/spec140/#extension_zoom_other_intervals";
49pub const WEBP_EXTENSION_NAME: &str = "gpkg_webp";
51pub const WEBP_EXTENSION_DEFINITION: &str =
53 "http://www.geopackage.org/spec140/#extension_tiles_webp";
54pub const TILE_EXTENSION_SCOPE: &str = "read-write";
56
57pub 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
84pub 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#[derive(Debug, Clone, Copy, PartialEq)]
118pub struct TileMatrixSet {
119 pub srs_id: i32,
121 pub min_x: f64,
123 pub min_y: f64,
125 pub max_x: f64,
127 pub max_y: f64,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct TileMatrix {
134 pub zoom_level: i64,
136 pub matrix_width: i64,
138 pub matrix_height: i64,
140 pub tile_width: i64,
142 pub tile_height: i64,
144 pub pixel_x_size: f64,
146 pub pixel_y_size: f64,
148}
149
150#[derive(Debug, thiserror::Error)]
156#[non_exhaustive]
157pub enum TileError {
158 #[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 min_x: f64,
165 min_y: f64,
167 max_x: f64,
169 max_y: f64,
171 },
172 #[error("zoom level {zoom_level} is negative")]
174 NegativeZoomLevel {
175 zoom_level: i64,
177 },
178 #[error("zoom level {zoom_level} has {field} {value}, which must be greater than 0")]
181 NonPositiveDimension {
182 zoom_level: i64,
184 field: &'static str,
187 value: i64,
189 },
190 #[error("zoom level {zoom_level} has {field} {value}, which must be greater than 0")]
193 NonPositivePixelSize {
194 zoom_level: i64,
196 field: &'static str,
198 value: f64,
200 },
201 #[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: i64,
209 previous_zoom_level: i64,
211 field: &'static str,
213 value: f64,
215 previous_value: f64,
217 },
218 #[error(
221 "zoom level {zoom_level} spans {actual} in {axis}, but the tile matrix set extent is {expected}"
222 )]
223 ExtentMismatch {
224 zoom_level: i64,
226 axis: &'static str,
228 expected: f64,
230 actual: f64,
232 },
233 #[error("zoom level {zoom_level} appears more than once")]
236 DuplicateZoomLevel {
237 zoom_level: i64,
239 },
240 #[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: i64,
247 column: i64,
249 row: i64,
251 matrix_width: i64,
253 matrix_height: i64,
255 },
256 #[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: i64,
264 },
265 #[error("tile payload is not a readable image: {reason}")]
267 UnreadablePayload {
268 reason: String,
270 },
271 #[error(
277 "coverage tile payload breaks tiled gridded coverage Requirement {requirement}: {detail}"
278 )]
279 CoverageProfileViolation {
280 requirement: u8,
282 detail: String,
284 },
285 #[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: i64,
293 expected_width: i64,
295 expected_height: i64,
297 actual_width: i64,
299 actual_height: i64,
301 },
302 #[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 min_zoom: i64,
310 max_zoom: i64,
312 },
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324#[non_exhaustive]
325pub enum TileFormat {
326 Png,
328 Jpeg,
330 Webp,
332 Tiff,
334 Other,
336}
337
338impl TileFormat {
339 pub fn is_core(self) -> bool {
342 matches!(self, Self::Png | Self::Jpeg)
343 }
344
345 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub struct TilePayload {
360 pub format: TileFormat,
362 pub width: i64,
364 pub height: i64,
366}
367
368pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
429pub struct TileCoord {
430 pub zoom_level: i64,
432 pub column: i64,
434 pub row: i64,
436}
437
438impl TileCoord {
439 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#[derive(Debug, Clone, Copy, PartialEq)]
455pub struct TileBounds {
456 pub min_x: f64,
458 pub min_y: f64,
460 pub max_x: f64,
462 pub max_y: f64,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub struct TileRange {
472 pub min_column: i64,
474 pub max_column: i64,
476 pub min_row: i64,
478 pub max_row: i64,
480}
481
482#[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 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 #[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 #[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 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 pub fn width(&self) -> f64 {
545 self.max_x - self.min_x
546 }
547
548 pub fn height(&self) -> f64 {
550 self.max_y - self.min_y
551 }
552
553 pub fn validate(&self, matrices: &[TileMatrix]) -> Result<(), TileError> {
565 self.validate_extent()?;
566 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 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 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 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 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 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 if !(self.min_x..=self.max_x).contains(&x) || !(self.min_y..=self.max_y).contains(&y) {
671 return None;
672 }
673 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 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 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 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 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 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 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 pub fn matches_xyz_grid(&self, matrix: &TileMatrix) -> bool {
837 self.is_web_mercator_quad() && matrix.is_quad_grid()
838 }
839
840 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 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 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
901fn 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 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 pub fn tile_span_x(&self) -> f64 {
944 self.tile_width as f64 * self.pixel_x_size
945 }
946
947 pub fn tile_span_y(&self) -> f64 {
949 self.tile_height as f64 * self.pixel_y_size
950 }
951
952 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 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 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 pub fn flip_row(&self, row: i64) -> i64 {
1002 self.matrix_height - 1 - row
1003 }
1004
1005 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 pub fn span_x(&self) -> f64 {
1017 self.matrix_width as f64 * self.tile_width as f64 * self.pixel_x_size
1018 }
1019
1020 pub fn span_y(&self) -> f64 {
1022 self.matrix_height as f64 * self.tile_height as f64 * self.pixel_y_size
1023 }
1024
1025 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 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 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 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 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 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 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 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 bytes.extend_from_slice(&[8, 6, 0, 0, 0, 0, 0, 0, 0]);
1251 bytes
1252 }
1253
1254 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 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 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(°enerate, 1.0, 1.0), None);
1392 assert_eq!(
1393 set.tile_range(°enerate, 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 #[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 #[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 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 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}