smart_package_tracker/render/
svg.rs1use alloc::string::String;
12use core::fmt::Write;
13
14use super::{hri, Layout, RenderOptions, Renderer};
15use crate::error::Result;
16use crate::symbology::Symbol;
17
18#[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 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 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
88fn 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
110fn 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
136fn 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
158fn 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("&"),
164 '<' => out.push_str("<"),
165 '>' => out.push_str(">"),
166 '"' => out.push_str("""),
167 '\'' => out.push_str("'"),
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 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 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; 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<B&C"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}