Skip to main content

modo/qrcode/
style.rs

1use std::fmt;
2
3use crate::qrcode::error::QrError;
4
5/// Shape of individual data modules (the small squares/dots).
6///
7/// Used in [`QrStyle::module_shape`]. The default is
8/// [`ModuleShape::RoundedSquare`] with `radius: 0.3`.
9#[derive(Debug, Clone, PartialEq)]
10pub enum ModuleShape {
11    /// Classic sharp-edged square.
12    Square,
13    /// Square with rounded corners. `radius` is a fraction of module
14    /// size in the range `0.0..=0.5`; values outside this range are
15    /// clamped at render time.
16    RoundedSquare {
17        /// Corner radius as a fraction of module size (0.0 = square, 0.5 = maximum rounding).
18        radius: f32,
19    },
20    /// Circular dot.
21    Circle,
22    /// 45-degree rotated square (diamond).
23    Diamond,
24}
25
26/// Shape of the three finder patterns (the large 7x7 corner markers).
27///
28/// Used in [`QrStyle::finder_shape`]. The default is [`FinderShape::Rounded`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FinderShape {
31    /// Classic concentric squares.
32    Square,
33    /// Concentric rounded rectangles.
34    Rounded,
35    /// Concentric circles.
36    Circle,
37}
38
39/// A color value for QR code rendering.
40///
41/// Used for [`QrStyle::fg_color`] and [`QrStyle::bg_color`].
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Color {
44    /// Hex string with `#` prefix: `"#000000"` (6-digit) or `"#000"` (3-digit shorthand).
45    Hex(String),
46    /// RGB components (red, green, blue), each 0--255.
47    Rgb(u8, u8, u8),
48}
49
50impl Color {
51    /// Resolves the color to a lowercase hex string with `#` prefix
52    /// (e.g. `"#1a1a2e"`).
53    ///
54    /// Three-digit shorthand is expanded (`"#fff"` becomes `"#ffffff"`).
55    ///
56    /// Returns [`QrError::InvalidColor`] if the hex value is malformed
57    /// (missing `#`, wrong length, or non-hex characters).
58    pub fn to_hex(&self) -> Result<String, QrError> {
59        match self {
60            Color::Rgb(r, g, b) => Ok(format!("#{r:02x}{g:02x}{b:02x}")),
61            Color::Hex(s) => {
62                let s = s.trim();
63                if !s.starts_with('#') {
64                    return Err(QrError::InvalidColor(s.to_string()));
65                }
66                let hex = &s[1..];
67                match hex.len() {
68                    3 => {
69                        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
70                            return Err(QrError::InvalidColor(s.to_string()));
71                        }
72                        let expanded: String = hex.chars().flat_map(|c| [c, c]).collect();
73                        Ok(format!("#{expanded}"))
74                    }
75                    6 => {
76                        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
77                            return Err(QrError::InvalidColor(s.to_string()));
78                        }
79                        Ok(s.to_lowercase())
80                    }
81                    _ => Err(QrError::InvalidColor(s.to_string())),
82                }
83            }
84        }
85    }
86}
87
88impl fmt::Display for Color {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self.to_hex() {
91            Ok(hex) => write!(f, "{hex}"),
92            Err(_) => write!(f, "(invalid)"),
93        }
94    }
95}
96
97/// Styling options for QR code SVG rendering.
98///
99/// All fields have sensible defaults via [`Default`]:
100///
101/// | Field | Default |
102/// |-------|---------|
103/// | `module_shape` | `RoundedSquare { radius: 0.3 }` |
104/// | `finder_shape` | `Rounded` |
105/// | `fg_color` | `Hex("#000000")` (black) |
106/// | `bg_color` | `Hex("#ffffff")` (white) |
107/// | `module_size` | `10` |
108/// | `quiet_zone` | `4` |
109///
110/// # Example
111///
112/// ```
113/// use modo::qrcode::{QrStyle, ModuleShape, FinderShape, Color};
114///
115/// let style = QrStyle {
116///     module_shape: ModuleShape::Circle,
117///     finder_shape: FinderShape::Circle,
118///     fg_color: Color::Rgb(26, 26, 46),
119///     ..Default::default()
120/// };
121/// ```
122#[derive(Debug, Clone, PartialEq)]
123pub struct QrStyle {
124    /// Shape of individual data modules.
125    pub module_shape: ModuleShape,
126    /// Shape of the three finder patterns (corner markers).
127    pub finder_shape: FinderShape,
128    /// Foreground (dark module) color. Default: black (`#000000`).
129    pub fg_color: Color,
130    /// Background color. Default: white (`#ffffff`).
131    pub bg_color: Color,
132    /// Size of each module in SVG units. Default: `10`.
133    pub module_size: u32,
134    /// Number of quiet-zone modules around the QR code. Default: `4`
135    /// (the spec-recommended minimum).
136    pub quiet_zone: u32,
137}
138
139impl Default for QrStyle {
140    fn default() -> Self {
141        Self {
142            module_shape: ModuleShape::RoundedSquare { radius: 0.3 },
143            finder_shape: FinderShape::Rounded,
144            fg_color: Color::Hex("#000000".into()),
145            bg_color: Color::Hex("#ffffff".into()),
146            module_size: 10,
147            quiet_zone: 4,
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    // --- Color ---
157
158    #[test]
159    fn rgb_to_hex() {
160        let c = Color::Rgb(26, 26, 46);
161        assert_eq!(c.to_hex().unwrap(), "#1a1a2e");
162    }
163
164    #[test]
165    fn rgb_black() {
166        let c = Color::Rgb(0, 0, 0);
167        assert_eq!(c.to_hex().unwrap(), "#000000");
168    }
169
170    #[test]
171    fn rgb_white() {
172        let c = Color::Rgb(255, 255, 255);
173        assert_eq!(c.to_hex().unwrap(), "#ffffff");
174    }
175
176    #[test]
177    fn hex_six_char_valid() {
178        let c = Color::Hex("#1a1a2e".into());
179        assert_eq!(c.to_hex().unwrap(), "#1a1a2e");
180    }
181
182    #[test]
183    fn hex_six_char_uppercase_normalized() {
184        let c = Color::Hex("#FF00AA".into());
185        assert_eq!(c.to_hex().unwrap(), "#ff00aa");
186    }
187
188    #[test]
189    fn hex_three_char_expanded() {
190        let c = Color::Hex("#fff".into());
191        assert_eq!(c.to_hex().unwrap(), "#ffffff");
192    }
193
194    #[test]
195    fn hex_three_char_color() {
196        let c = Color::Hex("#f0a".into());
197        assert_eq!(c.to_hex().unwrap(), "#ff00aa");
198    }
199
200    #[test]
201    fn hex_missing_hash_is_error() {
202        let c = Color::Hex("000000".into());
203        assert!(c.to_hex().is_err());
204    }
205
206    #[test]
207    fn hex_invalid_chars_is_error() {
208        let c = Color::Hex("#gggggg".into());
209        assert!(c.to_hex().is_err());
210    }
211
212    #[test]
213    fn hex_wrong_length_is_error() {
214        let c = Color::Hex("#12345".into());
215        assert!(c.to_hex().is_err());
216    }
217
218    #[test]
219    fn hex_named_color_is_error() {
220        let c = Color::Hex("red".into());
221        assert!(c.to_hex().is_err());
222    }
223
224    #[test]
225    fn hex_three_char_invalid_chars_is_error() {
226        let c = Color::Hex("#ggg".into());
227        assert!(c.to_hex().is_err());
228    }
229
230    // --- QrStyle defaults ---
231
232    #[test]
233    fn default_style_has_rounded_module_shape() {
234        let s = QrStyle::default();
235        assert_eq!(s.module_shape, ModuleShape::RoundedSquare { radius: 0.3 });
236    }
237
238    #[test]
239    fn default_style_has_rounded_finder_shape() {
240        let s = QrStyle::default();
241        assert_eq!(s.finder_shape, FinderShape::Rounded);
242    }
243
244    #[test]
245    fn default_style_colors() {
246        let s = QrStyle::default();
247        assert_eq!(s.fg_color.to_hex().unwrap(), "#000000");
248        assert_eq!(s.bg_color.to_hex().unwrap(), "#ffffff");
249    }
250
251    #[test]
252    fn default_style_sizes() {
253        let s = QrStyle::default();
254        assert_eq!(s.module_size, 10);
255        assert_eq!(s.quiet_zone, 4);
256    }
257}