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}