Skip to main content

smart_package_tracker/render/
svg.rs

1//! SVG rendering.
2//!
3//! The document carries physical `width`/`height` in millimetres alongside a
4//! `viewBox` in the same pixel units the PNG renderer uses, so the two formats
5//! describe the same geometry at the same physical size.
6//!
7//! Adjacent dark modules are merged into single `<rect>` elements, and
8//! `shape-rendering="crispEdges"` disables anti-aliasing — a grey, softened
9//! bar edge is exactly what degrades scan reliability.
10
11use alloc::string::String;
12use core::fmt::Write;
13
14use super::{hri, Layout, RenderOptions, Renderer};
15use crate::error::Result;
16use crate::symbology::Symbol;
17
18/// The SVG renderer.
19///
20/// # Examples
21///
22/// ```
23/// use smart_package_tracker::{RenderOptions, symbology::{Code128, Symbology}, render::{Svg, Renderer}};
24///
25/// let symbol = Code128.encode("PKG-9ED9285C")?;
26/// let doc = Svg.render(&symbol, &RenderOptions::default())?;
27/// assert!(doc.starts_with("<?xml"));
28/// assert!(doc.contains("<svg"));
29/// # Ok::<(), smart_package_tracker::Error>(())
30/// ```
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32pub struct Svg;
33
34impl Renderer for Svg {
35    type Output = String;
36
37    fn render(&self, symbol: &Symbol, options: &RenderOptions) -> Result<String> {
38        let layout = options.layout(symbol)?;
39        let mut out = String::with_capacity(1024);
40
41        let width_mm = px_to_mm(layout.width_px, options.dpi());
42        let height_mm = px_to_mm(layout.height_px, options.dpi());
43
44        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
45        // `write!` into a String cannot fail, so the results are discarded
46        // deliberately rather than propagated.
47        let _ = writeln!(
48            out,
49            "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" \
50             width=\"{width_mm:.4}mm\" height=\"{height_mm:.4}mm\" \
51             viewBox=\"0 0 {} {}\" shape-rendering=\"crispEdges\" role=\"img\">",
52            layout.width_px, layout.height_px
53        );
54
55        // Accessible name: screen readers and downstream tooling can recover
56        // the payload without decoding the bars.
57        let _ = writeln!(
58            out,
59            "  <title>{} barcode: {}</title>",
60            escape_xml(symbol.kind().name()),
61            escape_xml(symbol.payload())
62        );
63
64        let bg = options.background();
65        if bg.a > 0 {
66            let _ = writeln!(
67                out,
68                "  <rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
69                layout.width_px,
70                layout.height_px,
71                bg.to_hex()
72            );
73        }
74
75        let fg = options.foreground().to_hex();
76        let _ = writeln!(out, "  <g fill=\"{fg}\">");
77        write_modules(&mut out, symbol, &layout);
78        if layout.hri_scale > 0 {
79            write_hri(&mut out, symbol, &layout);
80        }
81        out.push_str("  </g>\n");
82        out.push_str("</svg>\n");
83
84        Ok(out)
85    }
86}
87
88/// Emit one `<rect>` per horizontal run of dark modules.
89fn write_modules(out: &mut String, symbol: &Symbol, layout: &Layout) {
90    let modules = symbol.modules();
91
92    for my in 0..modules.height() {
93        let (y, h) = if symbol.is_linear() {
94            (layout.symbol_y_px, layout.symbol_h_px)
95        } else {
96            (layout.symbol_y_px + my * layout.module_px, layout.module_px)
97        };
98
99        for (start, len) in dark_runs(modules.row(my)) {
100            let _ = writeln!(
101                out,
102                "    <rect x=\"{}\" y=\"{y}\" width=\"{}\" height=\"{h}\"/>",
103                layout.symbol_x_px + start * layout.module_px,
104                len * layout.module_px
105            );
106        }
107    }
108}
109
110/// Emit the human-readable line using the same bitmap font as the PNG
111/// renderer, so both formats produce identical glyph geometry.
112fn write_hri(out: &mut String, symbol: &Symbol, layout: &Layout) {
113    let scale = layout.hri_scale;
114
115    for (index, ch) in symbol.payload().chars().enumerate() {
116        let glyph = hri::glyph(ch);
117        let origin_x = layout.hri_x_px + (index as u32) * hri::ADVANCE * scale;
118
119        for gy in 0..hri::GLYPH_H {
120            let row: alloc::vec::Vec<bool> = (0..hri::GLYPH_W)
121                .map(|gx| hri::pixel(glyph, gx, gy))
122                .collect();
123            for (start, len) in dark_runs(&row) {
124                let _ = writeln!(
125                    out,
126                    "    <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{scale}\"/>",
127                    origin_x + start * scale,
128                    layout.hri_y_px + gy * scale,
129                    len * scale
130                );
131            }
132        }
133    }
134}
135
136/// Collapse a row of modules into `(start, length)` runs of dark modules.
137fn dark_runs(row: &[bool]) -> impl Iterator<Item = (u32, u32)> + '_ {
138    let mut i = 0usize;
139    core::iter::from_fn(move || {
140        while i < row.len() && !row[i] {
141            i += 1;
142        }
143        if i >= row.len() {
144            return None;
145        }
146        let start = i;
147        while i < row.len() && row[i] {
148            i += 1;
149        }
150        Some((start as u32, (i - start) as u32))
151    })
152}
153
154fn px_to_mm(px: u32, dpi: u32) -> f64 {
155    f64::from(px) / f64::from(dpi) * 25.4
156}
157
158/// Escape the five XML metacharacters.
159fn escape_xml(s: &str) -> String {
160    let mut out = String::with_capacity(s.len());
161    for c in s.chars() {
162        match c {
163            '&' => out.push_str("&amp;"),
164            '<' => out.push_str("&lt;"),
165            '>' => out.push_str("&gt;"),
166            '"' => out.push_str("&quot;"),
167            '\'' => out.push_str("&apos;"),
168            _ => out.push(c),
169        }
170    }
171    out
172}
173
174#[cfg(all(test, feature = "code128"))]
175mod tests {
176    use super::*;
177    use crate::render::{Color, QuietZone};
178    use crate::symbology::{Code128, Symbology};
179    use alloc::format;
180
181    fn symbol() -> Symbol {
182        Code128.encode("PKG-9ED9285C").unwrap()
183    }
184
185    fn render(opts: &RenderOptions) -> String {
186        Svg.render(&symbol(), opts).unwrap()
187    }
188
189    #[test]
190    fn declares_physical_size_and_a_matching_viewbox() {
191        let opts = RenderOptions::default();
192        let layout = opts.layout(&symbol()).unwrap();
193        let doc = render(&opts);
194
195        assert!(doc.contains(&format!(
196            "viewBox=\"0 0 {} {}\"",
197            layout.width_px, layout.height_px
198        )));
199        assert!(doc.contains("mm\""), "physical units missing");
200        assert!(doc.contains("shape-rendering=\"crispEdges\""));
201    }
202
203    #[test]
204    fn geometry_matches_the_png_renderer() {
205        // Both renderers consume the same Layout; this guards against one of
206        // them drifting.
207        let opts = RenderOptions::default();
208        let s = symbol();
209        let layout = opts.layout(&s).unwrap();
210        let doc = Svg.render(&s, &opts).unwrap();
211
212        // The first bar starts at the inner edge of the quiet zone.
213        assert!(doc.contains(&format!("<rect x=\"{}\" y=\"0\"", layout.symbol_x_px)));
214    }
215
216    #[test]
217    fn adjacent_modules_are_merged_into_single_rects() {
218        let opts = RenderOptions::builder()
219            .human_readable(false)
220            .build()
221            .unwrap();
222        let s = symbol();
223        let doc = Svg.render(&s, &opts).unwrap();
224
225        let rects = doc.matches("<rect").count() - 1; // minus the background
226        let dark_modules = (0..s.modules().width())
227            .filter(|x| s.modules().get(*x, 0))
228            .count();
229        assert!(
230            rects < dark_modules,
231            "expected merged runs, got {rects} rects for {dark_modules} dark modules"
232        );
233    }
234
235    #[test]
236    fn run_merging_is_correct() {
237        let runs: alloc::vec::Vec<_> =
238            dark_runs(&[false, true, true, false, true, false, false, true]).collect();
239        assert_eq!(runs, alloc::vec![(1, 2), (4, 1), (7, 1)]);
240        assert_eq!(dark_runs(&[false, false]).count(), 0);
241        assert_eq!(
242            dark_runs(&[true, true, true]).collect::<alloc::vec::Vec<_>>(),
243            alloc::vec![(0, 3)]
244        );
245    }
246
247    #[test]
248    fn carries_an_accessible_title() {
249        let doc = render(&RenderOptions::default());
250        assert!(doc.contains("<title>Code 128 barcode: PKG-9ED9285C</title>"));
251    }
252
253    #[test]
254    fn title_text_is_xml_escaped() {
255        let s = Code128.encode("A<B&C\"D").unwrap();
256        let doc = Svg.render(&s, &RenderOptions::default()).unwrap();
257        assert!(doc.contains("A&lt;B&amp;C&quot;D"));
258        assert!(!doc.contains("A<B&C"));
259    }
260
261    #[test]
262    fn transparent_background_emits_no_background_rect() {
263        let opts = RenderOptions::builder()
264            .colors(Color::BLACK, Color::TRANSPARENT)
265            .human_readable(false)
266            .build()
267            .unwrap();
268        let doc = render(&opts);
269        assert!(!doc.contains("fill=\"#00000000\""));
270    }
271
272    #[test]
273    fn custom_foreground_is_applied() {
274        let opts = RenderOptions::builder()
275            .colors(Color::rgb(0x12, 0x34, 0x56), Color::WHITE)
276            .build()
277            .unwrap();
278        assert!(render(&opts).contains("fill=\"#123456\""));
279    }
280
281    #[test]
282    fn no_quiet_zone_shifts_the_symbol_to_the_origin() {
283        let opts = RenderOptions::builder()
284            .quiet_zone(QuietZone::None)
285            .human_readable(false)
286            .build()
287            .unwrap();
288        assert!(render(&opts).contains("<rect x=\"0\" y=\"0\" width="));
289    }
290
291    #[test]
292    fn output_is_reproducible() {
293        let opts = RenderOptions::default();
294        assert_eq!(render(&opts), render(&opts));
295    }
296}