oxigeo_pmtiles/transcode.rs
1//! Re-compression of PMTiles archives between gzip / brotli / zstd formats.
2//!
3//! Currently the [`crate::pmtiles::PmTilesReader`] transparently decompresses
4//! tile payloads via OxiARC, and the [`crate::writer::PmTilesBuilder`] can be
5//! configured to advertise any [`Compression`] in the header. This module
6//! ties those pieces together and provides a one-shot API for converting an
7//! existing PMTiles archive whose tile payloads are compressed with one
8//! algorithm into a new archive whose payloads use a different algorithm.
9//!
10//! # Algorithm
11//! 1. Parse the source archive via [`PmTilesReader`].
12//! 2. Resolve the actual *source* compression — when the caller passes
13//! [`Compression::Unknown`] (the "auto" sentinel), use the header's
14//! `tile_compression` field.
15//! 3. Reject [`Compression::Unknown`] as a target compression.
16//! 4. Enumerate every logical tile via [`PmTilesReader::enumerate_tiles`].
17//! 5. For each tile, slice the raw (still-compressed) bytes out of the source
18//! buffer, decompress with the source codec, recompress with the target
19//! codec at the requested level, and dispatch to a fresh
20//! [`PmTilesBuilder`] via `add_tile_by_id`.
21//! 6. Mirror header metadata (tile type, zoom range, bounds, centre, JSON
22//! metadata) onto the builder so that the output archive remains
23//! semantically equivalent.
24//! 7. Set the builder's `tile_compression` header byte to the target
25//! algorithm so that consumers know how to decode the payloads.
26//! 8. `builder.build()` to assemble the new archive.
27//!
28//! # Identity transcode
29//! When the resolved source and target compression are identical, the raw
30//! tile bytes are copied verbatim without a decompress / recompress round
31//! trip, and counted under [`TranscodeStats::tiles_skipped_identity`].
32//! This is useful for "force a known compression byte without touching the
33//! data" and for unit-testing the identity path.
34
35use crate::error::PmTilesError;
36use crate::header::Compression;
37use crate::pmtiles::{PmTilesReader, TileInfo};
38use crate::writer::PmTilesBuilder;
39
40// ---------------------------------------------------------------------------
41// TranscodeOptions
42// ---------------------------------------------------------------------------
43
44/// Options controlling [`transcode_archive`] and
45/// [`transcode_archive_with_stats`].
46#[derive(Debug, Clone)]
47pub struct TranscodeOptions {
48 /// Source compression algorithm. When set to [`Compression::Unknown`]
49 /// the actual algorithm is auto-detected from the source archive's
50 /// `tile_compression` header field.
51 ///
52 /// Default: [`Compression::Unknown`] (auto-detect).
53 pub from: Compression,
54
55 /// Target compression algorithm. Must NOT be [`Compression::Unknown`];
56 /// the transcoder returns [`PmTilesError::UnsupportedCompression`] if it
57 /// is.
58 ///
59 /// Default: [`Compression::Gzip`].
60 pub to: Compression,
61
62 /// Codec-specific compression level (lower = faster / larger, higher =
63 /// slower / smaller). Interpreted by the target codec:
64 ///
65 /// * Gzip: 0–9 (default 6 when `None`)
66 /// * Brotli: 0–11 (default 6 when `None`)
67 /// * Zstd: ignored — the OxiARC zstd encoder does not currently expose
68 /// a level knob; the default is always used.
69 ///
70 /// Default: `None`.
71 pub level: Option<i32>,
72}
73
74impl Default for TranscodeOptions {
75 fn default() -> Self {
76 Self {
77 from: Compression::Unknown,
78 to: Compression::Gzip,
79 level: None,
80 }
81 }
82}
83
84// ---------------------------------------------------------------------------
85// TranscodeStats
86// ---------------------------------------------------------------------------
87
88/// Quantitative summary of a single transcode run.
89#[derive(Debug, Clone, Copy, Default)]
90pub struct TranscodeStats {
91 /// Number of tiles that were actually decompressed and recompressed.
92 pub tiles_transcoded: u64,
93
94 /// Number of tiles whose payload was passed through verbatim because the
95 /// resolved source and target compression were identical.
96 pub tiles_skipped_identity: u64,
97
98 /// Sum of compressed-on-input tile byte lengths.
99 pub bytes_before: u64,
100
101 /// Sum of compressed-on-output tile byte lengths.
102 pub bytes_after: u64,
103}
104
105impl TranscodeStats {
106 /// Output/input byte ratio. Returns `1.0` when `bytes_before == 0`.
107 pub fn ratio(&self) -> f64 {
108 if self.bytes_before == 0 {
109 1.0
110 } else {
111 self.bytes_after as f64 / self.bytes_before as f64
112 }
113 }
114}
115
116// ---------------------------------------------------------------------------
117// Low-level codec dispatch
118// ---------------------------------------------------------------------------
119
120/// Decompress `data` using the algorithm `c`.
121///
122/// * [`Compression::None`] / [`Compression::Unknown`] — returns a copy of
123/// `data` unmodified (so transcodes can chain through "unknown ⇒ x" without
124/// special-casing).
125///
126/// # Errors
127/// Returns [`PmTilesError::Decompression`] when the underlying OxiARC codec
128/// reports a failure.
129fn decompress_with(data: &[u8], c: Compression) -> Result<Vec<u8>, PmTilesError> {
130 match c {
131 Compression::None | Compression::Unknown => Ok(data.to_vec()),
132 Compression::Gzip => {
133 let mut reader = std::io::Cursor::new(data);
134 oxiarc_archive::gzip::decompress(&mut reader)
135 .map_err(|e| PmTilesError::Decompression(format!("Gzip decompression failed: {e}")))
136 }
137 Compression::Brotli => oxiarc_archive::brotli::decompress(data)
138 .map_err(|e| PmTilesError::Decompression(format!("Brotli decompression failed: {e}"))),
139 Compression::Zstd => oxiarc_archive::zstd::decompress(data)
140 .map_err(|e| PmTilesError::Decompression(format!("Zstd decompression failed: {e}"))),
141 }
142}
143
144/// Compress `data` using algorithm `c` at the optional `level`.
145///
146/// * [`Compression::None`] — returns a copy of `data`.
147/// * [`Compression::Unknown`] — returns [`PmTilesError::UnsupportedCompression`].
148///
149/// # Errors
150/// Returns [`PmTilesError::Decompression`] when the underlying OxiARC codec
151/// reports a failure, or [`PmTilesError::UnsupportedCompression`] for
152/// [`Compression::Unknown`].
153fn compress_with(data: &[u8], c: Compression, level: Option<i32>) -> Result<Vec<u8>, PmTilesError> {
154 match c {
155 Compression::None => Ok(data.to_vec()),
156 Compression::Gzip => {
157 // Clamp level into the valid gzip range [0, 9]; default 6.
158 let lvl = level.unwrap_or(6).clamp(0, 9) as u8;
159 oxiarc_archive::gzip::compress(data, lvl)
160 .map_err(|e| PmTilesError::Decompression(format!("Gzip compression failed: {e}")))
161 }
162 Compression::Brotli => {
163 // Brotli quality range is [0, 11]. Default to 6 (NORMAL).
164 let lvl = level.unwrap_or(6).clamp(0, 11) as u32;
165 oxiarc_archive::brotli::compress_with_quality(data, lvl)
166 .map_err(|e| PmTilesError::Decompression(format!("Brotli compression failed: {e}")))
167 }
168 Compression::Zstd => {
169 // OxiARC's zstd encoder does not expose a level today; the
170 // `level` argument is accepted but currently ignored.
171 oxiarc_archive::zstd::compress(data)
172 .map_err(|e| PmTilesError::Decompression(format!("Zstd compression failed: {e}")))
173 }
174 Compression::Unknown => Err(PmTilesError::UnsupportedCompression),
175 }
176}
177
178// ---------------------------------------------------------------------------
179// Tile-level transcoding
180// ---------------------------------------------------------------------------
181
182/// Transcode a single tile payload from one compression algorithm to another.
183///
184/// * When `from == to`, the input is returned unmodified (a clone).
185/// * Otherwise the payload is decompressed via `from` and recompressed via
186/// `to` at the requested `level`.
187///
188/// # Errors
189/// * [`PmTilesError::UnsupportedCompression`] when `to` is
190/// [`Compression::Unknown`].
191/// * [`PmTilesError::Decompression`] on codec failure.
192pub fn transcode_tile(
193 data: &[u8],
194 from: Compression,
195 to: Compression,
196 level: Option<i32>,
197) -> Result<Vec<u8>, PmTilesError> {
198 if to == Compression::Unknown {
199 return Err(PmTilesError::UnsupportedCompression);
200 }
201 if from == to {
202 return Ok(data.to_vec());
203 }
204 let raw = decompress_with(data, from)?;
205 compress_with(&raw, to, level)
206}
207
208// ---------------------------------------------------------------------------
209// Internal helpers (shared with crate::compact)
210// ---------------------------------------------------------------------------
211
212/// Extract the raw tile payload slice from the source archive bytes.
213///
214/// `tile_data_offset` is the absolute byte position of the tile-data section
215/// within `archive_bytes`; `info.data_offset` is relative to that section.
216///
217/// # Errors
218/// Returns [`PmTilesError::InvalidFormat`] when the computed range falls
219/// outside the archive bounds.
220fn extract_tile_bytes<'a>(
221 archive_bytes: &'a [u8],
222 tile_data_offset: u64,
223 info: &TileInfo,
224) -> Result<&'a [u8], PmTilesError> {
225 let abs_start = (tile_data_offset + info.data_offset) as usize;
226 let abs_end = abs_start + info.data_length as usize;
227 if abs_end > archive_bytes.len() {
228 return Err(PmTilesError::InvalidFormat(format!(
229 "Tile data for tile_id={} at [{abs_start}..{abs_end}) is out of bounds \
230 (archive is {} bytes)",
231 info.tile_id,
232 archive_bytes.len()
233 )));
234 }
235 Ok(&archive_bytes[abs_start..abs_end])
236}
237
238// ---------------------------------------------------------------------------
239// Archive-level transcoding
240// ---------------------------------------------------------------------------
241
242/// Re-compress every tile payload in a PMTiles v3 archive and return the
243/// resulting archive bytes.
244///
245/// See [`transcode_archive_with_stats`] for the variant that also returns
246/// statistics.
247///
248/// # Errors
249/// Propagates errors from [`transcode_archive_with_stats`].
250pub fn transcode_archive(bytes: &[u8], opts: &TranscodeOptions) -> Result<Vec<u8>, PmTilesError> {
251 let (out, _stats) = transcode_archive_with_stats(bytes, opts)?;
252 Ok(out)
253}
254
255/// Re-compress every tile payload in a PMTiles v3 archive and return both
256/// the resulting archive bytes and a [`TranscodeStats`] summary.
257///
258/// # Errors
259/// * [`PmTilesError::UnsupportedCompression`] when `opts.to` is
260/// [`Compression::Unknown`].
261/// * [`PmTilesError::InvalidFormat`] / [`PmTilesError::UnsupportedVersion`]
262/// when the source archive is malformed.
263/// * [`PmTilesError::Decompression`] on codec failure.
264pub fn transcode_archive_with_stats(
265 bytes: &[u8],
266 opts: &TranscodeOptions,
267) -> Result<(Vec<u8>, TranscodeStats), PmTilesError> {
268 // Reject Unknown as target up front so the caller does not have to wait
269 // for the per-tile codec dispatch to fail.
270 if opts.to == Compression::Unknown {
271 return Err(PmTilesError::UnsupportedCompression);
272 }
273
274 // -----------------------------------------------------------------------
275 // Step 1: Parse the source archive.
276 // -----------------------------------------------------------------------
277 let reader = PmTilesReader::from_bytes(bytes.to_vec())?;
278 let header = reader.header.clone();
279 let tile_data_offset = header.tile_data_offset;
280
281 // -----------------------------------------------------------------------
282 // Step 2: Resolve the effective source compression.
283 //
284 // When the caller passes `Compression::Unknown` we trust the header's
285 // `tile_compression` byte. When the caller passes an explicit codec we
286 // use that — this lets callers override a malformed header byte.
287 // -----------------------------------------------------------------------
288 let actual_from = if opts.from == Compression::Unknown {
289 header.tile_compression.clone()
290 } else {
291 opts.from.clone()
292 };
293
294 // -----------------------------------------------------------------------
295 // Step 3: Construct a fresh builder that mirrors the source header.
296 // -----------------------------------------------------------------------
297 let mut builder =
298 PmTilesBuilder::new(header.tile_type.clone(), header.min_zoom, header.max_zoom);
299 builder.set_bounds(
300 header.min_lon(),
301 header.min_lat(),
302 header.max_lon(),
303 header.max_lat(),
304 );
305 builder.set_center(header.center_lon(), header.center_lat(), header.center_zoom);
306 builder.set_tile_compression(opts.to.clone());
307 // The builder writes directory bytes uncompressed, so the output's
308 // internal_compression must be `None` regardless of the source.
309 builder.set_internal_compression(Compression::None);
310
311 // Mirror the source's JSON metadata. The reader decompresses it via the
312 // source's `internal_compression`; we re-emit it uncompressed (matching
313 // the builder's behaviour).
314 if let Ok(metadata) = reader.metadata()
315 && let Ok(json) = metadata.to_json()
316 {
317 builder.set_metadata(json);
318 }
319
320 // -----------------------------------------------------------------------
321 // Step 4: Walk every logical tile, transcode, dispatch.
322 // -----------------------------------------------------------------------
323 let mut stats = TranscodeStats::default();
324 let identity = actual_from == opts.to;
325
326 let tile_infos = reader.enumerate_tiles()?;
327 for info in &tile_infos {
328 let raw_compressed = extract_tile_bytes(bytes, tile_data_offset, info)?;
329 stats.bytes_before = stats
330 .bytes_before
331 .saturating_add(raw_compressed.len() as u64);
332
333 let transcoded = if identity {
334 raw_compressed.to_vec()
335 } else {
336 transcode_tile(
337 raw_compressed,
338 actual_from.clone(),
339 opts.to.clone(),
340 opts.level,
341 )?
342 };
343
344 stats.bytes_after = stats.bytes_after.saturating_add(transcoded.len() as u64);
345 if identity {
346 stats.tiles_skipped_identity = stats.tiles_skipped_identity.saturating_add(1);
347 } else {
348 stats.tiles_transcoded = stats.tiles_transcoded.saturating_add(1);
349 }
350
351 builder.add_tile_by_id(info.tile_id, &transcoded)?;
352 }
353
354 // -----------------------------------------------------------------------
355 // Step 5: Assemble the new archive.
356 // -----------------------------------------------------------------------
357 let out = builder.build()?;
358 Ok((out, stats))
359}
360
361// ---------------------------------------------------------------------------
362// Tests
363// ---------------------------------------------------------------------------
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn test_transcode_stats_default_ratio_is_one() {
371 let s = TranscodeStats::default();
372 assert!((s.ratio() - 1.0).abs() < f64::EPSILON);
373 }
374
375 #[test]
376 fn test_transcode_stats_ratio_half() {
377 let s = TranscodeStats {
378 tiles_transcoded: 1,
379 tiles_skipped_identity: 0,
380 bytes_before: 100,
381 bytes_after: 50,
382 };
383 assert!((s.ratio() - 0.5).abs() < f64::EPSILON);
384 }
385
386 #[test]
387 fn test_transcode_options_default_values() {
388 let opts = TranscodeOptions::default();
389 assert_eq!(opts.from, Compression::Unknown);
390 assert_eq!(opts.to, Compression::Gzip);
391 assert!(opts.level.is_none());
392 }
393
394 #[test]
395 fn test_transcode_tile_identity_none() {
396 // None ⇒ None is a no-op clone.
397 let raw = b"identity payload";
398 let out =
399 transcode_tile(raw, Compression::None, Compression::None, None).expect("transcode");
400 assert_eq!(out.as_slice(), raw);
401 }
402
403 #[test]
404 fn test_transcode_tile_unknown_target_errors() {
405 let raw = b"x";
406 let err = transcode_tile(raw, Compression::None, Compression::Unknown, None)
407 .expect_err("must reject Unknown target");
408 assert!(matches!(err, PmTilesError::UnsupportedCompression));
409 }
410
411 #[test]
412 fn test_decompress_with_none_is_passthrough() {
413 let data = b"raw";
414 assert_eq!(decompress_with(data, Compression::None).expect("ok"), data);
415 assert_eq!(
416 decompress_with(data, Compression::Unknown).expect("ok"),
417 data
418 );
419 }
420
421 #[test]
422 fn test_compress_with_none_is_passthrough() {
423 let data = b"raw";
424 assert_eq!(
425 compress_with(data, Compression::None, None).expect("ok"),
426 data
427 );
428 }
429
430 #[test]
431 fn test_compress_with_unknown_errors() {
432 let data = b"raw";
433 let err = compress_with(data, Compression::Unknown, None).expect_err("Unknown must error");
434 assert!(matches!(err, PmTilesError::UnsupportedCompression));
435 }
436}