Skip to content

Commit 180017f

Browse files
committed
feat(mbtiles): add pack and unpack subcommands
1 parent 6e870e5 commit 180017f

File tree

3 files changed

+267
-2
lines changed

3 files changed

+267
-2
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mbtiles/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ tilejson.workspace = true
4848
tokio = { workspace = true, features = ["rt-multi-thread"] }
4949
xxhash-rust.workspace = true
5050

51+
walkdir = "2.5.0"
52+
flate2.workspace = true
5153
[dev-dependencies]
5254
# For testing, might as well use the same async framework as the Martin itself
5355
actix-rt.workspace = true

mbtiles/src/bin/mbtiles.rs

Lines changed: 263 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
use std::io::{Read, Write};
12
use std::path::{Path, PathBuf};
23

34
use clap::builder::Styles;
45
use clap::builder::styling::AnsiColor;
56
use clap::{Parser, Subcommand};
7+
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
8+
use futures::TryStreamExt;
69
use log::error;
710
use mbtiles::{
8-
AggHashType, CopyDuplicateMode, CopyType, IntegrityCheckType, MbtResult, MbtTypeCli, Mbtiles,
9-
MbtilesCopier, PatchTypeCli, UpdateZoomType, apply_patch,
11+
AggHashType, CopyDuplicateMode, CopyType, IntegrityCheckType, MbtResult, MbtType, MbtTypeCli,
12+
Mbtiles, MbtilesCopier, PatchTypeCli, UpdateZoomType, apply_patch, create_flat_tables,
13+
create_metadata_table,
1014
};
1115
use tilejson::Bounds;
16+
use walkdir::WalkDir;
1217

1318
/// Defines the styles used for the CLI help output.
1419
const HELP_STYLES: Styles = Styles::styled()
@@ -104,6 +109,38 @@ enum Commands {
104109
#[arg(long, value_enum)]
105110
agg_hash: Option<AggHashType>,
106111
},
112+
/// Pack a directory tree of tiles into an MBTiles file
113+
#[command(name = "pack")]
114+
Pack {
115+
/// directory to read
116+
input_directory: PathBuf,
117+
/// MBTiles file to write
118+
output_file: PathBuf,
119+
/// Tile ID scheme for input directory
120+
#[arg(long, value_enum, default_value = "xyz")]
121+
scheme: TileScheme,
122+
},
123+
/// Unpack an MBTiles file into a directory tree of tiles
124+
#[command(name = "unpack")]
125+
Unpack {
126+
/// MBTiles file to read
127+
input_file: PathBuf,
128+
/// directory to write
129+
output_directory: PathBuf,
130+
/// Tile ID scheme for output directory
131+
#[arg(long, value_enum, default_value = "xyz")]
132+
scheme: TileScheme,
133+
},
134+
}
135+
136+
#[derive(Clone, Copy, PartialEq, Debug, clap::ValueEnum)]
137+
enum TileScheme {
138+
/// XYZ (aka. "slippy map") scheme where Y=0 is at the top
139+
#[value(name = "xyz")]
140+
Xyz,
141+
/// TMS scheme where Y=0 is at the bottom
142+
#[value(name = "tms")]
143+
Tms,
107144
}
108145

109146
#[allow(clippy::doc_markdown)]
@@ -300,6 +337,20 @@ async fn main_int() -> anyhow::Result<()> {
300337
println!("MBTiles file summary for {mbt}");
301338
println!("{}", mbt.summary(&mut conn).await?);
302339
}
340+
Commands::Pack {
341+
input_directory,
342+
output_file,
343+
scheme,
344+
} => {
345+
pack(&input_directory, &output_file, scheme).await?;
346+
}
347+
Commands::Unpack {
348+
input_file,
349+
output_directory,
350+
scheme,
351+
} => {
352+
unpack(&input_file, &output_directory, scheme).await?;
353+
}
303354
}
304355

305356
Ok(())
@@ -332,6 +383,216 @@ async fn meta_set_value(file: &Path, key: &str, value: Option<&str>) -> MbtResul
332383
}
333384
}
334385

386+
async fn pack(
387+
input_directory: &Path,
388+
output_file: &Path,
389+
scheme: TileScheme,
390+
) -> anyhow::Result<()> {
391+
if !input_directory.exists() {
392+
anyhow::bail!(
393+
"Input directory does not exist: {}",
394+
input_directory.display()
395+
);
396+
}
397+
if !input_directory.is_dir() {
398+
anyhow::bail!(
399+
"Input path is not a directory: {}",
400+
input_directory.display()
401+
);
402+
}
403+
404+
let mbt = Mbtiles::new(output_file)?;
405+
let mut conn = mbt.open_or_new().await?;
406+
407+
create_metadata_table(&mut conn).await?;
408+
create_flat_tables(&mut conn).await?;
409+
410+
let walker = WalkDir::new(input_directory);
411+
let entries = walker.into_iter().filter_entry(|entry| {
412+
let should_include = if entry.file_type().is_dir() {
413+
// skip directories except the root unless they have numeric names
414+
entry.depth() == 0
415+
|| entry
416+
.file_name()
417+
.to_str()
418+
.is_some_and(|s| s.parse::<u32>().is_ok())
419+
} else {
420+
// skip files that do not have a numeric basename
421+
entry
422+
.file_name()
423+
.to_str()
424+
.and_then(|s| s.split('.').next().map(|b| b.parse::<u32>().is_ok()))
425+
.unwrap_or(false)
426+
};
427+
428+
if !should_include {
429+
log::info!(
430+
"Skipping {}{}",
431+
entry.path().display(),
432+
if entry.file_type().is_dir() { "/" } else { "" }
433+
);
434+
}
435+
436+
should_include
437+
});
438+
439+
let mut format: Option<String> = None;
440+
let mut compress = false;
441+
442+
for entry in entries {
443+
let Some(entry) = entry.ok() else {
444+
continue;
445+
};
446+
447+
let path_components: Vec<_> = entry.path().iter().skip(1).collect();
448+
let coords: Vec<u32> = path_components
449+
.iter()
450+
.filter_map(|c| {
451+
c.to_str()
452+
.and_then(|s| s.split('.').next())
453+
.and_then(|basename| basename.parse().ok())
454+
})
455+
.collect();
456+
457+
if let [z, x, y] = coords.as_slice() {
458+
let (z, x, y) = (u8::try_from(*z)?, *x, *y);
459+
// TODO: set metadata format from extension of first file, and check that
460+
// subsequent files have the same extension
461+
if format.is_none() {
462+
format = match entry.path().extension().and_then(|s| s.to_str()) {
463+
Some("pbf" | "mvt") => Some("pbf".to_string()),
464+
Some("jpg" | "jpeg") => Some("jpg".to_string()),
465+
Some("webp") => Some("webp".to_string()),
466+
Some("png") => Some("png".to_string()),
467+
_ => {
468+
anyhow::bail!("Unsupported file extension: {}", entry.path().display());
469+
}
470+
};
471+
472+
if format == Some("pbf".to_string()) {
473+
compress = true;
474+
}
475+
}
476+
477+
let data = std::fs::read(entry.path())?;
478+
479+
let encoded = if compress {
480+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
481+
encoder.write_all(&data)?;
482+
encoder.finish()?
483+
} else {
484+
data
485+
};
486+
487+
// Convert from TMS to XYZ if necessary
488+
let y = match scheme {
489+
TileScheme::Xyz => mbtiles::invert_y_value(z, y),
490+
TileScheme::Tms => y,
491+
};
492+
493+
mbt.insert_tiles(
494+
&mut conn,
495+
MbtType::Flat,
496+
CopyDuplicateMode::Abort,
497+
&[(z, x, y, encoded)],
498+
)
499+
.await?;
500+
}
501+
}
502+
503+
if let Some(format) = format {
504+
mbt.set_metadata_value(&mut conn, "format", format).await?;
505+
}
506+
507+
// TODO: set minzoom, maxzoom, and bbox?
508+
// either compute them, or possibly read them from {input_directory}/metadata.json
509+
510+
Ok(())
511+
}
512+
513+
async fn unpack(
514+
input_file: &Path,
515+
output_directory: &Path,
516+
scheme: TileScheme,
517+
) -> anyhow::Result<()> {
518+
if !input_file.exists() {
519+
anyhow::bail!("Input file does not exist: {}", input_file.display());
520+
}
521+
522+
let mbt = Mbtiles::new(input_file)?;
523+
let mut conn = mbt.open_readonly().await?;
524+
525+
// Get the format from metadata to determine file extension and compression
526+
let format = mbt.get_metadata_value(&mut conn, "format").await?;
527+
let (extension, decompress) = match format.as_deref() {
528+
Some("pbf") => ("mvt", true),
529+
Some("jpg") => ("jpg", false),
530+
Some("png") => ("png", false),
531+
Some("webp") => ("webp", false),
532+
Some(unknown) => {
533+
anyhow::bail!("Unknown format in MBTiles metadata: {}", unknown);
534+
}
535+
None => anyhow::bail!("No format specified in MBTiles metadata"),
536+
};
537+
538+
// Create output directory if it doesn't exist
539+
std::fs::create_dir_all(output_directory)?;
540+
541+
// Query all tiles from the database
542+
let mut tiles = sqlx::query!("SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles ORDER BY zoom_level, tile_column, tile_row")
543+
.fetch(&mut conn);
544+
545+
while let Some(tile) = tiles.try_next().await? {
546+
let Some(z) = tile.zoom_level else {
547+
log::warn!("Skipping tile with missing zoom level");
548+
continue;
549+
};
550+
let Some(x) = tile.tile_column else {
551+
log::warn!("Skipping tile with missing tile column");
552+
continue;
553+
};
554+
let Some(y) = tile.tile_row else {
555+
log::warn!("Skipping tile with missing tile row");
556+
continue;
557+
};
558+
let Some(tile_data) = tile.tile_data else {
559+
log::warn!("Skipping tile at {z}/{x}/{y} with missing data");
560+
continue;
561+
};
562+
563+
let z = u8::try_from(z)?;
564+
let x = u32::try_from(x)?;
565+
let y = u32::try_from(y)?;
566+
567+
// Convert from TMS to XYZ if necessary
568+
let y = match scheme {
569+
TileScheme::Xyz => mbtiles::invert_y_value(z, y),
570+
TileScheme::Tms => y,
571+
};
572+
573+
let data = if decompress {
574+
let mut decoder = GzDecoder::new(&tile_data[..]);
575+
let mut decompressed = Vec::new();
576+
decoder.read_to_end(&mut decompressed)?;
577+
decompressed
578+
} else {
579+
tile_data
580+
};
581+
582+
// Create directory structure: output_directory/z/x/
583+
let tile_dir = output_directory.join(z.to_string()).join(x.to_string());
584+
std::fs::create_dir_all(&tile_dir)?;
585+
586+
// Write tile file: output_directory/z/x/y.ext
587+
let tile_file = tile_dir.join(format!("{y}.{extension}"));
588+
std::fs::write(&tile_file, &data)?;
589+
}
590+
591+
// TODO: write metadata.json file with minzoom, maxzoom, bounds, etc?
592+
593+
Ok(())
594+
}
595+
335596
#[cfg(test)]
336597
mod tests {
337598
use std::path::PathBuf;

0 commit comments

Comments
 (0)