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    /// # Errors
57    ///
58    /// Returns [`QrError::InvalidColor`] if the hex value is malformed
59    /// (missing `#`, wrong length, or non-hex characters).
60    pub fn to_hex(&self) -> Result<String, QrError> {
61        match self {
62            Color::Rgb(r, g, b) => Ok(format!("#{r:02x}{g:02x}{b:02x}")),
63            Color::Hex(s) => {
64                let s = s.trim();
65                if !s.starts_with('#') {
66                    return Err(QrError::InvalidColor(s.to_string()));
67                }
68                let hex = &s[1..];
69                match hex.len() {
70                    3 => {
71                        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
72                            return Err(QrError::InvalidColor(s.to_string()));
73                        }
74                        let expanded: String = hex.chars().flat_map(|c| [c, c]).collect();
75                        Ok(format!("#{expanded}"))
76                    }
77                    6 => {
78                        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
79                            return Err(QrError::InvalidColor(s.to_string()));
80                        }
81                        Ok(s.to_lowercase())
82                    }
83                    _ => Err(QrError::InvalidColor(s.to_string())),
84                }
85            }
86        }
87    }
88}
89
90impl fmt::Display for Color {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self.to_hex() {
93            Ok(hex) => write!(f, "{hex}"),
94            Err(_) => write!(f, "(invalid)"),
95        }
96    }
97}
98
99/// Styling options for QR code SVG rendering.
100///
101/// All fields have sensible defaults via [`Default`]:
102///
103/// | Field | Default |
104/// |-------|---------|
105/// | `module_shape` | `RoundedSquare { radius: 0.3 }` |
106/// | `finder_shape` | `Rounded` |
107/// | `fg_color` | `Hex("#000000")` (black) |
108/// | `bg_color` | `Hex("#ffffff")` (white) |
109/// | `module_size` | `10` |
110/// | `quiet_zone` | `4` |
111///
112/// # Example
113///
114/// ```
115/// use modo::qrcode::{QrStyle, ModuleShape, FinderShape, Color};
116///
117/// let style = QrStyle {
118///     module_shape: ModuleShape::Circle,
119///     finder_shape: FinderShape::Circle,
120///     fg_color: Color::Rgb(26, 26, 46),
121///     ..Default::default()
122/// };
123/// ```
124#[derive(Debug, Clone, PartialEq)]
125pub struct QrStyle {
126    /// Shape of individual data modules.
127    pub module_shape: ModuleShape,
128    /// Shape of the three finder patterns (corner markers).
129    pub finder_shape: FinderShape,
130    /// Foreground (dark module) color. Default: black (`#000000`).
131    pub fg_color: Color,
132    /// Background color. Default: white (`#ffffff`).
133    pub bg_color: Color,
134    /// Size of each module in SVG units. Default: `10`.
135    pub module_size: u32,
136    /// Number of quiet-zone modules around the QR code. Default: `4`
137    /// (the spec-recommended minimum).
138    pub quiet_zone: u32,
139}
140
141impl Default for QrStyle {
142    fn default() -> Self {
143        Self {
144            module_shape: ModuleShape::RoundedSquare { radius: 0.3 },
145            finder_shape: FinderShape::Rounded,
146            fg_color: Color::Hex("#000000".into()),
147            bg_color: Color::Hex("#ffffff".into()),
148            module_size: 10,
149            quiet_zone: 4,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    // --- Color ---
159
160    #[test]
161    fn rgb_to_hex() {
162        let c = Color::Rgb(26, 26, 46);
163        assert_eq!(c.to_hex().unwrap(), "#1a1a2e");
164    }
165
166    #[test]
167    fn rgb_black() {
168        let c = Color::Rgb(0, 0, 0);
169        assert_eq!(c.to_hex().unwrap(), "#000000");
170    }
171
172    #[test]
173    fn rgb_white() {
174        let c = Color::Rgb(255, 255, 255);
175        assert_eq!(c.to_hex().unwrap(), "#ffffff");
176    }
177
178    #[test]
179    fn hex_six_char_valid() {
180        let c = Color::Hex("#1a1a2e".into());
181        assert_eq!(c.to_hex().unwrap(), "#1a1a2e");
182    }
183
184    #[test]
185    fn hex_six_char_uppercase_normalized() {
186        let c = Color::Hex("#FF00AA".into());
187        assert_eq!(c.to_hex().unwrap(), "#ff00aa");
188    }
189
190    #[test]
191    fn hex_three_char_expanded() {
192        let c = Color::Hex("#fff".into());
193        assert_eq!(c.to_hex().unwrap(), "#ffffff");
194    }
195
196    #[test]
197    fn hex_three_char_color() {
198        let c = Color::Hex("#f0a".into());
199        assert_eq!(c.to_hex().unwrap(), "#ff00aa");
200    }
201
202    #[test]
203    fn hex_missing_hash_is_error() {
204        let c = Color::Hex("000000".into());
205        assert!(c.to_hex().is_err());
206    }
207
208    #[test]
209    fn hex_invalid_chars_is_error() {
210        let c = Color::Hex("#gggggg".into());
211        assert!(c.to_hex().is_err());
212    }
213
214    #[test]
215    fn hex_wrong_length_is_error() {
216        let c = Color::Hex("#12345".into());
217        assert!(c.to_hex().is_err());
218    }
219
220    #[test]
221    fn hex_named_color_is_error() {
222        let c = Color::Hex("red".into());
223        assert!(c.to_hex().is_err());
224    }
225
226    #[test]
227    fn hex_three_char_invalid_chars_is_error() {
228        let c = Color::Hex("#ggg".into());
229        assert!(c.to_hex().is_err());
230    }
231
232    // --- QrStyle defaults ---
233
234    #[test]
235    fn default_style_has_rounded_module_shape() {
236        let s = QrStyle::default();
237        assert_eq!(s.module_shape, ModuleShape::RoundedSquare { radius: 0.3 });
238    }
239
240    #[test]
241    fn default_style_has_rounded_finder_shape() {
242        let s = QrStyle::default();
243        assert_eq!(s.finder_shape, FinderShape::Rounded);
244    }
245
246    #[test]
247    fn default_style_colors() {
248        let s = QrStyle::default();
249        assert_eq!(s.fg_color.to_hex().unwrap(), "#000000");
250        assert_eq!(s.bg_color.to_hex().unwrap(), "#ffffff");
251    }
252
253    #[test]
254    fn default_style_sizes() {
255        let s = QrStyle::default();
256        assert_eq!(s.module_size, 10);
257        assert_eq!(s.quiet_zone, 4);
258    }
259}