1mod 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
24const MAX_DIMENSION_PX: u32 = 20_000;
29
30#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum Length {
37 Px(f64),
39 Mm(f64),
41 Mils(f64),
44 Inch(f64),
46}
47
48impl Length {
49 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct Color {
77 pub r: u8,
79 pub g: u8,
81 pub b: u8,
83 pub a: u8,
85}
86
87impl Color {
88 pub const BLACK: Self = Self::rgb(0, 0, 0);
90 pub const WHITE: Self = Self::rgb(255, 255, 255);
92 pub const TRANSPARENT: Self = Self::rgba(0, 0, 0, 0);
94
95 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
97 Self { r, g, b, a: 255 }
98 }
99
100 pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
102 Self { r, g, b, a }
103 }
104
105 pub const fn is_opaque(self) -> bool {
107 self.a == 255
108 }
109
110 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#[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 #[default]
138 Standard,
139 Modules(u32),
141 None,
147}
148
149#[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 pub fn builder() -> RenderOptionsBuilder {
200 RenderOptionsBuilder::default()
201 }
202
203 pub fn module_width(&self) -> Length {
205 self.module_width
206 }
207
208 pub fn height(&self) -> Length {
210 self.height
211 }
212
213 pub fn quiet_zone(&self) -> QuietZone {
215 self.quiet_zone
216 }
217
218 pub fn dpi(&self) -> u32 {
220 self.dpi
221 }
222
223 pub fn foreground(&self) -> Color {
225 self.foreground
226 }
227
228 pub fn background(&self) -> Color {
230 self.background
231 }
232
233 pub fn human_readable(&self) -> bool {
235 self.human_readable
236 }
237
238 pub fn layout(&self, symbol: &Symbol) -> Result<Layout> {
248 Layout::compute(symbol, self)
249 }
250}
251
252#[derive(Debug, Clone, Default)]
254pub struct RenderOptionsBuilder {
255 options: RenderOptions,
256}
257
258impl RenderOptionsBuilder {
259 pub fn module_width(mut self, width: Length) -> Self {
261 self.options.module_width = width;
262 self
263 }
264
265 pub fn height(mut self, height: Length) -> Self {
268 self.options.height = height;
269 self
270 }
271
272 pub fn quiet_zone(mut self, quiet_zone: QuietZone) -> Self {
274 self.options.quiet_zone = quiet_zone;
275 self
276 }
277
278 pub fn dpi(mut self, dpi: u32) -> Self {
280 self.options.dpi = dpi;
281 self
282 }
283
284 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 pub fn human_readable(mut self, enabled: bool) -> Self {
293 self.options.human_readable = enabled;
294 self
295 }
296
297 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329#[non_exhaustive]
330pub struct Layout {
331 pub module_px: u32,
333 pub quiet_x_px: u32,
335 pub quiet_y_px: u32,
338 pub symbol_w_px: u32,
340 pub symbol_h_px: u32,
343 pub symbol_x_px: u32,
345 pub symbol_y_px: u32,
347 pub hri_scale: u32,
350 pub hri_block_h_px: u32,
353 pub hri_x_px: u32,
355 pub hri_y_px: u32,
357 pub width_px: u32,
359 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 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 (scale, gap + hri::GLYPH_H * scale + gap, natural_w * scale)
399 } else {
400 (0, 0, 0)
401 };
402
403 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 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
454fn 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
467fn 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
478pub fn hri_supports(text: &str) -> bool {
493 text.chars().all(hri::is_supported)
494}
495
496pub trait Renderer {
501 type Output;
503
504 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 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 assert!(layout.hri_y_px > layout.symbol_y_px + layout.symbol_h_px);
603 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}