biosvg/
lib.rs

1//! Captcha based on SVG.
2//!
3//! ## Original idea
4//!
5//! [SVG绘制原理与验证码](https://blog.woooo.tech/posts/svg_1/)
6//!
7//! ## Usage
8//!
9//! `cargo add biosvg`
10//!
11//! ```rust
12//! let (answer, svg) = BiosvgBuilder::new()
13//!     .length(4)
14//!     .difficulty(6)
15//!     .colors(vec![
16//!         "#0078D6".to_string(),
17//!         "#aa3333".to_string(),
18//!         "#f08012".to_string(),
19//!         "#33aa00".to_string(),
20//!         "#aa33aa".to_string(),
21//!     ])
22//!     .build()
23//!     .unwrap();
24//! println!("answer: {}", answer);
25//! println!("svg: {}", svg);
26//! ```
27
28mod model;
29mod resource;
30use model::Command;
31use rand::{
32  rng,
33  seq::{IndexedRandom, SliceRandom},
34  Rng,
35};
36use resource::{FONT_PATHS, FONT_TABLE};
37
38/// BiosvgBuilder is a builder for generating svg captcha with random text
39#[derive(Debug, Clone, Default)]
40pub struct BiosvgBuilder {
41  length: usize,
42  difficulty: u16,
43  colors: Vec<String>,
44}
45
46impl BiosvgBuilder {
47  /// constructor
48  pub fn new() -> BiosvgBuilder {
49    BiosvgBuilder::default()
50  }
51
52  /// set length of captcha text
53  pub fn length(mut self, length: usize) -> BiosvgBuilder {
54    self.length = length;
55    self
56  }
57
58  /// set difficulty of captcha, `difficulty` number of noise lines will be
59  /// added
60  pub fn difficulty(mut self, difficulty: u16) -> BiosvgBuilder {
61    self.difficulty = difficulty;
62    self
63  }
64
65  /// set colors of captcha text and noise lines, each color will be used
66  /// randomly, please add at least 4 colors.
67  /// the result of captcha will have a transparent background,
68  /// so you should add colors that looks good on your website background
69  pub fn colors(mut self, colors: Vec<String>) -> BiosvgBuilder {
70    self.colors = colors;
71    self
72  }
73
74  /// build and generate svg captcha
75  pub fn build(self) -> Result<(String, String), model::PathError> {
76    // generate random text with length
77    let mut answer = String::new();
78    let mut rng = rng();
79    for _ in 0..self.length {
80      let index = rng.random_range(0..FONT_TABLE.len());
81      answer.push(String::from(FONT_TABLE).chars().nth(index).unwrap());
82    }
83
84    // split colors
85    let mut char_colors = Vec::new();
86    let mut line_colors = Vec::new();
87
88    // randomly split colors in self.colors, but keep the last one gives to the one
89    // who have less colors
90    let mut colors = self.colors.clone();
91    let last_color = colors.pop().unwrap();
92    for color in colors {
93      if rng.random_bool(0.5) {
94        char_colors.push(color);
95      } else {
96        line_colors.push(color);
97      }
98    }
99    if char_colors.len() > line_colors.len() {
100      line_colors.push(last_color);
101    } else {
102      char_colors.push(last_color);
103    }
104
105    let mut font_paths = Vec::new();
106    for ch in answer.chars() {
107      if let Some(path) = FONT_PATHS.get(ch.to_string().as_str()) {
108        let random_angle = rng.random_range(-0.2..0.2 * std::f64::consts::PI);
109        // let random_angle = random_angle + std::f64::consts::PI * 1.0;
110        let random_offset = rng.random_range(0.0..0.1 * path.width);
111        let random_color = char_colors.choose(&mut rng).unwrap();
112        let random_scale_x = rng.random_range(0.8..1.2);
113        let random_scale_y = rng.random_range(0.8..1.2);
114        let path = path
115          .with_color(random_color)
116          .scale(random_scale_x, random_scale_y)
117          .rotate(random_angle)
118          .offset(0.0, random_offset);
119
120        font_paths.push(path.clone())
121      }
122    }
123    let mut width = 0.0;
124    let mut height = 0.0;
125    for path in &font_paths {
126      width += path.width;
127      // height = max height of all paths
128      if path.height > height {
129        height = path.height;
130      }
131    }
132    width += 1.5 * height;
133    let mut start_point = height * 0.55;
134    let mut paths = Vec::new();
135    for path in font_paths {
136      let offset_x = start_point + path.width / 2.0;
137      let offset_y = (height * 1.5) / 2.0;
138      let mut random_splited_path = path.offset(offset_x, offset_y).random_split();
139      paths.append(random_splited_path.as_mut());
140      start_point += path.width + height * 0.4 / self.length as f64;
141    }
142    for _ in 1..self.difficulty {
143      let start_x = rng.random_range(0.0..width);
144      let end_x = rng.random_range(start_x..start_x + height);
145      let start_y = rng.random_range(0.0..height);
146      let end_y = rng.random_range(start_y..start_y + height);
147      let color = line_colors.choose(&mut rng).unwrap();
148      let start_command = Command {
149        x: start_x,
150        y: start_y,
151        command_type: model::CommandType::Move,
152      };
153      let end_command = Command {
154        x: end_x,
155        y: end_y,
156        command_type: model::CommandType::LineTo,
157      };
158      paths.push(model::Path {
159        commands: vec![start_command, end_command],
160        width,
161        height: height / 1.5,
162        color: color.clone(),
163      });
164    }
165    paths.shuffle(&mut rng);
166    let svg_content = paths
167      .iter()
168      .map(|path| path.to_string())
169      .collect::<Vec<String>>()
170      .join("");
171    Ok((
172      answer,
173      format!(
174        r#"<svg width="{}" height="{}" viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg" version="1.1">{}</svg>"#,
175        width,
176        height * 1.5,
177        width,
178        height * 1.5,
179        svg_content
180      ),
181    ))
182  }
183}
184
185#[cfg(test)]
186mod tests {
187  use super::*;
188
189  #[test]
190  fn it_works() {
191    let (answer, svg) = BiosvgBuilder::new()
192      .length(4)
193      .difficulty(6)
194      .colors(vec![
195        "#0078D6".to_string(),
196        "#aa3333".to_string(),
197        "#f08012".to_string(),
198        "#33aa00".to_string(),
199        "#aa33aa".to_string(),
200      ])
201      .build()
202      .unwrap();
203    println!("answer: {answer}");
204    println!("svg: {svg}");
205  }
206}