Skip to main content

oxigdal_pmtiles/
compact.rs

1//! PMTiles v3 archive compaction.
2//!
3//! Compaction rebuilds a PMTiles archive from scratch, removing all gaps that
4//! accumulate from deleted or replaced tiles.  The process reads every logical
5//! tile from the source archive, re-packs the tile data into a fresh
6//! [`PmTilesBuilder`], and re-encodes the directory.
7//!
8//! # Gap sources
9//! - **Deleted tiles** — directory entries removed in a previous write pass
10//!   leave their payload bytes unreachable in the data section.
11//! - **Replaced tiles** — when a tile is overwritten, the old payload at the
12//!   original offset is no longer referenced, fragmenting the data section.
13//! - **Fragmented run-length entries** — large run-length blocks that are
14//!   partially superseded leave gaps between the surviving logical tiles.
15//!
16//! # Algorithm
17//! 1. Parse the source archive via [`PmTilesReader`].
18//! 2. Call [`PmTilesReader::enumerate_tiles`] to obtain one [`TileInfo`] per
19//!    logical tile in tile-ID order.
20//! 3. For each tile, slice `bytes[tile_data_offset + data_offset .. + data_length]`
21//!    to obtain the raw payload.
22//! 4. When [`CompactOptions::deduplicate`] is `true`, track already-seen
23//!    content via a `HashMap<Vec<u8>, u64>` (bytes → first tile_id) and note
24//!    duplicates for statistics; the builder's own FNV-1a hash ensures the
25//!    physical payload is shared in the output regardless.
26//! 5. Feed all tiles into a fresh [`PmTilesBuilder`] via `add_tile_by_id`.
27//! 6. Propagate header fields (tile type, zoom, bounds, centre) from the source
28//!    when [`CompactOptions::preserve_metadata`] is `true`.
29//! 7. Call `builder.build()` to produce the compacted archive.
30
31use std::collections::HashMap;
32
33use crate::error::PmTilesError;
34use crate::header::{PmTilesHeader, TileType};
35use crate::pmtiles::{PmTilesReader, TileInfo};
36use crate::writer::PmTilesBuilder;
37
38// ---------------------------------------------------------------------------
39// CompactOptions
40// ---------------------------------------------------------------------------
41
42/// Options controlling how [`compact_archive`] and
43/// [`compact_archive_with_stats`] operate.
44#[derive(Debug, Clone)]
45pub struct CompactOptions {
46    /// When `true`, tiles with identical byte content share a single physical
47    /// payload in the output archive.  The builder already deduplicates by
48    /// FNV-1a hash; this flag additionally tracks duplicate dispatches so that
49    /// [`CompactStats::tiles_deduplicated`] is accurate.
50    ///
51    /// When `false`, every tile is dispatched to the builder individually,
52    /// but the builder still deduplicates by content hash unless disabled.
53    ///
54    /// Default: `true`.
55    pub deduplicate: bool,
56
57    /// When `true`, tiles are fed to the builder in ascending tile-ID order
58    /// (Hilbert-curve order), matching the PMTiles v3 clustered layout
59    /// recommendation.  [`PmTilesReader::enumerate_tiles`] already returns
60    /// tiles sorted by tile ID, so enabling this flag is a no-op in practice;
61    /// it exists to document *intent* and allow future optimisations.
62    ///
63    /// Default: `true`.
64    pub sort_by_tile_id: bool,
65
66    /// When `true`, the following header fields from the source archive are
67    /// propagated to the output archive:
68    /// - tile type
69    /// - zoom range (`min_zoom`, `max_zoom`)
70    /// - geographic bounding box (min/max lon/lat)
71    /// - centre longitude, latitude, and zoom
72    ///
73    /// When `false`, the builder uses its own defaults (tile type `Unknown`,
74    /// zoom range 0–0, world bounding box).
75    ///
76    /// Default: `true`.
77    pub preserve_metadata: bool,
78}
79
80impl Default for CompactOptions {
81    fn default() -> Self {
82        Self {
83            deduplicate: true,
84            sort_by_tile_id: true,
85            preserve_metadata: true,
86        }
87    }
88}
89
90// ---------------------------------------------------------------------------
91// CompactStats
92// ---------------------------------------------------------------------------
93
94/// Quantitative summary of a single compaction run.
95#[derive(Debug, Clone)]
96pub struct CompactStats {
97    /// Number of logical tiles read from the source archive (after run-length
98    /// expansion).  Equals [`PmTilesReader::enumerate_tiles`] length.
99    pub tiles_read: usize,
100
101    /// Number of tiles dispatched to the builder.  When deduplication is
102    /// enabled, tiles whose content was already seen are still dispatched
103    /// (the builder deduplicates the physical payload) but are counted under
104    /// `tiles_deduplicated`.
105    pub tiles_written: usize,
106
107    /// Number of tiles whose byte content was identical to a previously-seen
108    /// tile.  Only meaningful when [`CompactOptions::deduplicate`] is `true`;
109    /// otherwise always `0`.
110    pub tiles_deduplicated: usize,
111
112    /// Total byte length of the source archive.
113    pub bytes_before: usize,
114
115    /// Total byte length of the compacted output archive.
116    pub bytes_after: usize,
117
118    /// Percentage reduction in archive size:
119    /// `(bytes_before - bytes_after) / bytes_before * 100.0`.
120    /// Returns `0.0` when `bytes_before == 0`.
121    pub reduction_pct: f64,
122}
123
124impl CompactStats {
125    fn new(
126        tiles_read: usize,
127        tiles_written: usize,
128        tiles_deduplicated: usize,
129        bytes_before: usize,
130        bytes_after: usize,
131    ) -> Self {
132        let reduction_pct = if bytes_before == 0 {
133            0.0
134        } else {
135            let saved = bytes_before.saturating_sub(bytes_after) as f64;
136            saved / bytes_before as f64 * 100.0
137        };
138        Self {
139            tiles_read,
140            tiles_written,
141            tiles_deduplicated,
142            bytes_before,
143            bytes_after,
144            reduction_pct,
145        }
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Internal helpers
151// ---------------------------------------------------------------------------
152
153/// Extract the raw tile payload slice from the source archive bytes.
154///
155/// `tile_data_offset` is the absolute byte position of the tile-data section
156/// within `archive_bytes`; `info.data_offset` is relative to that section.
157///
158/// # Errors
159/// Returns [`PmTilesError::InvalidFormat`] when the computed range falls
160/// outside the archive bounds.
161fn extract_tile_bytes<'a>(
162    archive_bytes: &'a [u8],
163    tile_data_offset: u64,
164    info: &TileInfo,
165) -> Result<&'a [u8], PmTilesError> {
166    let abs_start = (tile_data_offset + info.data_offset) as usize;
167    let abs_end = abs_start + info.data_length as usize;
168    if abs_end > archive_bytes.len() {
169        return Err(PmTilesError::InvalidFormat(format!(
170            "Tile data for tile_id={} at [{abs_start}..{abs_end}) is out of bounds \
171             (archive is {} bytes)",
172            info.tile_id,
173            archive_bytes.len()
174        )));
175    }
176    Ok(&archive_bytes[abs_start..abs_end])
177}
178
179/// Copy geographic and centre metadata from `src` header onto `builder`.
180fn apply_source_metadata(builder: &mut PmTilesBuilder, src: &PmTilesHeader) {
181    builder.set_bounds(src.min_lon(), src.min_lat(), src.max_lon(), src.max_lat());
182    builder.set_center(src.center_lon(), src.center_lat(), src.center_zoom);
183}
184
185/// Compute the zoom range actually present in `tiles`.
186///
187/// Returns `(0, 0)` for an empty tile set.
188fn zoom_range_of(tiles: &[TileInfo]) -> (u8, u8) {
189    if tiles.is_empty() {
190        return (0, 0);
191    }
192    let mn = tiles.iter().map(|t| t.z).min().unwrap_or(0);
193    let mx = tiles.iter().map(|t| t.z).max().unwrap_or(0);
194    (mn, mx)
195}
196
197/// Core compaction logic shared by all public entry points.
198fn compact_inner(
199    bytes: &[u8],
200    options: &CompactOptions,
201) -> Result<(Vec<u8>, CompactStats), PmTilesError> {
202    let bytes_before = bytes.len();
203
204    // -----------------------------------------------------------------------
205    // Step 1: Parse source archive and enumerate all logical tiles.
206    // `enumerate_tiles` expands run-length entries and returns tiles sorted
207    // by tile_id.
208    // -----------------------------------------------------------------------
209    let reader = PmTilesReader::from_bytes(bytes.to_vec())?;
210    let tiles = reader.enumerate_tiles()?;
211    let tiles_read = tiles.len();
212    let tile_data_offset = reader.header.tile_data_offset;
213    let src_header = &reader.header;
214
215    // -----------------------------------------------------------------------
216    // Step 2: Determine zoom range and tile type for the output builder.
217    // -----------------------------------------------------------------------
218    let (effective_min_zoom, effective_max_zoom) = if options.preserve_metadata {
219        (src_header.min_zoom, src_header.max_zoom)
220    } else {
221        zoom_range_of(&tiles)
222    };
223
224    let tile_type = if options.preserve_metadata {
225        src_header.tile_type.clone()
226    } else {
227        TileType::Unknown
228    };
229
230    // -----------------------------------------------------------------------
231    // Step 3: Construct a fresh builder.
232    // -----------------------------------------------------------------------
233    let mut builder = PmTilesBuilder::new(tile_type, effective_min_zoom, effective_max_zoom);
234    if options.preserve_metadata {
235        apply_source_metadata(&mut builder, src_header);
236    }
237
238    // -----------------------------------------------------------------------
239    // Step 4: Feed tiles into the builder.
240    //
241    // `content_map` tracks already-seen raw bytes (keyed by the byte vector)
242    // purely for statistics when `deduplicate == true`.  The builder always
243    // deduplicates by FNV-1a hash regardless of this flag, but `content_map`
244    // lets us count how many tiles were "duplicate" from a logical perspective.
245    //
246    // All tiles are dispatched to the builder (we never skip a tile_id from
247    // the directory), so that every tile_id remains addressable in the output.
248    // The builder's deduplication means duplicate payloads share one physical
249    // copy in the tile-data section.
250    // -----------------------------------------------------------------------
251    let mut content_map: HashMap<Vec<u8>, u64> = HashMap::new();
252    let mut tiles_deduplicated = 0usize;
253
254    for info in &tiles {
255        let raw = extract_tile_bytes(bytes, tile_data_offset, info)?;
256
257        if options.deduplicate {
258            if content_map.contains_key(raw) {
259                tiles_deduplicated += 1;
260            } else {
261                content_map.insert(raw.to_vec(), info.tile_id);
262            }
263        }
264
265        // Always add the tile to the builder so every tile_id is represented.
266        builder.add_tile_by_id(info.tile_id, raw)?;
267    }
268
269    let tiles_written = tiles_read;
270
271    // -----------------------------------------------------------------------
272    // Step 5: Build the compacted archive.
273    // -----------------------------------------------------------------------
274    let compacted = builder.build()?;
275    let bytes_after = compacted.len();
276
277    let stats = CompactStats::new(
278        tiles_read,
279        tiles_written,
280        tiles_deduplicated,
281        bytes_before,
282        bytes_after,
283    );
284
285    Ok((compacted, stats))
286}
287
288// ---------------------------------------------------------------------------
289// Public API
290// ---------------------------------------------------------------------------
291
292/// Compact a PMTiles v3 archive in memory.
293///
294/// Reads all logical tiles from `bytes`, rebuilds the archive with fresh
295/// contiguous offsets, removes all unreachable gaps in the tile-data section,
296/// and re-encodes the directory.  The output is a valid PMTiles v3 archive.
297///
298/// # Gap removal
299/// Gaps arise from:
300/// - Deleted tiles (directory entries pointing to stale payload bytes).
301/// - Replaced tiles (new data written at different offsets; old bytes orphaned).
302/// - Fragmented run-length entries after partial updates.
303///
304/// After compaction the tile-data section contains only reachable payloads;
305/// the archive is typically smaller than or equal to the source.
306///
307/// # Errors
308/// - [`PmTilesError::InvalidFormat`] when the source archive is malformed.
309/// - [`PmTilesError::UnsupportedVersion`] when the source is not PMTiles v3.
310pub fn compact_archive(bytes: &[u8], options: &CompactOptions) -> Result<Vec<u8>, PmTilesError> {
311    let (compacted, _stats) = compact_inner(bytes, options)?;
312    Ok(compacted)
313}
314
315/// Compact with default options: deduplicate, sort by tile ID, preserve metadata.
316///
317/// Equivalent to `compact_archive(bytes, &CompactOptions::default())`.
318///
319/// # Errors
320/// Propagates errors from [`compact_archive`].
321pub fn compact_archive_default(bytes: &[u8]) -> Result<Vec<u8>, PmTilesError> {
322    compact_archive(bytes, &CompactOptions::default())
323}
324
325/// Compact a PMTiles v3 archive and return both the compacted bytes and run
326/// statistics.
327///
328/// The [`CompactStats`] includes byte counts before and after, tile counts,
329/// deduplication counts, and a percentage reduction.  This is useful for
330/// monitoring, logging, or deciding whether to replace the original archive.
331///
332/// # Errors
333/// Propagates errors from [`compact_archive`].
334pub fn compact_archive_with_stats(
335    bytes: &[u8],
336    options: &CompactOptions,
337) -> Result<(Vec<u8>, CompactStats), PmTilesError> {
338    compact_inner(bytes, options)
339}