Skip to main content

tylertoo_core/
compression.rs

1//! Compression support for PMTiles v3.
2//!
3//! PMTiles supports multiple compression algorithms:
4//! - None (1): No compression
5//! - Gzip (2): zlib/gzip compression (default)
6//! - Brotli (3): Brotli compression (good for web)
7//! - Zstd (4): Zstandard compression (fast, high ratio)
8//!
9//! This module provides a unified interface for compressing tile and directory data.
10
11use std::io::{self, Write};
12
13/// Compression algorithm for PMTiles.
14///
15/// Values match the PMTiles v3 spec:
16/// - 0: Unknown
17/// - 1: None
18/// - 2: Gzip
19/// - 3: Brotli
20/// - 4: Zstd
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22#[repr(u8)]
23pub enum Compression {
24    Unknown = 0,
25    None = 1,
26    #[default]
27    Gzip = 2,
28    Brotli = 3,
29    Zstd = 4,
30}
31
32impl Compression {
33    /// Parse compression from string (case-insensitive).
34    ///
35    /// Valid values: "none", "gzip", "brotli", "zstd"
36    #[allow(clippy::should_implement_trait)]
37    pub fn from_str(s: &str) -> Option<Self> {
38        match s.to_lowercase().as_str() {
39            "none" => Some(Compression::None),
40            "gzip" => Some(Compression::Gzip),
41            "brotli" => Some(Compression::Brotli),
42            "zstd" => Some(Compression::Zstd),
43            _ => Option::None,
44        }
45    }
46
47    /// Get the PMTiles byte code for this compression type.
48    pub fn code(&self) -> u8 {
49        *self as u8
50    }
51
52    /// Parse a PMTiles spec byte code back into a compression type.
53    ///
54    /// Inverse of [`Compression::code`]. Returns `None` for codes outside the
55    /// PMTiles v3 spec (0-4).
56    pub fn from_code(code: u8) -> Option<Self> {
57        match code {
58            0 => Some(Compression::Unknown),
59            1 => Some(Compression::None),
60            2 => Some(Compression::Gzip),
61            3 => Some(Compression::Brotli),
62            4 => Some(Compression::Zstd),
63            _ => Option::None,
64        }
65    }
66
67    /// Get a human-readable name for this compression type.
68    pub fn name(&self) -> &'static str {
69        match self {
70            Compression::Unknown => "unknown",
71            Compression::None => "none",
72            Compression::Gzip => "gzip",
73            Compression::Brotli => "brotli",
74            Compression::Zstd => "zstd",
75        }
76    }
77}
78
79/// One direction of the codec: the three per-algorithm entry points plus the
80/// verb used in the `Unknown` error message.
81///
82/// `compress` and `decompress` are mirror images. Writing the match arms twice
83/// meant every new algorithm had to be added in two places, so the dispatch
84/// lives here once and each public function supplies its own table.
85struct Codec {
86    verb: &'static str,
87    gzip: fn(&[u8]) -> io::Result<Vec<u8>>,
88    brotli: fn(&[u8]) -> io::Result<Vec<u8>>,
89    zstd: fn(&[u8]) -> io::Result<Vec<u8>>,
90}
91
92const COMPRESS: &Codec = &Codec {
93    verb: "compress",
94    gzip: compress_gzip,
95    brotli: compress_brotli,
96    zstd: compress_zstd,
97};
98
99const DECOMPRESS: &Codec = &Codec {
100    verb: "decompress",
101    gzip: decompress_gzip,
102    brotli: decompress_brotli,
103    zstd: decompress_zstd,
104};
105
106fn dispatch(data: &[u8], compression: Compression, codec: &Codec) -> io::Result<Vec<u8>> {
107    match compression {
108        Compression::Unknown => Err(io::Error::new(
109            io::ErrorKind::InvalidInput,
110            format!("Cannot {} with unknown compression type", codec.verb),
111        )),
112        Compression::None => Ok(data.to_vec()),
113        Compression::Gzip => (codec.gzip)(data),
114        Compression::Brotli => (codec.brotli)(data),
115        Compression::Zstd => (codec.zstd)(data),
116    }
117}
118
119/// Compress data using the specified algorithm.
120///
121/// # Arguments
122/// * `data` - Uncompressed input data
123/// * `compression` - Compression algorithm to use
124///
125/// # Returns
126/// Compressed data, or original data if compression is None.
127pub fn compress(data: &[u8], compression: Compression) -> io::Result<Vec<u8>> {
128    dispatch(data, compression, COMPRESS)
129}
130
131/// Decompress data using the specified algorithm.
132///
133/// Inverse of [`compress`]: the read side of the PMTiles pipeline (issue
134/// #112) uses this for directories, JSON metadata, and tile data, honoring
135/// the compression codes declared in the archive header.
136///
137/// # Arguments
138/// * `data` - Compressed input data
139/// * `compression` - Compression algorithm the data was compressed with
140///
141/// # Returns
142/// Decompressed data, or a copy of the input if compression is None.
143pub fn decompress(data: &[u8], compression: Compression) -> io::Result<Vec<u8>> {
144    dispatch(data, compression, DECOMPRESS)
145}
146
147/// Decompress gzip data.
148fn decompress_gzip(data: &[u8]) -> io::Result<Vec<u8>> {
149    use std::io::Read;
150    let mut out = Vec::new();
151    flate2::read::GzDecoder::new(data).read_to_end(&mut out)?;
152    Ok(out)
153}
154
155/// Decompress brotli data.
156fn decompress_brotli(data: &[u8]) -> io::Result<Vec<u8>> {
157    use std::io::Read;
158    let mut out = Vec::new();
159    brotli::Decompressor::new(data, 4096).read_to_end(&mut out)?;
160    Ok(out)
161}
162
163/// Decompress zstd data.
164fn decompress_zstd(data: &[u8]) -> io::Result<Vec<u8>> {
165    zstd::decode_all(data)
166}
167
168/// Compress data with gzip.
169fn compress_gzip(data: &[u8]) -> io::Result<Vec<u8>> {
170    use flate2::write::GzEncoder;
171    use flate2::Compression as GzCompression;
172
173    let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
174    encoder.write_all(data)?;
175    encoder.finish()
176}
177
178/// Compress data with brotli.
179fn compress_brotli(data: &[u8]) -> io::Result<Vec<u8>> {
180    use brotli::enc::BrotliEncoderParams;
181    use brotli::CompressorWriter;
182
183    // Use quality level 4 - good balance of speed and compression
184    // (tippecanoe uses quality 9, but we default to something faster)
185    let params = BrotliEncoderParams {
186        quality: 4,
187        ..Default::default()
188    };
189
190    let mut output = Vec::new();
191    {
192        let mut writer = CompressorWriter::with_params(&mut output, 4096, &params);
193        writer.write_all(data)?;
194    }
195    Ok(output)
196}
197
198/// Compress data with zstd.
199fn compress_zstd(data: &[u8]) -> io::Result<Vec<u8>> {
200    // Use compression level 3 (default) - good balance of speed and ratio
201    zstd::encode_all(data, 3)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    // -------------------------------------------------------------------------
209    // Compression Enum Tests
210    // -------------------------------------------------------------------------
211
212    #[test]
213    fn test_compression_codes_match_pmtiles_spec() {
214        // PMTiles v3 spec defines these exact byte values
215        assert_eq!(Compression::Unknown.code(), 0);
216        assert_eq!(Compression::None.code(), 1);
217        assert_eq!(Compression::Gzip.code(), 2);
218        assert_eq!(Compression::Brotli.code(), 3);
219        assert_eq!(Compression::Zstd.code(), 4);
220    }
221
222    #[test]
223    fn test_compression_default_is_gzip() {
224        // Gzip is the default for maximum compatibility:
225        // - Universally supported by all PMTiles viewers
226        // - Works in pmtiles.io without issues
227        // - Use --compression zstd for better performance when supported
228        assert_eq!(Compression::default(), Compression::Gzip);
229    }
230
231    #[test]
232    fn test_compression_from_str() {
233        assert_eq!(Compression::from_str("none"), Some(Compression::None));
234        assert_eq!(Compression::from_str("gzip"), Some(Compression::Gzip));
235        assert_eq!(Compression::from_str("brotli"), Some(Compression::Brotli));
236        assert_eq!(Compression::from_str("zstd"), Some(Compression::Zstd));
237        assert_eq!(Compression::from_str("GZIP"), Some(Compression::Gzip)); // case insensitive
238        assert_eq!(Compression::from_str("invalid"), Option::None);
239    }
240
241    #[test]
242    fn test_compression_names() {
243        assert_eq!(Compression::None.name(), "none");
244        assert_eq!(Compression::Gzip.name(), "gzip");
245        assert_eq!(Compression::Brotli.name(), "brotli");
246        assert_eq!(Compression::Zstd.name(), "zstd");
247    }
248
249    // -------------------------------------------------------------------------
250    // Compression Function Tests
251    // -------------------------------------------------------------------------
252
253    #[test]
254    fn test_compress_none_returns_original() {
255        let data = b"Hello, PMTiles!";
256        let compressed = compress(data, Compression::None).unwrap();
257        assert_eq!(compressed, data);
258    }
259
260    #[test]
261    fn test_compress_unknown_returns_error() {
262        let data = b"Hello, PMTiles!";
263        let result = compress(data, Compression::Unknown);
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn test_compress_gzip_produces_smaller_output() {
269        // Use a compressible pattern
270        let data = "Hello, PMTiles! ".repeat(100);
271        let compressed = compress(data.as_bytes(), Compression::Gzip).unwrap();
272        assert!(
273            compressed.len() < data.len(),
274            "Gzip should compress repetitive data: {} < {}",
275            compressed.len(),
276            data.len()
277        );
278    }
279
280    #[test]
281    fn test_compress_brotli_produces_smaller_output() {
282        let data = "Hello, PMTiles! ".repeat(100);
283        let compressed = compress(data.as_bytes(), Compression::Brotli).unwrap();
284        assert!(
285            compressed.len() < data.len(),
286            "Brotli should compress repetitive data: {} < {}",
287            compressed.len(),
288            data.len()
289        );
290    }
291
292    #[test]
293    fn test_compress_zstd_produces_smaller_output() {
294        let data = "Hello, PMTiles! ".repeat(100);
295        let compressed = compress(data.as_bytes(), Compression::Zstd).unwrap();
296        assert!(
297            compressed.len() < data.len(),
298            "Zstd should compress repetitive data: {} < {}",
299            compressed.len(),
300            data.len()
301        );
302    }
303
304    // -------------------------------------------------------------------------
305    // Decompression Roundtrip Tests
306    // -------------------------------------------------------------------------
307
308    #[test]
309    fn test_gzip_roundtrip() {
310        use flate2::read::GzDecoder;
311        use std::io::Read;
312
313        let original = b"Hello, PMTiles! This is test data for compression roundtrip.";
314        let compressed = compress(original, Compression::Gzip).unwrap();
315
316        let mut decoder = GzDecoder::new(&compressed[..]);
317        let mut decompressed = Vec::new();
318        decoder.read_to_end(&mut decompressed).unwrap();
319
320        assert_eq!(decompressed, original);
321    }
322
323    #[test]
324    fn test_brotli_roundtrip() {
325        use brotli::Decompressor;
326        use std::io::Read;
327
328        let original = b"Hello, PMTiles! This is test data for compression roundtrip.";
329        let compressed = compress(original, Compression::Brotli).unwrap();
330
331        let mut decompressor = Decompressor::new(&compressed[..], 4096);
332        let mut decompressed = Vec::new();
333        decompressor.read_to_end(&mut decompressed).unwrap();
334
335        assert_eq!(decompressed, original);
336    }
337
338    #[test]
339    fn test_zstd_roundtrip() {
340        let original = b"Hello, PMTiles! This is test data for compression roundtrip.";
341        let compressed = compress(original, Compression::Zstd).unwrap();
342
343        let decompressed = zstd::decode_all(&compressed[..]).unwrap();
344
345        assert_eq!(decompressed, original);
346    }
347
348    // -------------------------------------------------------------------------
349    // Edge Cases
350    // -------------------------------------------------------------------------
351
352    #[test]
353    fn test_compress_empty_data() {
354        // All compression types should handle empty input
355        for compression in [
356            Compression::None,
357            Compression::Gzip,
358            Compression::Brotli,
359            Compression::Zstd,
360        ] {
361            let result = compress(&[], compression);
362            assert!(
363                result.is_ok(),
364                "{} should handle empty data",
365                compression.name()
366            );
367        }
368    }
369
370    #[test]
371    fn test_compress_large_data() {
372        // Simulate a large tile (~1MB of data)
373        let data = vec![0x42u8; 1_000_000];
374
375        for compression in [Compression::Gzip, Compression::Brotli, Compression::Zstd] {
376            let result = compress(&data, compression);
377            assert!(
378                result.is_ok(),
379                "{} should handle large data",
380                compression.name()
381            );
382
383            let compressed = result.unwrap();
384            // Highly repetitive data should compress very well
385            assert!(
386                compressed.len() < data.len() / 10,
387                "{} should achieve >10x compression on uniform data",
388                compression.name()
389            );
390        }
391    }
392
393    // -------------------------------------------------------------------------
394    // Decompression (read side, issue #112)
395    // -------------------------------------------------------------------------
396
397    #[test]
398    fn test_from_code_inverts_code() {
399        for compression in [
400            Compression::Unknown,
401            Compression::None,
402            Compression::Gzip,
403            Compression::Brotli,
404            Compression::Zstd,
405        ] {
406            assert_eq!(
407                Compression::from_code(compression.code()),
408                Some(compression)
409            );
410        }
411        assert_eq!(Compression::from_code(5), None);
412        assert_eq!(Compression::from_code(255), None);
413    }
414
415    #[test]
416    fn test_decompress_roundtrips_every_codec() {
417        let original = b"PMTiles round-trip payload \x00\x01\x02 with some repetition repetition";
418        for compression in [
419            Compression::None,
420            Compression::Gzip,
421            Compression::Brotli,
422            Compression::Zstd,
423        ] {
424            let compressed = compress(original, compression).unwrap();
425            let decompressed = decompress(&compressed, compression).unwrap();
426            assert_eq!(
427                decompressed,
428                original.to_vec(),
429                "{} round-trip",
430                compression.name()
431            );
432        }
433    }
434
435    #[test]
436    fn test_decompress_empty_payload() {
437        for compression in [
438            Compression::None,
439            Compression::Gzip,
440            Compression::Brotli,
441            Compression::Zstd,
442        ] {
443            let compressed = compress(&[], compression).unwrap();
444            let decompressed = decompress(&compressed, compression).unwrap();
445            assert!(decompressed.is_empty(), "{}", compression.name());
446        }
447    }
448
449    #[test]
450    fn test_decompress_unknown_is_error() {
451        assert!(decompress(b"anything", Compression::Unknown).is_err());
452    }
453
454    #[test]
455    fn test_decompress_corrupt_gzip_is_error() {
456        assert!(decompress(b"not gzip at all", Compression::Gzip).is_err());
457    }
458}