Skip to main content

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}