smart-package-tracker 0.4.0

Generate package tracking IDs, render them as Code 128 barcodes (PNG and SVG), and read them back out of images
Documentation
//! QR Code encoding.
//!
//! Wraps the [`qrcode`](https://docs.rs/qrcode) crate, which implements
//! ISO/IEC 18004. Where Code 128 produces a single row of modules, QR produces
//! a square grid — but both arrive at the renderers as a [`BitMatrix`], so
//! nothing in the rendering path needed to change to support this.
//!
//! Only full QR Codes (versions 1 to 40) are supported. Micro QR is
//! deliberately excluded: it carries very little data and reader support is
//! patchy, and it uses a 2-module quiet zone rather than 4, which the symbology
//! metadata does not currently express per-symbol.
//!
//! QR Code is a registered trademark of Denso Wave Incorporated. Denso Wave
//! has waived patent enforcement for ISO/IEC 18004-conformant use.

use alloc::string::{String, ToString};
use alloc::vec::Vec;

use super::{BitMatrix, Symbol, Symbology, SymbologyKind};
use crate::error::{Error, Result};

/// Error correction level: how much of a damaged symbol can still be read.
///
/// Higher levels survive more damage but need a larger symbol for the same
/// payload. [`Ecc::Medium`] is the default and the usual choice for printed
/// labels; [`Ecc::Quartile`] or [`Ecc::High`] are worth the extra size for
/// codes that will be scuffed, curved around a parcel, or partly obscured.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Ecc {
    /// Recovers roughly 7% damage.
    Low,
    /// Recovers roughly 15% damage.
    #[default]
    Medium,
    /// Recovers roughly 25% damage.
    Quartile,
    /// Recovers roughly 30% damage.
    High,
}

impl Ecc {
    fn to_upstream(self) -> ::qrcode::EcLevel {
        match self {
            Self::Low => ::qrcode::EcLevel::L,
            Self::Medium => ::qrcode::EcLevel::M,
            Self::Quartile => ::qrcode::EcLevel::Q,
            Self::High => ::qrcode::EcLevel::H,
        }
    }
}

/// Symbol size selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum QrVersion {
    /// Pick the smallest version that fits the payload.
    #[default]
    Auto,
    /// Use exactly this version, 1 to 40.
    ///
    /// Fixing the version keeps every label the same physical size even as
    /// payload length varies, which matters when labels are pre-cut or slotted
    /// into a fixed layout. Encoding fails if the payload does not fit.
    Fixed(u8),
}

/// Lowest and highest full QR Code versions.
const MIN_VERSION: u8 = 1;
const MAX_VERSION: u8 = 40;

/// The QR Code symbology.
///
/// # Examples
///
/// ```
/// use smart_package_tracker::symbology::{Ecc, Qr, QrVersion, Symbology};
///
/// // Defaults: medium error correction, smallest version that fits.
/// let symbol = Qr::new().encode("PKG-9ED9285C")?;
/// assert!(!symbol.is_linear());
/// assert_eq!(symbol.modules().width(), symbol.modules().height());
///
/// // Or pin both, for labels that must stay a fixed size.
/// let symbol = Qr::new()
///     .ecc(Ecc::Quartile)
///     .version(QrVersion::Fixed(4))
///     .encode("PKG-9ED9285C")?;
/// assert_eq!(symbol.modules().width(), 33); // version 4 is 33x33
/// # Ok::<(), smart_package_tracker::Error>(())
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Qr {
    ecc: Ecc,
    version: QrVersion,
}

impl Qr {
    /// A QR encoder with medium error correction and automatic sizing.
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the error correction level.
    pub fn ecc(mut self, ecc: Ecc) -> Self {
        self.ecc = ecc;
        self
    }

    /// Set the symbol version.
    pub fn version(mut self, version: QrVersion) -> Self {
        self.version = version;
        self
    }

    /// The configured error correction level.
    pub fn ecc_level(&self) -> Ecc {
        self.ecc
    }

    /// The configured version selection.
    pub fn version_selection(&self) -> QrVersion {
        self.version
    }
}

impl Symbology for Qr {
    fn kind(&self) -> SymbologyKind {
        SymbologyKind::Qr
    }

    fn encode(&self, data: &str) -> Result<Symbol> {
        if data.is_empty() {
            return Err(Error::EmptyPayload);
        }

        let ec = self.ecc.to_upstream();
        let code = match self.version {
            QrVersion::Auto => ::qrcode::QrCode::with_error_correction_level(data, ec),
            QrVersion::Fixed(v) => {
                if !(MIN_VERSION..=MAX_VERSION).contains(&v) {
                    return Err(Error::Unencodable {
                        symbology: SymbologyKind::Qr.name(),
                        reason: alloc::format!(
                            "version must be between {MIN_VERSION} and {MAX_VERSION}, got {v}"
                        ),
                    });
                }
                ::qrcode::QrCode::with_version(data, ::qrcode::Version::Normal(i16::from(v)), ec)
            }
        }
        .map_err(|e| Error::Unencodable {
            symbology: SymbologyKind::Qr.name(),
            reason: describe(&e),
        })?;

        let width = u32::try_from(code.width()).map_err(|_| Error::Unencodable {
            symbology: SymbologyKind::Qr.name(),
            reason: "symbol is implausibly large".to_string(),
        })?;

        let bits: Vec<bool> = code
            .to_colors()
            .into_iter()
            .map(|c| c == ::qrcode::Color::Dark)
            .collect();

        let modules =
            BitMatrix::from_vec(width, width, bits).ok_or_else(|| Error::Unencodable {
                symbology: SymbologyKind::Qr.name(),
                reason: "encoder returned a module count that is not a square".to_string(),
            })?;

        Ok(Symbol::new(SymbologyKind::Qr, modules, data.to_string()))
    }
}

/// Translate the upstream error into something a caller can act on.
///
/// `QrError` is not `#[non_exhaustive]`, so this match is exhaustive and a new
/// upstream variant would be a compile error here rather than a silent
/// fallthrough — which is what we want.
fn describe(e: &::qrcode::types::QrError) -> String {
    use ::qrcode::types::QrError;
    match e {
        QrError::DataTooLong => {
            "payload is too long for the chosen version and error correction level".to_string()
        }
        QrError::InvalidVersion => "invalid version and error correction combination".to_string(),
        QrError::UnsupportedCharacterSet => {
            "payload contains characters this QR mode cannot encode".to_string()
        }
        QrError::InvalidEciDesignator => "invalid ECI designator".to_string(),
        QrError::InvalidCharacter => "payload contains an invalid character".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::format;

    #[test]
    fn produces_a_square_symbol() {
        let s = Qr::new().encode("PKG-9ED9285C").unwrap();
        let m = s.modules();
        assert_eq!(m.width(), m.height());
        assert!(!s.is_linear());
        assert_eq!(s.kind(), SymbologyKind::Qr);
        assert_eq!(s.payload(), "PKG-9ED9285C");
    }

    #[test]
    fn version_sizes_follow_the_standard() {
        // ISO/IEC 18004: version N is (17 + 4N) modules per side.
        for v in [1u8, 2, 4, 10, 40] {
            let s = Qr::new()
                .version(QrVersion::Fixed(v))
                .encode("PKG-9ED9285C")
                .unwrap();
            assert_eq!(
                s.modules().width(),
                17 + 4 * u32::from(v),
                "version {v} has the wrong size"
            );
        }
    }

    #[test]
    fn finder_patterns_sit_in_three_corners() {
        // Every QR code has a 7x7 finder pattern in the top-left, top-right
        // and bottom-left corners, and none in the bottom-right.
        let s = Qr::new().encode("PKG-9ED9285C").unwrap();
        let m = s.modules();
        let w = m.width();

        let is_finder = |ox: u32, oy: u32| {
            // Outer ring dark, inner ring light, 3x3 core dark.
            (0..7).all(|i| m.get(ox + i, oy) && m.get(ox + i, oy + 6))
                && (0..7).all(|i| m.get(ox, oy + i) && m.get(ox + 6, oy + i))
                && !m.get(ox + 1, oy + 1)
                && m.get(ox + 3, oy + 3)
        };

        assert!(is_finder(0, 0), "missing top-left finder");
        assert!(is_finder(w - 7, 0), "missing top-right finder");
        assert!(is_finder(0, w - 7), "missing bottom-left finder");
        assert!(
            !is_finder(w - 7, w - 7),
            "bottom-right should hold format data, not a finder"
        );
    }

    #[test]
    fn higher_error_correction_needs_a_larger_symbol() {
        let payload = "PKG-9ED9285C-0123456789-0123456789-0123456789";
        let low = Qr::new().ecc(Ecc::Low).encode(payload).unwrap();
        let high = Qr::new().ecc(Ecc::High).encode(payload).unwrap();
        assert!(
            high.modules().width() > low.modules().width(),
            "High ECC ({}) should not fit in the same size as Low ({})",
            high.modules().width(),
            low.modules().width()
        );
    }

    #[test]
    fn auto_version_picks_a_small_symbol() {
        let s = Qr::new().encode("PKG-9ED9285C").unwrap();
        assert!(
            s.modules().width() <= 25,
            "12 characters should fit in version 2 or smaller, got {}",
            s.modules().width()
        );
    }

    #[test]
    fn payload_too_long_for_a_fixed_version_is_an_error() {
        let long = "X".repeat(2000);
        let err = Qr::new()
            .version(QrVersion::Fixed(1))
            .encode(&long)
            .unwrap_err();
        assert!(matches!(err, Error::Unencodable { .. }), "got {err:?}");
        assert!(format!("{err}").contains("too long"), "got {err}");
    }

    #[test]
    fn out_of_range_versions_are_rejected() {
        for v in [0u8, 41, 255] {
            let err = Qr::new()
                .version(QrVersion::Fixed(v))
                .encode("PKG-9ED9285C")
                .unwrap_err();
            assert!(matches!(err, Error::Unencodable { .. }), "version {v}");
        }
    }

    #[test]
    fn rejects_an_empty_payload() {
        assert!(matches!(Qr::new().encode(""), Err(Error::EmptyPayload)));
    }

    #[test]
    fn encodes_unicode_payloads() {
        // QR handles arbitrary bytes, unlike Code 128 which is Latin-1 bound.
        let s = Qr::new().encode("PKG-\u{4e2d}\u{6587}").unwrap();
        assert!(s.modules().width() >= 21);
    }

    #[test]
    fn quiet_zone_is_four_modules() {
        assert_eq!(SymbologyKind::Qr.required_quiet_zone(), 4);
        assert!(!SymbologyKind::Qr.is_linear());
        assert_eq!(format!("{}", SymbologyKind::Qr), "QR Code");
    }

    #[test]
    fn encoding_is_deterministic() {
        let a = Qr::new().encode("PKG-9ED9285C").unwrap();
        let b = Qr::new().encode("PKG-9ED9285C").unwrap();
        assert_eq!(a, b);
    }
}