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}