#![cfg(feature = "mbtiles-export")]
use std::path::Path;
use oxisql_core::{Connection, ToSqlValue};
use oxisql_sqlite_compat::SqliteConnection;
use crate::error::GpkgError;
use crate::gpkg::GeoPackage;
use crate::tile_matrix::TileMatrix;
use crate::tile_pyramid::TilePyramidReader;
fn mbt_err(e: impl std::fmt::Display) -> GpkgError {
GpkgError::MbTilesExportError(e.to_string())
}
struct MbConn {
conn: SqliteConnection,
runtime: tokio::runtime::Runtime,
}
impl MbConn {
fn open<P: AsRef<Path>>(path: P) -> Result<Self, GpkgError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| mbt_err(format!("tokio runtime build failed: {e}")))?;
let path_str = path.as_ref().to_string_lossy().into_owned();
let conn = runtime
.block_on(SqliteConnection::open(&path_str))
.map_err(mbt_err)?;
Ok(Self { conn, runtime })
}
fn exec(&self, sql: &str, params: &[&dyn ToSqlValue]) -> Result<u64, GpkgError> {
self.runtime
.block_on(self.conn.execute(sql, params))
.map_err(mbt_err)
}
fn exec_batch(&self, sql: &str) -> Result<(), GpkgError> {
self.runtime
.block_on(self.conn.execute_batch(sql))
.map_err(mbt_err)?;
Ok(())
}
}
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 db = MbConn::open(path.as_ref())?;
let stats = self.export_to_conn(&db)?;
db.exec_batch("PRAGMA wal_checkpoint")?;
Ok(stats)
}
fn export_to_conn(&self, db: &MbConn) -> Result<GpkgMbTilesStats, GpkgError> {
create_schema(db)?;
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(db, &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 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;
let zoom_i = *zoom as i64;
let col_i = col as i64;
let tms_row_i = tms_row as i64;
let blob_vec: Vec<u8> = blob;
db.exec(
"INSERT OR REPLACE INTO tiles \
(zoom_level, tile_column, tile_row, tile_data) \
VALUES ($1, $2, $3, $4)",
&[&zoom_i, &col_i, &tms_row_i, &blob_vec],
)?;
tiles_written += 1;
}
}
}
let format = detected_format.unwrap_or_else(|| "png".to_string());
let metadata_keys = write_metadata(db, &self.table_name, min_zoom, max_zoom, &format)?;
Ok(GpkgMbTilesStats {
tiles_written,
min_zoom,
max_zoom,
bytes_written,
metadata_keys,
format,
})
}
}
fn create_schema(db: &MbConn) -> Result<(), GpkgError> {
db.exec_batch(
"
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)
);
",
)
}
fn write_metadata(
db: &MbConn,
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 {
let k_ref: &str = k;
let v_ref: &str = v.as_str();
db.exec(
"INSERT INTO metadata (name, value) VALUES ($1, $2)",
&[&k_ref, &v_ref],
)?;
}
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");
}
}