Skip to main content

qr_code_styling/plugins/
border.rs

1//! QR Border Plugin
2//!
3//! A plugin to add customizable borders and decorations to QR codes.
4//! Supports outer, main, and inner borders, as well as text or image
5//! decorations along each side.
6
7use std::collections::HashMap;
8
9/// Position for specifying where decorations should be placed.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum Position {
13    Top,
14    Bottom,
15    Left,
16    Right,
17}
18
19/// Type of decoration that can be applied to the QR code borders.
20#[derive(Debug, Clone, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub enum DecorationType {
23    /// Text decoration with the text content
24    Text(String),
25    /// Image decoration with image data (base64 or URL)
26    Image(String),
27}
28
29/// Style options for border elements.
30#[derive(Debug, Clone, PartialEq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct BorderOptions {
33    /// Border thickness in pixels
34    pub thickness: f64,
35    /// Border color (hex string like "#000000")
36    pub color: String,
37    /// Optional dash array for dashed borders (e.g., "5,5")
38    pub dasharray: Option<String>,
39}
40
41impl Default for BorderOptions {
42    fn default() -> Self {
43        Self {
44            thickness: 10.0,
45            color: "#000000".to_string(),
46            dasharray: None,
47        }
48    }
49}
50
51impl BorderOptions {
52    /// Create new border options.
53    pub fn new(thickness: f64, color: impl Into<String>) -> Self {
54        Self {
55            thickness,
56            color: color.into(),
57            dasharray: None,
58        }
59    }
60
61    /// Set dash array for dashed border.
62    pub fn with_dasharray(mut self, dasharray: impl Into<String>) -> Self {
63        self.dasharray = Some(dasharray.into());
64        self
65    }
66}
67
68/// Decoration configuration for text or image decorations.
69#[derive(Debug, Clone, PartialEq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub struct BorderDecoration {
72    /// Type of decoration (text or image)
73    pub decoration_type: DecorationType,
74    /// Optional CSS style string
75    pub style: Option<String>,
76}
77
78impl BorderDecoration {
79    /// Create a text decoration.
80    pub fn text(value: impl Into<String>) -> Self {
81        Self {
82            decoration_type: DecorationType::Text(value.into()),
83            style: None,
84        }
85    }
86
87    /// Create an image decoration.
88    pub fn image(value: impl Into<String>) -> Self {
89        Self {
90            decoration_type: DecorationType::Image(value.into()),
91            style: None,
92        }
93    }
94
95    /// Set CSS style.
96    pub fn with_style(mut self, style: impl Into<String>) -> Self {
97        self.style = Some(style.into());
98        self
99    }
100}
101
102/// Extension options for the QR Border Plugin.
103#[derive(Debug, Clone, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub struct QRBorderOptions {
106    /// Main border options
107    pub border: BorderOptions,
108    /// Corner roundness (0.0 = square, 1.0 = fully round)
109    pub round: f64,
110    /// Optional inner border
111    pub border_inner: Option<BorderOptions>,
112    /// Optional outer border
113    pub border_outer: Option<BorderOptions>,
114    /// Decorations mapped by position
115    pub decorations: HashMap<Position, BorderDecoration>,
116}
117
118impl Default for QRBorderOptions {
119    fn default() -> Self {
120        Self {
121            border: BorderOptions::default(),
122            round: 0.0,
123            border_inner: None,
124            border_outer: None,
125            decorations: HashMap::new(),
126        }
127    }
128}
129
130impl QRBorderOptions {
131    /// Create new border options with specified thickness and color.
132    pub fn new(thickness: f64, color: impl Into<String>) -> Self {
133        Self {
134            border: BorderOptions::new(thickness, color),
135            ..Default::default()
136        }
137    }
138
139    /// Set corner roundness.
140    pub fn with_round(mut self, round: f64) -> Self {
141        self.round = round.clamp(0.0, 1.0);
142        self
143    }
144
145    /// Set inner border.
146    pub fn with_inner_border(mut self, options: BorderOptions) -> Self {
147        self.border_inner = Some(options);
148        self
149    }
150
151    /// Set outer border.
152    pub fn with_outer_border(mut self, options: BorderOptions) -> Self {
153        self.border_outer = Some(options);
154        self
155    }
156
157    /// Add a decoration at the specified position.
158    pub fn with_decoration(mut self, position: Position, decoration: BorderDecoration) -> Self {
159        self.decorations.insert(position, decoration);
160        self
161    }
162
163    /// Add text decoration at the specified position.
164    pub fn with_text(mut self, position: Position, text: impl Into<String>) -> Self {
165        self.decorations
166            .insert(position, BorderDecoration::text(text));
167        self
168    }
169
170    /// Add text decoration with style at the specified position.
171    pub fn with_styled_text(
172        mut self,
173        position: Position,
174        text: impl Into<String>,
175        style: impl Into<String>,
176    ) -> Self {
177        self.decorations
178            .insert(position, BorderDecoration::text(text).with_style(style));
179        self
180    }
181}
182
183/// Border plugin for adding borders and decorations to QR codes.
184pub struct BorderPlugin {
185    options: QRBorderOptions,
186}
187
188impl BorderPlugin {
189    /// Create a new border plugin with the given options.
190    pub fn new(options: QRBorderOptions) -> Self {
191        Self { options }
192    }
193
194    /// Apply the border to an SVG string.
195    /// Returns the modified SVG with borders and decorations.
196    pub fn apply(&self, svg: &str, width: u32, height: u32) -> String {
197        let width = width as f64;
198        let height = height as f64;
199
200        let mut defs_content = String::new();
201        let mut elements_content = String::new();
202
203        // Create main border
204        let main_attrs = self.generate_rect_attributes(width, height, &self.options.border);
205        elements_content.push_str(&self.create_rect(&main_attrs));
206
207        // Create inner border if specified
208        if let Some(ref inner) = self.options.border_inner {
209            let mut inner_attrs = self.generate_rect_attributes(width, height, inner);
210
211            // Adjust inner border position and size
212            inner_attrs.x =
213                inner_attrs.x - inner.thickness + self.options.border.thickness;
214            inner_attrs.y =
215                inner_attrs.y - inner.thickness + self.options.border.thickness;
216            inner_attrs.width =
217                inner_attrs.width + 2.0 * (inner.thickness - self.options.border.thickness);
218            inner_attrs.height =
219                inner_attrs.height + 2.0 * (inner.thickness - self.options.border.thickness);
220            inner_attrs.rx = (inner_attrs.rx + inner.thickness - self.options.border.thickness)
221                .max(0.0);
222
223            elements_content.push_str(&self.create_rect(&inner_attrs));
224        }
225
226        // Create outer border if specified
227        if let Some(ref outer) = self.options.border_outer {
228            let outer_attrs = self.generate_rect_attributes(width, height, outer);
229            elements_content.push_str(&self.create_rect(&outer_attrs));
230        }
231
232        // Add decorations
233        for (position, decoration) in &self.options.decorations {
234            match &decoration.decoration_type {
235                DecorationType::Text(text) => {
236                    let (path_def, text_elem) = self.create_text_decoration(
237                        *position,
238                        text,
239                        decoration.style.as_deref(),
240                        width,
241                        height,
242                    );
243                    defs_content.push_str(&path_def);
244                    elements_content.push_str(&text_elem);
245                }
246                DecorationType::Image(src) => {
247                    let image_elem = self.create_image_decoration(
248                        *position,
249                        src,
250                        decoration.style.as_deref(),
251                        width,
252                        height,
253                    );
254                    elements_content.push_str(&image_elem);
255                }
256            }
257        }
258
259        // Inject into existing SVG
260        self.inject_into_svg(svg, &defs_content, &elements_content)
261    }
262
263    fn generate_rect_attributes(&self, width: f64, height: f64, options: &BorderOptions) -> RectAttributes {
264        let size = width.min(height);
265        let rx = ((size / 2.0) * self.options.round - options.thickness / 2.0).max(0.0);
266
267        RectAttributes {
268            fill: "none".to_string(),
269            x: (width - size + options.thickness) / 2.0,
270            y: (height - size + options.thickness) / 2.0,
271            width: size - options.thickness,
272            height: size - options.thickness,
273            stroke: options.color.clone(),
274            stroke_width: options.thickness,
275            stroke_dasharray: options.dasharray.clone().unwrap_or_default(),
276            rx,
277        }
278    }
279
280    fn create_rect(&self, attrs: &RectAttributes) -> String {
281        let dasharray_attr = if attrs.stroke_dasharray.is_empty() {
282            String::new()
283        } else {
284            format!(r#" stroke-dasharray="{}""#, attrs.stroke_dasharray)
285        };
286
287        format!(
288            r#"<rect fill="{}" x="{}" y="{}" width="{}" height="{}" stroke="{}" stroke-width="{}"{} rx="{}"/>
289"#,
290            attrs.fill,
291            attrs.x,
292            attrs.y,
293            attrs.width,
294            attrs.height,
295            attrs.stroke,
296            attrs.stroke_width,
297            dasharray_attr,
298            attrs.rx
299        )
300    }
301
302    fn create_text_decoration(
303        &self,
304        position: Position,
305        text: &str,
306        style: Option<&str>,
307        width: f64,
308        height: f64,
309    ) -> (String, String) {
310        let thickness = self.options.border.thickness;
311        let round = self.options.round;
312        let size = width.min(height);
313
314        // Center of the QR code
315        let cx = width / 2.0;
316        let cy = height / 2.0;
317
318        // Text radius - on the center of the border stroke
319        let text_radius = (size - thickness) / 2.0;
320
321        let path_id = format!("{:?}-text-path", position).to_lowercase();
322
323        // Create style with default values
324        let base_style = style.unwrap_or("font-size: 14px; font-family: Arial, sans-serif;");
325
326        // For circular borders, use curved text paths
327        if round >= 0.5 {
328            // Create arc path for text to follow
329            let path_d = match position {
330                Position::Top => {
331                    // Arc from left to right along the top (text reads left-to-right)
332                    format!(
333                        "M {},{} A {},{} 0 0 1 {},{}",
334                        cx - text_radius, cy,
335                        text_radius, text_radius,
336                        cx + text_radius, cy
337                    )
338                }
339                Position::Bottom => {
340                    // Arc from left to right along the bottom (sweep flag 0 for bottom arc)
341                    format!(
342                        "M {},{} A {},{} 0 0 0 {},{}",
343                        cx - text_radius, cy,
344                        text_radius, text_radius,
345                        cx + text_radius, cy
346                    )
347                }
348                Position::Left => {
349                    // Arc from top to bottom along the left (sweep flag 0)
350                    format!(
351                        "M {},{} A {},{} 0 0 0 {},{}",
352                        cx, cy - text_radius,
353                        text_radius, text_radius,
354                        cx, cy + text_radius
355                    )
356                }
357                Position::Right => {
358                    // Arc from top to bottom along the right
359                    format!(
360                        "M {},{} A {},{} 0 0 1 {},{}",
361                        cx, cy - text_radius,
362                        text_radius, text_radius,
363                        cx, cy + text_radius
364                    )
365                }
366            };
367
368            let path_def = format!(
369                "<path id=\"{}\" d=\"{}\" fill=\"none\"/>\n",
370                path_id, path_d
371            );
372
373            let text_elem = format!(
374                "<text style=\"{}\">\n  <textPath xlink:href=\"#{}\" href=\"#{}\" startOffset=\"50%\" text-anchor=\"middle\" dominant-baseline=\"central\">{}</textPath>\n</text>\n",
375                base_style, path_id, path_id, text
376            );
377
378            (path_def, text_elem)
379        } else {
380            // For rectangular borders, use straight text
381            let border_offset = thickness / 2.0;
382            let half_size = (size - thickness) / 2.0;
383
384            let (x, y, rotation) = match position {
385                Position::Top => (cx, cy - half_size - border_offset, 0.0),
386                Position::Bottom => (cx, cy + half_size + border_offset, 0.0),
387                Position::Left => (cx - half_size - border_offset, cy, -90.0),
388                Position::Right => (cx + half_size + border_offset, cy, 90.0),
389            };
390
391            let transform = if rotation != 0.0 {
392                format!(r#" transform="rotate({},{},{})""#, rotation, x, y)
393            } else {
394                String::new()
395            };
396
397            let text_elem = format!(
398                r#"<text x="{}" y="{}" text-anchor="middle" dominant-baseline="middle" style="{}"{}>{}</text>
399"#,
400                x, y, base_style, transform, text
401            );
402
403            (String::new(), text_elem)
404        }
405    }
406
407    fn create_image_decoration(
408        &self,
409        position: Position,
410        src: &str,
411        style: Option<&str>,
412        width: f64,
413        height: f64,
414    ) -> String {
415        let thickness = self.options.border.thickness;
416        let size = width.min(height);
417
418        let mut x = (width - size + thickness) / 2.0;
419        let mut y = (height - size + thickness) / 2.0;
420
421        match position {
422            Position::Top => {
423                x += (size - thickness) / 2.0;
424            }
425            Position::Right => {
426                x += size - thickness;
427                y += (size - thickness) / 2.0;
428            }
429            Position::Bottom => {
430                x += (size - thickness) / 2.0;
431                y += size - thickness;
432            }
433            Position::Left => {
434                y += (size - thickness) / 2.0;
435            }
436        }
437
438        let style_attr = style
439            .map(|s| format!(r#" style="{}""#, s))
440            .unwrap_or_default();
441
442        format!(
443            r#"<image href="{}" xlink:href="{}" x="{}" y="{}"{}/>"#,
444            src, src, x, y, style_attr
445        )
446    }
447
448    fn inject_into_svg(&self, svg: &str, defs_content: &str, elements_content: &str) -> String {
449        // Find the closing </svg> tag and insert before it
450        if let Some(close_pos) = svg.rfind("</svg>") {
451            let mut result = String::with_capacity(svg.len() + defs_content.len() + elements_content.len() + 100);
452            result.push_str(&svg[..close_pos]);
453
454            // Add to defs if we have path definitions
455            if !defs_content.is_empty() {
456                // Check if there's already a <defs> section
457                if let Some(defs_close) = svg.find("</defs>") {
458                    // Insert before </defs>
459                    let before_defs_close = &svg[..defs_close];
460                    let after_defs_close = &svg[defs_close..close_pos];
461                    result.clear();
462                    result.push_str(before_defs_close);
463                    result.push_str(defs_content);
464                    result.push_str(after_defs_close);
465                } else {
466                    // No defs section, add one
467                    result.push_str("<defs>\n");
468                    result.push_str(defs_content);
469                    result.push_str("</defs>\n");
470                }
471            }
472
473            result.push_str(elements_content);
474            result.push_str("</svg>");
475            result
476        } else {
477            // Fallback: return original SVG with border content appended
478            format!("{}\n{}", svg.trim_end(), elements_content)
479        }
480    }
481}
482
483/// Internal struct for rectangle attributes.
484struct RectAttributes {
485    fill: String,
486    x: f64,
487    y: f64,
488    width: f64,
489    height: f64,
490    stroke: String,
491    stroke_width: f64,
492    stroke_dasharray: String,
493    rx: f64,
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn test_border_options_default() {
502        let opts = BorderOptions::default();
503        assert_eq!(opts.thickness, 10.0);
504        assert_eq!(opts.color, "#000000");
505        assert!(opts.dasharray.is_none());
506    }
507
508    #[test]
509    fn test_qr_border_options_builder() {
510        let opts = QRBorderOptions::new(15.0, "#FF0000")
511            .with_round(0.5)
512            .with_text(Position::Top, "SCAN ME")
513            .with_inner_border(BorderOptions::new(5.0, "#00FF00"));
514
515        assert_eq!(opts.border.thickness, 15.0);
516        assert_eq!(opts.border.color, "#FF0000");
517        assert_eq!(opts.round, 0.5);
518        assert!(opts.border_inner.is_some());
519        assert!(opts.decorations.contains_key(&Position::Top));
520    }
521
522    #[test]
523    fn test_border_plugin_apply() {
524        let svg = r#"<?xml version="1.0"?>
525<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
526<defs></defs>
527<rect x="0" y="0" width="300" height="300" fill="white"/>
528</svg>"#;
529
530        let options = QRBorderOptions::new(10.0, "#000000").with_round(0.2);
531        let plugin = BorderPlugin::new(options);
532        let result = plugin.apply(svg, 300, 300);
533
534        assert!(result.contains("stroke=\"#000000\""));
535        assert!(result.contains("stroke-width=\"10\""));
536    }
537
538    #[test]
539    fn test_border_with_text_decoration() {
540        let svg = r#"<?xml version="1.0"?>
541<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
542<defs></defs>
543</svg>"#;
544
545        // Use round >= 0.5 to trigger textPath-based text decoration
546        let options = QRBorderOptions::new(20.0, "#333333")
547            .with_round(0.5)
548            .with_styled_text(Position::Top, "SCAN ME", "font-size: 14px; fill: #333;");
549
550        let plugin = BorderPlugin::new(options);
551        let result = plugin.apply(svg, 300, 300);
552
553        assert!(result.contains("SCAN ME"));
554        assert!(result.contains("textPath"));
555        assert!(result.contains("top-text-path"));
556    }
557}