Skip to main content

qr_code_styling/rendering/
svg_renderer.rs

1//! SVG renderer for QR codes.
2
3use std::f64::consts::PI;
4
5use crate::config::{Color, Gradient, QRCodeStylingOptions};
6use crate::core::QRMatrix;
7use crate::error::Result;
8use crate::figures::{QRCornerDot, QRCornerSquare, QRDot};
9use crate::types::{CornerSquareType, GradientType, ShapeType};
10
11/// SVG renderer for QR codes.
12pub struct SvgRenderer {
13    options: QRCodeStylingOptions,
14    instance_id: u64,
15}
16
17/// Square mask for corner squares (7x7 pattern).
18const SQUARE_MASK: [[u8; 7]; 7] = [
19    [1, 1, 1, 1, 1, 1, 1],
20    [1, 0, 0, 0, 0, 0, 1],
21    [1, 0, 0, 0, 0, 0, 1],
22    [1, 0, 0, 0, 0, 0, 1],
23    [1, 0, 0, 0, 0, 0, 1],
24    [1, 0, 0, 0, 0, 0, 1],
25    [1, 1, 1, 1, 1, 1, 1],
26];
27
28/// Dot mask for corner dots (7x7 pattern).
29const DOT_MASK: [[u8; 7]; 7] = [
30    [0, 0, 0, 0, 0, 0, 0],
31    [0, 0, 0, 0, 0, 0, 0],
32    [0, 0, 1, 1, 1, 0, 0],
33    [0, 0, 1, 1, 1, 0, 0],
34    [0, 0, 1, 1, 1, 0, 0],
35    [0, 0, 0, 0, 0, 0, 0],
36    [0, 0, 0, 0, 0, 0, 0],
37];
38
39static INSTANCE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
40
41impl SvgRenderer {
42    /// Create a new SVG renderer.
43    pub fn new(options: QRCodeStylingOptions) -> Self {
44        let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
45        Self {
46            options,
47            instance_id,
48        }
49    }
50
51    /// Render the QR code as SVG string.
52    pub fn render(&self, matrix: &QRMatrix) -> Result<String> {
53        let count = matrix.module_count();
54        let min_size = self.options.width.min(self.options.height) - self.options.margin * 2;
55        let real_qr_size = if self.options.shape == ShapeType::Circle {
56            min_size as f64 / 2.0_f64.sqrt()
57        } else {
58            min_size as f64
59        };
60        let dot_size = self.round_size(real_qr_size / count as f64);
61
62        // Calculate image hiding area if there's an image
63        let (hide_x_dots, hide_y_dots) = if self.options.image.is_some() {
64            self.calculate_image_hide_area(count, dot_size)
65        } else {
66            (0, 0)
67        };
68
69        let mut svg_content = String::with_capacity(10000);
70        let mut defs_content = String::new();
71        let mut elements_content = String::new();
72
73        // Draw background
74        let (bg_defs, bg_elements) = self.render_background();
75        defs_content.push_str(&bg_defs);
76        elements_content.push_str(&bg_elements);
77
78        // Draw dots
79        let (dots_defs, dots_elements) = self.render_dots(
80            matrix,
81            count,
82            dot_size,
83            hide_x_dots,
84            hide_y_dots,
85        );
86        defs_content.push_str(&dots_defs);
87        elements_content.push_str(&dots_elements);
88
89        // Draw corners
90        let (corners_defs, corners_elements) = self.render_corners(count, dot_size);
91        defs_content.push_str(&corners_defs);
92        elements_content.push_str(&corners_elements);
93
94        // Draw image if present
95        if let Some(ref image_data) = self.options.image {
96            let image_svg = self.render_image(count, dot_size, hide_x_dots, hide_y_dots, image_data);
97            elements_content.push_str(&image_svg);
98        }
99
100        // Build final SVG
101        let shape_rendering = if self.options.dots_options.round_size {
102            ""
103        } else {
104            r#" shape-rendering="crispEdges""#
105        };
106
107        svg_content.push_str(&format!(
108            r#"<?xml version="1.0" encoding="UTF-8"?>
109<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{}" height="{}" viewBox="0 0 {} {}"{}>
110<defs>
111{}
112</defs>
113{}
114</svg>"#,
115            self.options.width,
116            self.options.height,
117            self.options.width,
118            self.options.height,
119            shape_rendering,
120            defs_content,
121            elements_content
122        ));
123
124        Ok(svg_content)
125    }
126
127    fn render_background(&self) -> (String, String) {
128        let mut defs = String::new();
129        let mut elements = String::new();
130
131        let bg = &self.options.background_options;
132        let name = format!("background-color-{}", self.instance_id);
133
134        let (width, height) = if bg.round > 0.0 {
135            let size = self.options.width.min(self.options.height);
136            (size, size)
137        } else {
138            (self.options.width, self.options.height)
139        };
140
141        let x = self.round_size((self.options.width - width) as f64 / 2.0);
142        let y = self.round_size((self.options.height - height) as f64 / 2.0);
143
144        // Create clip path
145        let rx = if bg.round > 0.0 {
146            (height as f64 / 2.0) * bg.round
147        } else {
148            0.0
149        };
150
151        defs.push_str(&format!(
152            r#"<clipPath id="clip-path-{}"><rect x="{}" y="{}" width="{}" height="{}"{}/></clipPath>
153"#,
154            name,
155            x,
156            y,
157            width,
158            height,
159            if rx > 0.0 {
160                format!(r#" rx="{}""#, rx)
161            } else {
162                String::new()
163            }
164        ));
165
166        // Create color/gradient rect
167        let (grad_defs, fill) = self.create_color(
168            bg.gradient.as_ref(),
169            &bg.color,
170            0.0,
171            0.0,
172            0.0,
173            self.options.height as f64,
174            self.options.width as f64,
175            &name,
176        );
177        defs.push_str(&grad_defs);
178
179        elements.push_str(&format!(
180            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" clip-path="url(#clip-path-{})"/>
181"#,
182            0, 0, self.options.width, self.options.height, fill, name
183        ));
184
185        (defs, elements)
186    }
187
188    fn render_dots(
189        &self,
190        matrix: &QRMatrix,
191        count: usize,
192        dot_size: f64,
193        hide_x_dots: usize,
194        hide_y_dots: usize,
195    ) -> (String, String) {
196        let mut defs = String::new();
197        let mut clip_path_elements = String::new();
198
199        let x_beginning = self.round_size((self.options.width as f64 - count as f64 * dot_size) / 2.0);
200        let y_beginning = self.round_size((self.options.height as f64 - count as f64 * dot_size) / 2.0);
201
202        let dot_drawer = QRDot::new(self.options.dots_options.dot_type);
203        let name = format!("dot-color-{}", self.instance_id);
204
205        // Create dots clip path
206        for row in 0..count {
207            for col in 0..count {
208                // Apply filter
209                if !self.should_draw_dot(row, col, count, hide_x_dots, hide_y_dots) {
210                    continue;
211                }
212
213                if !matrix.is_dark(row, col) {
214                    continue;
215                }
216
217                let x = x_beginning + col as f64 * dot_size;
218                let y = y_beginning + row as f64 * dot_size;
219
220                let neighbor_fn = |x_offset: i32, y_offset: i32| -> bool {
221                    let new_col = col as i32 + x_offset;
222                    let new_row = row as i32 + y_offset;
223                    if new_col < 0 || new_row < 0 || new_col >= count as i32 || new_row >= count as i32
224                    {
225                        return false;
226                    }
227                    if !self.should_draw_dot(
228                        new_row as usize,
229                        new_col as usize,
230                        count,
231                        hide_x_dots,
232                        hide_y_dots,
233                    ) {
234                        return false;
235                    }
236                    matrix.is_dark(new_row as usize, new_col as usize)
237                };
238
239                let svg = dot_drawer.draw(x, y, dot_size, Some(&neighbor_fn));
240                clip_path_elements.push_str(&svg);
241                clip_path_elements.push('\n');
242            }
243        }
244
245        // Handle circle shape with fake edge dots
246        if self.options.shape == ShapeType::Circle {
247            let circle_dots = self.render_circle_edge_dots(matrix, count, dot_size, x_beginning, y_beginning, &dot_drawer);
248            clip_path_elements.push_str(&circle_dots);
249        }
250
251        defs.push_str(&format!(
252            r#"<clipPath id="clip-path-{}">
253{}
254</clipPath>
255"#,
256            name, clip_path_elements
257        ));
258
259        // Create color rect
260        let (grad_defs, fill) = self.create_color(
261            self.options.dots_options.gradient.as_ref(),
262            &self.options.dots_options.color,
263            0.0,
264            0.0,
265            0.0,
266            self.options.height as f64,
267            self.options.width as f64,
268            &name,
269        );
270        defs.push_str(&grad_defs);
271
272        let elements = format!(
273            r#"<rect x="0" y="0" width="{}" height="{}" fill="{}" clip-path="url(#clip-path-{})"/>
274"#,
275            self.options.width, self.options.height, fill, name
276        );
277
278        (defs, elements)
279    }
280
281    fn render_circle_edge_dots(
282        &self,
283        matrix: &QRMatrix,
284        count: usize,
285        dot_size: f64,
286        x_beginning: f64,
287        y_beginning: f64,
288        dot_drawer: &QRDot,
289    ) -> String {
290        let mut result = String::new();
291        let min_size = (self.options.width.min(self.options.height) - self.options.margin * 2) as f64;
292        let additional_dots = self.round_size((min_size / dot_size - count as f64) / 2.0) as usize;
293        let fake_count = count + additional_dots * 2;
294        let x_fake_beginning = x_beginning - additional_dots as f64 * dot_size;
295        let y_fake_beginning = y_beginning - additional_dots as f64 * dot_size;
296        let center = fake_count as f64 / 2.0;
297
298        let mut fake_matrix = vec![vec![0u8; fake_count]; fake_count];
299
300        for row in 0..fake_count {
301            for col in 0..fake_count {
302                // Skip inner area
303                if row >= additional_dots.saturating_sub(1)
304                    && row <= fake_count - additional_dots
305                    && col >= additional_dots.saturating_sub(1)
306                    && col <= fake_count - additional_dots
307                {
308                    continue;
309                }
310
311                // Skip outside circle
312                let dist = ((row as f64 - center).powi(2) + (col as f64 - center).powi(2)).sqrt();
313                if dist > center {
314                    continue;
315                }
316
317                // Get random dots from QR code
318                let source_col = if col < 2 * additional_dots {
319                    col
320                } else if col >= count {
321                    col.wrapping_sub(2 * additional_dots)
322                } else {
323                    col.wrapping_sub(additional_dots)
324                };
325                let source_row = if row < 2 * additional_dots {
326                    row
327                } else if row >= count {
328                    row.wrapping_sub(2 * additional_dots)
329                } else {
330                    row.wrapping_sub(additional_dots)
331                };
332
333                if source_row < count && source_col < count && matrix.is_dark(source_row, source_col) {
334                    fake_matrix[row][col] = 1;
335                }
336            }
337        }
338
339        for row in 0..fake_count {
340            for col in 0..fake_count {
341                if fake_matrix[row][col] == 0 {
342                    continue;
343                }
344
345                let x = x_fake_beginning + col as f64 * dot_size;
346                let y = y_fake_beginning + row as f64 * dot_size;
347
348                let neighbor_fn = |x_offset: i32, y_offset: i32| -> bool {
349                    let new_col = col as i32 + x_offset;
350                    let new_row = row as i32 + y_offset;
351                    if new_col < 0 || new_row < 0 || new_col >= fake_count as i32 || new_row >= fake_count as i32 {
352                        return false;
353                    }
354                    fake_matrix[new_row as usize][new_col as usize] == 1
355                };
356
357                let svg = dot_drawer.draw(x, y, dot_size, Some(&neighbor_fn));
358                result.push_str(&svg);
359                result.push('\n');
360            }
361        }
362
363        result
364    }
365
366    fn render_corners(&self, count: usize, dot_size: f64) -> (String, String) {
367        let mut defs = String::new();
368        let mut elements = String::new();
369
370        let x_beginning = self.round_size((self.options.width as f64 - count as f64 * dot_size) / 2.0);
371        let y_beginning = self.round_size((self.options.height as f64 - count as f64 * dot_size) / 2.0);
372
373        let corners_square_size = dot_size * 7.0;
374        let corners_dot_size = dot_size * 3.0;
375
376        // Three corners: top-left, top-right, bottom-left
377        let corner_positions = [
378            (0, 0, 0.0),
379            (1, 0, PI / 2.0),
380            (0, 1, -PI / 2.0),
381        ];
382
383        for (column, row, rotation) in corner_positions {
384            let x = x_beginning + column as f64 * dot_size * (count - 7) as f64;
385            let y = y_beginning + row as f64 * dot_size * (count - 7) as f64;
386
387            // Render corner square
388            let (sq_defs, sq_elements) = self.render_corner_square(
389                x, y, corners_square_size, dot_size, rotation, column, row,
390            );
391            defs.push_str(&sq_defs);
392            elements.push_str(&sq_elements);
393
394            // Render corner dot
395            let (dot_defs, dot_elements) = self.render_corner_dot(
396                x + dot_size * 2.0,
397                y + dot_size * 2.0,
398                corners_dot_size,
399                dot_size,
400                rotation,
401                column,
402                row,
403            );
404            defs.push_str(&dot_defs);
405            elements.push_str(&dot_elements);
406        }
407
408        (defs, elements)
409    }
410
411    fn render_corner_square(
412        &self,
413        x: f64,
414        y: f64,
415        size: f64,
416        _dot_size: f64,
417        rotation: f64,
418        column: usize,
419        row: usize,
420    ) -> (String, String) {
421        let mut defs = String::new();
422        let mut clip_path_content = String::new();
423
424        let name = format!("corners-square-color-{}-{}-{}", column, row, self.instance_id);
425
426        let sq_options = &self.options.corners_square_options;
427
428        // Use corner square drawer if specific type is set
429        match sq_options.square_type {
430            CornerSquareType::Square | CornerSquareType::Dot | CornerSquareType::ExtraRounded => {
431                let drawer = QRCornerSquare::new(sq_options.square_type);
432                let svg = drawer.draw(x, y, size, rotation);
433                clip_path_content.push_str(&svg);
434            }
435        }
436
437        defs.push_str(&format!(
438            r#"<clipPath id="clip-path-{}">
439{}
440</clipPath>
441"#,
442            name, clip_path_content
443        ));
444
445        // Create color
446        let (grad_defs, fill) = self.create_color(
447            sq_options.gradient.as_ref(),
448            &sq_options.color,
449            rotation,
450            x,
451            y,
452            size,
453            size,
454            &name,
455        );
456        defs.push_str(&grad_defs);
457
458        let elements = format!(
459            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" clip-path="url(#clip-path-{})"/>
460"#,
461            x, y, size, size, fill, name
462        );
463
464        (defs, elements)
465    }
466
467    fn render_corner_dot(
468        &self,
469        x: f64,
470        y: f64,
471        size: f64,
472        _dot_size: f64,
473        rotation: f64,
474        column: usize,
475        row: usize,
476    ) -> (String, String) {
477        let mut defs = String::new();
478        let mut clip_path_content = String::new();
479
480        let name = format!("corners-dot-color-{}-{}-{}", column, row, self.instance_id);
481
482        let dot_options = &self.options.corners_dot_options;
483
484        // Use corner dot drawer
485        let drawer = QRCornerDot::new(dot_options.dot_type);
486        let svg = drawer.draw(x, y, size, rotation);
487        clip_path_content.push_str(&svg);
488
489        defs.push_str(&format!(
490            r#"<clipPath id="clip-path-{}">
491{}
492</clipPath>
493"#,
494            name, clip_path_content
495        ));
496
497        // Create color
498        let (grad_defs, fill) = self.create_color(
499            dot_options.gradient.as_ref(),
500            &dot_options.color,
501            rotation,
502            x,
503            y,
504            size,
505            size,
506            &name,
507        );
508        defs.push_str(&grad_defs);
509
510        let elements = format!(
511            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" clip-path="url(#clip-path-{})"/>
512"#,
513            x, y, size, size, fill, name
514        );
515
516        (defs, elements)
517    }
518
519    fn render_image(
520        &self,
521        count: usize,
522        dot_size: f64,
523        hide_x_dots: usize,
524        hide_y_dots: usize,
525        image_data: &[u8],
526    ) -> String {
527        let x_beginning = self.round_size((self.options.width as f64 - count as f64 * dot_size) / 2.0);
528        let y_beginning = self.round_size((self.options.height as f64 - count as f64 * dot_size) / 2.0);
529
530        let width = hide_x_dots as f64 * dot_size;
531        let height = hide_y_dots as f64 * dot_size;
532
533        let margin = self.options.image_options.margin as f64;
534        let dx = x_beginning + self.round_size(margin + (count as f64 * dot_size - width) / 2.0);
535        let dy = y_beginning + self.round_size(margin + (count as f64 * dot_size - height) / 2.0);
536        let dw = width - margin * 2.0;
537        let dh = height - margin * 2.0;
538
539        // Encode image as base64 data URL
540        let base64_data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, image_data);
541
542        // Detect mime type from image data
543        let mime_type = if image_data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
544            "image/png"
545        } else if image_data.starts_with(&[0xFF, 0xD8]) {
546            "image/jpeg"
547        } else if image_data.starts_with(b"RIFF") && image_data.len() > 12 && &image_data[8..12] == b"WEBP" {
548            "image/webp"
549        } else {
550            "image/png" // Default
551        };
552
553        let data_url = format!("data:{};base64,{}", mime_type, base64_data);
554
555        format!(
556            r#"<image href="{}" xlink:href="{}" x="{}" y="{}" width="{}px" height="{}px"/>
557"#,
558            data_url, data_url, dx, dy, dw, dh
559        )
560    }
561
562    fn create_color(
563        &self,
564        gradient: Option<&Gradient>,
565        color: &Color,
566        additional_rotation: f64,
567        x: f64,
568        y: f64,
569        height: f64,
570        width: f64,
571        name: &str,
572    ) -> (String, String) {
573        let mut defs = String::new();
574
575        if let Some(grad) = gradient {
576            let size = width.max(height);
577
578            match grad.gradient_type {
579                GradientType::Radial => {
580                    let cx = x + width / 2.0;
581                    let cy = y + height / 2.0;
582                    let r = size / 2.0;
583
584                    defs.push_str(&format!(
585                        r#"<radialGradient id="{}" gradientUnits="userSpaceOnUse" fx="{}" fy="{}" cx="{}" cy="{}" r="{}">
586"#,
587                        name, cx, cy, cx, cy, r
588                    ));
589
590                    for stop in &grad.color_stops {
591                        defs.push_str(&format!(
592                            r#"<stop offset="{}%" stop-color="{}"/>
593"#,
594                            stop.offset * 100.0,
595                            stop.color.to_hex()
596                        ));
597                    }
598
599                    defs.push_str("</radialGradient>\n");
600                }
601                GradientType::Linear => {
602                    let rotation = (grad.rotation + additional_rotation) % (2.0 * PI);
603                    let positive_rotation = (rotation + 2.0 * PI) % (2.0 * PI);
604
605                    let (mut x0, mut y0, mut x1, mut y1) = (
606                        x + width / 2.0,
607                        y + height / 2.0,
608                        x + width / 2.0,
609                        y + height / 2.0,
610                    );
611
612                    if (positive_rotation >= 0.0 && positive_rotation <= 0.25 * PI)
613                        || (positive_rotation > 1.75 * PI && positive_rotation <= 2.0 * PI)
614                    {
615                        x0 -= width / 2.0;
616                        y0 -= (height / 2.0) * rotation.tan();
617                        x1 += width / 2.0;
618                        y1 += (height / 2.0) * rotation.tan();
619                    } else if positive_rotation > 0.25 * PI && positive_rotation <= 0.75 * PI {
620                        y0 -= height / 2.0;
621                        x0 -= (width / 2.0) / rotation.tan();
622                        y1 += height / 2.0;
623                        x1 += (width / 2.0) / rotation.tan();
624                    } else if positive_rotation > 0.75 * PI && positive_rotation <= 1.25 * PI {
625                        x0 += width / 2.0;
626                        y0 += (height / 2.0) * rotation.tan();
627                        x1 -= width / 2.0;
628                        y1 -= (height / 2.0) * rotation.tan();
629                    } else if positive_rotation > 1.25 * PI && positive_rotation <= 1.75 * PI {
630                        y0 += height / 2.0;
631                        x0 += (width / 2.0) / rotation.tan();
632                        y1 -= height / 2.0;
633                        x1 -= (width / 2.0) / rotation.tan();
634                    }
635
636                    defs.push_str(&format!(
637                        r#"<linearGradient id="{}" gradientUnits="userSpaceOnUse" x1="{}" y1="{}" x2="{}" y2="{}">
638"#,
639                        name,
640                        x0.round(),
641                        y0.round(),
642                        x1.round(),
643                        y1.round()
644                    ));
645
646                    for stop in &grad.color_stops {
647                        defs.push_str(&format!(
648                            r#"<stop offset="{}%" stop-color="{}"/>
649"#,
650                            stop.offset * 100.0,
651                            stop.color.to_hex()
652                        ));
653                    }
654
655                    defs.push_str("</linearGradient>\n");
656                }
657            }
658
659            (defs, format!("url(#{})", name))
660        } else {
661            (String::new(), color.to_hex())
662        }
663    }
664
665    fn should_draw_dot(
666        &self,
667        row: usize,
668        col: usize,
669        count: usize,
670        hide_x_dots: usize,
671        hide_y_dots: usize,
672    ) -> bool {
673        // Hide dots behind image
674        if self.options.image_options.hide_background_dots && self.options.image.is_some() {
675            let x_start = (count - hide_x_dots) / 2;
676            let x_end = (count + hide_x_dots) / 2;
677            let y_start = (count - hide_y_dots) / 2;
678            let y_end = (count + hide_y_dots) / 2;
679
680            if row >= y_start && row < y_end && col >= x_start && col < x_end {
681                return false;
682            }
683        }
684
685        // Skip corner squares (finder patterns)
686        // Top-left
687        if row < 7 && col < 7 {
688            if SQUARE_MASK[row][col] == 1 || DOT_MASK[row][col] == 1 {
689                return false;
690            }
691        }
692
693        // Top-right
694        if row < 7 && col >= count - 7 {
695            let local_col = col - (count - 7);
696            if SQUARE_MASK[row][local_col] == 1 || DOT_MASK[row][local_col] == 1 {
697                return false;
698            }
699        }
700
701        // Bottom-left
702        if row >= count - 7 && col < 7 {
703            let local_row = row - (count - 7);
704            if SQUARE_MASK[local_row][col] == 1 || DOT_MASK[local_row][col] == 1 {
705                return false;
706            }
707        }
708
709        true
710    }
711
712    fn calculate_image_hide_area(&self, count: usize, _dot_size: f64) -> (usize, usize) {
713        // Calculate based on error correction level and image size
714        let error_correction_percent = self.options.qr_options.error_correction_level.percentage();
715        let cover_level = self.options.image_options.image_size * error_correction_percent;
716        let max_hidden_dots = (cover_level * (count * count) as f64).floor() as usize;
717        let max_hidden_axis_dots = count.saturating_sub(14);
718
719        // Simple calculation for image area
720        // Use aspect ratio 1:1 for simplicity (can be enhanced with actual image dimensions)
721        let mut hide_dots = (max_hidden_dots as f64).sqrt().floor() as usize;
722
723        // Ensure odd number for center alignment
724        if hide_dots % 2 == 0 {
725            hide_dots = hide_dots.saturating_sub(1);
726        }
727
728        // Clamp to max
729        hide_dots = hide_dots.min(max_hidden_axis_dots);
730
731        (hide_dots, hide_dots)
732    }
733
734    fn round_size(&self, value: f64) -> f64 {
735        if self.options.dots_options.round_size {
736            value.floor()
737        } else {
738            value
739        }
740    }
741}