use std::path::{Path, PathBuf};
use std::sync::Once;
use futures::StreamExt as _;
use martin_tile_utils::{Encoding, Format, TileInfo, decode_gzip, decode_zlib, encode_gzip};
use walkdir::WalkDir;
use crate::{
CopyDuplicateMode, MbtError, MbtResult, MbtType, Mbtiles, UpdateZoomType, create_flat_tables,
create_metadata_table, invert_y_value,
};
type TileCandidate = (PathBuf, (u8, u32, u32), Format);
const PACK_BATCH_SIZE: usize = 1000;
const PACK_READ_CONCURRENCY: usize = 64;
const UNPACK_WRITE_CONCURRENCY: usize = 64;
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum TileScheme {
#[cfg_attr(feature = "cli", value(name = "xyz"))]
#[default]
Xyz,
#[cfg_attr(feature = "cli", value(name = "tms"))]
Tms,
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum PackCompression {
#[cfg_attr(feature = "cli", value(name = "auto"))]
#[default]
Auto,
#[cfg_attr(feature = "cli", value(name = "none"))]
None,
#[cfg_attr(feature = "cli", value(name = "gzip", alias = "gz"))]
Gzip,
}
fn walk_tile_candidates(input_directory: &Path) -> impl Iterator<Item = MbtResult<TileCandidate>> {
let warned_about_dirs = Once::new();
let warned_about_files = Once::new();
WalkDir::new(input_directory)
.follow_links(true)
.into_iter()
.filter_entry(move |entry| {
if entry.file_type().is_dir() {
let keep = entry.depth() == 0
|| entry
.file_name()
.to_str()
.is_some_and(|s| s.parse::<u32>().is_ok());
if !keep {
warned_about_dirs.call_once(|| {
tracing::info!(
"Skipping {} and similarly-named directories; expected numeric `z`/`x` directory names",
entry.path().display()
);
});
}
keep
} else {
let keep = entry
.path()
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.parse::<u32>().is_ok());
if !keep {
warned_about_files.call_once(|| {
tracing::info!(
"Skipping {} and similarly-named files; expected numeric `y.<ext>` file names",
entry.path().display()
);
});
}
keep
}
})
.filter_map(|entry| {
let entry = match entry {
Ok(entry) => entry,
Err(e) => return Some(Err(std::io::Error::from(e).into())),
};
if entry.file_type().is_dir() {
return None;
}
let path = entry.path();
let coords = tile_coords(path)?;
let Some(detected) = path
.extension()
.and_then(|e| e.to_str())
.and_then(Format::parse)
else {
return Some(Err(MbtError::UnsupportedFileExtension(path.to_path_buf())));
};
Some(Ok((path.to_path_buf(), coords, detected)))
})
}
pub async fn pack(
input_directory: &Path,
output_file: &Path,
scheme: TileScheme,
compression: PackCompression,
) -> MbtResult<()> {
let mbt = Mbtiles::new(output_file)?;
let mut conn = mbt.open_or_new().await?;
create_metadata_table(&mut conn, false).await?;
create_flat_tables(&mut conn, false).await?;
let mut reads = futures::stream::iter(walk_tile_candidates(input_directory))
.map(|candidate| async move {
let (path, (z, x, y), detected) = candidate?;
let data = tokio::fs::read(&path).await?;
let target = match compression {
PackCompression::Auto if detected == Format::Mvt => Encoding::Gzip,
PackCompression::Auto | PackCompression::None => Encoding::Uncompressed,
PackCompression::Gzip => Encoding::Gzip,
};
let encoded = recode_tile(data, target)?;
Ok::<_, MbtError>((z, x, y, detected, path, encoded))
})
.buffered(PACK_READ_CONCURRENCY);
let mut format: Option<Format> = None;
let mut batch: Vec<(u8, u32, u32, Vec<u8>)> = Vec::with_capacity(PACK_BATCH_SIZE);
while let Some(tile) = reads.next().await {
let (z, x, y, detected, path, encoded) = tile?;
match format {
None => format = Some(detected),
Some(f) if f != detected => {
return Err(MbtError::InconsistentTileFormats {
old: f,
new: detected,
path,
});
}
Some(_) => {}
}
let y = match scheme {
TileScheme::Xyz => y,
TileScheme::Tms => invert_y_value(z, y),
};
batch.push((z, x, y, encoded));
if batch.len() >= PACK_BATCH_SIZE {
mbt.insert_tiles(&mut conn, MbtType::Flat, CopyDuplicateMode::Abort, &batch)
.await?;
batch.clear();
}
}
if !batch.is_empty() {
mbt.insert_tiles(&mut conn, MbtType::Flat, CopyDuplicateMode::Abort, &batch)
.await?;
}
if let Some(format) = format {
mbt.set_metadata_value(&mut conn, "format", format.metadata_format_value())
.await?;
}
mbt.update_metadata(&mut conn, UpdateZoomType::Reset)
.await?;
if let Some(bbox) = mbt.summary(&mut conn).await?.bbox {
mbt.set_metadata_value(&mut conn, "bounds", bbox).await?;
}
Ok(())
}
pub async fn unpack(
input_file: &Path,
output_directory: &Path,
scheme: TileScheme,
) -> MbtResult<()> {
if !input_file.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Input file does not exist: {}", input_file.display()),
)
.into());
}
let mbt = Mbtiles::new(input_file)?;
let mut conn = mbt.open_readonly().await?;
let format = mbt.get_metadata_value(&mut conn, "format").await?;
let Some(format_str) = format.as_deref() else {
return Err(MbtError::NoFormatInMetadata(input_file.to_path_buf()));
};
let extension = Format::parse(format_str)
.ok_or_else(|| MbtError::UnknownFormatInMetadata {
format: format_str.to_string(),
path: input_file.to_path_buf(),
})?
.metadata_format_value();
tokio::fs::create_dir_all(output_directory).await?;
let writes = mbt
.stream_tiles(&mut conn)
.map(|tile| async move {
let (coord, data) = tile?;
let Some(data) = data else {
return Ok::<_, MbtError>(());
};
let y = match scheme {
TileScheme::Xyz => coord.y,
TileScheme::Tms => invert_y_value(coord.z, coord.y),
};
let data = if TileInfo::detect(&data).encoding == Encoding::Gzip {
decode_gzip(&data)?
} else {
data
};
let tile_dir = output_directory
.join(coord.z.to_string())
.join(coord.x.to_string());
tokio::fs::create_dir_all(&tile_dir).await?;
tokio::fs::write(tile_dir.join(format!("{y}.{extension}")), &data).await?;
Ok(())
})
.buffer_unordered(UNPACK_WRITE_CONCURRENCY);
futures::pin_mut!(writes);
while let Some(result) = writes.next().await {
result?;
}
Ok(())
}
fn tile_coords(path: &Path) -> Option<(u8, u32, u32)> {
let y = path.file_stem()?.to_str()?.parse::<u32>().ok()?;
let mut dirs = path.ancestors().skip(1);
let x = dirs.next()?.file_name()?.to_str()?.parse::<u32>().ok()?;
let z = dirs.next()?.file_name()?.to_str()?.parse::<u8>().ok()?;
Some((z, x, y))
}
fn recode_tile(data: Vec<u8>, target: Encoding) -> MbtResult<Vec<u8>> {
let current = TileInfo::detect(&data).encoding;
if current == target {
return Ok(data);
}
let plain = match current {
Encoding::Uncompressed | Encoding::Internal => data,
Encoding::Gzip => decode_gzip(&data)?,
Encoding::Zlib => decode_zlib(&data)?,
Encoding::Brotli | Encoding::Zstd => {
return Err(MbtError::CannotRecodeCompressedTile(current));
}
};
match target {
Encoding::Uncompressed => Ok(plain),
Encoding::Gzip => Ok(encode_gzip(&plain)?),
other => Err(MbtError::UnsupportedPackTarget(other)),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::tile_coords;
#[test]
fn test_tile_coords() {
assert_eq!(tile_coords(Path::new("0/0/0.png")), Some((0, 0, 0)));
assert_eq!(
tile_coords(Path::new("any/prefix/3/4/5.pbf")),
Some((3, 4, 5))
);
assert_eq!(tile_coords(Path::new("3/4/5")), Some((3, 4, 5)));
assert_eq!(tile_coords(Path::new("z/4/5.png")), None);
assert_eq!(tile_coords(Path::new("3/x/5.png")), None);
assert_eq!(tile_coords(Path::new("3/4/y.png")), None);
assert_eq!(tile_coords(Path::new("999/4/5.png")), None);
assert_eq!(tile_coords(Path::new("5.png")), None);
}
}