Skip to main content

stt_core/
compression.rs

1//! Compression utilities for tiles. The format is **zstd-only** — there is no
2//! gzip path (it was never shipped; zstd beats it on both ratio and browser
3//! decode). `Compression::None` exists only for already-incompressible blobs.
4
5use crate::error::{Error, Result};
6use crate::types::Compression;
7
8/// Compress data using the specified compression method
9pub fn compress(data: &[u8], compression: Compression) -> Result<Vec<u8>> {
10    match compression {
11        Compression::None => Ok(data.to_vec()),
12        Compression::Zstd => compress_zstd(data),
13    }
14}
15
16/// Decompress data using the specified compression method
17pub fn decompress(data: &[u8], compression: Compression) -> Result<Vec<u8>> {
18    match compression {
19        Compression::None => Ok(data.to_vec()),
20        Compression::Zstd => decompress_zstd(data),
21    }
22}
23
24/// Default zstd level. Level 3 is zstd's documented "fast" sweet spot —
25/// roughly gzip-6 ratio at ~5x the encode speed; higher levels save a few
26/// percent at significant CPU cost.
27/// Default zstd level for the packed format — see the constant doc above.
28/// `compress_zstd_with_dict` and the writers use this unless an explicit level
29/// is threaded through (e.g. `stt-build --zstd-level`).
30pub const ZSTD_LEVEL: i32 = 3;
31
32/// Highest level we expose. zstd defines 1..=22; level 19 is the practical
33/// ceiling (19 ≈ 22 on STT tiles, measured) but we accept up to 22.
34pub const ZSTD_LEVEL_MAX: i32 = 22;
35
36fn compress_zstd(data: &[u8]) -> Result<Vec<u8>> {
37    zstd::stream::encode_all(data, ZSTD_LEVEL).map_err(|e| Error::Compression(e.to_string()))
38}
39
40fn decompress_zstd(data: &[u8]) -> Result<Vec<u8>> {
41    zstd::stream::decode_all(data).map_err(|e| Error::Decompression(e.to_string()))
42}
43
44/// Compress a payload with zstd using an optional pre-shared dictionary, at the
45/// default level ([`ZSTD_LEVEL`]). Thin wrapper over
46/// [`compress_zstd_with_dict_level`] — kept as the stable call site for the
47/// many writers that don't tune the level.
48pub fn compress_zstd_with_dict(data: &[u8], dict: Option<&[u8]>) -> Result<Vec<u8>> {
49    compress_zstd_with_dict_level(data, dict, ZSTD_LEVEL)
50}
51
52/// Compress a payload with zstd at an explicit `level`, with an optional
53/// pre-shared dictionary.
54///
55/// The packed format is **write-once / serve-many**: build CPU is paid once,
56/// offline, while the bytes are paid on every client fetch. Decompression speed
57/// is independent of the level the frame was written at, so a higher build-time
58/// level is a pure win on the wire (measured −10..19% at level 19 vs 3). When
59/// `dict` is `Some`, the encoder primes itself with the dictionary — the decoder
60/// needs the same dictionary to round-trip.
61pub fn compress_zstd_with_dict_level(
62    data: &[u8],
63    dict: Option<&[u8]>,
64    level: i32,
65) -> Result<Vec<u8>> {
66    let level = level.clamp(1, ZSTD_LEVEL_MAX);
67    let Some(dict) = dict else {
68        return zstd::stream::encode_all(data, level).map_err(|e| Error::Compression(e.to_string()));
69    };
70    let mut out = Vec::with_capacity(data.len() / 2 + 64);
71    let mut encoder = zstd::stream::Encoder::with_dictionary(&mut out, level, dict)
72        .map_err(|e| Error::Compression(e.to_string()))?;
73    use std::io::Write;
74    encoder
75        .write_all(data)
76        .map_err(|e| Error::Compression(e.to_string()))?;
77    encoder
78        .finish()
79        .map_err(|e| Error::Compression(e.to_string()))?;
80    Ok(out)
81}
82
83/// Decompress zstd data, applying a pre-shared dictionary when supplied.
84pub fn decompress_zstd_with_dict(data: &[u8], dict: Option<&[u8]>) -> Result<Vec<u8>> {
85    let Some(dict) = dict else {
86        return decompress_zstd(data);
87    };
88    let mut decoder = zstd::stream::Decoder::with_dictionary(data, dict)
89        .map_err(|e| Error::Decompression(e.to_string()))?;
90    let mut out = Vec::new();
91    use std::io::Read as _;
92    decoder
93        .read_to_end(&mut out)
94        .map_err(|e| Error::Decompression(e.to_string()))?;
95    Ok(out)
96}
97
98/// Train a zstd dictionary from a sample of payloads.
99///
100/// Returns `None` when the sample is too small for zstd's dictionary builder
101/// to produce a useful dictionary; callers should treat this as "ship without
102/// a dictionary" rather than an error.
103pub fn train_zstd_dictionary(samples: &[Vec<u8>], max_size: usize) -> Option<Vec<u8>> {
104    let slices: Vec<&[u8]> = samples.iter().map(|s| s.as_slice()).collect();
105    train_zstd_dictionary_from_slices(&slices, max_size)
106}
107
108/// Train a zstd dictionary from borrowed payload slices (no cloning).
109///
110/// Same contract as [`train_zstd_dictionary`]: returns `None` when the sample
111/// is too small or too sparse for zstd's builder to produce a useful
112/// dictionary, so callers treat that as "ship without a dictionary".
113pub fn train_zstd_dictionary_from_slices(samples: &[&[u8]], max_size: usize) -> Option<Vec<u8>> {
114    if samples.is_empty() {
115        return None;
116    }
117    match zstd::dict::from_samples(samples, max_size) {
118        Ok(dict) if !dict.is_empty() => Some(dict),
119        _ => None,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_zstd_roundtrip() {
129        let data = b"Hello, world! This is a test string that should compress well."
130            .repeat(50);
131        let compressed = compress(&data, Compression::Zstd).unwrap();
132        assert!(compressed.len() < data.len());
133
134        let decompressed = decompress(&compressed, Compression::Zstd).unwrap();
135        assert_eq!(decompressed, data);
136    }
137
138    #[test]
139    fn test_none_roundtrip() {
140        let data = b"No compression";
141        let compressed = compress(data, Compression::None).unwrap();
142        assert_eq!(&compressed, data);
143
144        let decompressed = decompress(&compressed, Compression::None).unwrap();
145        assert_eq!(&decompressed, data);
146    }
147}