geronimo_captcha/
sprite.rs

1use crate::image::rotate_image;
2use crate::{CaptchaError, GenerationOptions};
3
4use ab_glyph::{FontArc, PxScale};
5use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
6use image::{DynamicImage, GenericImage, ImageBuffer, ImageReader, Limits, Rgba, imageops};
7use imageproc::drawing::draw_text_mut;
8use once_cell::sync::Lazy;
9use rand::prelude::SliceRandom;
10use rand::{Rng, rng};
11#[cfg(feature = "parallel")]
12use rayon::prelude::*;
13use std::io::Cursor;
14
15static FONT: Lazy<FontArc> = Lazy::new(|| {
16    FontArc::try_from_slice(include_bytes!("../assets/Roboto-Bold.ttf"))
17        .expect("embedded font should be valid")
18});
19
20pub trait SpriteTarget: Sized {
21    fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self;
22}
23
24pub struct SpriteUri(pub String);
25
26impl SpriteTarget for SpriteUri {
27    fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self {
28        SpriteUri(sprite_to_base64(&bytes, mime))
29    }
30}
31
32pub struct SpriteBinary {
33    pub bytes: Vec<u8>,
34    pub mime: &'static str,
35}
36
37impl SpriteTarget for SpriteBinary {
38    fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self {
39        SpriteBinary { bytes, mime }
40    }
41}
42
43#[derive(Clone, Copy, Debug)]
44pub enum SpriteFormat {
45    Jpeg { quality: u8 },
46    Webp { quality: u8, lossless: bool },
47}
48
49impl Default for SpriteFormat {
50    fn default() -> Self {
51        SpriteFormat::Jpeg { quality: 70 }
52    }
53}
54
55pub fn create_sprite(
56    base_buf: &[u8],
57    opts: &GenerationOptions,
58) -> crate::Result<(DynamicImage, u8)> {
59    let mut reader = ImageReader::with_format(Cursor::new(base_buf), image::ImageFormat::Jpeg);
60    if let Some(limits) = opts.limits.clone() {
61        reader.limits(limits);
62    } else {
63        let mut limits = Limits::default();
64        limits.max_image_width = Some(4096);
65        limits.max_image_height = Some(4096);
66        limits.max_alloc = Some(128 * 1024 * 1024);
67
68        reader.limits(limits);
69    }
70
71    let base = reader.decode().map_err(CaptchaError::Decode)?.resize_exact(
72        opts.cell_size,
73        opts.cell_size,
74        imageops::FilterType::Nearest,
75    );
76    let mut rng = rng();
77
78    let correct_angle = 0.0;
79    let incorrect_angles = [
80        38.0, 88.0, 114.0, 138.0, 176.0, 200.0, 229.0, 255.0, 278.0, 314.0, 320.0,
81    ];
82
83    let mut angles = Vec::with_capacity(1 + incorrect_angles.len());
84    angles.push(correct_angle);
85    angles.extend_from_slice(&incorrect_angles);
86
87    let precomputed: Vec<(f32, image::RgbaImage)> = {
88        #[cfg(feature = "parallel")]
89        {
90            angles
91                .par_iter()
92                .map(|&a| (a, rotate_image(&base, a).to_rgba8()))
93                .collect()
94        }
95        #[cfg(not(feature = "parallel"))]
96        {
97            angles
98                .iter()
99                .map(|&a| (a, rotate_image(&base, a).to_rgba8()))
100                .collect()
101        }
102    };
103
104    let mut tiles = vec![(true, correct_angle)];
105    let mut others = incorrect_angles.to_vec();
106
107    others.shuffle(&mut rng);
108
109    for &angle in others.iter().take(8) {
110        tiles.push((false, angle));
111    }
112
113    tiles.shuffle(&mut rng);
114
115    let font = &*FONT;
116    let cols = 3;
117    let rows = 3;
118    let spacing = 4;
119    let sprite_width = cols * opts.cell_size + (cols - 1) * spacing;
120    let sprite_height = rows * opts.cell_size + (rows - 1) * spacing;
121
122    let mut sprite_buf =
123        ImageBuffer::from_pixel(sprite_width, sprite_height, Rgba([255, 255, 255, 255]));
124
125    let mut correct_number = 0;
126
127    for (i, (is_correct, angle)) in tiles.iter().enumerate() {
128        // Create and draw each tile
129        let tile_scale = 0.5 + rng.random_range(0.0..0.3);
130        let shrink_size = (opts.cell_size as f32 * tile_scale) as u32;
131        let rotated = precomputed
132            .iter()
133            .find(|(a, _)| (*a - *angle).abs() < f32::EPSILON)
134            .map(|(_, img)| img)
135            .ok_or_else(|| CaptchaError::Internal("missing precomputed angle".into()))?;
136
137        let mut tile = image::imageops::resize(
138            rotated,
139            shrink_size,
140            shrink_size,
141            imageops::FilterType::Lanczos3,
142        );
143
144        let should_flip = rng.random_bool(0.5);
145        if should_flip {
146            tile = imageops::flip_horizontal(&tile);
147        }
148
149        let col = i as u32 % cols;
150        let row = i as u32 / cols;
151
152        let base_x = col * (opts.cell_size + spacing);
153        let base_y = row * (opts.cell_size + spacing);
154
155        let offset_x = (opts.cell_size - shrink_size) / 2;
156        let offset_y = (opts.cell_size - shrink_size) / 2;
157
158        let jitter_limit_x = offset_x as i32;
159        let jitter_limit_y = offset_y as i32;
160
161        let jitter_x = rng.random_range(-jitter_limit_x..=jitter_limit_x);
162        let jitter_y = rng.random_range(-jitter_limit_y..=jitter_limit_y);
163
164        let draw_x = (base_x as i32 + offset_x as i32 + jitter_x) as u32;
165        let draw_y = (base_y as i32 + offset_y as i32 + jitter_y) as u32;
166
167        sprite_buf
168            .copy_from(&tile, draw_x, draw_y)
169            .map_err(|e| CaptchaError::Internal(format!("copy tile into sprite buffer: {e}")))?;
170
171        // Draw the number label
172        let label = format!("{}", i + 1);
173
174        let label_x = draw_x.saturating_add(shrink_size).saturating_sub(16);
175        let label_y = draw_y.saturating_add(shrink_size).saturating_sub(16);
176
177        let scale_factor = rng.random_range(0.13..=0.17);
178        let scale = PxScale::from(opts.cell_size as f32 * scale_factor);
179
180        let color = Rgba([
181            rng.random_range(0..100),
182            rng.random_range(0..100),
183            rng.random_range(0..100),
184            255,
185        ]);
186
187        let offset_x = rng.random_range(0..=3);
188        let offset_y = rng.random_range(0..=3);
189
190        draw_text_mut(
191            &mut sprite_buf,
192            color,
193            (label_x + offset_x) as i32,
194            (label_y + offset_y) as i32,
195            scale,
196            &font,
197            &label,
198        );
199
200        if *is_correct {
201            correct_number = (i + 1) as u8;
202        }
203    }
204
205    Ok((DynamicImage::ImageRgba8(sprite_buf), correct_number))
206}
207
208fn sprite_to_base64(buf: &[u8], mime: &str) -> String {
209    format!("data:{};base64,{}", mime, BASE64_STANDARD.encode(buf))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::challenge::generate;
216    use crate::image::NoiseOptions;
217    use std::fs;
218    use std::path::Path;
219
220    const SECRET: &[u8] = b"secret-key";
221
222    fn load_sample_image() -> Vec<u8> {
223        include_bytes!("../assets/sample1.jpg").to_vec()
224    }
225
226    #[test]
227    fn test_sprite_prefix_jpeg_and_decode() {
228        let base = load_sample_image();
229        let opts = GenerationOptions {
230            cell_size: 120,
231            sprite_format: SpriteFormat::Jpeg { quality: 60 },
232            limits: None,
233        };
234
235        let ch = generate::<SpriteUri>(&base, SECRET, &opts, NoiseOptions::default())
236            .expect("jpeg generation failed");
237        assert!(ch.sprite.0.starts_with("data:image/jpeg;base64,"));
238
239        let data_b64 = ch
240            .sprite
241            .0
242            .split_once(',')
243            .map(|x| x.1)
244            .expect("missing data uri payload");
245        let bytes = BASE64_STANDARD
246            .decode(data_b64)
247            .expect("base64 decode jpeg");
248
249        let _img = ImageReader::new(Cursor::new(bytes))
250            .with_guessed_format()
251            .expect("guess jpeg format")
252            .decode()
253            .expect("decode jpeg");
254    }
255
256    #[test]
257    fn test_sprite_prefix_webp_and_decode() {
258        let base = load_sample_image();
259        let opts = GenerationOptions {
260            cell_size: 120,
261            sprite_format: SpriteFormat::Webp {
262                quality: 75,
263                lossless: false,
264            },
265            limits: None,
266        };
267
268        let ch = generate::<SpriteUri>(&base, SECRET, &opts, NoiseOptions::default())
269            .expect("webp generation failed");
270        assert!(ch.sprite.0.starts_with("data:image/webp;base64,"));
271
272        let data_b64 = ch
273            .sprite
274            .0
275            .split_once(',')
276            .map(|x| x.1)
277            .expect("missing data uri payload");
278        let bytes = BASE64_STANDARD
279            .decode(data_b64)
280            .expect("base64 decode webp");
281
282        let _img = ImageReader::new(Cursor::new(bytes))
283            .with_guessed_format()
284            .expect("guess webp format")
285            .decode()
286            .expect("decode webp");
287    }
288
289    #[test]
290    fn test_sprite_binary_jpeg_and_decode() {
291        let base = load_sample_image();
292        let opts = GenerationOptions {
293            cell_size: 150,
294            sprite_format: SpriteFormat::Jpeg { quality: 70 },
295            limits: None,
296        };
297        let ch = generate::<SpriteBinary>(&base, SECRET, &opts, NoiseOptions::default())
298            .expect("jpeg binary generation failed");
299
300        assert_eq!(ch.sprite.mime, "image/jpeg");
301        assert!(!ch.sprite.bytes.is_empty());
302
303        fs::write(Path::new("examples/exampleX.jpeg"), &ch.sprite.bytes)
304            .expect("Failed to save generated image");
305
306        let _img = ImageReader::new(Cursor::new(&ch.sprite.bytes))
307            .with_guessed_format()
308            .expect("guess jpeg format")
309            .decode()
310            .expect("decode jpeg binary");
311    }
312
313    #[test]
314    fn test_sprite_binary_webp_and_decode() {
315        let base = load_sample_image();
316        let opts = GenerationOptions {
317            cell_size: 150,
318            sprite_format: SpriteFormat::Webp {
319                quality: 70,
320                lossless: false,
321            },
322            limits: None,
323        };
324        let ch = generate::<SpriteBinary>(&base, SECRET, &opts, NoiseOptions::default())
325            .expect("webp binary generation failed");
326
327        assert_eq!(ch.sprite.mime, "image/webp");
328        assert!(!ch.sprite.bytes.is_empty());
329
330        fs::write(Path::new("examples/exampleX.webp"), &ch.sprite.bytes)
331            .expect("Failed to save generated image");
332
333        let _img = ImageReader::new(Cursor::new(&ch.sprite.bytes))
334            .with_guessed_format()
335            .expect("guess webp format")
336            .decode()
337            .expect("decode webp binary");
338    }
339}