|
| 1 | +use std::io::{Read, Write}; |
1 | 2 | use std::path::{Path, PathBuf}; |
2 | 3 |
|
3 | 4 | use clap::builder::Styles; |
4 | 5 | use clap::builder::styling::AnsiColor; |
5 | 6 | use clap::{Parser, Subcommand}; |
| 7 | +use flate2::{Compression, read::GzDecoder, write::GzEncoder}; |
| 8 | +use futures::TryStreamExt; |
6 | 9 | use log::error; |
7 | 10 | 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, |
10 | 14 | }; |
11 | 15 | use tilejson::Bounds; |
| 16 | +use walkdir::WalkDir; |
12 | 17 |
|
13 | 18 | /// Defines the styles used for the CLI help output. |
14 | 19 | const HELP_STYLES: Styles = Styles::styled() |
@@ -104,6 +109,38 @@ enum Commands { |
104 | 109 | #[arg(long, value_enum)] |
105 | 110 | agg_hash: Option<AggHashType>, |
106 | 111 | }, |
| 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, |
107 | 144 | } |
108 | 145 |
|
109 | 146 | #[allow(clippy::doc_markdown)] |
@@ -300,6 +337,20 @@ async fn main_int() -> anyhow::Result<()> { |
300 | 337 | println!("MBTiles file summary for {mbt}"); |
301 | 338 | println!("{}", mbt.summary(&mut conn).await?); |
302 | 339 | } |
| 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 | + } |
303 | 354 | } |
304 | 355 |
|
305 | 356 | Ok(()) |
@@ -332,6 +383,216 @@ async fn meta_set_value(file: &Path, key: &str, value: Option<&str>) -> MbtResul |
332 | 383 | } |
333 | 384 | } |
334 | 385 |
|
| 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 | + |
335 | 596 | #[cfg(test)] |
336 | 597 | mod tests { |
337 | 598 | use std::path::PathBuf; |
|
0 commit comments