use crate::Error;
use crate::ident::quote;
const EXTENT_TOLERANCE: f64 = 1e-9;
pub const WEB_MERCATOR_HALF_SPAN: f64 = 20_037_508.342_789_244;
pub const TILE_DATA_COLUMN: &str = "tile_data";
pub const ZOOM_OTHER_EXTENSION_NAME: &str = "gpkg_zoom_other";
pub const ZOOM_OTHER_EXTENSION_DEFINITION: &str =
"http://www.geopackage.org/spec140/#extension_zoom_other_intervals";
pub const WEBP_EXTENSION_NAME: &str = "gpkg_webp";
pub const WEBP_EXTENSION_DEFINITION: &str =
"http://www.geopackage.org/spec140/#extension_tiles_webp";
pub const TILE_EXTENSION_SCOPE: &str = "read-write";
pub fn is_power_of_two_ladder(matrices: &[TileMatrix]) -> bool {
let mut sorted: Vec<&TileMatrix> = matrices.iter().collect();
sorted.sort_unstable_by_key(|matrix| matrix.zoom_level);
sorted.windows(2).all(|pair| {
let [lower, upper] = pair else {
return true;
};
[
(lower.pixel_x_size, upper.pixel_x_size),
(lower.pixel_y_size, upper.pixel_y_size),
]
.iter()
.all(|(lower, upper)| {
let halved = lower / 2.0;
(halved - upper).abs() <= EXTENT_TOLERANCE * halved.abs().max(upper.abs())
})
})
}
pub fn create_tile_table_sql(table: &str) -> Result<String, Error> {
Ok(format!(
"CREATE TABLE {} (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
zoom_level INTEGER NOT NULL, \
tile_column INTEGER NOT NULL, \
tile_row INTEGER NOT NULL, \
tile_data BLOB NOT NULL, \
UNIQUE (zoom_level, tile_column, tile_row))",
quote(table)?
))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileMatrixSet {
pub srs_id: i32,
pub min_x: f64,
pub min_y: f64,
pub max_x: f64,
pub max_y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileMatrix {
pub zoom_level: i64,
pub matrix_width: i64,
pub matrix_height: i64,
pub tile_width: i64,
pub tile_height: i64,
pub pixel_x_size: f64,
pub pixel_y_size: f64,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TileError {
#[error(
"tile matrix set extent [{min_x}, {min_y}, {max_x}, {max_y}] is not a finite box with min < max on both axes"
)]
InvalidExtent {
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
},
#[error("zoom level {zoom_level} is negative")]
NegativeZoomLevel {
zoom_level: i64,
},
#[error("zoom level {zoom_level} has {field} {value}, which must be greater than 0")]
NonPositiveDimension {
zoom_level: i64,
field: &'static str,
value: i64,
},
#[error("zoom level {zoom_level} has {field} {value}, which must be greater than 0")]
NonPositivePixelSize {
zoom_level: i64,
field: &'static str,
value: f64,
},
#[error(
"zoom level {zoom_level} has {field} {value}, which is not smaller than the {previous_value} of zoom level {previous_zoom_level}"
)]
PixelSizeNotDescending {
zoom_level: i64,
previous_zoom_level: i64,
field: &'static str,
value: f64,
previous_value: f64,
},
#[error(
"zoom level {zoom_level} spans {actual} in {axis}, but the tile matrix set extent is {expected}"
)]
ExtentMismatch {
zoom_level: i64,
axis: &'static str,
expected: f64,
actual: f64,
},
#[error("zoom level {zoom_level} appears more than once")]
DuplicateZoomLevel {
zoom_level: i64,
},
#[error(
"tile ({column}, {row}) is outside the {matrix_width} by {matrix_height} grid of zoom level {zoom_level}"
)]
CoordOutsideMatrix {
zoom_level: i64,
column: i64,
row: i64,
matrix_width: i64,
matrix_height: i64,
},
#[error(
"zoom level {zoom_level} is not addressed as an XYZ grid: that needs a web mercator quad extent and a 2^zoom square matrix"
)]
NotAnXyzGrid {
zoom_level: i64,
},
#[error("tile payload is not a readable image: {reason}")]
UnreadablePayload {
reason: String,
},
#[error(
"tile is {actual_width} by {actual_height} pixels, but zoom level {zoom_level} declares {expected_width} by {expected_height}"
)]
PayloadSizeMismatch {
zoom_level: i64,
expected_width: i64,
expected_height: i64,
actual_width: i64,
actual_height: i64,
},
#[error(
"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"
)]
InvalidZoomRange {
min_zoom: i64,
max_zoom: i64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TileFormat {
Png,
Jpeg,
Webp,
Tiff,
Other,
}
impl TileFormat {
pub fn is_core(self) -> bool {
matches!(self, Self::Png | Self::Jpeg)
}
pub fn mime_type(self) -> Option<&'static str> {
match self {
Self::Png => Some("image/png"),
Self::Jpeg => Some("image/jpeg"),
Self::Webp => Some("image/webp"),
Self::Tiff => Some("image/tiff"),
Self::Other => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TilePayload {
pub format: TileFormat,
pub width: i64,
pub height: i64,
}
pub fn probe(bytes: &[u8]) -> Result<TilePayload, TileError> {
let unreadable = |error: imagesize::ImageError| TileError::UnreadablePayload {
reason: error.to_string(),
};
let format = match imagesize::image_type(bytes).map_err(unreadable)? {
imagesize::ImageType::Png => TileFormat::Png,
imagesize::ImageType::Jpeg => TileFormat::Jpeg,
imagesize::ImageType::Webp => TileFormat::Webp,
imagesize::ImageType::Tiff => TileFormat::Tiff,
_ => TileFormat::Other,
};
let size = imagesize::blob_size(bytes).map_err(unreadable)?;
let (Ok(width), Ok(height)) = (i64::try_from(size.width), i64::try_from(size.height)) else {
return Err(TileError::UnreadablePayload {
reason: format!("header declares {} by {} pixels", size.width, size.height),
});
};
Ok(TilePayload {
format,
width,
height,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TileCoord {
pub zoom_level: i64,
pub column: i64,
pub row: i64,
}
impl TileCoord {
pub fn new(zoom_level: i64, column: i64, row: i64) -> Self {
Self {
zoom_level,
column,
row,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileBounds {
pub min_x: f64,
pub min_y: f64,
pub max_x: f64,
pub max_y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TileRange {
pub min_column: i64,
pub max_column: i64,
pub min_row: i64,
pub max_row: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ZoomLadder {
min_zoom: i64,
max_zoom: i64,
base_matrix_width: i64,
base_matrix_height: i64,
tile_width: i64,
tile_height: i64,
}
impl ZoomLadder {
pub fn new(min_zoom: i64, max_zoom: i64) -> Self {
Self {
min_zoom,
max_zoom,
base_matrix_width: 1,
base_matrix_height: 1,
tile_width: 256,
tile_height: 256,
}
}
#[must_use]
pub fn base_grid(mut self, columns: i64, rows: i64) -> Self {
self.base_matrix_width = columns;
self.base_matrix_height = rows;
self
}
#[must_use]
pub fn tile_size(mut self, width: i64, height: i64) -> Self {
self.tile_width = width;
self.tile_height = height;
self
}
}
impl TileMatrixSet {
pub fn new(srs_id: i32, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self {
srs_id,
min_x,
min_y,
max_x,
max_y,
}
}
pub fn width(&self) -> f64 {
self.max_x - self.min_x
}
pub fn height(&self) -> f64 {
self.max_y - self.min_y
}
pub fn validate(&self, matrices: &[TileMatrix]) -> Result<(), TileError> {
self.validate_extent()?;
let mut sorted: Vec<&TileMatrix> = matrices.iter().collect();
sorted.sort_unstable_by_key(|matrix| matrix.zoom_level);
let mut previous: Option<&TileMatrix> = None;
for matrix in sorted {
matrix.validate()?;
self.validate_span(matrix)?;
if let Some(previous) = previous {
previous.validate_precedes(matrix)?;
}
previous = Some(matrix);
}
Ok(())
}
fn validate_extent(&self) -> Result<(), TileError> {
let ordered = self.min_x < self.max_x && self.min_y < self.max_y;
let finite = self.min_x.is_finite()
&& self.min_y.is_finite()
&& self.max_x.is_finite()
&& self.max_y.is_finite();
if ordered && finite {
Ok(())
} else {
Err(TileError::InvalidExtent {
min_x: self.min_x,
min_y: self.min_y,
max_x: self.max_x,
max_y: self.max_y,
})
}
}
pub fn tile_bounds(&self, matrix: &TileMatrix, column: i64, row: i64) -> TileBounds {
let span_x = matrix.tile_span_x();
let span_y = matrix.tile_span_y();
let min_x = self.min_x + column as f64 * span_x;
let max_y = self.max_y - row as f64 * span_y;
TileBounds {
min_x,
min_y: max_y - span_y,
max_x: min_x + span_x,
max_y,
}
}
pub fn tile_at(&self, matrix: &TileMatrix, x: f64, y: f64) -> Option<(i64, i64)> {
if x < self.min_x || x > self.max_x || y < self.min_y || y > self.max_y {
return None;
}
let column = ((x - self.min_x) / matrix.tile_span_x()).floor() as i64;
let row = ((self.max_y - y) / matrix.tile_span_y()).floor() as i64;
Some((
column.clamp(0, matrix.matrix_width.saturating_sub(1)),
row.clamp(0, matrix.matrix_height.saturating_sub(1)),
))
}
pub fn tile_range(
&self,
matrix: &TileMatrix,
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
) -> Option<TileRange> {
if min_x > max_x || min_y > max_y {
return None;
}
if max_x < self.min_x || min_x > self.max_x || max_y < self.min_y || min_y > self.max_y {
return None;
}
let (west, east) = (min_x.max(self.min_x), max_x.min(self.max_x));
let (south, north) = (min_y.max(self.min_y), max_y.min(self.max_y));
let (min_column, min_row) = self.tile_at(matrix, west, north)?;
let (max_column, max_row) = self.tile_at(matrix, east, south)?;
Some(TileRange {
min_column,
max_column,
min_row,
max_row,
})
}
pub fn ladder(&self, ladder: ZoomLadder) -> Result<Vec<TileMatrix>, TileError> {
self.validate_extent()?;
if ladder.min_zoom < 0 || ladder.max_zoom < ladder.min_zoom {
return Err(TileError::InvalidZoomRange {
min_zoom: ladder.min_zoom,
max_zoom: ladder.max_zoom,
});
}
for (field, value) in [
("matrix_width", ladder.base_matrix_width),
("matrix_height", ladder.base_matrix_height),
("tile_width", ladder.tile_width),
("tile_height", ladder.tile_height),
] {
if value <= 0 {
return Err(TileError::NonPositiveDimension {
zoom_level: ladder.min_zoom,
field,
value,
});
}
}
let levels = usize::try_from(ladder.max_zoom - ladder.min_zoom + 1).unwrap_or(0);
let mut matrices = Vec::with_capacity(levels);
let (mut matrix_width, mut matrix_height) =
(ladder.base_matrix_width, ladder.base_matrix_height);
for zoom_level in ladder.min_zoom..=ladder.max_zoom {
matrices.push(TileMatrix {
zoom_level,
matrix_width,
matrix_height,
tile_width: ladder.tile_width,
tile_height: ladder.tile_height,
pixel_x_size: self.width() / (matrix_width as f64 * ladder.tile_width as f64),
pixel_y_size: self.height() / (matrix_height as f64 * ladder.tile_height as f64),
});
let overflow = || TileError::InvalidZoomRange {
min_zoom: ladder.min_zoom,
max_zoom: ladder.max_zoom,
};
matrix_width = matrix_width.checked_mul(2).ok_or_else(overflow)?;
matrix_height = matrix_height.checked_mul(2).ok_or_else(overflow)?;
}
Ok(matrices)
}
pub fn web_mercator_quad() -> Self {
Self::new(
3857,
-WEB_MERCATOR_HALF_SPAN,
-WEB_MERCATOR_HALF_SPAN,
WEB_MERCATOR_HALF_SPAN,
WEB_MERCATOR_HALF_SPAN,
)
}
pub fn is_web_mercator_quad(&self) -> bool {
self.srs_id == 3857
&& [
(self.min_x, -WEB_MERCATOR_HALF_SPAN),
(self.min_y, -WEB_MERCATOR_HALF_SPAN),
(self.max_x, WEB_MERCATOR_HALF_SPAN),
(self.max_y, WEB_MERCATOR_HALF_SPAN),
]
.iter()
.all(|(actual, expected)| {
(actual - expected).abs() <= EXTENT_TOLERANCE * expected.abs()
})
}
pub fn matches_xyz_grid(&self, matrix: &TileMatrix) -> bool {
self.is_web_mercator_quad() && matrix.is_quad_grid()
}
pub fn xyz_to_tile(
&self,
matrix: &TileMatrix,
z: i64,
x: i64,
y: i64,
) -> Result<TileCoord, TileError> {
if matrix.zoom_level != z || !self.matches_xyz_grid(matrix) {
return Err(TileError::NotAnXyzGrid { zoom_level: z });
}
matrix.check_contains(x, y)?;
Ok(TileCoord::new(z, x, y))
}
pub fn tile_to_xyz(
&self,
matrix: &TileMatrix,
coord: TileCoord,
) -> Result<(i64, i64, i64), TileError> {
if matrix.zoom_level != coord.zoom_level || !self.matches_xyz_grid(matrix) {
return Err(TileError::NotAnXyzGrid {
zoom_level: coord.zoom_level,
});
}
matrix.check_contains(coord.column, coord.row)?;
Ok((coord.zoom_level, coord.column, coord.row))
}
fn validate_span(&self, matrix: &TileMatrix) -> Result<(), TileError> {
for (axis, expected, actual) in [
("x", self.width(), matrix.span_x()),
("y", self.height(), matrix.span_y()),
] {
if (expected - actual).abs() > EXTENT_TOLERANCE * expected.abs().max(actual.abs()) {
return Err(TileError::ExtentMismatch {
zoom_level: matrix.zoom_level,
axis,
expected,
actual,
});
}
}
Ok(())
}
}
impl TileMatrix {
pub fn new(
zoom_level: i64,
matrix_width: i64,
matrix_height: i64,
tile_width: i64,
tile_height: i64,
pixel_x_size: f64,
pixel_y_size: f64,
) -> Self {
Self {
zoom_level,
matrix_width,
matrix_height,
tile_width,
tile_height,
pixel_x_size,
pixel_y_size,
}
}
pub fn tile_span_x(&self) -> f64 {
self.tile_width as f64 * self.pixel_x_size
}
pub fn tile_span_y(&self) -> f64 {
self.tile_height as f64 * self.pixel_y_size
}
pub fn contains(&self, column: i64, row: i64) -> bool {
(0..self.matrix_width).contains(&column) && (0..self.matrix_height).contains(&row)
}
pub fn check_contains(&self, column: i64, row: i64) -> Result<(), TileError> {
if self.contains(column, row) {
Ok(())
} else {
Err(TileError::CoordOutsideMatrix {
zoom_level: self.zoom_level,
column,
row,
matrix_width: self.matrix_width,
matrix_height: self.matrix_height,
})
}
}
pub fn check_payload(&self, payload: &TilePayload) -> Result<(), TileError> {
if payload.width == self.tile_width && payload.height == self.tile_height {
Ok(())
} else {
Err(TileError::PayloadSizeMismatch {
zoom_level: self.zoom_level,
expected_width: self.tile_width,
expected_height: self.tile_height,
actual_width: payload.width,
actual_height: payload.height,
})
}
}
pub fn flip_row(&self, row: i64) -> i64 {
self.matrix_height - 1 - row
}
fn is_quad_grid(&self) -> bool {
u32::try_from(self.zoom_level)
.ok()
.filter(|zoom| *zoom < 63)
.and_then(|zoom| 1_i64.checked_shl(zoom))
.is_some_and(|side| self.matrix_width == side && self.matrix_height == side)
}
pub fn span_x(&self) -> f64 {
self.matrix_width as f64 * self.tile_width as f64 * self.pixel_x_size
}
pub fn span_y(&self) -> f64 {
self.matrix_height as f64 * self.tile_height as f64 * self.pixel_y_size
}
pub fn validate(&self) -> Result<(), TileError> {
if self.zoom_level < 0 {
return Err(TileError::NegativeZoomLevel {
zoom_level: self.zoom_level,
});
}
for (field, value) in [
("matrix_width", self.matrix_width),
("matrix_height", self.matrix_height),
("tile_width", self.tile_width),
("tile_height", self.tile_height),
] {
if value <= 0 {
return Err(TileError::NonPositiveDimension {
zoom_level: self.zoom_level,
field,
value,
});
}
}
for (field, value) in [
("pixel_x_size", self.pixel_x_size),
("pixel_y_size", self.pixel_y_size),
] {
if !value.is_finite() || value <= 0.0 {
return Err(TileError::NonPositivePixelSize {
zoom_level: self.zoom_level,
field,
value,
});
}
}
Ok(())
}
fn validate_precedes(&self, next: &TileMatrix) -> Result<(), TileError> {
if self.zoom_level == next.zoom_level {
return Err(TileError::DuplicateZoomLevel {
zoom_level: self.zoom_level,
});
}
for (field, value, previous_value) in [
("pixel_x_size", next.pixel_x_size, self.pixel_x_size),
("pixel_y_size", next.pixel_y_size, self.pixel_y_size),
] {
if value >= previous_value {
return Err(TileError::PixelSizeNotDescending {
zoom_level: next.zoom_level,
previous_zoom_level: self.zoom_level,
field,
value,
previous_value,
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn square_pyramid() -> (TileMatrixSet, Vec<TileMatrix>) {
let set = TileMatrixSet::new(4326, 0.0, 0.0, 256.0, 256.0);
let matrices = vec![
TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
TileMatrix::new(1, 2, 2, 256, 256, 0.5, 0.5),
];
(set, matrices)
}
#[test]
fn valid_pyramid_passes() {
let (set, matrices) = square_pyramid();
set.validate(&matrices).unwrap();
}
#[test]
fn unordered_input_is_validated_in_zoom_order() {
let (set, mut matrices) = square_pyramid();
matrices.reverse();
set.validate(&matrices).unwrap();
}
#[test]
fn empty_pyramid_breaks_no_rule() {
let (set, _) = square_pyramid();
set.validate(&[]).unwrap();
}
#[test]
fn requirement_144_extent_must_be_a_well_ordered_box() {
let inverted = TileMatrixSet::new(4326, 256.0, 0.0, 0.0, 256.0);
assert!(matches!(
inverted.validate(&[]),
Err(TileError::InvalidExtent { .. })
));
let degenerate = TileMatrixSet::new(4326, 0.0, 0.0, 0.0, 256.0);
assert!(matches!(
degenerate.validate(&[]),
Err(TileError::InvalidExtent { .. })
));
let infinite = TileMatrixSet::new(4326, 0.0, 0.0, f64::INFINITY, 256.0);
assert!(matches!(
infinite.validate(&[]),
Err(TileError::InvalidExtent { .. })
));
}
#[test]
fn requirement_45_tile_grid_spans_the_extent() {
let (set, _) = square_pyramid();
let short = TileMatrix::new(1, 1, 2, 256, 256, 0.5, 0.5);
assert!(matches!(
set.validate(&[short]),
Err(TileError::ExtentMismatch { axis: "x", .. })
));
}
#[test]
fn requirement_45_tolerates_rounding_in_a_derived_pixel_size() {
let set = TileMatrixSet::new(
3857,
-20_037_508.34,
-20_037_508.34,
20_037_508.34,
20_037_508.34,
);
let pixel = set.width() / 256.0;
set.validate(&[TileMatrix::new(0, 1, 1, 256, 256, pixel, pixel)])
.unwrap();
}
#[test]
fn requirements_46_to_50_reject_non_positive_dimensions() {
let (set, _) = square_pyramid();
assert!(matches!(
set.validate(&[TileMatrix::new(-1, 1, 1, 256, 256, 1.0, 1.0)]),
Err(TileError::NegativeZoomLevel { zoom_level: -1 })
));
for matrix in [
TileMatrix::new(0, 0, 1, 256, 256, 1.0, 1.0),
TileMatrix::new(0, 1, 0, 256, 256, 1.0, 1.0),
TileMatrix::new(0, 1, 1, 0, 256, 1.0, 1.0),
TileMatrix::new(0, 1, 1, 256, 0, 1.0, 1.0),
] {
assert!(matches!(
set.validate(&[matrix]),
Err(TileError::NonPositiveDimension { .. })
));
}
}
#[test]
fn requirements_51_and_52_reject_non_positive_pixel_sizes() {
let (set, _) = square_pyramid();
for matrix in [
TileMatrix::new(0, 1, 1, 256, 256, 0.0, 1.0),
TileMatrix::new(0, 1, 1, 256, 256, 1.0, -1.0),
TileMatrix::new(0, 1, 1, 256, 256, f64::NAN, 1.0),
TileMatrix::new(0, 1, 1, 256, 256, 1.0, f64::INFINITY),
] {
assert!(matches!(
set.validate(&[matrix]),
Err(TileError::NonPositivePixelSize { .. })
));
}
}
#[test]
fn requirement_53_pixel_sizes_descend_with_zoom() {
let set = TileMatrixSet::new(4326, 0.0, 0.0, 256.0, 256.0);
let flat = [
TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
TileMatrix::new(1, 1, 1, 256, 256, 1.0, 1.0),
];
assert!(matches!(
set.validate(&flat),
Err(TileError::PixelSizeNotDescending { zoom_level: 1, .. })
));
}
#[test]
fn duplicate_zoom_levels_are_rejected() {
let (set, _) = square_pyramid();
let duplicated = [
TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
TileMatrix::new(0, 1, 1, 256, 256, 1.0, 1.0),
];
assert!(matches!(
set.validate(&duplicated),
Err(TileError::DuplicateZoomLevel { zoom_level: 0 })
));
}
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
bytes.extend_from_slice(&13_u32.to_be_bytes());
bytes.extend_from_slice(b"IHDR");
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&[8, 6, 0, 0, 0, 0, 0, 0, 0]);
bytes
}
fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&[0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
bytes
}
fn webp_bytes(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::from(*b"RIFF");
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8X");
bytes.extend_from_slice(&10_u32.to_le_bytes());
bytes.extend_from_slice(&[0, 0, 0, 0]);
bytes.extend_from_slice(&(width - 1).to_le_bytes()[..3]);
bytes.extend_from_slice(&(height - 1).to_le_bytes()[..3]);
bytes
}
#[test]
fn probe_reads_format_and_size() {
assert_eq!(
probe(&png_bytes(256, 256)).unwrap(),
TilePayload {
format: TileFormat::Png,
width: 256,
height: 256,
}
);
assert_eq!(
probe(&jpeg_bytes(512, 128)).unwrap(),
TilePayload {
format: TileFormat::Jpeg,
width: 512,
height: 128,
}
);
assert_eq!(
probe(&webp_bytes(256, 256)).unwrap(),
TilePayload {
format: TileFormat::Webp,
width: 256,
height: 256,
}
);
}
#[test]
fn probe_rejects_what_is_not_a_readable_image() {
let png = png_bytes(256, 256);
for bytes in [b"not an image at all".as_slice(), &[], &png[..12]] {
assert!(matches!(
probe(bytes),
Err(TileError::UnreadablePayload { .. })
));
}
}
#[test]
fn payload_size_is_checked_against_its_zoom_level() {
let (_, matrices) = square_pyramid();
let zoom0 = matrices[0];
zoom0
.check_payload(&probe(&png_bytes(256, 256)).unwrap())
.unwrap();
assert!(matches!(
zoom0.check_payload(&probe(&png_bytes(512, 256)).unwrap()),
Err(TileError::PayloadSizeMismatch {
expected_width: 256,
actual_width: 512,
..
})
));
}
#[test]
fn only_png_and_jpeg_need_no_extension() {
assert!(TileFormat::Png.is_core());
assert!(TileFormat::Jpeg.is_core());
assert!(!TileFormat::Webp.is_core());
assert!(!TileFormat::Tiff.is_core());
assert_eq!(TileFormat::Png.mime_type(), Some("image/png"));
assert_eq!(TileFormat::Other.mime_type(), None);
}
#[test]
fn tile_bounds_count_rows_from_the_top() {
let (set, matrices) = square_pyramid();
let zoom1 = matrices[1];
assert_eq!(
set.tile_bounds(&zoom1, 0, 0),
TileBounds {
min_x: 0.0,
min_y: 128.0,
max_x: 128.0,
max_y: 256.0,
},
"row 0 is the northern row"
);
assert_eq!(
set.tile_bounds(&zoom1, 1, 1),
TileBounds {
min_x: 128.0,
min_y: 0.0,
max_x: 256.0,
max_y: 128.0,
}
);
}
#[test]
fn tile_at_is_inclusive_at_the_edges() {
let (set, matrices) = square_pyramid();
let zoom1 = matrices[1];
assert_eq!(set.tile_at(&zoom1, 0.0, 256.0), Some((0, 0)));
assert_eq!(
set.tile_at(&zoom1, 256.0, 0.0),
Some((1, 1)),
"the far corner belongs to the last tile, not to one past it"
);
assert_eq!(set.tile_at(&zoom1, 200.0, 200.0), Some((1, 0)));
assert_eq!(set.tile_at(&zoom1, -0.5, 100.0), None);
}
#[test]
fn tile_range_clamps_to_the_grid() {
let (set, matrices) = square_pyramid();
let zoom1 = matrices[1];
assert_eq!(
set.tile_range(&zoom1, -1000.0, -1000.0, 1000.0, 1000.0),
Some(TileRange {
min_column: 0,
max_column: 1,
min_row: 0,
max_row: 1,
})
);
assert_eq!(
set.tile_range(&zoom1, 1.0, 200.0, 2.0, 210.0),
Some(TileRange {
min_column: 0,
max_column: 0,
min_row: 0,
max_row: 0,
})
);
assert_eq!(set.tile_range(&zoom1, 300.0, 300.0, 400.0, 400.0), None);
assert_eq!(set.tile_range(&zoom1, 100.0, 100.0, 0.0, 0.0), None);
}
#[test]
fn flip_row_converts_tms_both_ways() {
let (_, matrices) = square_pyramid();
let zoom1 = matrices[1];
assert_eq!(zoom1.flip_row(0), 1);
assert_eq!(zoom1.flip_row(zoom1.flip_row(0)), 0);
}
#[test]
fn xyz_indices_hold_only_on_the_quad() {
let quad = TileMatrixSet::web_mercator_quad();
let matrices = quad.ladder(ZoomLadder::new(0, 2)).unwrap();
let zoom2 = matrices[2];
assert_eq!(
quad.xyz_to_tile(&zoom2, 2, 3, 1).unwrap(),
TileCoord::new(2, 3, 1)
);
assert_eq!(
quad.tile_to_xyz(&zoom2, TileCoord::new(2, 3, 1)).unwrap(),
(2, 3, 1)
);
assert!(matches!(
quad.xyz_to_tile(&zoom2, 2, 4, 0),
Err(TileError::CoordOutsideMatrix { .. })
));
let geographic = TileMatrixSet::new(4326, -180.0, -90.0, 180.0, 90.0);
let geographic_matrices = geographic
.ladder(ZoomLadder::new(0, 1).base_grid(2, 1))
.unwrap();
assert!(matches!(
geographic.xyz_to_tile(&geographic_matrices[0], 0, 0, 0),
Err(TileError::NotAnXyzGrid { .. })
));
}
#[test]
fn ladder_levels_span_the_extent() {
let quad = TileMatrixSet::web_mercator_quad();
let matrices = quad.ladder(ZoomLadder::new(0, 5)).unwrap();
assert_eq!(matrices.len(), 6);
assert_eq!(matrices[5].matrix_width, 32);
assert_eq!(matrices[5].matrix_height, 32);
assert_eq!(matrices[5].tile_width, 256);
quad.validate(&matrices).unwrap();
}
#[test]
fn ladder_rejects_unusable_shapes() {
let quad = TileMatrixSet::web_mercator_quad();
for ladder in [
ZoomLadder::new(3, 1),
ZoomLadder::new(-1, 1),
ZoomLadder::new(0, 70),
] {
assert!(matches!(
quad.ladder(ladder),
Err(TileError::InvalidZoomRange { .. })
));
}
assert!(matches!(
quad.ladder(ZoomLadder::new(0, 1).tile_size(0, 256)),
Err(TileError::NonPositiveDimension { .. })
));
}
#[test]
fn tile_table_ddl() {
assert_eq!(
create_tile_table_sql("basemap").unwrap(),
"CREATE TABLE \"basemap\" (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
zoom_level INTEGER NOT NULL, \
tile_column INTEGER NOT NULL, \
tile_row INTEGER NOT NULL, \
tile_data BLOB NOT NULL, \
UNIQUE (zoom_level, tile_column, tile_row))"
);
}
#[test]
fn quotes_awkward_identifiers() {
assert!(
create_tile_table_sql("we\"ird")
.unwrap()
.starts_with("CREATE TABLE \"we\"\"ird\" (")
);
create_tile_table_sql("").unwrap_err();
}
}