Skip to main content

qrcode_parse/
gs1.rs

1//! GS1 application-identifier (AI) parsing.
2//!
3//! A GS1 QR payload is a sequence of application-identifier/value pairs. Fixed-
4//! length AIs are concatenated directly; variable-length AIs are terminated by
5//! the GS separator byte (`0x1D`) which a QR decoder emits for FNC1 in GS1 mode
6//! (see `bits.rs`). This parser splits such a byte stream back into elements.
7//!
8//! It ships with a curated table of the most common AIs plus the
9//! measure-measure families (`310n`–`369n`). Unknown AIs are surfaced
10//! best-effort (2-digit id, value up to the next GS) so real-world data is
11//! never silently truncated; full coverage of all 200+ GS1 AIs is out of scope.
12
13#[cfg(not(feature = "std"))]
14#[allow(unused_imports)]
15use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
16
17use crate::ParseError;
18
19/// The length kind of a GS1 application identifier.
20#[derive(Clone, Copy, Debug)]
21enum Len {
22    /// A fixed number of bytes (no GS separator follows).
23    Fixed(usize),
24    /// Variable length, terminated by `0x1D` (GS) or end of data.
25    Variable,
26}
27
28/// A static entry in the curated application-identifier table.
29struct AiSpec {
30    /// The application-identifier digits (2–4).
31    ai: &'static str,
32    /// Human-readable title.
33    desc: &'static str,
34    /// Value length kind.
35    len: Len,
36}
37
38/// Curated table of common GS1 application identifiers.
39#[rustfmt::skip]
40const AI_TABLE: &[AiSpec] = &[
41    AiSpec { ai: "00",  desc: "SSCC (serial shipping container code)",     len: Len::Fixed(18) },
42    AiSpec { ai: "01",  desc: "GTIN (global trade item number)",            len: Len::Fixed(14) },
43    AiSpec { ai: "02",  desc: "GTIN of contained items",                    len: Len::Fixed(14) },
44    AiSpec { ai: "10",  desc: "Batch or lot number",                         len: Len::Variable },
45    AiSpec { ai: "11",  desc: "Production date (YYMMDD)",                    len: Len::Fixed(6) },
46    AiSpec { ai: "13",  desc: "Packaging date (YYMMDD)",                     len: Len::Fixed(6) },
47    AiSpec { ai: "15",  desc: "Best-before date (YYMMDD)",                   len: Len::Fixed(6) },
48    AiSpec { ai: "16",  desc: "Sell-by date (YYMMDD)",                       len: Len::Fixed(6) },
49    AiSpec { ai: "17",  desc: "Expiration date (YYMMDD)",                   len: Len::Fixed(6) },
50    AiSpec { ai: "20",  desc: "Internal product variant",                    len: Len::Fixed(2) },
51    AiSpec { ai: "21",  desc: "Serial number",                               len: Len::Variable },
52    AiSpec { ai: "22",  desc: "Consumer product variant",                    len: Len::Variable },
53    AiSpec { ai: "30",  desc: "Count of items (variable measure)",          len: Len::Variable },
54    AiSpec { ai: "37",  desc: "Count of items contained",                   len: Len::Variable },
55    AiSpec { ai: "240", desc: "Additional item identification",             len: Len::Variable },
56    AiSpec { ai: "241", desc: "Customer part number",                        len: Len::Variable },
57    AiSpec { ai: "242", desc: "Made-to-order variation number",             len: Len::Variable },
58    AiSpec { ai: "243", desc: "Packaging component number",                  len: Len::Variable },
59    AiSpec { ai: "244", desc: "Ground crew badge number",                    len: Len::Variable },
60    AiSpec { ai: "400", desc: "Customer order number",                       len: Len::Variable },
61    AiSpec { ai: "401", desc: "Consignment number",                          len: Len::Variable },
62    AiSpec { ai: "402", desc: "Global shipment identification number",      len: Len::Fixed(17) },
63    AiSpec { ai: "403", desc: "Routing code",                                 len: Len::Variable },
64    AiSpec { ai: "410", desc: "Ship to / deliver to GLN",                    len: Len::Fixed(13) },
65    AiSpec { ai: "411", desc: "Bill to / invoice to GLN",                    len: Len::Fixed(13) },
66    AiSpec { ai: "412", desc: "Purchased from GLN",                          len: Len::Fixed(13) },
67    AiSpec { ai: "413", desc: "Ship for / deliver for GLN",                  len: Len::Fixed(13) },
68    AiSpec { ai: "414", desc: "Identification of a physical location (GLN)", len: Len::Fixed(13) },
69    AiSpec { ai: "415", desc: "GLN of the invoicing party",                  len: Len::Fixed(13) },
70    AiSpec { ai: "416", desc: "GLN of the physical location",                len: Len::Fixed(13) },
71    AiSpec { ai: "420", desc: "Ship to / deliver to postal code",            len: Len::Variable },
72    AiSpec { ai: "421", desc: "Ship to / deliver to postal code (with ISO country prefix)", len: Len::Variable },
73    AiSpec { ai: "422", desc: "Country of origin (ISO 3166)",                len: Len::Fixed(3) },
74    AiSpec { ai: "423", desc: "Country of initial processing",               len: Len::Variable },
75    AiSpec { ai: "425", desc: "Country of disassembly",                      len: Len::Variable },
76    AiSpec { ai: "426", desc: "Country of processing (ISO 3166)",            len: Len::Fixed(3) },
77    AiSpec { ai: "427", desc: "Country subdivision (ISO 3166-2)",            len: Len::Variable },
78];
79
80/// A resolved application identifier match: how many digits it occupies, its
81/// description, and its value length kind.
82struct AiMatch {
83    /// Number of leading digits forming the AI (2, 3, or 4).
84    len: usize,
85    /// Human-readable title.
86    desc: &'static str,
87    /// Value length kind.
88    length: Len,
89}
90
91/// Returns `true` if the 3-digit value is a GS1 trade-item-measure family
92/// prefix (`310`–`316`, `320`–`326`, `334`–`337`, `340`–`357`, `360`–`369`).
93/// These are 4-digit AIs (3-digit prefix + 1 decimal-count digit) with a fixed
94/// 6-digit value. The gaps (e.g. `317`–`319`, `37x`) are deliberately excluded
95/// so the 2-digit AI `37` (count) is not misread as a measure code.
96fn is_measure_family(d: u32) -> bool {
97    (310..=316).contains(&d)
98        || (320..=326).contains(&d)
99        || (334..=337).contains(&d)
100        || (340..=357).contains(&d)
101        || (360..=369).contains(&d)
102}
103
104/// Parses the first `n` bytes as a decimal number, or `None` if there are
105/// fewer than `n` bytes or any is not an ASCII digit.
106fn digits(data: &[u8], n: usize) -> Option<u32> {
107    if data.len() < n {
108        return None;
109    }
110    let mut v = 0u32;
111    for &b in &data[..n] {
112        if !b.is_ascii_digit() {
113            return None;
114        }
115        v = v * 10 + u32::from(b - b'0');
116    }
117    Some(v)
118}
119
120/// Matches the application identifier at the start of `data`, preferring the
121/// longest match. Measure families are checked before the curated table.
122fn match_ai(data: &[u8]) -> Option<AiMatch> {
123    // Measure family: 3-digit prefix with a 4th digit present → 4-digit AI, fixed 6.
124    if let Some(d3) = digits(data, 3)
125        && digits(data, 4).is_some()
126        && is_measure_family(d3)
127    {
128        return Some(AiMatch { len: 4, desc: "Trade item measure (GS1 310n–369n family)", length: Len::Fixed(6) });
129    }
130    // Longest exact table match (4 → 3 → 2).
131    for &n in &[4usize, 3, 2] {
132        if data.len() >= n {
133            let Ok(prefix) = core::str::from_utf8(&data[..n]) else {
134                continue;
135            };
136            if let Some(spec) = AI_TABLE.iter().find(|s| s.ai == prefix) {
137                return Some(AiMatch { len: n, desc: spec.desc, length: spec.len });
138            }
139        }
140    }
141    None
142}
143
144/// One application-identifier/value pair split out of a GS1 payload.
145#[non_exhaustive]
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Gs1Element {
148    /// The application-identifier digits (e.g. `"01"`).
149    ai: String,
150    /// The raw value bytes (binary-safe; GS1 values are usually ASCII).
151    value: Vec<u8>,
152    /// Human-readable description of the AI (always a static string).
153    description: &'static str,
154}
155
156impl Gs1Element {
157    /// The application-identifier digits.
158    #[must_use]
159    pub fn ai(&self) -> &str {
160        &self.ai
161    }
162
163    /// The raw value bytes.
164    #[must_use]
165    pub fn value(&self) -> &[u8] {
166        &self.value
167    }
168
169    /// Human-readable description of the AI.
170    #[must_use]
171    pub fn description(&self) -> &'static str {
172        self.description
173    }
174}
175
176/// A parsed GS1 payload: the recovered [`Gs1Element`]s plus the original bytes.
177#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Gs1Result {
180    /// The recovered application-identifier/value pairs, in order.
181    elements: Vec<Gs1Element>,
182    /// The original input bytes.
183    raw_data: Vec<u8>,
184}
185
186impl Gs1Result {
187    /// Parses a GS1 payload (`&[u8]` with `0x1D` separators between
188    /// variable-length AIs) into its elements.
189    ///
190    /// Tolerant: unknown AIs are surfaced best-effort rather than rejected, so
191    /// a payload using an AI outside the curated table still yields elements
192    /// (with a generic description) instead of failing.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`ParseError::InvalidFormat`] only if the input is non-empty but
197    /// does not begin with an application-identifier digit.
198    pub fn parse(data: &[u8]) -> Result<Self, ParseError> {
199        if data.is_empty() {
200            return Ok(Self { elements: Vec::new(), raw_data: Vec::new() });
201        }
202        if !data[0].is_ascii_digit() {
203            return Err(ParseError::InvalidFormat);
204        }
205
206        let mut elements = Vec::new();
207        let mut i = 0;
208        while i < data.len() {
209            let rest = &data[i..];
210            let (ai_len, desc, length) = match match_ai(rest) {
211                Some(m) => (m.len, m.desc, m.length),
212                None => {
213                    // Unknown AI: take up to 2 leading digits as the id and
214                    // consume the value up to the next GS separator.
215                    let ai_len = rest.iter().take(2).take_while(|b| b.is_ascii_digit()).count().max(1);
216                    let ai_len = ai_len.min(rest.len());
217                    let value_end = sep_or_end(rest, ai_len);
218                    elements.push(Gs1Element {
219                        ai: core::str::from_utf8(&rest[..ai_len]).unwrap_or("").to_owned(),
220                        value: rest[ai_len..value_end].to_vec(),
221                        description: "Unknown application identifier",
222                    });
223                    i += value_end + usize::from(rest.get(value_end) == Some(&0x1D));
224                    continue;
225                }
226            };
227
228            let ai = core::str::from_utf8(&rest[..ai_len]).unwrap_or("").to_owned();
229            i += ai_len;
230            let value_end = match length {
231                Len::Fixed(n) => (i + n).min(data.len()),
232                Len::Variable => sep_or_end(data, i),
233            };
234            elements.push(Gs1Element { ai, value: data[i..value_end].to_vec(), description: desc });
235            i = value_end;
236            // Skip the GS separator that terminates a variable-length value.
237            if matches!(length, Len::Variable) && data.get(i) == Some(&0x1D) {
238                i += 1;
239            }
240        }
241
242        Ok(Self { elements, raw_data: data.to_vec() })
243    }
244
245    /// The recovered application-identifier/value pairs, in order.
246    #[must_use]
247    pub fn elements(&self) -> &[Gs1Element] {
248        &self.elements
249    }
250
251    /// The original input bytes.
252    #[must_use]
253    pub fn raw_data(&self) -> &[u8] {
254        &self.raw_data
255    }
256}
257
258/// Index (relative to `start`) of the next `0x1D` separator, or the data length
259/// if none remains.
260fn sep_or_end(data: &[u8], start: usize) -> usize {
261    data[start..].iter().position(|&b| b == 0x1D).map_or(data.len(), |p| start + p)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn parses_gtin_dates_batch_serial() {
270        // 01 (GTIN, fixed 14) + 15 (best-before, fixed 6) + 10 (batch, var) + 21 (serial, var)
271        let mut data = Vec::new();
272        data.extend_from_slice(b"0104912345123459"); // 01 + GTIN14
273        data.extend_from_slice(b"15970331"); // 15 + YYMMDD6
274        data.extend_from_slice(b"10BATCH123"); // 10 + lot (variable)
275        data.push(0x1D);
276        data.extend_from_slice(b"21SERIAL9"); // 21 + serial (variable, last)
277
278        let r = Gs1Result::parse(&data).unwrap();
279        let e = r.elements();
280        assert_eq!(e.len(), 4);
281        assert_eq!(e[0].ai(), "01");
282        assert_eq!(e[0].value(), b"04912345123459");
283        assert!(e[0].description().contains("GTIN"));
284        assert_eq!(e[1].ai(), "15");
285        assert_eq!(e[1].value(), b"970331");
286        assert_eq!(e[2].ai(), "10");
287        assert_eq!(e[2].value(), b"BATCH123");
288        assert_eq!(e[3].ai(), "21");
289        assert_eq!(e[3].value(), b"SERIAL9");
290        assert_eq!(r.raw_data(), &data[..]);
291    }
292
293    #[test]
294    fn parses_measure_family() {
295        // 3100 = net weight (kg, 0 decimals); 6-digit value.
296        let data = b"3100001234";
297        let r = Gs1Result::parse(data).unwrap();
298        let e = r.elements();
299        assert_eq!(e.len(), 1);
300        assert_eq!(e[0].ai(), "3100");
301        assert_eq!(e[0].value(), b"001234");
302        assert!(e[0].description().contains("measure"));
303    }
304
305    #[test]
306    fn count_ai_not_misread_as_measure() {
307        // "37" is count (variable); must not be swallowed by the measure range.
308        let mut data = Vec::new();
309        data.extend_from_slice(b"0112345678901237"); // 01 GTIN14
310        data.extend_from_slice(b"3712"); // 37 count + "12" (variable, to end)
311        let r = Gs1Result::parse(&data).unwrap();
312        let e = r.elements();
313        assert_eq!(e[1].ai(), "37");
314        assert_eq!(e[1].value(), b"12");
315    }
316
317    #[test]
318    fn unknown_ai_is_surfaced() {
319        // "91" is not in the curated table.
320        let r = Gs1Result::parse(b"91ABCDEF").unwrap();
321        let e = r.elements();
322        assert_eq!(e.len(), 1);
323        assert_eq!(e[0].ai(), "91");
324        assert_eq!(e[0].value(), b"ABCDEF");
325        assert_eq!(e[0].description(), "Unknown application identifier");
326    }
327
328    #[test]
329    fn empty_input_yields_no_elements() {
330        let r = Gs1Result::parse(b"").unwrap();
331        assert!(r.elements().is_empty());
332    }
333
334    #[test]
335    fn non_digit_start_errors() {
336        assert_eq!(Gs1Result::parse(b"X123"), Err(ParseError::InvalidFormat));
337    }
338
339    #[test]
340    fn gln_fixed_length() {
341        // 410 (ship-to GLN, fixed 13).
342        let r = Gs1Result::parse(b"4101234567890128").unwrap();
343        let e = r.elements();
344        assert_eq!(e.len(), 1);
345        assert_eq!(e[0].ai(), "410");
346        assert_eq!(e[0].value(), b"1234567890128");
347    }
348}