Skip to main content

gamut_webp/vp8l/
encoder.rs

1//! VP8L image-data encoder.
2//!
3//! Produces a conformant, bit-exact-lossless VP8L bitstream. The encoder applies the forward
4//! transforms (subtract-green, then the spatial predictor, then the color transform), emitting each
5//! transform and its sub-resolution data, and then codes the residual image as literal ARGB pixels
6//! under a single prefix-code group. The remaining density features (color indexing, LZ77 backward
7//! references, the color cache, and multi-group entropy images) layer on in later commits of the
8//! issue-#22 series.
9//!
10//! Compression *quality* (optimal transform/mode choice, LZ77 parsing, entropy clustering) is
11//! deferred to issue #31; this code only needs to emit a valid stream that round-trips. Each
12//! channel's prefix code is built from the true histogram via
13//! [`build_length_limited_lengths`] and written
14//! with the normal code-length code. Single-symbol codes (e.g. a solid color, or the unused distance
15//! code) consume no bits, exactly as the decoder expects.
16
17use gamut_core::{Dimensions, Error, Result};
18
19use crate::vp8l::bit_io::BitWriter;
20use crate::vp8l::color_cache::ColorCache;
21use crate::vp8l::div_round_up;
22use crate::vp8l::header::Vp8lHeader;
23use crate::vp8l::lz77::{BackwardRefs, pixel_distance_to_code, value_to_prefix};
24use crate::vp8l::prefix::{
25    MAX_CODE_LENGTH, NUM_DISTANCE_CODES, NUM_LENGTH_CODES, NUM_LITERAL_CODES, PrefixEncoder,
26    build_length_limited_lengths, green_alphabet_size, write_normal_prefix_code,
27};
28use crate::vp8l::transform::{
29    COLOR_INDEXING_TRANSFORM, COLOR_TRANSFORM, PREDICTOR_TRANSFORM, SUBTRACT_GREEN_TRANSFORM,
30    alpha, blue, forward_color, forward_color_indexing, forward_predictor, forward_subtract_green,
31    green, red, subtract_pixels,
32};
33
34/// Block-size exponent for the predictor/color sub-images (16×16 blocks). Optimal block sizing is a
35/// density tuning knob deferred to issue #31.
36const TRANSFORM_SIZE_BITS: u8 = 4;
37
38/// Maximum number of distinct colors for the color-indexing (palette) transform (RFC 9649 §4.4).
39const MAX_PALETTE_SIZE: usize = 256;
40
41/// First green-alphabet symbol denoting an LZ77 length code.
42const LENGTH_CODE_BASE: usize = NUM_LITERAL_CODES;
43/// First green-alphabet symbol denoting a color-cache index.
44const CACHE_CODE_BASE: usize = NUM_LITERAL_CODES + NUM_LENGTH_CODES;
45
46/// Block-size exponent for the meta-prefix (entropy) image (16×16 meta-blocks).
47const PREFIX_BITS: u32 = 4;
48/// Cap on prefix-code groups; beyond this the encoder falls back to a single group rather than pay
49/// the per-group overhead (a naive bound — smarter clustering is deferred to issue #31).
50const MAX_GROUPS: u32 = 256;
51
52/// One coding decision for the entropy-coded pixel stream.
53enum Token {
54    /// A literal ARGB pixel (green, red, blue, alpha symbols).
55    Literal(u32),
56    /// An LZ77 backward reference: copy `len` pixels from `dist` pixels back.
57    Copy { len: u32, dist: u32 },
58    /// A color-cache hit at the given slot.
59    CacheIndex(u16),
60}
61
62impl Token {
63    /// Number of output pixels this token produces.
64    fn pixel_count(&self) -> usize {
65        match self {
66            Token::Copy { len, .. } => *len as usize,
67            Token::Literal(_) | Token::CacheIndex(_) => 1,
68        }
69    }
70
71    /// The green-alphabet symbol this token codes (literal green, length code, or cache index) —
72    /// used as a cheap per-block signature for grouping.
73    fn green_symbol(&self) -> usize {
74        match *self {
75            Token::Literal(p) => green(p) as usize,
76            Token::Copy { len, .. } => LENGTH_CODE_BASE + value_to_prefix(len).0 as usize,
77            Token::CacheIndex(idx) => CACHE_CODE_BASE + idx as usize,
78        }
79    }
80}
81
82/// Encodes an ARGB image (scan order, `0xAARRGGBB`) to a VP8L bitstream body — the bytes that go
83/// inside the `VP8L` chunk, signature byte included.
84///
85/// # Errors
86///
87/// Returns [`Error::InvalidInput`] if `argb.len()` does not equal `width * height`, or the
88/// dimensions are out of the VP8L range.
89pub fn encode(argb: &[u32], dims: Dimensions) -> Result<Vec<u8>> {
90    check_dimensions(argb, dims)?;
91    let alpha_is_used = argb.iter().any(|&p| alpha(p) != 0xff);
92    let header = Vp8lHeader::from_dimensions(dims, alpha_is_used)?;
93    let mut w = BitWriter::new();
94    header.write(&mut w);
95    write_image_body(&mut w, argb, dims);
96    Ok(w.finish())
97}
98
99/// Encodes an ARGB image to a **headerless** VP8L image-stream — the transform chain plus the
100/// spatially-coded image, without the dimension-carrying header. This is the form a
101/// lossless-compressed `ALPH` chunk takes (RFC 9649 §2.7.1, implicit dimensions); the header in a
102/// full [`encode`] is exactly five (byte-aligned) bytes, so this is that output minus those bytes.
103///
104/// # Errors
105///
106/// Returns [`Error::InvalidInput`] if `argb.len()` does not equal `width * height`.
107pub fn encode_image(argb: &[u32], dims: Dimensions) -> Result<Vec<u8>> {
108    check_dimensions(argb, dims)?;
109    let mut w = BitWriter::new();
110    write_image_body(&mut w, argb, dims);
111    Ok(w.finish())
112}
113
114/// Validates that `argb` holds exactly `width * height` pixels.
115fn check_dimensions(argb: &[u32], dims: Dimensions) -> Result<()> {
116    if (dims.width as usize).checked_mul(dims.height as usize) == Some(argb.len()) {
117        Ok(())
118    } else {
119        Err(Error::InvalidInput(
120            "VP8L: pixel buffer does not match dimensions",
121        ))
122    }
123}
124
125/// Writes the image body (transforms + spatially-coded image). Few-color images take the palette
126/// path; everything else takes the spatial-transform path. Choosing the densest path for a given
127/// image is a tuning concern deferred to issue #31.
128fn write_image_body(w: &mut BitWriter, argb: &[u32], dims: Dimensions) {
129    match build_palette(argb) {
130        Some(palette) => encode_palette(w, argb, dims, &palette),
131        None => encode_spatial(w, argb, dims),
132    }
133}
134
135/// Encodes via the spatial transforms (subtract-green, predictor, color) applied in read order; the
136/// decoder inverts them last-first.
137fn encode_spatial(w: &mut BitWriter, argb: &[u32], dims: Dimensions) {
138    let (width, height) = (dims.width, dims.height);
139    let mut pixels = argb.to_vec();
140
141    forward_subtract_green(&mut pixels);
142    write_transform_tag(w, SUBTRACT_GREEN_TRANSFORM);
143
144    let (residual, predictor_sub) = forward_predictor(&pixels, width, height, TRANSFORM_SIZE_BITS);
145    pixels = residual;
146    write_transform_tag(w, PREDICTOR_TRANSFORM);
147    w.write_bits(u32::from(TRANSFORM_SIZE_BITS - 2), 3);
148    write_sub_image(w, &predictor_sub);
149
150    let color_sub = forward_color(&mut pixels, width, height, TRANSFORM_SIZE_BITS);
151    write_transform_tag(w, COLOR_TRANSFORM);
152    w.write_bits(u32::from(TRANSFORM_SIZE_BITS - 2), 3);
153    write_sub_image(w, &color_sub);
154
155    w.write_bits(0, 1); // End of transforms.
156    write_main_image(w, &pixels, width);
157}
158
159/// Encodes via the color-indexing (palette) transform: the subtraction-coded palette followed by the
160/// bundled index image (RFC 9649 §4.4).
161fn encode_palette(w: &mut BitWriter, argb: &[u32], dims: Dimensions, palette: &[u32]) {
162    write_transform_tag(w, COLOR_INDEXING_TRANSFORM);
163    w.write_bits((palette.len() - 1) as u32, 8);
164
165    // The palette is stored as a height-1 image, subtraction-coded onto the previous entry.
166    let mut palette_image = vec![0u32; palette.len()];
167    palette_image[0] = palette[0];
168    for i in 1..palette.len() {
169        palette_image[i] = subtract_pixels(palette[i], palette[i - 1]);
170    }
171    write_sub_image(w, &palette_image);
172
173    let (bundled, bundled_width) = forward_color_indexing(argb, dims.width, dims.height, palette);
174    w.write_bits(0, 1); // End of transforms.
175    write_main_image(w, &bundled, bundled_width);
176}
177
178/// Collects the distinct colors in first-seen order, or `None` if there are more than
179/// [`MAX_PALETTE_SIZE`] (in which case the palette transform does not apply). Sorting the palette for
180/// denser subtraction coding is deferred to issue #31.
181fn build_palette(pixels: &[u32]) -> Option<Vec<u32>> {
182    use std::collections::HashSet;
183    let mut seen = HashSet::new();
184    let mut palette = Vec::new();
185    for &p in pixels {
186        if seen.insert(p) {
187            if palette.len() == MAX_PALETTE_SIZE {
188                return None;
189            }
190            palette.push(p);
191        }
192    }
193    Some(palette)
194}
195
196/// Writes a "transform present" bit followed by the 2-bit transform type.
197fn write_transform_tag(w: &mut BitWriter, transform_type: u8) {
198    w.write_bits(1, 1);
199    w.write_bits(u32::from(transform_type), 2);
200}
201
202/// Per-channel histograms used to build one prefix-code group.
203struct Histograms {
204    green: Vec<u32>,
205    red: Vec<u32>,
206    blue: Vec<u32>,
207    alpha: Vec<u32>,
208    distance: Vec<u32>,
209}
210
211impl Histograms {
212    fn new(cache_size: usize) -> Self {
213        Self {
214            green: vec![0; green_alphabet_size(cache_size)],
215            red: vec![0; NUM_LITERAL_CODES],
216            blue: vec![0; NUM_LITERAL_CODES],
217            alpha: vec![0; NUM_LITERAL_CODES],
218            distance: vec![0; NUM_DISTANCE_CODES],
219        }
220    }
221
222    /// Accumulates a token's symbols.
223    fn add(&mut self, token: &Token, width: u32) {
224        match *token {
225            Token::Literal(p) => {
226                self.green[green(p) as usize] += 1;
227                self.red[red(p) as usize] += 1;
228                self.blue[blue(p) as usize] += 1;
229                self.alpha[alpha(p) as usize] += 1;
230            }
231            Token::Copy { len, dist } => {
232                self.green[LENGTH_CODE_BASE + value_to_prefix(len).0 as usize] += 1;
233                let dist_code = pixel_distance_to_code(dist, width);
234                self.distance[value_to_prefix(dist_code).0 as usize] += 1;
235            }
236            Token::CacheIndex(idx) => self.green[CACHE_CODE_BASE + idx as usize] += 1,
237        }
238    }
239
240    /// Builds the five prefix codes.
241    fn build(&self) -> CodeGroup {
242        CodeGroup {
243            green: build_code(&self.green),
244            red: build_code(&self.red),
245            blue: build_code(&self.blue),
246            alpha: build_code(&self.alpha),
247            distance: build_code(&self.distance),
248        }
249    }
250}
251
252/// A built prefix-code group ready to emit symbols with.
253struct CodeGroup {
254    green: PrefixEncoder,
255    red: PrefixEncoder,
256    blue: PrefixEncoder,
257    alpha: PrefixEncoder,
258    distance: PrefixEncoder,
259}
260
261impl CodeGroup {
262    /// Writes the five code descriptions in bitstream order.
263    fn write_descriptions(&self, w: &mut BitWriter) {
264        write_normal_prefix_code(w, self.green.lengths());
265        write_normal_prefix_code(w, self.red.lengths());
266        write_normal_prefix_code(w, self.blue.lengths());
267        write_normal_prefix_code(w, self.alpha.lengths());
268        write_normal_prefix_code(w, self.distance.lengths());
269    }
270
271    /// Emits one token's symbols (and any LZ77 extra bits).
272    fn write_token(&self, w: &mut BitWriter, token: &Token, width: u32) {
273        match *token {
274            Token::Literal(p) => {
275                self.green.write_symbol(w, green(p) as usize);
276                self.red.write_symbol(w, red(p) as usize);
277                self.blue.write_symbol(w, blue(p) as usize);
278                self.alpha.write_symbol(w, alpha(p) as usize);
279            }
280            Token::Copy { len, dist } => {
281                let (len_code, len_bits, len_extra) = value_to_prefix(len);
282                self.green
283                    .write_symbol(w, LENGTH_CODE_BASE + len_code as usize);
284                w.write_bits(len_extra, u32::from(len_bits));
285                let (dist_sym, dist_bits, dist_extra) =
286                    value_to_prefix(pixel_distance_to_code(dist, width));
287                self.distance.write_symbol(w, dist_sym as usize);
288                w.write_bits(dist_extra, u32::from(dist_bits));
289            }
290            Token::CacheIndex(idx) => self.green.write_symbol(w, CACHE_CODE_BASE + idx as usize),
291        }
292    }
293}
294
295/// Writes a sub-resolution image (predictor/color sub-images, palette, entropy image) as literal
296/// pixels: no color cache, no meta prefix codes (the decoder reads these with `allow_meta = false`).
297fn write_sub_image(w: &mut BitWriter, pixels: &[u32]) {
298    w.write_bits(0, 1); // No color cache.
299    let mut hist = Histograms::new(0);
300    for &p in pixels {
301        hist.add(&Token::Literal(p), 0);
302    }
303    let codes = hist.build();
304    codes.write_descriptions(w);
305    for &p in pixels {
306        codes.write_token(w, &Token::Literal(p), 0);
307    }
308}
309
310/// Writes the top-level image with a color cache, LZ77 backward references, and (when the encoder
311/// splits the image into multiple statistical regions) a meta-prefix entropy image.
312fn write_main_image(w: &mut BitWriter, pixels: &[u32], width: u32) {
313    let cache_bits = pick_cache_bits(pixels.len());
314    let cache_size = if cache_bits > 0 {
315        1usize << cache_bits
316    } else {
317        0
318    };
319    if cache_bits > 0 {
320        w.write_bits(1, 1);
321        w.write_bits(cache_bits, 4);
322    } else {
323        w.write_bits(0, 1);
324    }
325
326    let tokens = tokenize(pixels, cache_bits);
327    let height = (pixels.len() as u32).checked_div(width).unwrap_or(0);
328    let groups = assign_groups(&tokens, width, height);
329
330    if groups.num_groups > 1 {
331        w.write_bits(1, 1); // Meta prefix codes present.
332        w.write_bits(groups.prefix_bits - 2, 3);
333        write_sub_image(w, &groups.entropy_image());
334    } else {
335        w.write_bits(0, 1); // Single meta prefix code.
336    }
337
338    // Histogram tokens into their groups (a copy's symbols use its start-position group), build a
339    // code group each, emit the descriptions, then replay the tokens.
340    let mut histograms: Vec<Histograms> = (0..groups.num_groups)
341        .map(|_| Histograms::new(cache_size))
342        .collect();
343    let mut pos = 0usize;
344    for token in &tokens {
345        histograms[groups.group_at(pos, width)].add(token, width);
346        pos += token.pixel_count();
347    }
348    let code_groups: Vec<CodeGroup> = histograms.iter().map(Histograms::build).collect();
349    for group in &code_groups {
350        group.write_descriptions(w);
351    }
352    let mut pos = 0usize;
353    for token in &tokens {
354        code_groups[groups.group_at(pos, width)].write_token(w, token, width);
355        pos += token.pixel_count();
356    }
357}
358
359/// The assignment of image meta-blocks to prefix-code groups.
360struct GroupAssignment {
361    prefix_bits: u32,
362    grid_width: u32,
363    block_group: Vec<u32>,
364    num_groups: u32,
365}
366
367impl GroupAssignment {
368    /// The group for the meta-block containing pixel index `pos`.
369    fn group_at(&self, pos: usize, width: u32) -> usize {
370        if self.num_groups <= 1 || width == 0 {
371            return 0;
372        }
373        let x = pos as u32 % width;
374        let y = pos as u32 / width;
375        let block = (y >> self.prefix_bits) * self.grid_width + (x >> self.prefix_bits);
376        self.block_group.get(block as usize).copied().unwrap_or(0) as usize
377    }
378
379    /// Builds the entropy image: one pixel per meta-block with the group id in the green channel.
380    fn entropy_image(&self) -> Vec<u32> {
381        self.block_group
382            .iter()
383            .map(|&g| crate::vp8l::transform::make_argb(0xff, 0, g as u8, 0))
384            .collect()
385    }
386}
387
388/// Assigns meta-blocks to groups by a cheap signature (each block's most frequent green symbol).
389/// Distinct signatures become distinct groups; if every block matches, a single group is used (no
390/// entropy-image overhead). Smarter, cost-aware clustering is deferred to issue #31.
391fn assign_groups(tokens: &[Token], width: u32, height: u32) -> GroupAssignment {
392    use std::collections::HashMap;
393    let grid_width = div_round_up(width, 1 << PREFIX_BITS);
394    let grid_height = div_round_up(height, 1 << PREFIX_BITS);
395    let num_blocks = (grid_width as usize) * (grid_height as usize);
396    let single = GroupAssignment {
397        prefix_bits: PREFIX_BITS,
398        grid_width,
399        block_group: vec![0; num_blocks.max(1)],
400        num_groups: 1,
401    };
402    if num_blocks <= 1 || width == 0 {
403        return single;
404    }
405
406    // Per-block green-symbol counts → most frequent symbol as the block signature.
407    let mut counts: Vec<HashMap<usize, u32>> = (0..num_blocks).map(|_| HashMap::new()).collect();
408    let mut pos = 0usize;
409    for token in tokens {
410        let x = pos as u32 % width;
411        let y = pos as u32 / width;
412        let block = ((y >> PREFIX_BITS) * grid_width + (x >> PREFIX_BITS)) as usize;
413        if let Some(counter) = counts.get_mut(block) {
414            *counter.entry(token.green_symbol()).or_insert(0) += 1;
415        }
416        pos += token.pixel_count();
417    }
418
419    let mut signature_group: HashMap<usize, u32> = HashMap::new();
420    let mut block_group = vec![0u32; num_blocks];
421    let mut num_groups = 0u32;
422    for (block, counter) in counts.iter().enumerate() {
423        let signature = counter
424            .iter()
425            .max_by_key(|&(_, &count)| count)
426            .map_or(0, |(&sym, _)| sym);
427        let group = match signature_group.get(&signature) {
428            Some(&g) => g,
429            None => {
430                let id = num_groups;
431                signature_group.insert(signature, id);
432                num_groups += 1;
433                id
434            }
435        };
436        block_group[block] = group;
437    }
438
439    if num_groups <= 1 || num_groups > MAX_GROUPS {
440        return single;
441    }
442    GroupAssignment {
443        prefix_bits: PREFIX_BITS,
444        grid_width,
445        block_group,
446        num_groups,
447    }
448}
449
450/// Tokenizes `pixels` into literals, LZ77 copies, and color-cache hits, simulating the cache exactly
451/// as the decoder will reconstruct it (every produced pixel is inserted, in stream order). Token
452/// preference is copy > cache > literal — a simple deterministic policy; optimal parsing is deferred
453/// to issue #31.
454fn tokenize(pixels: &[u32], cache_bits: u32) -> Vec<Token> {
455    let n = pixels.len();
456    let mut tokens = Vec::new();
457    let mut refs = BackwardRefs::new(n);
458    let mut cache = if cache_bits > 0 {
459        ColorCache::new(cache_bits).ok()
460    } else {
461        None
462    };
463
464    let mut i = 0;
465    while i < n {
466        if let Some((len, dist)) = refs.find(pixels, i) {
467            tokens.push(Token::Copy { len, dist });
468            let end = i + len as usize;
469            while i < end {
470                refs.insert(pixels, i);
471                if let Some(c) = cache.as_mut() {
472                    c.insert(pixels[i]);
473                }
474                i += 1;
475            }
476        } else {
477            let pixel = pixels[i];
478            let hit_slot = cache.as_ref().and_then(|c| {
479                let slot = c.slot(pixel);
480                (c.lookup(slot as u32) == pixel).then_some(slot)
481            });
482            match hit_slot {
483                Some(slot) => tokens.push(Token::CacheIndex(slot as u16)),
484                None => tokens.push(Token::Literal(pixel)),
485            }
486            if let Some(c) = cache.as_mut() {
487                c.insert(pixel);
488            }
489            refs.insert(pixels, i);
490            i += 1;
491        }
492    }
493    tokens
494}
495
496/// Chooses a color-cache size for an image of `num_pixels` pixels: off for tiny images, otherwise
497/// roughly `ceil(log2(num_pixels))` capped to 10. Optimal sizing is deferred to issue #31.
498fn pick_cache_bits(num_pixels: usize) -> u32 {
499    if num_pixels < 16 {
500        0
501    } else {
502        (usize::BITS - (num_pixels - 1).leading_zeros()).clamp(1, 10)
503    }
504}
505
506/// Builds a [`PrefixEncoder`] from a histogram, forcing a valid single-symbol-0 code if the
507/// histogram is empty (so an unused alphabet still emits a complete code).
508fn build_code(histogram: &[u32]) -> PrefixEncoder {
509    let mut lengths = build_length_limited_lengths(histogram, MAX_CODE_LENGTH as u8);
510    // An all-zero histogram yields an empty code; code symbol 0 at length 1 so the tree is valid.
511    if !lengths.is_empty() && lengths.iter().all(|&l| l == 0) {
512        lengths[0] = 1;
513    }
514    PrefixEncoder::from_lengths(&lengths)
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::vp8l::decoder::decode;
521    use crate::vp8l::transform::make_argb;
522
523    fn round_trip(argb: &[u32], width: u32, height: u32) {
524        let dims = Dimensions { width, height };
525        let bitstream = encode(argb, dims).expect("encode");
526        let (decoded_dims, pixels) = decode(&bitstream).expect("decode");
527        assert_eq!(decoded_dims, dims);
528        assert_eq!(pixels, argb, "round-trip mismatch at {width}x{height}");
529    }
530
531    #[test]
532    fn round_trips_single_pixel() {
533        round_trip(&[make_argb(0xff, 0x12, 0x34, 0x56)], 1, 1);
534    }
535
536    #[test]
537    fn round_trips_gradient() {
538        let (w, h) = (8u32, 5u32);
539        let img: Vec<u32> = (0..w * h)
540            .map(|i| make_argb(0xff, (i * 3) as u8, (i * 7) as u8, i as u8))
541            .collect();
542        round_trip(&img, w, h);
543    }
544
545    #[test]
546    fn round_trips_solid_color() {
547        let img = vec![make_argb(0xff, 9, 9, 9); 17 * 9];
548        round_trip(&img, 17, 9);
549    }
550
551    #[test]
552    fn round_trips_many_colors_via_spatial_path() {
553        // > 256 distinct colors forces the spatial-transform path (subtract-green/predictor/color).
554        let (w, h) = (64u32, 48u32);
555        let img: Vec<u32> = (0..(w * h))
556            .map(|i| {
557                make_argb(
558                    0xff,
559                    (i & 0xff) as u8,
560                    ((i >> 8) & 0xff) as u8,
561                    (i * 13) as u8,
562                )
563            })
564            .collect();
565        assert!(
566            build_palette(&img).is_none(),
567            "test image must exceed the palette limit"
568        );
569        round_trip(&img, w, h);
570    }
571
572    #[test]
573    fn round_trips_repetitive_spatial_with_backward_refs() {
574        // A >256-color tile repeated horizontally, so the spatial residual has LZ77 matches.
575        let (tile_w, h) = (40u32, 12u32);
576        let tile: Vec<u32> = (0..(tile_w * h))
577            .map(|i| {
578                make_argb(
579                    0xff,
580                    (i & 0xff) as u8,
581                    ((i >> 8) & 0xff) as u8,
582                    (i * 7) as u8,
583                )
584            })
585            .collect();
586        let w = tile_w * 2;
587        let img: Vec<u32> = (0..(w * h))
588            .map(|i| {
589                let (x, y) = (i % w, i / w);
590                tile[(y * tile_w + x % tile_w) as usize]
591            })
592            .collect();
593        assert!(build_palette(&img).is_none());
594        round_trip(&img, w, h);
595    }
596
597    #[test]
598    fn round_trips_repetitive_palette_with_backward_refs() {
599        // A repetitive few-color image: palette path with the cache + LZ77 on the bundled indices.
600        let palette = [
601            make_argb(0xff, 1, 2, 3),
602            make_argb(0xff, 4, 5, 6),
603            make_argb(0xff, 7, 8, 9),
604        ];
605        let (w, h) = (32u32, 8u32);
606        let img: Vec<u32> = (0..(w * h)).map(|i| palette[(i % 3) as usize]).collect();
607        round_trip(&img, w, h);
608    }
609
610    #[test]
611    fn round_trips_horizontal_run() {
612        // Long horizontal runs of one color exercise distance-1 (run-length) backward references.
613        let (w, h) = (50u32, 4u32);
614        let img: Vec<u32> = (0..(w * h))
615            .map(|i| {
616                if (i / w) % 2 == 0 {
617                    make_argb(0xff, 200, 100, 50)
618                } else {
619                    make_argb(0xff, 10, 20, 30)
620                }
621            })
622            .collect();
623        round_trip(&img, w, h);
624    }
625
626    #[test]
627    fn distinct_regions_use_multiple_groups() {
628        // A 32-color image (palette path, no bundling) whose top and bottom halves draw their
629        // indices from disjoint ranges, scattered so they stay literals rather than LZ77 runs. The
630        // first-seen palette order maps the top colors to low indices and the bottom colors to high
631        // ones, so the meta-blocks have clearly different green statistics → multiple groups.
632        let palette: Vec<u32> = (0..32)
633            .map(|i| make_argb(0xff, i as u8, (i * 7) as u8, (i * 13) as u8))
634            .collect();
635        let (w, h) = (32u32, 32u32);
636        let img: Vec<u32> = (0..(w * h))
637            .map(|i| {
638                let (x, y) = ((i % w) as usize, (i / w) as usize);
639                let scatter = (x * 7 + y * 11) % 16;
640                let idx = if y < 16 { scatter } else { 16 + scatter };
641                palette[idx]
642            })
643            .collect();
644
645        // Replicate the encoder's internal grouping to assert it splits into multiple groups.
646        let detected = build_palette(&img).expect("few-color image has a palette");
647        let (bundled, bundled_width) = forward_color_indexing(&img, w, h, &detected);
648        let tokens = tokenize(&bundled, pick_cache_bits(bundled.len()));
649        let assignment =
650            assign_groups(&tokens, bundled_width, bundled.len() as u32 / bundled_width);
651        assert!(
652            assignment.num_groups >= 2,
653            "expected multiple groups, got {}",
654            assignment.num_groups
655        );
656
657        round_trip(&img, w, h);
658    }
659
660    #[test]
661    fn assign_groups_merges_uniform_blocks() {
662        // All-literal tokens with the same green symbol must collapse to a single group.
663        let tokens: Vec<Token> = (0..1024)
664            .map(|_| Token::Literal(make_argb(0xff, 0, 7, 0)))
665            .collect();
666        let assignment = assign_groups(&tokens, 32, 32);
667        assert_eq!(assignment.num_groups, 1);
668    }
669
670    #[test]
671    fn round_trips_two_color_palette() {
672        // A 2-color image bundles 8 pixels per byte (width_bits = 3).
673        let (a, b) = (make_argb(0xff, 0, 0, 0), make_argb(0xff, 255, 255, 255));
674        let img: Vec<u32> = (0..30).map(|i| if i % 3 == 0 { a } else { b }).collect();
675        round_trip(&img, 6, 5);
676    }
677
678    #[test]
679    fn preserves_non_opaque_alpha() {
680        let img: Vec<u32> = (0..16)
681            .map(|i| make_argb((i * 16) as u8, i as u8, (255 - i) as u8, 0))
682            .collect();
683        round_trip(&img, 4, 4);
684        // The header's alpha hint should be set when any pixel is non-opaque.
685        let bitstream = encode(
686            &img,
687            Dimensions {
688                width: 4,
689                height: 4,
690            },
691        )
692        .unwrap();
693        let mut r = crate::vp8l::bit_io::BitReader::new(&bitstream);
694        let header = Vp8lHeader::read(&mut r).unwrap();
695        assert!(header.alpha_is_used);
696    }
697
698    #[test]
699    fn rejects_dimension_mismatch() {
700        assert!(matches!(
701            encode(
702                &[0, 0, 0],
703                Dimensions {
704                    width: 2,
705                    height: 2
706                }
707            ),
708            Err(Error::InvalidInput(_))
709        ));
710    }
711}