#![cfg(feature = "mbtiles-export")]
use rusqlite::{Connection, params};
use std::path::Path;
use crate::error::GpkgError;
use crate::gpkg::GeoPackage;
use crate::tile_matrix::TileMatrix;
use crate::tile_pyramid::TilePyramidReader;
pub struct GpkgMbTilesExporter<'a> {
gpkg: &'a GeoPackage,
table_name: String,
}
#[derive(Debug, Clone)]
pub struct GpkgMbTilesStats {
pub tiles_written: u64,
pub min_zoom: u32,
pub max_zoom: u32,
pub bytes_written: u64,
pub metadata_keys: usize,
pub format: String,
}
impl<'a> GpkgMbTilesExporter<'a> {
pub fn new(gpkg: &'a GeoPackage, table_name: &str) -> Result<Self, GpkgError> {
let _reader = TilePyramidReader::open(gpkg, table_name)?;
Ok(Self {
gpkg,
table_name: table_name.to_string(),
})
}
pub fn export_to_path<P: AsRef<Path>>(&self, path: P) -> Result<GpkgMbTilesStats, GpkgError> {
let conn = Connection::open(path.as_ref())
.map_err(|e| GpkgError::MbTilesExportError(e.to_string()))?;
self.export_to_connection(&conn)
}
pub fn export_to_connection(&self, conn: &Connection) -> Result<GpkgMbTilesStats, GpkgError> {
create_schema(conn)?;
let reader = TilePyramidReader::open(self.gpkg, &self.table_name)?;
let zoom_levels = reader.zoom_levels();
if zoom_levels.is_empty() {
let metadata_keys = write_metadata(conn, &self.table_name, 0, 0, "png")?;
return Ok(GpkgMbTilesStats {
tiles_written: 0,
min_zoom: 0,
max_zoom: 0,
bytes_written: 0,
metadata_keys,
format: "png".to_string(),
});
}
let min_zoom = zoom_levels[0];
let max_zoom = zoom_levels[zoom_levels.len() - 1];
let mut insert_stmt = conn
.prepare(
"INSERT OR REPLACE INTO tiles \
(zoom_level, tile_column, tile_row, tile_data) \
VALUES (?1, ?2, ?3, ?4)",
)
.map_err(|e| GpkgError::MbTilesExportError(e.to_string()))?;
let mut tiles_written: u64 = 0;
let mut bytes_written: u64 = 0;
let mut detected_format: Option<String> = None;
for zoom in &zoom_levels {
let matrix: &TileMatrix = match reader.tile_matrix(*zoom) {
Some(m) => m,
None => continue,
};
let cols = matrix.matrix_width;
let rows = matrix.matrix_height;
for col in 0..cols {
for gpkg_row in 0..rows {
let blob = match reader.get_tile(*zoom, col, gpkg_row)? {
Some(b) if !b.is_empty() => b,
_ => continue, };
if detected_format.is_none() {
detected_format = Some(detect_format_from_blob(&blob).to_string());
}
let tms_row = xyz_to_tms_row(*zoom, gpkg_row);
bytes_written += blob.len() as u64;
insert_stmt
.execute(params![*zoom, col, tms_row, blob.as_slice()])
.map_err(|e| GpkgError::MbTilesExportError(e.to_string()))?;
tiles_written += 1;
}
}
}
drop(insert_stmt);
let format = detected_format.unwrap_or_else(|| "png".to_string());
let metadata_keys = write_metadata(conn, &self.table_name, min_zoom, max_zoom, &format)?;
Ok(GpkgMbTilesStats {
tiles_written,
min_zoom,
max_zoom,
bytes_written,
metadata_keys,
format,
})
}
}
pub fn create_schema(conn: &Connection) -> Result<(), GpkgError> {
conn.execute_batch(
"
PRAGMA journal_mode=OFF;
PRAGMA synchronous=OFF;
CREATE TABLE IF NOT EXISTS metadata (
name TEXT NOT NULL,
value TEXT
);
CREATE TABLE IF NOT EXISTS tiles (
zoom_level INTEGER NOT NULL,
tile_column INTEGER NOT NULL,
tile_row INTEGER NOT NULL,
tile_data BLOB NOT NULL,
PRIMARY KEY (zoom_level, tile_column, tile_row)
) WITHOUT ROWID;
",
)
.map_err(|e| GpkgError::MbTilesExportError(e.to_string()))
}
fn write_metadata(
conn: &Connection,
table_name: &str,
min_zoom: u32,
max_zoom: u32,
format: &str,
) -> Result<usize, GpkgError> {
let rows: &[(&str, String)] = &[
("name", table_name.to_string()),
("type", "overlay".to_string()),
("version", "1".to_string()),
(
"description",
format!("Exported from GeoPackage table '{table_name}'"),
),
("format", format.to_string()),
("minzoom", min_zoom.to_string()),
("maxzoom", max_zoom.to_string()),
];
for (k, v) in rows {
conn.execute(
"INSERT INTO metadata (name, value) VALUES (?1, ?2)",
params![k, v],
)
.map_err(|e| GpkgError::MbTilesExportError(e.to_string()))?;
}
Ok(rows.len())
}
pub fn detect_format_from_blob(blob: &[u8]) -> &'static str {
if blob.starts_with(b"\x89PNG") {
"png"
} else if blob.starts_with(b"\xff\xd8\xff") {
"jpg"
} else if blob.starts_with(b"RIFF") && blob.get(8..12) == Some(b"WEBP") {
"webp"
} else {
"png"
}
}
pub fn xyz_to_tms_row(zoom: u32, xyz_row: u32) -> u32 {
(1u32 << zoom).saturating_sub(1).saturating_sub(xyz_row)
}
#[cfg(test)]
mod unit_tests {
use super::{detect_format_from_blob, xyz_to_tms_row};
#[test]
fn test_xyz_to_tms_row_zoom0() {
assert_eq!(xyz_to_tms_row(0, 0), 0);
}
#[test]
fn test_xyz_to_tms_row_zoom1() {
assert_eq!(xyz_to_tms_row(1, 0), 1);
assert_eq!(xyz_to_tms_row(1, 1), 0);
}
#[test]
fn test_xyz_to_tms_row_zoom10() {
assert_eq!(xyz_to_tms_row(10, 500), 523);
}
#[test]
fn test_detect_format_png() {
let blob = b"\x89PNG\r\n\x1a\n";
assert_eq!(detect_format_from_blob(blob), "png");
}
#[test]
fn test_detect_format_jpeg() {
let blob = b"\xff\xd8\xff\xe0";
assert_eq!(detect_format_from_blob(blob), "jpg");
}
#[test]
fn test_detect_format_webp() {
let blob = b"RIFF\x00\x00\x00\x00WEBP";
assert_eq!(detect_format_from_blob(blob), "webp");
}
#[test]
fn test_detect_format_unknown_falls_back_to_png() {
let blob = b"\x00\x01\x02\x03";
assert_eq!(detect_format_from_blob(blob), "png");
}
}