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