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
30pub(crate) const MAX_PIXELS: u64 = 64_000_000;
38
39#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum Length {
46 Px(f64),
48 Mm(f64),
50 Mils(f64),
53 Inch(f64),
55}
56
57impl Length {
58 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct Color {
86 pub r: u8,
88 pub g: u8,
90 pub b: u8,
92 pub a: u8,
94}
95
96impl Color {
97 pub const BLACK: Self = Self::rgb(0, 0, 0);
99 pub const WHITE: Self = Self::rgb(255, 255, 255);
101 pub const TRANSPARENT: Self = Self::rgba(0, 0, 0, 0);
103
104 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
106 Self { r, g, b, a: 255 }
107 }
108
109 pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
111 Self { r, g, b, a }
112 }
113
114 pub const fn is_opaque(self) -> bool {
116 self.a == 255
117 }
118
119 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#[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 #[default]
147 Standard,
148 Modules(u32),
150 None,
156}
157
158#[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 pub fn builder() -> RenderOptionsBuilder {
209 RenderOptionsBuilder::default()
210 }
211
212 pub fn module_width(&self) -> Length {
214 self.module_width
215 }
216
217 pub fn height(&self) -> Length {
219 self.height
220 }
221
222 pub fn quiet_zone(&self) -> QuietZone {
224 self.quiet_zone
225 }
226
227 pub fn dpi(&self) -> u32 {
229 self.dpi
230 }
231
232 pub fn foreground(&self) -> Color {
234 self.foreground
235 }
236
237 pub fn background(&self) -> Color {
239 self.background
240 }
241
242 pub fn human_readable(&self) -> bool {
244 self.human_readable
245 }
246
247 pub fn layout(&self, symbol: &Symbol) -> Result<Layout> {
257 Layout::compute(symbol, self)
258 }
259}
260
261#[derive(Debug, Clone, Default)]
263pub struct RenderOptionsBuilder {
264 options: RenderOptions,
265}
266
267impl RenderOptionsBuilder {
268 pub fn module_width(mut self, width: Length) -> Self {
270 self.options.module_width = width;
271 self
272 }
273
274 pub fn height(mut self, height: Length) -> Self {
277 self.options.height = height;
278 self
279 }
280
281 pub fn quiet_zone(mut self, quiet_zone: QuietZone) -> Self {
283 self.options.quiet_zone = quiet_zone;
284 self
285 }
286
287 pub fn dpi(mut self, dpi: u32) -> Self {
289 self.options.dpi = dpi;
290 self
291 }
292
293 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 pub fn human_readable(mut self, enabled: bool) -> Self {
302 self.options.human_readable = enabled;
303 self
304 }
305
306 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338#[non_exhaustive]
339pub struct Layout {
340 pub module_px: u32,
342 pub quiet_x_px: u32,
344 pub quiet_y_px: u32,
347 pub symbol_w_px: u32,
349 pub symbol_h_px: u32,
352 pub symbol_x_px: u32,
354 pub symbol_y_px: u32,
356 pub hri_scale: u32,
359 pub hri_block_h_px: u32,
362 pub hri_x_px: u32,
364 pub hri_y_px: u32,
366 pub width_px: u32,
368 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 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 (scale, gap + hri::GLYPH_H * scale + gap, natural_w * scale)
408 } else {
409 (0, 0, 0)
410 };
411
412 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 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 (
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
478fn 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
491fn 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
502pub fn hri_supports(text: &str) -> bool {
517 text.chars().all(hri::is_supported)
518}
519
520pub trait Renderer {
525 type Output;
527
528 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 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 assert!(layout.hri_y_px > layout.symbol_y_px + layout.symbol_h_px);
627 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 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 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}