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
//! SVG rendering.
//!
//! The document carries physical `width`/`height` in millimetres alongside a
//! `viewBox` in the same pixel units the PNG renderer uses, so the two formats
//! describe the same geometry at the same physical size.
//!
//! Adjacent dark modules are merged into single `<rect>` elements, and
//! `shape-rendering="crispEdges"` disables anti-aliasing — a grey, softened
//! bar edge is exactly what degrades scan reliability.

use alloc::string::String;
use core::fmt::Write;

use super::{hri, Layout, RenderOptions, Renderer};
use crate::error::Result;
use crate::symbology::Symbol;

/// The SVG renderer.
///
/// # Examples
///
/// ```
/// # #[cfg(feature = "code128")]
/// # fn main() -> Result<(), smart_package_tracker::Error> {
/// use smart_package_tracker::{RenderOptions, symbology::{Code128, Symbology}, render::{Svg, Renderer}};
///
/// let symbol = Code128.encode("PKG-9ED9285C")?;
/// let doc = Svg.render(&symbol, &RenderOptions::default())?;
/// assert!(doc.starts_with("<?xml"));
/// assert!(doc.contains("<svg"));
/// # Ok(())
/// # }
/// # #[cfg(not(feature = "code128"))]
/// # fn main() {}
/// ```
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Svg;

impl Renderer for Svg {
    type Output = String;

    fn render(&self, symbol: &Symbol, options: &RenderOptions) -> Result<String> {
        let layout = options.layout(symbol)?;
        let mut out = String::with_capacity(1024);

        let width_mm = px_to_mm(layout.width_px, options.dpi());
        let height_mm = px_to_mm(layout.height_px, options.dpi());

        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
        // `write!` into a String cannot fail, so the results are discarded
        // deliberately rather than propagated.
        let _ = writeln!(
            out,
            "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" \
             width=\"{width_mm:.4}mm\" height=\"{height_mm:.4}mm\" \
             viewBox=\"0 0 {} {}\" shape-rendering=\"crispEdges\" role=\"img\">",
            layout.width_px, layout.height_px
        );

        // Accessible name: screen readers and downstream tooling can recover
        // the payload without decoding the bars.
        let _ = writeln!(
            out,
            "  <title>{} barcode: {}</title>",
            escape_xml(symbol.kind().name()),
            escape_xml(symbol.payload())
        );

        let bg = options.background();
        if bg.a > 0 {
            let _ = writeln!(
                out,
                "  <rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
                layout.width_px,
                layout.height_px,
                bg.to_hex()
            );
        }

        let fg = options.foreground().to_hex();
        let _ = writeln!(out, "  <g fill=\"{fg}\">");
        write_modules(&mut out, symbol, &layout);
        if layout.hri_scale > 0 {
            write_hri(&mut out, symbol, &layout);
        }
        out.push_str("  </g>\n");
        out.push_str("</svg>\n");

        Ok(out)
    }
}

/// Emit one `<rect>` per horizontal run of dark modules.
fn write_modules(out: &mut String, symbol: &Symbol, layout: &Layout) {
    let modules = symbol.modules();

    for my in 0..modules.height() {
        let (y, h) = if symbol.is_linear() {
            (layout.symbol_y_px, layout.symbol_h_px)
        } else {
            (layout.symbol_y_px + my * layout.module_px, layout.module_px)
        };

        for (start, len) in dark_runs(modules.row(my)) {
            let _ = writeln!(
                out,
                "    <rect x=\"{}\" y=\"{y}\" width=\"{}\" height=\"{h}\"/>",
                layout.symbol_x_px + start * layout.module_px,
                len * layout.module_px
            );
        }
    }
}

/// Emit the human-readable line using the same bitmap font as the PNG
/// renderer, so both formats produce identical glyph geometry.
fn write_hri(out: &mut String, symbol: &Symbol, layout: &Layout) {
    let scale = layout.hri_scale;

    for (index, ch) in symbol.payload().chars().enumerate() {
        let glyph = hri::glyph(ch);
        let origin_x = layout.hri_x_px + (index as u32) * hri::ADVANCE * scale;

        for gy in 0..hri::GLYPH_H {
            let row: alloc::vec::Vec<bool> = (0..hri::GLYPH_W)
                .map(|gx| hri::pixel(glyph, gx, gy))
                .collect();
            for (start, len) in dark_runs(&row) {
                let _ = writeln!(
                    out,
                    "    <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{scale}\"/>",
                    origin_x + start * scale,
                    layout.hri_y_px + gy * scale,
                    len * scale
                );
            }
        }
    }
}

/// Collapse a row of modules into `(start, length)` runs of dark modules.
fn dark_runs(row: &[bool]) -> impl Iterator<Item = (u32, u32)> + '_ {
    let mut i = 0usize;
    core::iter::from_fn(move || {
        while i < row.len() && !row[i] {
            i += 1;
        }
        if i >= row.len() {
            return None;
        }
        let start = i;
        while i < row.len() && row[i] {
            i += 1;
        }
        Some((start as u32, (i - start) as u32))
    })
}

fn px_to_mm(px: u32, dpi: u32) -> f64 {
    f64::from(px) / f64::from(dpi) * 25.4
}

/// Escape the five XML metacharacters.
fn escape_xml(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            _ => out.push(c),
        }
    }
    out
}

#[cfg(all(test, feature = "code128"))]
mod tests {
    use super::*;
    use crate::render::{Color, QuietZone};
    use crate::symbology::{Code128, Symbology};
    use alloc::format;

    fn symbol() -> Symbol {
        Code128.encode("PKG-9ED9285C").unwrap()
    }

    fn render(opts: &RenderOptions) -> String {
        Svg.render(&symbol(), opts).unwrap()
    }

    #[test]
    fn declares_physical_size_and_a_matching_viewbox() {
        let opts = RenderOptions::default();
        let layout = opts.layout(&symbol()).unwrap();
        let doc = render(&opts);

        assert!(doc.contains(&format!(
            "viewBox=\"0 0 {} {}\"",
            layout.width_px, layout.height_px
        )));
        assert!(doc.contains("mm\""), "physical units missing");
        assert!(doc.contains("shape-rendering=\"crispEdges\""));
    }

    #[test]
    fn geometry_matches_the_png_renderer() {
        // Both renderers consume the same Layout; this guards against one of
        // them drifting.
        let opts = RenderOptions::default();
        let s = symbol();
        let layout = opts.layout(&s).unwrap();
        let doc = Svg.render(&s, &opts).unwrap();

        // The first bar starts at the inner edge of the quiet zone.
        assert!(doc.contains(&format!("<rect x=\"{}\" y=\"0\"", layout.symbol_x_px)));
    }

    #[test]
    fn adjacent_modules_are_merged_into_single_rects() {
        let opts = RenderOptions::builder()
            .human_readable(false)
            .build()
            .unwrap();
        let s = symbol();
        let doc = Svg.render(&s, &opts).unwrap();

        let rects = doc.matches("<rect").count() - 1; // minus the background
        let dark_modules = (0..s.modules().width())
            .filter(|x| s.modules().get(*x, 0))
            .count();
        assert!(
            rects < dark_modules,
            "expected merged runs, got {rects} rects for {dark_modules} dark modules"
        );
    }

    #[test]
    fn run_merging_is_correct() {
        let runs: alloc::vec::Vec<_> =
            dark_runs(&[false, true, true, false, true, false, false, true]).collect();
        assert_eq!(runs, alloc::vec![(1, 2), (4, 1), (7, 1)]);
        assert_eq!(dark_runs(&[false, false]).count(), 0);
        assert_eq!(
            dark_runs(&[true, true, true]).collect::<alloc::vec::Vec<_>>(),
            alloc::vec![(0, 3)]
        );
    }

    #[test]
    fn carries_an_accessible_title() {
        let doc = render(&RenderOptions::default());
        assert!(doc.contains("<title>Code 128 barcode: PKG-9ED9285C</title>"));
    }

    #[test]
    fn title_text_is_xml_escaped() {
        let s = Code128.encode("A<B&C\"D").unwrap();
        let doc = Svg.render(&s, &RenderOptions::default()).unwrap();
        assert!(doc.contains("A&lt;B&amp;C&quot;D"));
        assert!(!doc.contains("A<B&C"));
    }

    #[test]
    fn transparent_background_emits_no_background_rect() {
        let opts = RenderOptions::builder()
            .colors(Color::BLACK, Color::TRANSPARENT)
            .human_readable(false)
            .build()
            .unwrap();
        let doc = render(&opts);
        assert!(!doc.contains("fill=\"#00000000\""));
    }

    #[test]
    fn custom_foreground_is_applied() {
        let opts = RenderOptions::builder()
            .colors(Color::rgb(0x12, 0x34, 0x56), Color::WHITE)
            .build()
            .unwrap();
        assert!(render(&opts).contains("fill=\"#123456\""));
    }

    #[test]
    fn no_quiet_zone_shifts_the_symbol_to_the_origin() {
        let opts = RenderOptions::builder()
            .quiet_zone(QuietZone::None)
            .human_readable(false)
            .build()
            .unwrap();
        assert!(render(&opts).contains("<rect x=\"0\" y=\"0\" width="));
    }

    #[test]
    fn output_is_reproducible() {
        let opts = RenderOptions::default();
        assert_eq!(render(&opts), render(&opts));
    }
}