Skip to main content

smart_package_tracker/symbology/
code128.rs

1//! Code 128 encoding and decoding.
2//!
3//! Wraps the [`code128`](https://docs.rs/code128) crate, which implements
4//! ISO/IEC 15417 including automatic character-set selection — digit runs are
5//! compressed into Set C without the caller having to ask, which matters for
6//! the long numeric payloads carriers use.
7//!
8//! The upstream crate reports bar coordinates with its own 10-module quiet
9//! zone already applied. This module strips that offset so a [`Symbol`] holds
10//! the symbol and nothing else, leaving quiet zones to
11//! [`RenderOptions`](crate::RenderOptions).
12
13use alloc::string::{String, ToString};
14use alloc::vec;
15use alloc::vec::Vec;
16
17use super::{BitMatrix, Decoder, Symbol, Symbology, SymbologyKind};
18use crate::error::{Error, Result};
19
20/// Quiet zone baked into `code128`'s coordinate space, in modules per side.
21const UPSTREAM_QUIET_ZONE: u32 = 10;
22
23/// The Code 128 symbology.
24///
25/// # Examples
26///
27/// ```
28/// use smart_package_tracker::symbology::{Code128, Symbology};
29///
30/// let symbol = Code128.encode("PKG-9ED9285C")?;
31/// assert!(symbol.is_linear());
32/// assert_eq!(symbol.modules().height(), 1);
33/// # Ok::<(), smart_package_tracker::Error>(())
34/// ```
35#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
36pub struct Code128;
37
38impl Symbology for Code128 {
39    fn kind(&self) -> SymbologyKind {
40        SymbologyKind::Code128
41    }
42
43    fn encode(&self, data: &str) -> Result<Symbol> {
44        if data.is_empty() {
45            return Err(Error::EmptyPayload);
46        }
47
48        let code = ::code128::Code128::encode_str(data).ok_or_else(|| Error::Unencodable {
49            symbology: SymbologyKind::Code128.name(),
50            reason: "payload contains characters outside ISO/IEC 8859-1 (Latin-1)".to_string(),
51        })?;
52
53        // `len()` counts the quiet zone on both sides; the symbol itself is
54        // what remains.
55        let total = code.len() as u32;
56        let width = total
57            .checked_sub(2 * UPSTREAM_QUIET_ZONE)
58            .filter(|w| *w > 0)
59            .ok_or_else(|| Error::Unencodable {
60                symbology: SymbologyKind::Code128.name(),
61                reason: "encoder produced a degenerate symbol".to_string(),
62            })?;
63
64        let mut row = vec![false; width as usize];
65        for bar in code.bar_coordinates() {
66            let start = bar.x.saturating_sub(UPSTREAM_QUIET_ZONE) as usize;
67            // Clamp both ends, not just the far one: a bar reported at or past
68            // the trailing quiet zone would otherwise slice backwards and
69            // panic. Upstream does not emit one today; this keeps a version
70            // bump from turning that into a crash.
71            if start >= row.len() {
72                continue;
73            }
74            let end = (start + bar.width as usize).min(row.len());
75            row[start..end].fill(true);
76        }
77
78        Ok(Symbol::new(
79            SymbologyKind::Code128,
80            BitMatrix::from_row(row),
81            data.to_string(),
82        ))
83    }
84}
85
86impl Decoder for Code128 {
87    fn kind(&self) -> SymbologyKind {
88        SymbologyKind::Code128
89    }
90
91    fn decode(&self, modules: &BitMatrix) -> Result<String> {
92        if modules.height() != 1 {
93            return Err(Error::Decode(
94                "expected a single-row linear symbol".to_string(),
95            ));
96        }
97
98        let bars = to_bars(modules.row(0));
99        if bars.is_empty() {
100            return Err(Error::Decode("symbol contains no bars".to_string()));
101        }
102
103        let bytes = ::code128::decode(&bars).map_err(|e| Error::Decode(alloc::format!("{e:?}")))?;
104
105        // The decoder returns Latin-1 bytes; widening each byte to a `char` is
106        // the exact inverse of the encoder's Latin-1 narrowing.
107        Ok(bytes.into_iter().map(|b| b as char).collect())
108    }
109}
110
111/// Decode a Code 128 [`Symbol`] back into its payload.
112///
113/// This reconstructs bar/space runs from the module grid and hands them to the
114/// upstream decoder, so it exercises the same conversion the renderers rely
115/// on. That makes it a genuine round-trip check rather than a trivial identity
116/// on the stored payload.
117///
118/// # Errors
119///
120/// Returns [`Error::Decode`] if the symbol is not a linear Code 128 symbol, or
121/// if the module pattern is not a valid Code 128 sequence.
122pub fn decode(symbol: &Symbol) -> Result<String> {
123    if symbol.kind() != SymbologyKind::Code128 {
124        return Err(Error::Decode(alloc::format!(
125            "expected a Code 128 symbol, found {}",
126            symbol.kind()
127        )));
128    }
129
130    Code128.decode(symbol.modules())
131}
132
133/// Convert a module row into the bar/space runs the upstream decoder expects.
134fn to_bars(row: &[bool]) -> Vec<::code128::Bar> {
135    let mut bars = Vec::new();
136    let mut i = 0;
137
138    while i < row.len() {
139        if !row[i] {
140            i += 1;
141            continue;
142        }
143        let bar_start = i;
144        while i < row.len() && row[i] {
145            i += 1;
146        }
147        let width = (i - bar_start) as u8;
148
149        let space_start = i;
150        while i < row.len() && !row[i] {
151            i += 1;
152        }
153        let space = (i - space_start) as u8;
154
155        bars.push(::code128::Bar { width, space });
156    }
157
158    bars
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use alloc::format;
165
166    /// Published Code 128 start patterns (11 modules each).
167    const START_A: &str = "11010000100";
168    const START_B: &str = "11010010000";
169    const START_C: &str = "11010011100";
170    /// Published Code 128 stop pattern (13 modules, including the two extra
171    /// termination bars).
172    const STOP: &str = "1100011101011";
173
174    fn as_bits(symbol: &Symbol) -> String {
175        symbol
176            .modules()
177            .row(0)
178            .iter()
179            .map(|d| if *d { '1' } else { '0' })
180            .collect()
181    }
182
183    #[test]
184    fn starts_with_a_valid_start_pattern() {
185        let bits = as_bits(&Code128.encode("PKG-9ED9285C").unwrap());
186        let start = &bits[..11];
187        assert!(
188            start == START_A || start == START_B || start == START_C,
189            "unexpected start pattern {start}"
190        );
191    }
192
193    #[test]
194    fn numeric_payloads_use_the_set_c_start_pattern() {
195        // A pure digit run should be encoded two digits per symbol.
196        let bits = as_bits(&Code128.encode("1234567890").unwrap());
197        assert_eq!(&bits[..11], START_C, "digit runs should start in Set C");
198    }
199
200    #[test]
201    fn ends_with_the_stop_pattern() {
202        let bits = as_bits(&Code128.encode("PKG-9ED9285C").unwrap());
203        assert!(bits.ends_with(STOP), "missing or malformed stop pattern");
204    }
205
206    #[test]
207    fn width_is_a_whole_number_of_symbols_plus_the_stop_bars() {
208        // Every Code 128 character occupies 11 modules; the stop pattern adds
209        // two more.
210        let bits = as_bits(&Code128.encode("PKG-9ED9285C").unwrap());
211        assert_eq!(
212            (bits.len() - 2) % 11,
213            0,
214            "symbol width {} is not 11n + 2",
215            bits.len()
216        );
217    }
218
219    #[test]
220    fn set_c_halves_the_width_of_long_digit_runs() {
221        let digits = "12345678901234567890"; // 20 digits
222        let letters = "ABCDEFGHIJKLMNOPQRST"; // 20 letters
223        let numeric = Code128.encode(digits).unwrap().modules().width();
224        let alpha = Code128.encode(letters).unwrap().modules().width();
225        assert!(
226            numeric < alpha,
227            "Set C compression not applied: {numeric} modules vs {alpha}"
228        );
229    }
230
231    #[test]
232    fn round_trips_through_the_module_grid() {
233        for payload in [
234            "PKG-9ED9285C",
235            "1234567890123456789012",
236            "A",
237            "Mixed 123 Case!",
238            "~$%^&*()_+",
239        ] {
240            let symbol = Code128.encode(payload).unwrap();
241            let decoded = decode(&symbol).unwrap_or_else(|e| panic!("{payload}: {e}"));
242            assert_eq!(decoded, payload);
243        }
244    }
245
246    #[test]
247    fn rejects_an_empty_payload() {
248        assert!(matches!(Code128.encode(""), Err(Error::EmptyPayload)));
249    }
250
251    #[test]
252    fn rejects_characters_outside_latin1() {
253        let err = Code128.encode("PKG-\u{4e2d}\u{6587}").unwrap_err();
254        assert!(matches!(err, Error::Unencodable { .. }), "got {err:?}");
255    }
256
257    #[test]
258    fn quiet_zone_is_not_part_of_the_symbol() {
259        let symbol = Code128.encode("A").unwrap();
260        let row = symbol.modules().row(0);
261        assert!(row[0], "symbol must begin with a bar, not a quiet zone");
262        assert!(
263            *row.last().unwrap(),
264            "symbol must end with a bar, not a quiet zone"
265        );
266    }
267
268    #[test]
269    fn payload_is_preserved_on_the_symbol() {
270        let symbol = Code128.encode("PKG-9ED9285C").unwrap();
271        assert_eq!(symbol.payload(), "PKG-9ED9285C");
272        assert_eq!(format!("{}", symbol.kind()), "Code 128");
273    }
274}