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, text_w_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            // Pad below the text as well as above it: glyphs flush against the
397            // image edge are the first thing a thermal printer clips.
398            (scale, gap + hri::GLYPH_H * scale + gap, natural_w * scale)
399        } else {
400            (0, 0, 0)
401        };
402
403        // At scale 1 the text can still be wider than the symbol, which is
404        // reachable for a compact QR code with a long payload. Widen the image
405        // to fit rather than letting glyphs run past its edge and be clipped.
406        let content_w_px = symbol_w_px.max(text_w_px);
407
408        let width_px = content_w_px.saturating_add(quiet_x_px.saturating_mul(2));
409        let height_px = symbol_h_px
410            .saturating_add(quiet_y_px.saturating_mul(2))
411            .saturating_add(hri_block_h_px);
412
413        // Centre the symbol and the text independently within the content box.
414        let symbol_x_px = quiet_x_px + (content_w_px - symbol_w_px) / 2;
415        let (hri_x_px, hri_y_px) = if draw_hri {
416            (
417                quiet_x_px + (content_w_px - text_w_px) / 2,
418                quiet_y_px + symbol_h_px + module_px,
419            )
420        } else {
421            (0, 0)
422        };
423
424        if width_px == 0 || height_px == 0 {
425            return Err(Error::InvalidRenderOptions(
426                "computed image has zero area".into(),
427            ));
428        }
429        if width_px > MAX_DIMENSION_PX || height_px > MAX_DIMENSION_PX {
430            return Err(Error::InvalidRenderOptions(alloc::format!(
431                "computed image is {width_px}x{height_px} px, exceeding the {MAX_DIMENSION_PX} px limit; \
432                 reduce module_width, height, or dpi"
433            )));
434        }
435
436        Ok(Self {
437            module_px,
438            quiet_x_px,
439            quiet_y_px,
440            symbol_w_px,
441            symbol_h_px,
442            symbol_x_px,
443            symbol_y_px: quiet_y_px,
444            hri_scale,
445            hri_block_h_px,
446            hri_x_px,
447            hri_y_px,
448            width_px,
449            height_px,
450        })
451    }
452}
453
454/// Round a positive `f64` to `u32`, saturating rather than wrapping.
455fn round_to_u32(v: f64) -> u32 {
456    if !v.is_finite() || v <= 0.0 {
457        return 0;
458    }
459    let rounded = round_half_up(v);
460    if rounded >= f64::from(u32::MAX) {
461        u32::MAX
462    } else {
463        rounded as u32
464    }
465}
466
467/// `f64::round` is not available in `core`, so round half away from zero by
468/// hand. Inputs here are always non-negative.
469fn round_half_up(v: f64) -> f64 {
470    let truncated = v as i64 as f64;
471    if v - truncated >= 0.5 {
472        truncated + 1.0
473    } else {
474        truncated
475    }
476}
477
478/// Whether every character in `text` has a real glyph in the embedded HRI font.
479///
480/// Human-readable text is drawn with a built-in 5x7 bitmap font covering
481/// `0-9`, `A-Z`, space, and `- . / * + $ % :`. Anything else renders as a
482/// hollow box. Call this to detect that before it reaches a label.
483///
484/// # Examples
485///
486/// ```
487/// use smart_package_tracker::render::hri_supports;
488///
489/// assert!(hri_supports("PKG-9ED9285C"));
490/// assert!(!hri_supports("pkg-lowercase"));
491/// ```
492pub fn hri_supports(text: &str) -> bool {
493    text.chars().all(hri::is_supported)
494}
495
496/// Draws a [`Symbol`] into some output representation.
497///
498/// Implementations must honour the shared [`Layout`] so that all output
499/// formats stay geometrically identical.
500pub trait Renderer {
501    /// What this renderer produces — `Vec<u8>` for PNG, `String` for SVG.
502    type Output;
503
504    /// Render `symbol`.
505    ///
506    /// # Errors
507    ///
508    /// Returns [`Error::InvalidRenderOptions`] if the geometry is unusable, or
509    /// [`Error::Render`] if the underlying encoder fails.
510    fn render(&self, symbol: &Symbol, options: &RenderOptions) -> Result<Self::Output>;
511}
512
513#[cfg(all(test, feature = "code128"))]
514mod tests {
515    use super::*;
516    use crate::symbology::{Code128, Symbology};
517
518    fn symbol() -> Symbol {
519        Code128.encode("PKG-9ED9285C").unwrap()
520    }
521
522    #[test]
523    fn lengths_convert_consistently() {
524        assert_eq!(Length::Inch(1.0).to_px(300), 300.0);
525        assert_eq!(Length::Mils(1000.0).to_px(300), 300.0);
526        assert_eq!(Length::Px(42.0).to_px(300), 42.0);
527        assert!((Length::Mm(25.4).to_px(300) - 300.0).abs() < 1e-9);
528        assert!((Length::Inch(1.0).to_mm(300) - 25.4).abs() < 1e-9);
529    }
530
531    #[test]
532    fn module_width_snaps_to_whole_pixels() {
533        let s = symbol();
534        // 13 mil at 300 dpi is 3.9 px, which must round to 4.
535        let layout = RenderOptions::default().layout(&s).unwrap();
536        assert_eq!(layout.module_px, 4);
537        assert_eq!(layout.symbol_w_px % layout.module_px, 0);
538    }
539
540    #[test]
541    fn module_width_never_collapses_to_zero() {
542        let opts = RenderOptions::builder()
543            .module_width(Length::Mils(1.0))
544            .dpi(72)
545            .build()
546            .unwrap();
547        assert_eq!(opts.layout(&symbol()).unwrap().module_px, 1);
548    }
549
550    #[test]
551    fn standard_quiet_zone_is_ten_modules_per_side() {
552        let s = symbol();
553        let layout = RenderOptions::default().layout(&s).unwrap();
554        assert_eq!(layout.quiet_x_px, 10 * layout.module_px);
555        assert_eq!(layout.width_px, layout.symbol_w_px + 2 * layout.quiet_x_px);
556    }
557
558    #[test]
559    fn linear_symbols_get_no_vertical_quiet_zone() {
560        let layout = RenderOptions::default().layout(&symbol()).unwrap();
561        assert_eq!(layout.quiet_y_px, 0);
562        assert_eq!(layout.symbol_y_px, 0);
563    }
564
565    #[test]
566    fn quiet_zone_can_be_overridden() {
567        let s = symbol();
568        let none = RenderOptions::builder()
569            .quiet_zone(QuietZone::None)
570            .build()
571            .unwrap()
572            .layout(&s)
573            .unwrap();
574        assert_eq!(none.quiet_x_px, 0);
575        assert_eq!(none.width_px, none.symbol_w_px);
576
577        let explicit = RenderOptions::builder()
578            .quiet_zone(QuietZone::Modules(2))
579            .build()
580            .unwrap()
581            .layout(&s)
582            .unwrap();
583        assert_eq!(explicit.quiet_x_px, 2 * explicit.module_px);
584    }
585
586    #[test]
587    fn hri_is_centred_and_fits_within_the_symbol() {
588        let s = symbol();
589        let layout = RenderOptions::default().layout(&s).unwrap();
590        assert!(layout.hri_scale >= 1);
591        let text_w = hri::text_width(s.payload().chars().count() as u32) * layout.hri_scale;
592        assert!(text_w <= layout.symbol_w_px, "HRI wider than the symbol");
593        assert!(layout.hri_x_px >= layout.quiet_x_px);
594        assert!(layout.hri_x_px + text_w <= layout.width_px);
595        assert!(layout.hri_y_px + hri::GLYPH_H * layout.hri_scale <= layout.height_px);
596    }
597
598    #[test]
599    fn hri_is_padded_away_from_both_edges() {
600        let layout = RenderOptions::default().layout(&symbol()).unwrap();
601        // Gap above the text, between it and the bars.
602        assert!(layout.hri_y_px > layout.symbol_y_px + layout.symbol_h_px);
603        // Gap below the text, before the image edge.
604        let text_bottom = layout.hri_y_px + hri::GLYPH_H * layout.hri_scale;
605        assert!(
606            text_bottom < layout.height_px,
607            "HRI is flush against the bottom edge and may be clipped"
608        );
609    }
610
611    #[test]
612    fn disabling_hri_removes_the_text_block() {
613        let layout = RenderOptions::builder()
614            .human_readable(false)
615            .build()
616            .unwrap()
617            .layout(&symbol())
618            .unwrap();
619        assert_eq!(layout.hri_scale, 0);
620        assert_eq!(layout.hri_block_h_px, 0);
621        assert_eq!(layout.height_px, layout.symbol_h_px);
622    }
623
624    #[test]
625    fn builder_rejects_degenerate_options() {
626        assert!(RenderOptions::builder().dpi(0).build().is_err());
627        assert!(RenderOptions::builder()
628            .module_width(Length::Mm(0.0))
629            .build()
630            .is_err());
631        assert!(RenderOptions::builder()
632            .height(Length::Mm(-1.0))
633            .build()
634            .is_err());
635        assert!(RenderOptions::builder()
636            .module_width(Length::Mm(f64::NAN))
637            .build()
638            .is_err());
639    }
640
641    #[test]
642    fn absurd_geometry_is_rejected_rather_than_allocated() {
643        let opts = RenderOptions::builder()
644            .module_width(Length::Inch(10.0))
645            .dpi(1200)
646            .build()
647            .unwrap();
648        assert!(matches!(
649            opts.layout(&symbol()),
650            Err(Error::InvalidRenderOptions(_))
651        ));
652    }
653
654    #[test]
655    fn colors_format_as_css_hex() {
656        assert_eq!(Color::BLACK.to_hex(), "#000000");
657        assert_eq!(Color::WHITE.to_hex(), "#ffffff");
658        assert_eq!(Color::rgba(1, 2, 3, 4).to_hex(), "#01020304");
659        assert!(Color::BLACK.is_opaque());
660        assert!(!Color::TRANSPARENT.is_opaque());
661    }
662
663    #[test]
664    fn rounding_is_half_up_and_saturating() {
665        assert_eq!(round_to_u32(3.4), 3);
666        assert_eq!(round_to_u32(3.5), 4);
667        assert_eq!(round_to_u32(-1.0), 0);
668        assert_eq!(round_to_u32(f64::NAN), 0);
669        assert_eq!(round_to_u32(f64::INFINITY), 0);
670        assert_eq!(round_to_u32(1e30), u32::MAX);
671    }
672}