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, 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 let x = quiet_x_px + symbol_w_px.saturating_sub(text_w) / 2;
401 let y = quiet_y_px + symbol_h_px + gap;
402 (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
444fn 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
457fn 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
468pub fn hri_supports(text: &str) -> bool {
483 text.chars().all(hri::is_supported)
484}
485
486pub trait Renderer {
491 type Output;
493
494 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 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 assert!(layout.hri_y_px > layout.symbol_y_px + layout.symbol_h_px);
593 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}