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)]
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 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 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
93fn 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
115fn 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
141fn 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
163fn 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("&"),
169 '<' => out.push_str("<"),
170 '>' => out.push_str(">"),
171 '"' => out.push_str("""),
172 '\'' => out.push_str("'"),
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 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 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; 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<B&C"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}