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