Skip to main content

qrcode_rs/
structured_append.rs

1//! ISO/IEC 18004 §7.4 Structured Append — splitting one payload across
2//! 2..=16 QR symbols.
3//!
4//! Each symbol in a Structured Append sequence carries a 20-bit header (see
5//! [`Bits::push_structured_append_header`]) at the very start of its bit
6//! stream, before the data mode indicator, so a spec-aware decoder can
7//! reassemble the original message in order and verify it via the shared
8//! parity byte.
9//!
10//! Encoding is one-way — a [`QrCode`] does not retain its input payload — so
11//! this module only *encodes* the split. To recombine symbols a decoder has
12//! scanned, use [`reassemble`] with the per-symbol metadata that decoder
13//! supplies.
14
15#[cfg(not(feature = "std"))]
16#[allow(unused_imports)]
17use alloc::vec::Vec;
18
19use core::cmp::min;
20use core::fmt::{Display, Error, Formatter};
21
22use crate::QrCode;
23use crate::bits::{self, Bits};
24use crate::optimize::{Optimizer, Parser, Segment, total_encoded_len};
25use crate::types::{EcLevel, QrError, QrResult, Version};
26
27/// A Structured Append sequence builder: splits one payload across 2..=16 QR
28/// symbols.
29///
30/// Construct with [`StructuredAppend::new`], then call
31/// [`StructuredAppend::encode`] to emit the symbols. Every emitted symbol is a
32/// complete, independently-scannable QR code that also carries the Structured
33/// Append header, so a compliant reader knows how the symbols belong together.
34#[non_exhaustive]
35#[derive(Debug, Clone, Copy)]
36pub struct StructuredAppend<'a> {
37    /// Number of symbols in the sequence (`2..=16`).
38    symbols: u8,
39    /// The original, un-split payload.
40    payload: &'a [u8],
41    /// XOR of every payload byte — the Structured Append parity, stored
42    /// identically in every emitted symbol.
43    parity: u8,
44}
45
46impl<'a> StructuredAppend<'a> {
47    /// Creates a builder that will split `payload` across `symbols` QR symbols.
48    ///
49    /// `symbols` must be in `2..=16` (per ISO/IEC 18004 §7.4 a Structured
50    /// Append sequence has at least two symbols). The parity byte (the XOR of
51    /// every payload byte) is computed once here and reused for every symbol.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`QrError::InvalidStructuredAppend`] if `symbols` is not in
56    /// `2..=16`.
57    ///
58    /// ```
59    /// use qrcode_rs::structured_append::StructuredAppend;
60    /// use qrcode_rs::EcLevel;
61    ///
62    /// let sa = StructuredAppend::new(3, b"split across three symbols")?;
63    /// let codes = sa.encode(EcLevel::M)?;
64    /// assert_eq!(codes.len(), 3);
65    /// # Ok::<(), qrcode_rs::QrError>(())
66    /// ```
67    pub fn new(symbols: u8, payload: &'a [u8]) -> QrResult<Self> {
68        if !(2..=16).contains(&symbols) {
69            return Err(QrError::InvalidStructuredAppend { value: symbols });
70        }
71        let parity = payload.iter().fold(0u8, |acc, &byte| acc ^ byte);
72        Ok(Self { symbols, payload, parity })
73    }
74
75    /// The number of symbols the payload will be split across.
76    #[must_use]
77    pub const fn symbols(&self) -> u8 {
78        self.symbols
79    }
80
81    /// The Structured Append parity byte (the XOR of every payload byte),
82    /// stored identically in every emitted symbol so a reader can verify it has
83    /// reassembled the right group.
84    #[must_use]
85    pub const fn parity(&self) -> u8 {
86        self.parity
87    }
88
89    /// The payload being split.
90    #[must_use]
91    pub fn payload(&self) -> &'a [u8] {
92        self.payload
93    }
94
95    /// Encodes the payload into [`Self::symbols`] QR codes.
96    ///
97    /// The payload is split as evenly as possible (the last symbol may be
98    /// shorter). Each symbol is encoded at the smallest version that fits its
99    /// chunk plus the 20-bit Structured Append header, at error-correction
100    /// level `ec`. Symbols in a sequence may therefore differ in version — this
101    /// is permitted by the standard.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`QrError::DataTooLong`] if any chunk cannot fit even version 40
106    /// at the requested error-correction level.
107    pub fn encode(&self, ec: EcLevel) -> QrResult<Vec<QrCode>> {
108        let n = usize::from(self.symbols);
109        let chunk = self.payload.len().div_ceil(n);
110        let mut codes = Vec::with_capacity(n);
111        for i in 0..n {
112            // Clamp `start` so a payload shorter than `n * chunk` yields empty
113            // trailing slices (valid `&[len..len]`) rather than panicking.
114            let start = min(i * chunk, self.payload.len());
115            let end = min((i + 1) * chunk, self.payload.len());
116            let piece = &self.payload[start..end];
117            let code = encode_one_symbol(piece, i as u8 + 1, self.symbols, self.parity, ec)?;
118            codes.push(code);
119        }
120        Ok(codes)
121    }
122}
123
124/// Encodes a single payload chunk as one QR symbol carrying its Structured
125/// Append header, picking the smallest version that fits the chunk plus the
126/// 20-bit header overhead.
127///
128/// Mirrors [`bits::encode_auto`](crate::bits::encode_auto)'s tier-based search:
129/// the segment split is constant within a character-count tier (the per-segment
130/// length is tier-invariant), so we optimize once per tier (V9 / V26 / V40) and
131/// let [`bits::find_min_version`] pick the smallest fitting version. This runs
132/// the optimizer ~3× per chunk instead of ~40×. The constant 20-bit header is
133/// added to the encoded length before the capacity check.
134fn encode_one_symbol(data: &[u8], position: u8, total: u8, parity: u8, ec: EcLevel) -> QrResult<QrCode> {
135    let segments = Parser::new(data).collect::<Vec<Segment>>();
136    for &checkpoint in &[Version::Normal(9), Version::Normal(26), Version::Normal(40)] {
137        let opt = Optimizer::new(segments.iter().copied(), checkpoint).collect::<Vec<_>>();
138        // +20 bits: the Structured Append header (4-bit mode + 8-bit sequence
139        // indicator + 8-bit parity) prepended before the data mode indicator.
140        let total_len = total_encoded_len(&opt, checkpoint) + 20;
141        if total_len <= bits::data_capacity_bits(checkpoint, ec)? {
142            let version = bits::find_min_version(total_len, ec);
143            let mut bits = Bits::new(version);
144            bits.reserve(total_len);
145            bits.push_structured_append_header(position, total, parity)?;
146            bits.push_segments(data, opt.into_iter())?;
147            bits.push_terminator(ec)?;
148            return QrCode::with_bits(bits, ec);
149        }
150    }
151    Err(QrError::DataTooLong)
152}
153
154/// Errors returned by [`reassemble`] when decoded Structured Append symbols
155/// cannot be recombined.
156///
157/// The enum is `#[non_exhaustive]`: future versions may add variants, so
158/// external callers should match with a `_` arm.
159#[non_exhaustive]
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum SaError {
162    /// Fewer symbols were supplied than the total count requires.
163    Incomplete,
164    /// Two symbols claim the same position. Carries the duplicated position.
165    DuplicatePosition(u8),
166    /// The symbols disagree on the total symbol count.
167    CountMismatch,
168    /// The symbols disagree on the parity byte.
169    ParityMismatch,
170    /// A total count or position is outside its valid range (`2..=16` /
171    /// `1..=total`). Carries the offending value.
172    OutOfRange(u8),
173    /// The decoded bit stream does not begin with the Structured Append mode
174    /// indicator (`0011`) — the symbol is not part of a Structured Append
175    /// sequence (or the wrong bytes were supplied).
176    NotStructuredAppend,
177    /// The bit stream was truncated or otherwise malformed while parsing a
178    /// Structured Append header or data segment.
179    MalformedStream,
180}
181
182impl Display for SaError {
183    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
184        match self {
185            Self::Incomplete => f.write_str("incomplete Structured Append sequence (symbols missing)"),
186            Self::DuplicatePosition(position) => {
187                write!(f, "duplicate Structured Append position {position}")
188            }
189            Self::CountMismatch => f.write_str("Structured Append symbols disagree on the total count"),
190            Self::ParityMismatch => f.write_str("Structured Append symbols disagree on the parity byte"),
191            Self::OutOfRange(value) => {
192                write!(f, "Structured Append value {value} out of range (total 2..=16, position 1..=total)")
193            }
194            Self::NotStructuredAppend => f.write_str("not a Structured Append symbol (no `0011` mode indicator)"),
195            Self::MalformedStream => f.write_str("malformed Structured Append bit stream"),
196        }
197    }
198}
199
200impl ::core::error::Error for SaError {}
201
202/// One decoded Structured Append symbol's metadata, ready for [`reassemble`].
203///
204/// Build this from whatever your decoder exposes: the decoded 1-based position,
205/// decoded total count, parity byte, and that symbol's decoded payload bytes.
206/// The raw 8-bit symbol-sequence indicator stores `position - 1` in its high
207/// nibble and `total - 1` in its low nibble; convert those fields before
208/// constructing `SaSymbol`. The fields are public so it can be constructed with
209/// a struct literal.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct SaSymbol<'a> {
212    /// 1-based position of this symbol within the sequence (`1..=total`).
213    pub position: u8,
214    /// Total number of symbols in the sequence (`2..=16`).
215    pub total: u8,
216    /// The parity byte (identical in every symbol of the sequence).
217    pub parity: u8,
218    /// This symbol's decoded payload bytes.
219    pub data: &'a [u8],
220}
221
222/// Reassembles the original message from decoded Structured Append symbols.
223///
224/// Validates that every symbol agrees on the total count and parity, that the
225/// positions form a complete `1..=total` set (no gaps, no duplicates), then
226/// orders them by position and concatenates their data.
227///
228/// The parity byte each symbol carries is the XOR of the *original* full
229/// message, not of any one symbol — so `reassemble` only checks that all
230/// symbols report the *same* parity. To confirm the reassembled bytes match a
231/// known original, XOR them yourself and compare to that shared parity.
232///
233/// # Errors
234///
235/// Returns [`SaError`] if `parts` is empty, disagrees on the total count or
236/// parity, holds an out-of-range value, is incomplete, or repeats a position.
237pub fn reassemble(parts: &[SaSymbol<'_>]) -> Result<Vec<u8>, SaError> {
238    let Some(first) = parts.first() else { return Err(SaError::Incomplete) };
239    if !(2..=16).contains(&first.total) {
240        return Err(SaError::OutOfRange(first.total));
241    }
242    let total = first.total;
243    let parity = first.parity;
244    for p in parts {
245        if p.total != total {
246            return Err(SaError::CountMismatch);
247        }
248        if p.parity != parity {
249            return Err(SaError::ParityMismatch);
250        }
251        if !(1..=total).contains(&p.position) {
252            return Err(SaError::OutOfRange(p.position));
253        }
254    }
255    if parts.len() != usize::from(total) {
256        return Err(SaError::Incomplete);
257    }
258    let mut seen = [false; 16];
259    for p in parts {
260        let idx = usize::from(p.position - 1);
261        if seen[idx] {
262            return Err(SaError::DuplicatePosition(p.position));
263        }
264        seen[idx] = true;
265    }
266    let mut ordered: Vec<&SaSymbol<'_>> = parts.iter().collect();
267    ordered.sort_by_key(|s| s.position);
268    let mut out = Vec::new();
269    for s in ordered {
270        out.extend_from_slice(s.data);
271    }
272    Ok(out)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::StructuredAppend;
278    use crate::types::{EcLevel, QrError};
279    use alloc::{vec, vec::Vec};
280
281    #[test]
282    fn test_new_rejects_out_of_range() {
283        assert_eq!(StructuredAppend::new(1, b"x").err(), Some(QrError::InvalidStructuredAppend { value: 1 }));
284        assert_eq!(StructuredAppend::new(17, b"x").err(), Some(QrError::InvalidStructuredAppend { value: 17 }));
285    }
286
287    #[test]
288    fn test_new_accepts_bounds() {
289        assert!(StructuredAppend::new(2, b"x").is_ok());
290        assert!(StructuredAppend::new(16, b"x").is_ok());
291    }
292
293    #[test]
294    fn test_parity_xor() {
295        assert_eq!(StructuredAppend::new(3, &[0x01, 0x02, 0x03]).unwrap().parity(), 0x00);
296        assert_eq!(StructuredAppend::new(2, &[0xff, 0x0f]).unwrap().parity(), 0xf0);
297        // 0..=255 each value appears once → XOR is 0.
298        let bytes: Vec<u8> = (0u8..=255).collect();
299        assert_eq!(StructuredAppend::new(2, &bytes).unwrap().parity(), 0);
300    }
301
302    #[test]
303    fn test_parity_empty() {
304        assert_eq!(StructuredAppend::new(2, b"").unwrap().parity(), 0);
305    }
306
307    #[test]
308    fn test_encode_count_and_versions() {
309        let payload = b"Split this payload across several QR symbols for resilience.";
310        let codes = StructuredAppend::new(3, payload).unwrap().encode(EcLevel::M).unwrap();
311        assert_eq!(codes.len(), 3);
312        for code in &codes {
313            assert!(!code.info().version().is_micro(), "Structured Append must be Normal QR");
314        }
315    }
316
317    #[test]
318    fn test_encode_all_normal_across_counts() {
319        let payload = b"the quick brown fox jumps over the lazy dog";
320        for n in 2..=16u8 {
321            let codes = StructuredAppend::new(n, payload).unwrap().encode(EcLevel::L).unwrap();
322            assert_eq!(codes.len(), usize::from(n));
323            assert!(codes.iter().all(|c| !c.info().version().is_micro()), "n={n} produced a Micro QR");
324        }
325    }
326
327    #[test]
328    fn test_encode_empty_payload() {
329        // Degenerate but well-formed: each symbol carries only header + terminator.
330        let codes = StructuredAppend::new(2, b"").unwrap().encode(EcLevel::M).unwrap();
331        assert_eq!(codes.len(), 2);
332        assert!(codes.iter().all(|c| !c.info().version().is_micro()));
333    }
334
335    #[test]
336    fn test_encode_deterministic() {
337        let payload = b"deterministic encoding";
338        let a = StructuredAppend::new(3, payload).unwrap().encode(EcLevel::M).unwrap();
339        let b = StructuredAppend::new(3, payload).unwrap().encode(EcLevel::M).unwrap();
340        // Identical inputs → identical module grids.
341        for (a, b) in a.iter().zip(b.iter()) {
342            assert_eq!(a.to_colors(), b.to_colors());
343        }
344    }
345
346    #[test]
347    fn test_encode_too_long() {
348        // 16 symbols × 4000 bytes > version-40-H capacity → each chunk overflows.
349        let payload = vec![0u8; 16 * 4000];
350        let result = StructuredAppend::new(16, &payload).unwrap().encode(EcLevel::H);
351        assert_eq!(result.err(), Some(QrError::DataTooLong));
352    }
353}
354
355#[cfg(test)]
356mod reassemble_tests {
357    use super::{SaError, SaSymbol, reassemble};
358    use alloc::vec::Vec;
359
360    fn sym(position: u8, total: u8, parity: u8, data: &[u8]) -> SaSymbol<'_> {
361        SaSymbol { position, total, parity, data }
362    }
363
364    #[test]
365    fn test_reassemble_ok() {
366        let parts = [sym(1, 3, 0x5a, b"hel"), sym(2, 3, 0x5a, b"lo "), sym(3, 3, 0x5a, b"world")];
367        assert_eq!(reassemble(&parts).unwrap(), b"hello world");
368    }
369
370    #[test]
371    fn test_reassemble_out_of_order() {
372        // Supplied as 3, 1, 2 — reassemble must order by position.
373        let parts = [sym(3, 3, 0x5a, b"wor"), sym(1, 3, 0x5a, b"hel"), sym(2, 3, 0x5a, b"lo")];
374        assert_eq!(reassemble(&parts).unwrap(), b"hellowor");
375    }
376
377    #[test]
378    fn test_reassemble_empty() {
379        assert_eq!(reassemble(&[]), Err(SaError::Incomplete));
380    }
381
382    #[test]
383    fn test_reassemble_incomplete() {
384        let parts = [sym(1, 3, 0x5a, b"a"), sym(2, 3, 0x5a, b"b")];
385        assert_eq!(reassemble(&parts), Err(SaError::Incomplete));
386    }
387
388    #[test]
389    fn test_reassemble_duplicate() {
390        let parts = [sym(1, 3, 0x5a, b"a"), sym(1, 3, 0x5a, b"b"), sym(3, 3, 0x5a, b"c")];
391        assert_eq!(reassemble(&parts), Err(SaError::DuplicatePosition(1)));
392    }
393
394    #[test]
395    fn test_reassemble_count_mismatch() {
396        let parts = [sym(1, 3, 0x5a, b"a"), sym(2, 4, 0x5a, b"b")];
397        assert_eq!(reassemble(&parts), Err(SaError::CountMismatch));
398    }
399
400    #[test]
401    fn test_reassemble_parity_mismatch() {
402        let parts = [sym(1, 2, 0x5a, b"a"), sym(2, 2, 0x5b, b"b")];
403        assert_eq!(reassemble(&parts), Err(SaError::ParityMismatch));
404    }
405
406    #[test]
407    fn test_reassemble_out_of_range_total() {
408        assert_eq!(reassemble(&[sym(1, 1, 0, b"a")]), Err(SaError::OutOfRange(1)));
409        assert_eq!(reassemble(&[sym(1, 17, 0, b"a")]), Err(SaError::OutOfRange(17)));
410    }
411
412    #[test]
413    fn test_reassemble_out_of_range_position() {
414        let parts = [sym(0, 2, 0x5a, b"a"), sym(2, 2, 0x5a, b"b")];
415        assert_eq!(reassemble(&parts), Err(SaError::OutOfRange(0)));
416        let parts = [sym(1, 2, 0x5a, b"a"), sym(3, 2, 0x5a, b"b")];
417        assert_eq!(reassemble(&parts), Err(SaError::OutOfRange(3)));
418    }
419
420    #[test]
421    fn test_reassemble_max_sequence() {
422        // 16 symbols (the spec maximum): positions 1..=16, each carrying one byte.
423        let bytes: Vec<u8> = (1u8..=16).collect();
424        let parts: Vec<SaSymbol<'_>> = bytes
425            .iter()
426            .enumerate()
427            .map(|(i, _)| SaSymbol {
428                position: u8::try_from(i + 1).unwrap(),
429                total: 16,
430                parity: 0xff,
431                data: &bytes[i..=i],
432            })
433            .collect();
434        assert_eq!(reassemble(&parts).unwrap(), bytes);
435    }
436}