Skip to main content

smart_package_tracker/render/
mod.rs

1//! Turning [`Symbol`]s into images.
2//!
3//! Renderers share a single [`Layout`] computation, so PNG and SVG output are
4//! geometrically identical for the same [`RenderOptions`] — an SVG preview
5//! matches what the PNG will print.
6
7mod hri;
8
9#[cfg(feature = "png")]
10pub mod png;
11#[cfg(feature = "svg")]
12pub mod svg;
13
14#[cfg(feature = "png")]
15pub use png::Png;
16#[cfg(feature = "svg")]
17pub use svg::Svg;
18
19use alloc::string::String;
20
21use crate::error::{Error, Result};
22use crate::symbology::Symbol;
23
24/// Refuse to allocate images larger than this on either axis.
25///
26/// A barcode that needs more than 20,000 pixels a side is a configuration
27/// mistake, and failing loudly beats attempting a multi-gigabyte allocation.
28const MAX_DIMENSION_PX: u32 = 20_000;
29
30/// A physical or device length.
31///
32/// Barcode geometry is specified in physical units — the X-dimension of a
33/// shipping label is 13 mil, not "3 pixels" — so lengths carry their unit and
34/// are converted against the output DPI.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum Length {
37    /// Device pixels, used as-is regardless of DPI.
38    Px(f64),
39    /// Millimetres.
40    Mm(f64),
41    /// Thousandths of an inch. The conventional unit for barcode
42    /// X-dimensions; 13 mil is the common shipping-label default.
43    Mils(f64),
44    /// Inches.
45    Inch(f64),
46}
47
48impl Length {
49    /// Convert to device pixels at `dpi`.
50    pub fn to_px(self, dpi: u32) -> f64 {
51        let dpi = f64::from(dpi);
52        match self {
53            Self::Px(v) => v,
54            Self::Mm(v) => v / 25.4 * dpi,
55            Self::Mils(v) => v / 1000.0 * dpi,
56            Self::Inch(v) => v * dpi,
57        }
58    }
59
60    /// Convert to millimetres at `dpi` (needed for SVG physical dimensions).
61    pub fn to_mm(self, dpi: u32) -> f64 {
62        self.to_px(dpi) / f64::from(dpi) * 25.4
63    }
64
65    fn is_positive(self) -> bool {
66        let v = match self {
67            Self::Px(v) | Self::Mm(v) | Self::Mils(v) | Self::Inch(v) => v,
68        };
69        v.is_finite() && v > 0.0
70    }
71}
72
73/// An 8-bit RGBA colour.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct Color {
77    /// Red channel.
78    pub r: u8,
79    /// Green channel.
80    pub g: u8,
81    /// Blue channel.
82    pub b: u8,
83    /// Alpha channel; 255 is opaque.
84    pub a: u8,
85}
86
87impl Color {
88    /// Opaque black.
89    pub const BLACK: Self = Self::rgb(0, 0, 0);
90    /// Opaque white.
91    pub const WHITE: Self = Self::rgb(255, 255, 255);
92    /// Fully transparent.
93    pub const TRANSPARENT: Self = Self::rgba(0, 0, 0, 0);
94
95    /// An opaque colour.
96    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
97        Self { r, g, b, a: 255 }
98    }
99
100    /// A colour with an explicit alpha channel.
101    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
102        Self { r, g, b, a }
103    }
104
105    /// Whether the colour is fully opaque.
106    pub const fn is_opaque(self) -> bool {
107        self.a == 255
108    }
109
110    /// CSS hex notation: `#rrggbb`, or `#rrggbbaa` when not opaque.
111    pub fn to_hex(self) -> String {
112        const HEX: &[u8; 16] = b"0123456789abcdef";
113        let mut s = String::with_capacity(9);
114        s.push('#');
115        let mut push = |v: u8| {
116            s.push(HEX[(v >> 4) as usize] as char);
117            s.push(HEX[(v & 0x0f) as usize] as char);
118        };
119        push(self.r);
120        push(self.g);
121        push(self.b);
122        if !self.is_opaque() {
123            push(self.a);
124        }
125        s
126    }
127}
128
129/// How much blank space to leave around the symbol.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132#[non_exhaustive]
133pub enum QuietZone {
134    /// Whatever the symbology's specification requires — 10 modules for
135    /// Code 128. This is the default and the only setting that guarantees
136    /// reliable scanning.
137    #[default]
138    Standard,
139    /// An explicit number of modules per side.
140    Modules(u32),
141    /// No quiet zone.
142    ///
143    /// Only appropriate when the surrounding layout already guarantees blank
144    /// space. A barcode printed flush against other content will frequently
145    /// fail to scan.
146    None,
147}
148
149/// How a symbol should be drawn.
150///
151/// Defaults target a 300 DPI thermal shipping-label printer: a 13 mil
152/// X-dimension, 25 mm bar height, standard quiet zones, and human-readable
153/// text.
154///
155/// # Examples
156///
157/// ```
158/// use smart_package_tracker::{Color, Length, QuietZone, RenderOptions};
159///
160/// let options = RenderOptions::builder()
161///     .module_width(Length::Mils(13.0))
162///     .height(Length::Mm(25.0))
163///     .quiet_zone(QuietZone::Standard)
164///     .dpi(300)
165///     .colors(Color::BLACK, Color::WHITE)
166///     .human_readable(true)
167///     .build()?;
168///
169/// assert_eq!(options.dpi(), 300);
170/// # Ok::<(), smart_package_tracker::Error>(())
171/// ```
172#[derive(Debug, Clone, PartialEq)]
173pub struct RenderOptions {
174    module_width: Length,
175    height: Length,
176    quiet_zone: QuietZone,
177    dpi: u32,
178    foreground: Color,
179    background: Color,
180    human_readable: bool,
181}
182
183impl Default for RenderOptions {
184    fn default() -> Self {
185        Self {
186            module_width: Length::Mils(13.0),
187            height: Length::Mm(25.0),
188            quiet_zone: QuietZone::Standard,
189            dpi: 300,
190            foreground: Color::BLACK,
191            background: Color::WHITE,
192            human_readable: true,
193        }
194    }
195}
196
197impl RenderOptions {
198    /// Start from the defaults and override what you need.
199    pub fn builder() -> RenderOptionsBuilder {
200        RenderOptionsBuilder::default()
201    }
202
203    /// The X-dimension: width of one module.
204    pub fn module_width(&self) -> Length {
205        self.module_width
206    }
207
208    /// Bar height, for linear symbologies.
209    pub fn height(&self) -> Length {
210        self.height
211    }
212
213    /// Quiet zone setting.
214    pub fn quiet_zone(&self) -> QuietZone {
215        self.quiet_zone
216    }
217
218    /// Output resolution, used to convert physical lengths to pixels.
219    pub fn dpi(&self) -> u32 {
220        self.dpi
221    }
222
223    /// Colour of dark modules.
224    pub fn foreground(&self) -> Color {
225        self.foreground
226    }
227
228    /// Colour of light modules and margins.
229    pub fn background(&self) -> Color {
230        self.background
231    }
232
233    /// Whether to print the payload beneath the symbol.
234    pub fn human_readable(&self) -> bool {
235        self.human_readable
236    }
237
238    /// Compute the pixel geometry for `symbol` under these options.
239    ///
240    /// Exposed because label layout often needs the final size before
241    /// rendering.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`Error::InvalidRenderOptions`] if the result would exceed
246    /// 20,000 pixels on either axis.
247    pub fn layout(&self, symbol: &Symbol) -> Result<Layout> {
248        Layout::compute(symbol, self)
249    }
250}
251
252/// Builder for [`RenderOptions`].
253#[derive(Debug, Clone, Default)]
254pub struct RenderOptionsBuilder {
255    options: RenderOptions,
256}
257
258impl RenderOptionsBuilder {
259    /// Set the X-dimension (width of one module).
260    pub fn module_width(mut self, width: Length) -> Self {
261        self.options.module_width = width;
262        self
263    }
264
265    /// Set the bar height. Ignored by matrix symbologies, which derive height
266    /// from their module grid.
267    pub fn height(mut self, height: Length) -> Self {
268        self.options.height = height;
269        self
270    }
271
272    /// Set the quiet zone.
273    pub fn quiet_zone(mut self, quiet_zone: QuietZone) -> Self {
274        self.options.quiet_zone = quiet_zone;
275        self
276    }
277
278    /// Set the output resolution.
279    pub fn dpi(mut self, dpi: u32) -> Self {
280        self.options.dpi = dpi;
281        self
282    }
283
284    /// Set foreground and background colours.
285    pub fn colors(mut self, foreground: Color, background: Color) -> Self {
286        self.options.foreground = foreground;
287        self.options.background = background;
288        self
289    }
290
291    /// Enable or disable the human-readable text line.
292    pub fn human_readable(mut self, enabled: bool) -> Self {
293        self.options.human_readable = enabled;
294        self
295    }
296
297    /// Validate and build.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`Error::InvalidRenderOptions`] for a non-positive DPI, module
302    /// width, or height.
303    pub fn build(self) -> Result<RenderOptions> {
304        let o = &self.options;
305        if o.dpi == 0 {
306            return Err(Error::InvalidRenderOptions("dpi must be positive".into()));
307        }
308        if !o.module_width.is_positive() {
309            return Err(Error::InvalidRenderOptions(
310                "module_width must be a positive, finite length".into(),
311            ));
312        }
313        if !o.height.is_positive() {
314            return Err(Error::InvalidRenderOptions(
315                "height must be a positive, finite length".into(),
316            ));
317        }
318        Ok(self.options)
319    }
320}
321
322/// Pixel geometry of a rendered symbol.
323///
324/// Every measurement is a whole number of pixels, and the symbol width is an
325/// exact multiple of the module width. Snapping to integers matters: a module
326/// that straddles a pixel boundary is rendered as a grey edge, which degrades
327/// the contrast a scanner relies on.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329#[non_exhaustive]
330pub struct Layout {
331    /// Width of one module, in pixels. Always at least 1.
332    pub module_px: u32,
333    /// Horizontal quiet zone per side, in pixels.
334    pub quiet_x_px: u32,
335    /// Vertical quiet zone per side, in pixels. Zero for linear symbologies,
336    /// which need no vertical quiet zone.
337    pub quiet_y_px: u32,
338    /// Symbol width, in pixels.
339    pub symbol_w_px: u32,
340    /// Symbol height, in pixels. For linear symbologies this is the bar
341    /// height; for matrix symbologies it follows the module grid.
342    pub symbol_h_px: u32,
343    /// Left edge of the symbol.
344    pub symbol_x_px: u32,
345    /// Top edge of the symbol.
346    pub symbol_y_px: u32,
347    /// Integer scale factor applied to the 5x7 HRI font. Zero when no HRI is
348    /// drawn.
349    pub hri_scale: u32,
350    /// Total vertical space taken by the HRI line, including the gap above it.
351    /// Zero when no HRI is drawn.
352    pub hri_block_h_px: u32,
353    /// Left edge of the HRI text.
354    pub hri_x_px: u32,
355    /// Top edge of the HRI text.
356    pub hri_y_px: u32,
357    /// Total image width.
358    pub width_px: u32,
359    /// Total image height.
360    pub height_px: u32,
361}
362
363impl Layout {
364    fn compute(symbol: &Symbol, options: &RenderOptions) -> Result<Self> {
365        let modules = symbol.modules();
366
367        let module_px = round_to_u32(options.module_width.to_px(options.dpi)).max(1);
368
369        let quiet_modules = match options.quiet_zone {
370            QuietZone::Standard => symbol.kind().required_quiet_zone(),
371            QuietZone::Modules(n) => n,
372            QuietZone::None => 0,
373        };
374        let quiet_x_px = quiet_modules.saturating_mul(module_px);
375        let quiet_y_px = if symbol.is_linear() { 0 } else { quiet_x_px };
376
377        let symbol_w_px = modules.width().saturating_mul(module_px);
378        let symbol_h_px = if symbol.is_linear() {
379            round_to_u32(options.height.to_px(options.dpi)).max(1)
380        } else {
381            modules.height().saturating_mul(module_px)
382        };
383
384        // HRI: scale the bitmap font up until the text fills the symbol width,
385        // without letting it grow taller than the symbol itself.
386        let char_count = symbol.payload().chars().count() as u32;
387        let draw_hri = options.human_readable && char_count > 0;
388
389        let (hri_scale, hri_block_h_px, hri_x_px, hri_y_px) = if draw_hri {
390            let natural_w = hri::text_width(char_count);
391            let width_limited = symbol_w_px.checked_div(natural_w).unwrap_or(1);
392            let height_limited = symbol_h_px / hri::GLYPH_H;
393            let scale = width_limited.min(height_limited).max(1);
394
395            let gap = module_px;
396            let text_w = natural_w * scale;
397            let text_h = hri::GLYPH_H * scale;
398            // Centre the text, clamping if an enormous font scale would
399            // otherwise push it left of the quiet zone.
400            let x = quiet_x_px + symbol_w_px.saturating_sub(text_w) / 2;
401            let y = quiet_y_px + symbol_h_px + gap;
402            // Pad below the text as well as above it: glyphs flush against the
403            // image edge are the first thing a thermal printer clips.
404            (scale, gap + text_h + gap, x, y)
405        } else {
406            (0, 0, 0, 0)
407        };
408
409        let width_px = symbol_w_px.saturating_add(quiet_x_px.saturating_mul(2));
410        let height_px = symbol_h_px
411            .saturating_add(quiet_y_px.saturating_mul(2))
412            .saturating_add(hri_block_h_px);
413
414        if width_px == 0 || height_px == 0 {
415            return Err(Error::InvalidRenderOptions(
416                "computed image has zero area".into(),
417            ));
418        }
419        if width_px > MAX_DIMENSION_PX || height_px > MAX_DIMENSION_PX {
420            return Err(Error::InvalidRenderOptions(alloc::format!(
421                "computed image is {width_px}x{height_px} px, exceeding the {MAX_DIMENSION_PX} px limit; \
422                 reduce module_width, height, or dpi"
423            )));
424        }
425
426        Ok(Self {
427            module_px,
428            quiet_x_px,
429            quiet_y_px,
430            symbol_w_px,
431            symbol_h_px,
432            symbol_x_px: quiet_x_px,
433            symbol_y_px: quiet_y_px,
434            hri_scale,
435            hri_block_h_px,
436            hri_x_px,
437            hri_y_px,
438            width_px,
439            height_px,
440        })
441    }
442}
443
444/// Round a positive `f64` to `u32`, saturating rather than wrapping.
445fn round_to_u32(v: f64) -> u32 {
446    if !v.is_finite() || v <= 0.0 {
447        return 0;
448    }
449    let rounded = round_half_up(v);
450    if rounded >= f64::from(u32::MAX) {
451        u32::MAX
452    } else {
453        rounded as u32
454    }
455}
456
457/// `f64::round` is not available in `core`, so round half away from zero by
458/// hand. Inputs here are always non-negative.
459fn round_half_up(v: f64) -> f64 {
460    let truncated = v as i64 as f64;
461    if v - truncated >= 0.5 {
462        truncated + 1.0
463    } else {
464        truncated
465    }
466}
467
468/// Whether every character in `text` has a real glyph in the embedded HRI font.
469///
470/// Human-readable text is drawn with a built-in 5x7 bitmap font covering
471/// `0-9`, `A-Z`, space, and `- . / * + $ % :`. Anything else renders as a
472/// hollow box. Call this to detect that before it reaches a label.
473///
474/// # Examples
475///
476/// ```
477/// use smart_package_tracker::render::hri_supports;
478///
479/// assert!(hri_supports("PKG-9ED9285C"));
480/// assert!(!hri_supports("pkg-lowercase"));
481/// ```
482pub fn hri_supports(text: &str) -> bool {
483    text.chars().all(hri::is_supported)
484}
485
486/// Draws a [`Symbol`] into some output representation.
487///
488/// Implementations must honour the shared [`Layout`] so that all output
489/// formats stay geometrically identical.
490pub trait Renderer {
491    /// What this renderer produces — `Vec<u8>` for PNG, `String` for SVG.
492    type Output;
493
494    /// Render `symbol`.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`Error::InvalidRenderOptions`] if the geometry is unusable, or
499    /// [`Error::Render`] if the underlying encoder fails.
500    fn render(&self, symbol: &Symbol, options: &RenderOptions) -> Result<Self::Output>;
501}
502
503#[cfg(all(test, feature = "code128"))]
504mod tests {
505    use super::*;
506    use crate::symbology::{Code128, Symbology};
507
508    fn symbol() -> Symbol {
509        Code128.encode("PKG-9ED9285C").unwrap()
510    }
511
512    #[test]
513    fn lengths_convert_consistently() {
514        assert_eq!(Length::Inch(1.0).to_px(300), 300.0);
515        assert_eq!(Length::Mils(1000.0).to_px(300), 300.0);
516        assert_eq!(Length::Px(42.0).to_px(300), 42.0);
517        assert!((Length::Mm(25.4).to_px(300) - 300.0).abs() < 1e-9);
518        assert!((Length::Inch(1.0).to_mm(300) - 25.4).abs() < 1e-9);
519    }
520
521    #[test]
522    fn module_width_snaps_to_whole_pixels() {
523        let s = symbol();
524        // 13 mil at 300 dpi is 3.9 px, which must round to 4.
525        let layout = RenderOptions::default().layout(&s).unwrap();
526        assert_eq!(layout.module_px, 4);
527        assert_eq!(layout.symbol_w_px % layout.module_px, 0);
528    }
529
530    #[test]
531    fn module_width_never_collapses_to_zero() {
532        let opts = RenderOptions::builder()
533            .module_width(Length::Mils(1.0))
534            .dpi(72)
535            .build()
536            .unwrap();
537        assert_eq!(opts.layout(&symbol()).unwrap().module_px, 1);
538    }
539
540    #[test]
541    fn standard_quiet_zone_is_ten_modules_per_side() {
542        let s = symbol();
543        let layout = RenderOptions::default().layout(&s).unwrap();
544        assert_eq!(layout.quiet_x_px, 10 * layout.module_px);
545        assert_eq!(layout.width_px, layout.symbol_w_px + 2 * layout.quiet_x_px);
546    }
547
548    #[test]
549    fn linear_symbols_get_no_vertical_quiet_zone() {
550        let layout = RenderOptions::default().layout(&symbol()).unwrap();
551        assert_eq!(layout.quiet_y_px, 0);
552        assert_eq!(layout.symbol_y_px, 0);
553    }
554
555    #[test]
556    fn quiet_zone_can_be_overridden() {
557        let s = symbol();
558        let none = RenderOptions::builder()
559            .quiet_zone(QuietZone::None)
560            .build()
561            .unwrap()
562            .layout(&s)
563            .unwrap();
564        assert_eq!(none.quiet_x_px, 0);
565        assert_eq!(none.width_px, none.symbol_w_px);
566
567        let explicit = RenderOptions::builder()
568            .quiet_zone(QuietZone::Modules(2))
569            .build()
570            .unwrap()
571            .layout(&s)
572            .unwrap();
573        assert_eq!(explicit.quiet_x_px, 2 * explicit.module_px);
574    }
575
576    #[test]
577    fn hri_is_centred_and_fits_within_the_symbol() {
578        let s = symbol();
579        let layout = RenderOptions::default().layout(&s).unwrap();
580        assert!(layout.hri_scale >= 1);
581        let text_w = hri::text_width(s.payload().chars().count() as u32) * layout.hri_scale;
582        assert!(text_w <= layout.symbol_w_px, "HRI wider than the symbol");
583        assert!(layout.hri_x_px >= layout.quiet_x_px);
584        assert!(layout.hri_x_px + text_w <= layout.width_px);
585        assert!(layout.hri_y_px + hri::GLYPH_H * layout.hri_scale <= layout.height_px);
586    }
587
588    #[test]
589    fn hri_is_padded_away_from_both_edges() {
590        let layout = RenderOptions::default().layout(&symbol()).unwrap();
591        // Gap above the text, between it and the bars.
592        assert!(layout.hri_y_px > layout.symbol_y_px + layout.symbol_h_px);
593        // Gap below the text, before the image edge.
594        let text_bottom = layout.hri_y_px + hri::GLYPH_H * layout.hri_scale;
595        assert!(
596            text_bottom < layout.height_px,
597            "HRI is flush against the bottom edge and may be clipped"
598        );
599    }
600
601    #[test]
602    fn disabling_hri_removes_the_text_block() {
603        let layout = RenderOptions::builder()
604            .human_readable(false)
605            .build()
606            .unwrap()
607            .layout(&symbol())
608            .unwrap();
609        assert_eq!(layout.hri_scale, 0);
610        assert_eq!(layout.hri_block_h_px, 0);
611        assert_eq!(layout.height_px, layout.symbol_h_px);
612    }
613
614    #[test]
615    fn builder_rejects_degenerate_options() {
616        assert!(RenderOptions::builder().dpi(0).build().is_err());
617        assert!(RenderOptions::builder()
618            .module_width(Length::Mm(0.0))
619            .build()
620            .is_err());
621        assert!(RenderOptions::builder()
622            .height(Length::Mm(-1.0))
623            .build()
624            .is_err());
625        assert!(RenderOptions::builder()
626            .module_width(Length::Mm(f64::NAN))
627            .build()
628            .is_err());
629    }
630
631    #[test]
632    fn absurd_geometry_is_rejected_rather_than_allocated() {
633        let opts = RenderOptions::builder()
634            .module_width(Length::Inch(10.0))
635            .dpi(1200)
636            .build()
637            .unwrap();
638        assert!(matches!(
639            opts.layout(&symbol()),
640            Err(Error::InvalidRenderOptions(_))
641        ));
642    }
643
644    #[test]
645    fn colors_format_as_css_hex() {
646        assert_eq!(Color::BLACK.to_hex(), "#000000");
647        assert_eq!(Color::WHITE.to_hex(), "#ffffff");
648        assert_eq!(Color::rgba(1, 2, 3, 4).to_hex(), "#01020304");
649        assert!(Color::BLACK.is_opaque());
650        assert!(!Color::TRANSPARENT.is_opaque());
651    }
652
653    #[test]
654    fn rounding_is_half_up_and_saturating() {
655        assert_eq!(round_to_u32(3.4), 3);
656        assert_eq!(round_to_u32(3.5), 4);
657        assert_eq!(round_to_u32(-1.0), 0);
658        assert_eq!(round_to_u32(f64::NAN), 0);
659        assert_eq!(round_to_u32(f64::INFINITY), 0);
660        assert_eq!(round_to_u32(1e30), u32::MAX);
661    }
662}