Skip to main content

bwipp/
encoding.rs

1//! Core encoding representations.
2//!
3//! All symbologies produce an [`Encoded`] value that the renderer then turns
4//! into SVG or PNG. The two shapes mirror BWIPP's split between `renlinear`
5//! (1D) and `renmatrix` (2D).
6
7/// Output of encoding a barcode payload.
8#[derive(Debug, Clone)]
9pub enum Encoded {
10    /// One-dimensional barcode: a run-length stream of bar widths in modules.
11    Linear(LinearPattern),
12    /// Two-dimensional barcode: a black/white grid of modules.
13    Matrix(BitMatrix),
14    /// 4-state postal code: each character produces one bar of one of four
15    /// vertical shapes (full / ascender / descender / tracker), separated by
16    /// fixed-width gaps. Used by Royal Mail, KIX, Japan Post, DAFT, etc.
17    Postal4State(Postal4Pattern),
18    /// Stacked / multi-row 1D barcode: each row is its own [`LinearPattern`]
19    /// drawn beneath the previous one, with a fixed-height row separator.
20    /// Used by Codablock-F, PDF417, GS1 DataBar Stacked, and the
21    /// composite-code 2D companion.
22    Stacked(StackedPattern),
23    /// 2D dot-matrix barcode: a sparse pattern of dots on a parity grid.
24    /// Each "on" cell is rendered as a round dot rather than a square
25    /// module — the defining visual feature of DotCode.
26    Dots(DotMatrix),
27    /// MaxiCode hexagonal symbol — a fixed 33×30 cell grid where
28    /// odd-numbered rows are physically offset by half a module to
29    /// the right, producing a hex-packed layout. Boxed because the
30    /// inline `[bool; 990]` cells array dwarfs the other variants.
31    Hex(Box<crate::symbology::maxicode::MaxiCodeSymbol>),
32    /// Multi-colour 2D matrix barcode — each cell carries a palette
33    /// index `0..=7` rather than a single bit. Currently used by
34    /// Ultracode, which uses an 8-entry palette of CMYWK colours
35    /// (white, cyan, magenta, yellow, green, blue, red, black per
36    /// the AIM USS Ultracode spec). The renderer emits one coloured
37    /// rect per cell in SVG / one RGB byte per pixel in PNG.
38    ColorMatrix(ColorMatrix),
39}
40
41/// A 2D colour matrix: `rows × columns` cells where each cell
42/// carries a palette index into an 8-entry `[Rgb8; 8]` colour table.
43/// Cells with palette index 0 are treated as background (typically
44/// white) by the renderer — no rect / pixel emitted.
45///
46/// The 8-colour palette is fixed at the symbology level (e.g. the
47/// AIM Ultracode CMYWK palette is defined in
48/// [`crate::symbology::ultracode::ULTRACODE_PALETTE`]) so the
49/// `palette` field is included in the struct for the renderer's
50/// convenience.
51#[derive(Debug, Clone)]
52pub struct ColorMatrix {
53    width: usize,
54    height: usize,
55    /// Each entry is a palette index 0..=7. Index 0 = background.
56    cells: Vec<u8>,
57    /// 8-entry RGB palette (sRGB 8-bit per channel).
58    palette: [Rgb8; 8],
59}
60
61/// 8-bit-per-channel sRGB colour. Used in [`ColorMatrix`] palettes
62/// and the renderer dispatch.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct Rgb8 {
65    /// Red channel (0..=255).
66    pub r: u8,
67    /// Green channel (0..=255).
68    pub g: u8,
69    /// Blue channel (0..=255).
70    pub b: u8,
71}
72
73impl Rgb8 {
74    /// Construct an RGB colour from 8-bit channel values.
75    #[inline]
76    pub const fn new(r: u8, g: u8, b: u8) -> Self {
77        Self { r, g, b }
78    }
79
80    /// Format the colour as a CSS hex string `#rrggbb` (lowercase).
81    pub fn to_css_hex(&self) -> String {
82        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
83    }
84}
85
86impl ColorMatrix {
87    /// Allocate an all-background (palette index 0) matrix of the
88    /// given dimensions, with the given colour palette.
89    pub fn new(width: usize, height: usize, palette: [Rgb8; 8]) -> Self {
90        Self {
91            width,
92            height,
93            cells: vec![0; width * height],
94            palette,
95        }
96    }
97
98    /// Width in modules.
99    #[inline]
100    pub fn width(&self) -> usize {
101        self.width
102    }
103
104    /// Height in modules.
105    #[inline]
106    pub fn height(&self) -> usize {
107        self.height
108    }
109
110    /// Read a cell's palette index (`0..=7`).
111    #[inline]
112    pub fn get(&self, x: usize, y: usize) -> u8 {
113        self.cells[y * self.width + x]
114    }
115
116    /// Write a cell's palette index. Caller is responsible for
117    /// passing a value in `0..=7`; out-of-range values are masked
118    /// to 3 bits to keep the field invariant.
119    #[inline]
120    pub fn set(&mut self, x: usize, y: usize, palette_idx: u8) {
121        self.cells[y * self.width + x] = palette_idx & 0b111;
122    }
123
124    /// Borrow the 8-entry RGB palette.
125    #[inline]
126    pub fn palette(&self) -> &[Rgb8; 8] {
127        &self.palette
128    }
129
130    /// Resolve a cell to its RGB colour.
131    #[inline]
132    pub fn cell_color(&self, x: usize, y: usize) -> Rgb8 {
133        self.palette[self.get(x, y) as usize]
134    }
135}
136
137/// A sparse 2D dot grid: `rows × columns` cells where `true` means a
138/// dot is present at that position. Only odd-parity positions
139/// (`(x + y) % 2 == 1`) ever carry true cells from a DotCode encoder;
140/// the renderer treats all true cells uniformly.
141#[derive(Debug, Clone)]
142pub struct DotMatrix {
143    width: usize,
144    height: usize,
145    data: Vec<bool>,
146}
147
148impl DotMatrix {
149    /// Allocate a `width × height` grid with every cell `false`.
150    pub fn new(width: usize, height: usize) -> Self {
151        Self {
152            width,
153            height,
154            data: vec![false; width * height],
155        }
156    }
157
158    /// Width in cells.
159    pub fn width(&self) -> usize {
160        self.width
161    }
162
163    /// Height in cells.
164    pub fn height(&self) -> usize {
165        self.height
166    }
167
168    /// Read a cell. `true` means a dot is present at `(x, y)`.
169    pub fn get(&self, x: usize, y: usize) -> bool {
170        self.data[y * self.width + x]
171    }
172
173    /// Write a cell.
174    pub fn set(&mut self, x: usize, y: usize, value: bool) {
175        self.data[y * self.width + x] = value;
176    }
177}
178
179/// A single bar in a 4-state postal code.
180#[derive(Copy, Clone, Debug, PartialEq, Eq)]
181pub enum Bar4State {
182    /// Short bar centered vertically (codeword digit `0`).
183    Tracker,
184    /// Bar that extends from the centerline down to the baseline (digit `1`).
185    Descender,
186    /// Bar that extends from the top down to the centerline (digit `2`).
187    Ascender,
188    /// Full-height bar (digit `3`).
189    Full,
190}
191
192impl Bar4State {
193    /// Construct from a 0..=3 codeword (returns `None` for other values).
194    pub fn from_digit(d: u8) -> Option<Self> {
195        Some(match d {
196            0 => Self::Tracker,
197            1 => Self::Descender,
198            2 => Self::Ascender,
199            3 => Self::Full,
200            _ => return None,
201        })
202    }
203
204    /// Whether the bar reaches the top of the symbol (`Ascender` or `Full`).
205    pub fn has_ascender(self) -> bool {
206        matches!(self, Self::Ascender | Self::Full)
207    }
208
209    /// Whether the bar reaches the bottom (`Descender` or `Full`).
210    pub fn has_descender(self) -> bool {
211        matches!(self, Self::Descender | Self::Full)
212    }
213}
214
215/// A 4-state postal symbol: an ordered sequence of bars with optional
216/// human-readable text.
217#[derive(Debug, Clone)]
218pub struct Postal4Pattern {
219    /// Each element is one bar; bars are separated by single-module gaps in
220    /// the rendered output.
221    pub bars: Vec<Bar4State>,
222    /// Human-readable text rendered beneath the symbol when
223    /// [`crate::Options::include_text`] is `true`.
224    pub text: Option<String>,
225}
226
227/// A stacked / multi-row 1D barcode. Each row carries its own
228/// [`LinearPattern`]; the renderer draws them top-to-bottom with a one-
229/// module spacer between rows.
230#[derive(Debug, Clone)]
231pub struct StackedPattern {
232    /// Rows, top to bottom. All rows must share the same `total_width()`.
233    pub rows: Vec<LinearPattern>,
234    /// Optional human-readable text rendered below the entire symbol.
235    pub text: Option<String>,
236}
237
238impl StackedPattern {
239    /// Build a `StackedPattern` from a non-empty vec of rows. Verifies that
240    /// every row has the same total width (returns the first mismatch as an
241    /// `Err` of `(expected, got)` for the caller to report).
242    pub fn new(rows: Vec<LinearPattern>, text: Option<String>) -> Result<Self, (u32, u32)> {
243        if rows.is_empty() {
244            return Ok(Self { rows, text });
245        }
246        let w = rows[0].total_width();
247        for r in &rows[1..] {
248            let rw = r.total_width();
249            if rw != w {
250                return Err((w, rw));
251            }
252        }
253        Ok(Self { rows, text })
254    }
255
256    /// Width of one row in modules (all rows share this width).
257    pub fn width(&self) -> u32 {
258        self.rows.first().map(|r| r.total_width()).unwrap_or(0)
259    }
260
261    /// Number of rows.
262    pub fn height_rows(&self) -> usize {
263        self.rows.len()
264    }
265}
266
267impl Postal4Pattern {
268    /// Build a [`Postal4Pattern`] from a slice of 0..=3 codeword digits.
269    /// Returns `None` if any digit is out of range.
270    pub fn from_digits(digits: &[u8], text: Option<String>) -> Option<Self> {
271        let mut bars = Vec::with_capacity(digits.len());
272        for &d in digits {
273            bars.push(Bar4State::from_digit(d)?);
274        }
275        Some(Self { bars, text })
276    }
277
278    /// Total number of bars in the symbol.
279    pub fn len(&self) -> usize {
280        self.bars.len()
281    }
282
283    /// Whether the symbol is empty.
284    pub fn is_empty(&self) -> bool {
285        self.bars.is_empty()
286    }
287}
288
289/// A 1D bar pattern as a run-length encoding.
290///
291/// `bars[i]` is the width (in modules) of the i-th element. Even indices
292/// (0, 2, 4, ...) are bars (foreground), odd indices are spaces (background).
293/// This matches BWIPP's `sbs` (start-bar-space) convention.
294#[derive(Debug, Clone)]
295pub struct LinearPattern {
296    /// Run-length widths, starting with a bar.
297    pub bars: Vec<u8>,
298    /// Human-readable text rendered beneath the bars (if the symbology
299    /// supports it).
300    pub text: Option<String>,
301}
302
303impl LinearPattern {
304    /// Build a `LinearPattern` from a string of '1' and '0' characters where
305    /// '1' is a bar module and '0' is a space module.
306    pub fn from_modules(modules: &str, text: Option<String>) -> Self {
307        let mut bars = Vec::new();
308        let chars: Vec<char> = modules.chars().collect();
309        if chars.is_empty() {
310            return Self { bars, text };
311        }
312        // The pattern always starts with a bar. If the first module is '0',
313        // we emit a zero-width bar so the alternation invariant holds.
314        let mut expected = '1';
315        let mut run: u8 = 0;
316        for c in chars {
317            if c == expected {
318                run += 1;
319            } else {
320                bars.push(run);
321                run = 1;
322                expected = if expected == '1' { '0' } else { '1' };
323            }
324        }
325        bars.push(run);
326        Self { bars, text }
327    }
328
329    /// Total width in modules (sum of all bar/space widths).
330    pub fn total_width(&self) -> u32 {
331        self.bars.iter().map(|&b| u32::from(b)).sum()
332    }
333}
334
335/// A 2D black/white module grid. `(0, 0)` is the top-left.
336#[derive(Debug, Clone)]
337pub struct BitMatrix {
338    width: usize,
339    height: usize,
340    data: Vec<bool>,
341}
342
343impl BitMatrix {
344    /// Allocate an all-white matrix.
345    pub fn new(width: usize, height: usize) -> Self {
346        Self {
347            width,
348            height,
349            data: vec![false; width * height],
350        }
351    }
352
353    /// Width in modules.
354    pub fn width(&self) -> usize {
355        self.width
356    }
357
358    /// Height in modules.
359    pub fn height(&self) -> usize {
360        self.height
361    }
362
363    /// Read a module. `true` is black/foreground.
364    pub fn get(&self, x: usize, y: usize) -> bool {
365        self.data[y * self.width + x]
366    }
367
368    /// Write a module.
369    pub fn set(&mut self, x: usize, y: usize, value: bool) {
370        self.data[y * self.width + x] = value;
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn from_modules_run_length_encodes() {
380        let p = LinearPattern::from_modules("111001100011", None);
381        assert_eq!(p.bars, vec![3, 2, 2, 3, 2]);
382        assert_eq!(p.total_width(), 12);
383    }
384
385    /// Stage 11.A8c — pin `LinearPattern::from_modules` corner cases
386    /// beyond the canonical mid-length input. The function maintains
387    /// the invariant "pattern always starts with a bar": if the first
388    /// module is '0', it emits a zero-width bar to keep
389    /// even-indexed slots representing bars.
390    ///
391    /// Mutations to catch:
392    ///   - `expected = '1'` → `'0'` (inverted initial polarity)
393    ///   - `run += 1` → `run *= 2` or similar
394    ///   - the alternation toggle `if expected == '1' { '0' } else { '1' }`
395    ///   - skipping the final `bars.push(run)` after the loop
396    #[test]
397    fn from_modules_edge_cases() {
398        // Empty → no bars.
399        let p = LinearPattern::from_modules("", None);
400        assert!(p.bars.is_empty(), "empty input → empty bars");
401        assert_eq!(p.total_width(), 0);
402
403        // Single bar.
404        let p = LinearPattern::from_modules("1", None);
405        assert_eq!(p.bars, vec![1]);
406
407        // Single space → leading 0-width bar then 1-wide space.
408        let p = LinearPattern::from_modules("0", None);
409        assert_eq!(
410            p.bars,
411            vec![0, 1],
412            "single '0' inserts a 0-width bar to preserve alternation"
413        );
414
415        // Two bars, one wide.
416        let p = LinearPattern::from_modules("11", None);
417        assert_eq!(p.bars, vec![2]);
418
419        // Bar-space-bar.
420        let p = LinearPattern::from_modules("101", None);
421        assert_eq!(p.bars, vec![1, 1, 1]);
422
423        // Leading two zeros: 0-width bar + 2-wide space + 2 bars.
424        let p = LinearPattern::from_modules("0011", None);
425        assert_eq!(p.bars, vec![0, 2, 2]);
426
427        // Trailing space at the end.
428        let p = LinearPattern::from_modules("100", None);
429        assert_eq!(p.bars, vec![1, 2]);
430
431        // Trailing bar at the end.
432        let p = LinearPattern::from_modules("0010", None);
433        assert_eq!(p.bars, vec![0, 2, 1, 1]);
434
435        // Full alternation.
436        let p = LinearPattern::from_modules("10101", None);
437        assert_eq!(p.bars, vec![1, 1, 1, 1, 1]);
438
439        // text passthrough.
440        let p = LinearPattern::from_modules("1", Some("X".into()));
441        assert_eq!(p.text.as_deref(), Some("X"));
442    }
443
444    #[test]
445    fn matrix_default_white_and_set_works() {
446        let mut m = BitMatrix::new(5, 3);
447        assert!(!m.get(2, 1));
448        m.set(2, 1, true);
449        assert!(m.get(2, 1));
450        assert!(!m.get(0, 0));
451    }
452
453    /// Stage 11.A8c — pin `DotMatrix::new`/`get`/`set` round-trip.
454    /// DotMatrix carries the dot pattern for DotCode encoding;
455    /// previously no direct test existed (it was only exercised
456    /// end-to-end through the dotcode renderer). Mutations on
457    /// width/height accessors or set→get round-trip would propagate
458    /// to renderer pixel placement, so a direct test pins:
459    ///   - Default-false invariant after new().
460    ///   - set() write + get() read round-trip preserves the value.
461    ///   - width() / height() report constructor values.
462    ///   - Setting one cell doesn't affect other cells.
463    #[test]
464    fn dot_matrix_new_set_get() {
465        let mut m = DotMatrix::new(7, 4);
466        assert_eq!(m.width(), 7);
467        assert_eq!(m.height(), 4);
468        // All cells default false.
469        for y in 0..4 {
470            for x in 0..7 {
471                assert!(!m.get(x, y), "({x},{y}) should default false");
472            }
473        }
474        // Set one cell, verify only that cell is true.
475        m.set(3, 2, true);
476        assert!(m.get(3, 2));
477        for y in 0..4 {
478            for x in 0..7 {
479                let expected = (x, y) == (3, 2);
480                assert_eq!(m.get(x, y), expected, "({x},{y})");
481            }
482        }
483        // Set another, verify both true (and others still false).
484        m.set(0, 0, true);
485        assert!(m.get(0, 0));
486        assert!(m.get(3, 2));
487        // Set a previously-true cell back to false.
488        m.set(3, 2, false);
489        assert!(!m.get(3, 2));
490        assert!(m.get(0, 0)); // unaffected
491    }
492
493    #[test]
494    fn bar4state_from_digit_round_trip() {
495        assert_eq!(Bar4State::from_digit(0), Some(Bar4State::Tracker));
496        assert_eq!(Bar4State::from_digit(1), Some(Bar4State::Descender));
497        assert_eq!(Bar4State::from_digit(2), Some(Bar4State::Ascender));
498        assert_eq!(Bar4State::from_digit(3), Some(Bar4State::Full));
499        assert_eq!(Bar4State::from_digit(4), None);
500    }
501
502    #[test]
503    fn bar4state_ascender_descender_flags() {
504        assert!(Bar4State::Ascender.has_ascender());
505        assert!(Bar4State::Full.has_ascender());
506        assert!(!Bar4State::Descender.has_ascender());
507        assert!(!Bar4State::Tracker.has_ascender());
508
509        assert!(Bar4State::Descender.has_descender());
510        assert!(Bar4State::Full.has_descender());
511        assert!(!Bar4State::Ascender.has_descender());
512        assert!(!Bar4State::Tracker.has_descender());
513    }
514
515    #[test]
516    fn postal4_pattern_from_digits_rejects_out_of_range() {
517        assert!(Postal4Pattern::from_digits(&[0, 1, 2, 4], None).is_none());
518        let p = Postal4Pattern::from_digits(&[3, 2, 1, 0], None).unwrap();
519        assert_eq!(p.len(), 4);
520        assert_eq!(
521            p.bars,
522            vec![
523                Bar4State::Full,
524                Bar4State::Ascender,
525                Bar4State::Descender,
526                Bar4State::Tracker
527            ]
528        );
529    }
530
531    /// Stage 11.A8c — pin `Postal4Pattern::is_empty` and `len`
532    /// behavior. `is_empty` is a thin wrapper around `bars.is_empty()`
533    /// but had no direct test; mutations like `is_empty` → `!is_empty`
534    /// or `true` would survive since callers typically check `len()`
535    /// instead.
536    #[test]
537    fn postal4_pattern_len_and_is_empty() {
538        // Empty input → empty pattern.
539        let empty = Postal4Pattern::from_digits(&[], None).unwrap();
540        assert_eq!(empty.len(), 0);
541        assert!(empty.is_empty());
542
543        // Single bar → not empty.
544        let one = Postal4Pattern::from_digits(&[0], None).unwrap();
545        assert_eq!(one.len(), 1);
546        assert!(!one.is_empty(), "1-bar pattern must NOT be empty");
547
548        // Multi-bar.
549        let four = Postal4Pattern::from_digits(&[3, 2, 1, 0], None).unwrap();
550        assert_eq!(four.len(), 4);
551        assert!(!four.is_empty());
552
553        // Direct construction (no `from_digits` filter).
554        let direct = Postal4Pattern {
555            bars: vec![],
556            text: Some("ignored".into()),
557        };
558        assert!(
559            direct.is_empty(),
560            "empty bars → is_empty regardless of text"
561        );
562        assert_eq!(direct.len(), 0);
563    }
564
565    /// Stage 11.A8c — pin `StackedPattern::new`'s width-uniformity
566    /// validation. The constructor returns `Err((expected, got))`
567    /// when row widths don't all match, and `Ok` for empty or
568    /// uniform input. No direct test existed; the function is only
569    /// exercised end-to-end through stacked encoders (codablockf,
570    /// code16k, code49) where a row-width mismatch would surface as
571    /// a downstream encoding error.
572    ///
573    /// Pinning the helper directly catches:
574    ///   - `delete the width check loop` → unequal rows silently accepted
575    ///   - `if rw != w` → `==` swap → only equal-width rows would error
576    ///   - The (expected, got) tuple ordering
577    ///   - Empty/single-row passthrough behavior
578    #[test]
579    fn stacked_pattern_new_width_uniformity() {
580        // Empty rows → Ok with empty rows.
581        let empty: Vec<LinearPattern> = Vec::new();
582        let sp = StackedPattern::new(empty, None).unwrap();
583        assert_eq!(sp.height_rows(), 0);
584        assert_eq!(sp.width(), 0);
585
586        // Single row → Ok.
587        let single = vec![LinearPattern {
588            bars: vec![1, 2, 3, 4],
589            text: None,
590        }];
591        let sp = StackedPattern::new(single, Some("hi".into())).unwrap();
592        assert_eq!(sp.height_rows(), 1);
593        assert_eq!(sp.width(), 10); // 1+2+3+4
594        assert_eq!(sp.text.as_deref(), Some("hi"));
595
596        // Three rows, same width (1+2+3 = 6) → Ok.
597        let uniform = vec![
598            LinearPattern {
599                bars: vec![1, 2, 3],
600                text: None,
601            },
602            LinearPattern {
603                bars: vec![3, 2, 1],
604                text: None,
605            },
606            LinearPattern {
607                bars: vec![2, 2, 2],
608                text: None,
609            },
610        ];
611        let sp = StackedPattern::new(uniform, None).unwrap();
612        assert_eq!(sp.height_rows(), 3);
613        assert_eq!(sp.width(), 6);
614
615        // Width mismatch at row 1: expected 6, got 7. Err((6, 7)).
616        let mismatch = vec![
617            LinearPattern {
618                bars: vec![1, 2, 3],
619                text: None,
620            }, // total_width = 6
621            LinearPattern {
622                bars: vec![1, 2, 4],
623                text: None,
624            }, // total_width = 7
625        ];
626        match StackedPattern::new(mismatch, None) {
627            Err((expected, got)) => {
628                assert_eq!(expected, 6, "first row's width is the 'expected'");
629                assert_eq!(got, 7, "diverging row's width is 'got'");
630            }
631            Ok(_) => panic!("width mismatch should error"),
632        }
633
634        // Mismatch in 3rd row (rows 0, 1 match; row 2 diverges) —
635        // the loop reports the first mismatch.
636        let late_mismatch = vec![
637            LinearPattern {
638                bars: vec![1, 2, 3],
639                text: None,
640            }, // 6
641            LinearPattern {
642                bars: vec![3, 2, 1],
643                text: None,
644            }, // 6
645            LinearPattern {
646                bars: vec![5, 5, 5],
647                text: None,
648            }, // 15
649        ];
650        match StackedPattern::new(late_mismatch, None) {
651            Err((expected, got)) => {
652                assert_eq!((expected, got), (6, 15));
653            }
654            Ok(_) => panic!("late mismatch must error"),
655        }
656    }
657
658    /// Pinned `Rgb8` helpers: constructor preserves channels and the
659    /// CSS hex formatter zero-pads each channel to 2 lowercase hex
660    /// digits. SVG renderers depend on this exact format.
661    #[test]
662    fn rgb8_constructor_and_css_hex() {
663        let c = Rgb8::new(0x12, 0xab, 0xff);
664        assert_eq!((c.r, c.g, c.b), (0x12, 0xab, 0xff));
665        assert_eq!(c.to_css_hex(), "#12abff");
666        // Zero-padded.
667        assert_eq!(Rgb8::new(0, 0, 0).to_css_hex(), "#000000");
668        assert_eq!(Rgb8::new(0x0a, 0, 0x0f).to_css_hex(), "#0a000f");
669    }
670
671    /// `ColorMatrix::new` allocates an all-background matrix; `set`
672    /// writes a palette index that `get` reads back, and `cell_color`
673    /// resolves through the palette table.
674    #[test]
675    fn color_matrix_new_set_get_cell_color() {
676        let palette: [Rgb8; 8] = [
677            Rgb8::new(0xff, 0xff, 0xff),
678            Rgb8::new(0x10, 0x20, 0x30),
679            Rgb8::new(0x40, 0x50, 0x60),
680            Rgb8::new(0x70, 0x80, 0x90),
681            Rgb8::new(0xa0, 0xb0, 0xc0),
682            Rgb8::new(0xd0, 0xe0, 0xf0),
683            Rgb8::new(0x01, 0x02, 0x03),
684            Rgb8::new(0x00, 0x00, 0x00),
685        ];
686        let mut m = ColorMatrix::new(4, 3, palette);
687        assert_eq!(m.width(), 4);
688        assert_eq!(m.height(), 3);
689        // Fresh matrix is all-background (index 0).
690        assert_eq!(m.get(0, 0), 0);
691        assert_eq!(m.get(3, 2), 0);
692        assert_eq!(m.cell_color(0, 0), palette[0]);
693
694        // Set a few cells and read back.
695        m.set(0, 0, 5);
696        m.set(3, 2, 7);
697        m.set(1, 1, 2);
698        assert_eq!(m.get(0, 0), 5);
699        assert_eq!(m.get(3, 2), 7);
700        assert_eq!(m.get(1, 1), 2);
701        assert_eq!(m.cell_color(0, 0), palette[5]);
702        assert_eq!(m.cell_color(3, 2), palette[7]);
703        assert_eq!(m.cell_color(1, 1), palette[2]);
704    }
705
706    /// Out-of-range palette indices passed to `set` are masked to
707    /// 3 bits so the cell field stays in `0..=7`. This is the
708    /// invariant the renderer relies on for safe palette indexing.
709    #[test]
710    fn color_matrix_set_masks_out_of_range_index() {
711        let palette: [Rgb8; 8] = [Rgb8::new(0, 0, 0); 8];
712        let mut m = ColorMatrix::new(1, 1, palette);
713        m.set(0, 0, 9); // 0b1001 → masked to 0b001 = 1
714        assert_eq!(m.get(0, 0), 1);
715        m.set(0, 0, 0xff); // 0b11111111 → masked to 0b111 = 7
716        assert_eq!(m.get(0, 0), 7);
717        m.set(0, 0, 0x08); // 0b1000 → masked to 0b000 = 0
718        assert_eq!(m.get(0, 0), 0);
719    }
720
721    /// `palette()` borrows the matrix's palette table — the renderer
722    /// uses this to look up RGB for each cell index without copying.
723    #[test]
724    fn color_matrix_palette_borrow() {
725        let palette: [Rgb8; 8] = [
726            Rgb8::new(0x11, 0x22, 0x33),
727            Rgb8::new(0x44, 0x55, 0x66),
728            Rgb8::new(0x77, 0x88, 0x99),
729            Rgb8::new(0xaa, 0xbb, 0xcc),
730            Rgb8::new(0xdd, 0xee, 0xff),
731            Rgb8::new(0x00, 0x11, 0x22),
732            Rgb8::new(0x33, 0x44, 0x55),
733            Rgb8::new(0x66, 0x77, 0x88),
734        ];
735        let m = ColorMatrix::new(2, 2, palette);
736        let borrowed = m.palette();
737        assert_eq!(borrowed[0], palette[0]);
738        assert_eq!(borrowed[7], palette[7]);
739        // Ensure it's the same array (length pinned at 8).
740        assert_eq!(borrowed.len(), 8);
741    }
742
743    /// Stage 11.A8c — pin `Rgb8::to_css_hex` at boundary colors.
744    /// Kills the `{:02x}` formatter mutations and channel-order
745    /// swap mutations on line 82.
746    #[test]
747    fn rgb8_to_css_hex_known_colors() {
748        // Black.
749        assert_eq!(Rgb8::new(0, 0, 0).to_css_hex(), "#000000");
750        // White.
751        assert_eq!(Rgb8::new(255, 255, 255).to_css_hex(), "#ffffff");
752        // Pure red — distinguishes channel order.
753        assert_eq!(Rgb8::new(255, 0, 0).to_css_hex(), "#ff0000");
754        // Pure green.
755        assert_eq!(Rgb8::new(0, 255, 0).to_css_hex(), "#00ff00");
756        // Pure blue.
757        assert_eq!(Rgb8::new(0, 0, 255).to_css_hex(), "#0000ff");
758        // Lowercase hex letters (the {:02x} formatter).
759        assert_eq!(Rgb8::new(0xAB, 0xCD, 0xEF).to_css_hex(), "#abcdef");
760        // Two-digit zero padding for small values.
761        assert_eq!(Rgb8::new(1, 2, 3).to_css_hex(), "#010203");
762    }
763
764    /// Stage 11.A8c — pin `ColorMatrix::set` palette-index masking
765    /// to 3 bits. Kills `& 0b111` mutation on line 121.
766    #[test]
767    fn color_matrix_set_masks_to_three_bits() {
768        let palette = [Rgb8::new(0, 0, 0); 8];
769        let mut cm = ColorMatrix::new(2, 2, palette);
770        // In-range values stored as-is.
771        cm.set(0, 0, 5);
772        assert_eq!(cm.get(0, 0), 5);
773        cm.set(1, 0, 7);
774        assert_eq!(cm.get(1, 0), 7);
775        // Out-of-range values masked to 3 bits.
776        cm.set(0, 1, 8); // 0b1000 → masked to 0
777        assert_eq!(cm.get(0, 1), 0);
778        cm.set(1, 1, 0xFF); // 0b11111111 → masked to 0b111 = 7
779        assert_eq!(cm.get(1, 1), 7);
780    }
781
782    /// Stage 11.A8c — pin `ColorMatrix::cell_color` palette resolution.
783    /// Kills `get(x,y) as usize` index mutations on line 133.
784    #[test]
785    fn color_matrix_cell_color_resolves_palette() {
786        let palette = [
787            Rgb8::new(0, 0, 0),
788            Rgb8::new(255, 0, 0),
789            Rgb8::new(0, 255, 0),
790            Rgb8::new(0, 0, 255),
791            Rgb8::new(255, 255, 0),
792            Rgb8::new(255, 0, 255),
793            Rgb8::new(0, 255, 255),
794            Rgb8::new(255, 255, 255),
795        ];
796        let mut cm = ColorMatrix::new(1, 1, palette);
797        cm.set(0, 0, 3);
798        assert_eq!(cm.cell_color(0, 0), Rgb8::new(0, 0, 255));
799        cm.set(0, 0, 5);
800        assert_eq!(cm.cell_color(0, 0), Rgb8::new(255, 0, 255));
801    }
802}