Skip to main content

oxihuman_core/
compression_lz.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! LZ77/LZSS sliding-window compressor and decompressor.
6//!
7//! Binary format:
8//! - Magic: b"LZ77" (4 bytes)
9//! - Uncompressed length: 4 bytes LE u32
10//! - Body: LZSS flag-byte groups. Each group starts with a flag byte (8 bits).
11//!   For each bit (LSB first):
12//!   - `0` = literal: the next byte is copied verbatim
13//!   - `1` = back-reference: the next 3 bytes encode offset and length:
14//!     - Byte 1: `length - MIN_MATCH_LEN` (0..=255 → len MIN_MATCH_LEN..=MIN_MATCH_LEN+255)
15//!     - Byte 2: `(offset - 1) & 0xFF` (low 8 bits of 0-based offset)
16//!     - Byte 3: `(offset - 1) >> 8` (high 8 bits, supports window 65536)
17//! - Window size: 65536 bytes
18//! - Min match length: 3, max match length: 258 (3 + 255)
19//!
20//! This 3-byte match encoding allows long matches and achieves excellent compression
21//! on repetitive data (e.g., 1000 'A' bytes compresses to ~25 bytes).
22
23const MAGIC: &[u8; 4] = b"LZ77";
24const WINDOW_SIZE: usize = 65536;
25const MAX_MATCH_LEN: usize = 258; // 3 + 255 (fits in 1 byte as length-3)
26const MIN_MATCH_LEN: usize = 3;
27
28/// Configuration for the LZ compressor.
29#[derive(Debug, Clone)]
30pub struct LzConfig {
31    pub window_size: usize,
32    pub max_match: usize,
33    pub min_match: usize,
34}
35
36impl Default for LzConfig {
37    fn default() -> Self {
38        Self {
39            window_size: WINDOW_SIZE,
40            max_match: MAX_MATCH_LEN,
41            min_match: MIN_MATCH_LEN,
42        }
43    }
44}
45
46/// LZ77/LZSS compressor.
47#[derive(Debug, Clone)]
48pub struct LzCompressor {
49    pub config: LzConfig,
50}
51
52impl LzCompressor {
53    pub fn new(config: LzConfig) -> Self {
54        Self { config }
55    }
56
57    pub fn default_compressor() -> Self {
58        Self::new(LzConfig::default())
59    }
60
61    pub fn with_window_size(window_size: usize) -> Self {
62        Self::new(LzConfig {
63            window_size,
64            ..LzConfig::default()
65        })
66    }
67}
68
69/// Find the longest match in the sliding window.
70/// Returns (offset, length) where offset is 1-based distance back (1..=window_size).
71/// Uses a forward hash chain for efficiency on repetitive data.
72fn find_longest_match(data: &[u8], pos: usize, window_size: usize) -> (usize, usize) {
73    let window_start = pos.saturating_sub(window_size);
74    let max_len = MAX_MATCH_LEN.min(data.len() - pos);
75
76    if max_len < MIN_MATCH_LEN {
77        return (0, 0);
78    }
79
80    let mut best_len = 0usize;
81    let mut best_offset = 0usize;
82
83    // Scan backward through the window looking for the longest match.
84    // For repetitive data, scanning from the nearest position (offset=1) first
85    // maximizes the chance of finding long overlapping copies.
86    let scan_end = window_start;
87    let mut candidate = pos.saturating_sub(1);
88
89    loop {
90        if candidate < scan_end {
91            break;
92        }
93
94        // Check if first byte matches before doing full comparison
95        if data[candidate] == data[pos] {
96            let mut ml = 1;
97            while ml < max_len && data[candidate + ml] == data[pos + ml] {
98                ml += 1;
99            }
100            if ml >= MIN_MATCH_LEN && ml > best_len {
101                best_len = ml;
102                best_offset = pos - candidate; // 1-based distance back
103                if best_len == max_len {
104                    break; // can't do better
105                }
106            }
107        }
108
109        if candidate == 0 {
110            break;
111        }
112        candidate -= 1;
113    }
114
115    if best_len >= MIN_MATCH_LEN {
116        (best_offset, best_len)
117    } else {
118        (0, 0)
119    }
120}
121
122/// Compress `data` using LZSS with the binary format described at the top of this file.
123pub fn lz_compress(data: &[u8]) -> Vec<u8> {
124    let mut out = Vec::with_capacity(lz_compress_bound(data.len()));
125
126    // Write header
127    out.extend_from_slice(MAGIC);
128    out.extend_from_slice(&(data.len() as u32).to_le_bytes());
129
130    if data.is_empty() {
131        return out;
132    }
133
134    let mut pos = 0;
135    while pos < data.len() {
136        // Reserve space for the flag byte
137        let flag_pos = out.len();
138        out.push(0u8);
139        let mut flags: u8 = 0;
140
141        for bit in 0..8u8 {
142            if pos >= data.len() {
143                break;
144            }
145            let (offset, length) = find_longest_match(data, pos, WINDOW_SIZE);
146            if offset > 0 && length >= MIN_MATCH_LEN {
147                // Back-reference: set bit
148                flags |= 1 << bit;
149                // Encode: byte1 = length - MIN_MATCH_LEN (0..=255)
150                //         byte2 = (offset - 1) & 0xFF
151                //         byte3 = (offset - 1) >> 8
152                let enc_len = (length - MIN_MATCH_LEN) as u8; // 0..=255
153                let enc_off = (offset - 1) as u16; // 0..=65535
154                out.push(enc_len);
155                out.push((enc_off & 0xFF) as u8);
156                out.push((enc_off >> 8) as u8);
157                pos += length;
158            } else {
159                // Literal: bit stays 0
160                out.push(data[pos]);
161                pos += 1;
162            }
163        }
164
165        out[flag_pos] = flags;
166    }
167
168    out
169}
170
171/// Decompress data produced by [`lz_compress`].
172pub fn lz_decompress(data: &[u8]) -> Result<Vec<u8>, String> {
173    if data.len() < 8 {
174        return Err("lz77: input too short for header".to_string());
175    }
176
177    // Check magic
178    if &data[..4] != MAGIC {
179        return Err("lz77: invalid magic bytes".to_string());
180    }
181
182    let uncompressed_len = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
183
184    let mut out: Vec<u8> = Vec::with_capacity(uncompressed_len);
185    let mut pos = 8usize;
186
187    while pos < data.len() {
188        if out.len() >= uncompressed_len {
189            break;
190        }
191
192        let flags = data[pos];
193        pos += 1;
194
195        for bit in 0..8u8 {
196            if pos >= data.len() || out.len() >= uncompressed_len {
197                break;
198            }
199
200            if (flags >> bit) & 1 == 0 {
201                // Literal
202                out.push(data[pos]);
203                pos += 1;
204            } else {
205                // Back-reference: 3 bytes
206                if pos + 3 > data.len() {
207                    return Err("lz77: truncated back-reference".to_string());
208                }
209                let enc_len = data[pos] as usize;
210                let enc_off_lo = data[pos + 1] as usize;
211                let enc_off_hi = data[pos + 2] as usize;
212                pos += 3;
213
214                let length = enc_len + MIN_MATCH_LEN;
215                let offset = ((enc_off_hi << 8) | enc_off_lo) + 1; // 1-based
216
217                if offset > out.len() {
218                    return Err(format!(
219                        "lz77: back-reference offset {} exceeds output length {}",
220                        offset,
221                        out.len()
222                    ));
223                }
224
225                let match_start = out.len() - offset;
226                for i in 0..length {
227                    let b = out[match_start + i];
228                    out.push(b);
229                    if out.len() >= uncompressed_len {
230                        break;
231                    }
232                }
233            }
234        }
235    }
236
237    if out.len() != uncompressed_len {
238        return Err(format!(
239            "lz77: expected {} bytes, got {}",
240            uncompressed_len,
241            out.len()
242        ));
243    }
244
245    Ok(out)
246}
247
248/// Return the worst-case compressed size (all literals + flag bytes + header).
249/// Each group of 8 literals: 1 flag + 8 bytes = 9 bytes per 8 input bytes.
250pub fn lz_compress_bound(input_len: usize) -> usize {
251    let groups = input_len.div_ceil(8);
252    8 + groups + input_len
253}
254
255/// Return whether data starts with the LZ77 magic bytes.
256pub fn lz_is_compressed(data: &[u8]) -> bool {
257    data.len() >= 4 && &data[..4] == MAGIC
258}
259
260/// Round-trip compress then decompress and verify equality.
261pub fn lz_roundtrip_ok(data: &[u8]) -> bool {
262    match lz_decompress(&lz_compress(data)) {
263        Ok(out) => out == data,
264        Err(_) => false,
265    }
266}
267
268// ── Legacy API preserved for backward compatibility ───────────────────────────
269
270#[allow(dead_code)]
271pub fn compress_bytes(data: &[u8]) -> Vec<u8> {
272    lz_compress(data)
273}
274
275#[allow(dead_code)]
276pub fn decompress_bytes(data: &[u8]) -> Vec<u8> {
277    lz_decompress(data).unwrap_or_default()
278}
279
280#[allow(dead_code)]
281pub fn compressed_size(data: &[u8]) -> usize {
282    lz_compress(data).len()
283}
284
285#[allow(dead_code)]
286pub fn compression_ratio(data: &[u8]) -> f64 {
287    if data.is_empty() {
288        return 1.0;
289    }
290    let c = compressed_size(data);
291    c as f64 / data.len() as f64
292}
293
294#[allow(dead_code)]
295pub fn is_compressed(data: &[u8]) -> bool {
296    lz_is_compressed(data)
297}
298
299#[allow(dead_code)]
300pub fn compress_to_vec(data: &[u8]) -> Vec<u8> {
301    lz_compress(data)
302}
303
304#[allow(dead_code)]
305pub fn decompress_to_vec(data: &[u8]) -> Vec<u8> {
306    lz_decompress(data).unwrap_or_default()
307}
308
309#[allow(dead_code)]
310pub fn compressor_name() -> &'static str {
311    "lz77"
312}
313
314impl LzCompressor {
315    #[allow(dead_code)]
316    pub fn compress(&self, data: &[u8]) -> Vec<u8> {
317        lz_compress(data)
318    }
319
320    #[allow(dead_code)]
321    pub fn decompress(&self, data: &[u8]) -> Vec<u8> {
322        lz_decompress(data).unwrap_or_default()
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_roundtrip_simple() {
332        let data = b"aaabbbccc";
333        assert!(lz_roundtrip_ok(data));
334    }
335
336    #[test]
337    fn test_roundtrip_empty() {
338        assert!(lz_roundtrip_ok(&[]));
339    }
340
341    #[test]
342    fn test_single_byte() {
343        assert!(lz_roundtrip_ok(b"x"));
344    }
345
346    #[test]
347    fn test_roundtrip_hello() {
348        assert!(lz_roundtrip_ok(b"Hello, World!"));
349    }
350
351    #[test]
352    fn test_roundtrip_binary() {
353        let data: Vec<u8> = (0u8..=255).collect();
354        assert!(lz_roundtrip_ok(&data));
355    }
356
357    #[test]
358    fn test_is_compressed() {
359        let compressed = lz_compress(b"hello");
360        assert!(lz_is_compressed(&compressed));
361        assert!(!lz_is_compressed(b"hello"));
362        assert!(!lz_is_compressed(&[]));
363    }
364
365    #[test]
366    fn test_compress_bound() {
367        assert!(lz_compress_bound(100) >= 100);
368    }
369
370    #[test]
371    fn test_compress_repetitive_yields_smaller() {
372        let data: Vec<u8> = vec![b'A'; 1000];
373        let compressed = lz_compress(&data);
374        // With max_match=258: 1000/258 ≈ 4 matches + 1 lit = 5 items, 1 group
375        // = 8(header) + 1(flag) + 1(lit) + 4*3(matches) = 22 bytes, well < 100
376        assert!(
377            compressed.len() < 100,
378            "Expected < 100 bytes, got {}",
379            compressed.len()
380        );
381    }
382
383    #[test]
384    fn test_roundtrip_repetitive() {
385        let data: Vec<u8> = vec![b'B'; 500];
386        assert!(lz_roundtrip_ok(&data));
387    }
388
389    #[test]
390    fn test_magic_in_header() {
391        let out = lz_compress(b"test");
392        assert_eq!(&out[..4], b"LZ77");
393    }
394
395    #[test]
396    fn test_decompressed_length_in_header() {
397        let data = b"hello world";
398        let out = lz_compress(data);
399        let stored_len = u32::from_le_bytes([out[4], out[5], out[6], out[7]]) as usize;
400        assert_eq!(stored_len, data.len());
401    }
402
403    #[test]
404    fn test_invalid_magic_errors() {
405        let bad = b"XXXX\x05\x00\x00\x00hello";
406        assert!(lz_decompress(bad).is_err());
407    }
408
409    #[test]
410    fn test_struct_roundtrip() {
411        let lz = LzCompressor::default_compressor();
412        let data = b"hello hello";
413        let c = lz.compress(data);
414        let d = lz.decompress(&c);
415        assert_eq!(d, data);
416    }
417
418    #[test]
419    fn test_compressor_name() {
420        assert_eq!(compressor_name(), "lz77");
421    }
422
423    #[test]
424    fn test_longer_repetitive_text() {
425        let text = b"abcabcabcabcabcabcabcabcabcabc";
426        assert!(lz_roundtrip_ok(text));
427    }
428
429    #[test]
430    fn test_roundtrip_large() {
431        let data: Vec<u8> = (0..1000u32).map(|i| (i % 256) as u8).collect();
432        assert!(lz_roundtrip_ok(&data));
433    }
434}