Skip to main content

geronimo_captcha/
sprite.rs

1use crate::image::warp_tile;
2use crate::{CaptchaError, GenerationOptions};
3
4use ab_glyph::{FontArc, InvalidFont, PxScale};
5use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
6use image::{GenericImage, ImageBuffer, ImageReader, Limits, Rgba, RgbaImage, imageops};
7use imageproc::drawing::draw_text_mut;
8use rand::prelude::SliceRandom;
9use rand::rngs::ThreadRng;
10use rand::{RngExt, rng};
11#[cfg(feature = "parallel")]
12use rayon::prelude::*;
13use std::io::Cursor;
14use std::sync::LazyLock;
15
16static FONT: LazyLock<Result<FontArc, InvalidFont>> =
17    LazyLock::new(|| FontArc::try_from_slice(include_bytes!("../assets/Roboto-Bold.ttf")));
18
19pub(crate) const SPRITE_BG: Rgba<u8> = Rgba([255, 255, 255, 255]);
20
21const COLS: u32 = 3;
22const ROWS: u32 = 3;
23const SPACING: u32 = 4;
24const TILES: usize = (COLS * ROWS) as usize;
25
26const UPRIGHT_JITTER_DEG: f32 = 5.0;
27const MIN_TILE_SCALE: f32 = 0.5;
28const MAX_TILE_SCALE: f32 = 0.8;
29
30const INCORRECT_ANGLES: [f32; 11] = [
31    38.0, 88.0, 114.0, 138.0, 176.0, 200.0, 229.0, 255.0, 278.0, 314.0, 320.0,
32];
33
34const LABELS: [&str; TILES] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
35
36/// Destination for the encoded sprite bytes.
37pub trait SpriteTarget: Sized {
38    fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self;
39}
40
41/// Sprite as a `data:` URI, ready for `<img src>`.
42pub struct SpriteUri(pub String);
43
44impl SpriteTarget for SpriteUri {
45    fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self {
46        SpriteUri(sprite_to_base64(&bytes, mime))
47    }
48}
49
50/// Sprite as raw encoded bytes plus its MIME type.
51pub struct SpriteBinary {
52    pub bytes: Vec<u8>,
53    pub mime: &'static str,
54}
55
56impl SpriteTarget for SpriteBinary {
57    fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self {
58        SpriteBinary { bytes, mime }
59    }
60}
61
62#[derive(Clone, Copy, Debug)]
63pub enum SpriteFormat {
64    Jpeg { quality: u8 },
65    Webp { quality: u8, lossless: bool },
66}
67
68impl Default for SpriteFormat {
69    fn default() -> Self {
70        SpriteFormat::Jpeg { quality: 70 }
71    }
72}
73
74impl SpriteFormat {
75    pub(crate) fn quality(&self) -> u8 {
76        match *self {
77            SpriteFormat::Jpeg { quality } => quality,
78            SpriteFormat::Webp { quality, .. } => quality,
79        }
80    }
81}
82
83#[derive(Clone, Copy)]
84struct Tile {
85    angle: f32,
86    scale: f32,
87    flip: bool,
88    correct: bool,
89}
90
91pub fn decode_base(
92    base_buf: &[u8],
93    format: image::ImageFormat,
94    opts: &GenerationOptions,
95) -> crate::Result<RgbaImage> {
96    let mut limits = Limits::default();
97    limits.max_image_width = Some(opts.limits.max_width);
98    limits.max_image_height = Some(opts.limits.max_height);
99    limits.max_alloc = Some(opts.limits.max_alloc);
100
101    let mut reader = ImageReader::with_format(Cursor::new(base_buf), format);
102    reader.limits(limits);
103
104    Ok(reader
105        .decode()
106        .map_err(CaptchaError::Decode)?
107        .resize_exact(
108            opts.cell_size,
109            opts.cell_size,
110            imageops::FilterType::Nearest,
111        )
112        .into_rgba8())
113}
114
115pub fn create_sprite(base: &RgbaImage, opts: &GenerationOptions) -> crate::Result<(RgbaImage, u8)> {
116    let font = font()?;
117    let mut rng = rng();
118
119    let mut wrong = INCORRECT_ANGLES;
120    wrong.shuffle(&mut rng);
121
122    let mut tiles = [Tile {
123        angle: 0.0,
124        scale: 0.0,
125        flip: false,
126        correct: false,
127    }; TILES];
128
129    for (i, tile) in tiles.iter_mut().enumerate() {
130        tile.angle = match i {
131            0 => rng.random_range(-UPRIGHT_JITTER_DEG..=UPRIGHT_JITTER_DEG),
132            _ => wrong[i - 1],
133        };
134        tile.scale = rng.random_range(MIN_TILE_SCALE..=MAX_TILE_SCALE);
135        tile.flip = rng.random_bool(0.5);
136        tile.correct = i == 0;
137    }
138
139    tiles.shuffle(&mut rng);
140
141    let sprite_width = COLS * opts.cell_size + (COLS - 1) * SPACING;
142    let sprite_height = ROWS * opts.cell_size + (ROWS - 1) * SPACING;
143
144    let mut sprite = ImageBuffer::from_pixel(sprite_width, sprite_height, SPRITE_BG);
145    let mut correct_number = 0;
146
147    #[cfg(feature = "parallel")]
148    let rendered = tiles
149        .par_iter()
150        .map(|tile| {
151            let mut out = RgbaImage::new(0, 0);
152            render_tile(base, *tile, opts.cell_size, &mut out).map(|()| out)
153        })
154        .collect::<crate::Result<Vec<_>>>()?;
155
156    #[cfg(not(feature = "parallel"))]
157    let mut scratch = RgbaImage::new(0, 0);
158
159    for (i, tile) in tiles.iter().enumerate() {
160        #[cfg(feature = "parallel")]
161        let rendered = rendered
162            .get(i)
163            .ok_or_else(|| CaptchaError::Internal(format!("tile {i} was not rendered")))?;
164
165        #[cfg(not(feature = "parallel"))]
166        let rendered = {
167            render_tile(base, *tile, opts.cell_size, &mut scratch)?;
168            &scratch
169        };
170
171        place_tile(&mut sprite, rendered, i, opts.cell_size, &mut rng, font)?;
172
173        if tile.correct {
174            correct_number = (i + 1) as u8;
175        }
176    }
177
178    Ok((sprite, correct_number))
179}
180
181fn render_tile(
182    base: &RgbaImage,
183    tile: Tile,
184    cell_size: u32,
185    out: &mut RgbaImage,
186) -> crate::Result<()> {
187    let size = (cell_size as f32 * tile.scale) as u32;
188
189    let mut raw = std::mem::replace(out, RgbaImage::new(0, 0)).into_raw();
190    raw.resize(size as usize * size as usize * 4, 0);
191
192    *out = RgbaImage::from_raw(size, size, raw)
193        .ok_or_else(|| CaptchaError::Internal("tile buffer does not fit its dimensions".into()))?;
194
195    warp_tile(base, tile.angle, tile.flip, SPRITE_BG, out);
196
197    Ok(())
198}
199
200fn place_tile(
201    sprite: &mut RgbaImage,
202    tile: &RgbaImage,
203    index: usize,
204    cell_size: u32,
205    rng: &mut ThreadRng,
206    font: &FontArc,
207) -> crate::Result<()> {
208    let size = tile.width();
209
210    let col = index as u32 % COLS;
211    let row = index as u32 / COLS;
212
213    let base_x = col * (cell_size + SPACING);
214    let base_y = row * (cell_size + SPACING);
215
216    let offset = (cell_size - size) / 2;
217    let jitter_limit = offset as i32;
218
219    let draw_x =
220        (base_x + offset).saturating_add_signed(rng.random_range(-jitter_limit..=jitter_limit));
221    let draw_y =
222        (base_y + offset).saturating_add_signed(rng.random_range(-jitter_limit..=jitter_limit));
223
224    sprite
225        .copy_from(tile, draw_x, draw_y)
226        .map_err(|e| CaptchaError::Internal(format!("copy tile into sprite buffer: {e}")))?;
227
228    let label_x = draw_x.saturating_add(size).saturating_sub(16);
229    let label_y = draw_y.saturating_add(size).saturating_sub(16);
230
231    let scale = PxScale::from(cell_size as f32 * rng.random_range(0.13..=0.17));
232    let color = Rgba([
233        rng.random_range(0..100),
234        rng.random_range(0..100),
235        rng.random_range(0..100),
236        255,
237    ]);
238
239    draw_text_mut(
240        sprite,
241        color,
242        (label_x + rng.random_range(0..=3)) as i32,
243        (label_y + rng.random_range(0..=3)) as i32,
244        scale,
245        font,
246        LABELS[index],
247    );
248
249    Ok(())
250}
251
252fn sprite_to_base64(buf: &[u8], mime: &str) -> String {
253    const PREFIX: &str = "data:";
254    const INFIX: &str = ";base64,";
255
256    let mut uri =
257        String::with_capacity(PREFIX.len() + mime.len() + INFIX.len() + buf.len().div_ceil(3) * 4);
258
259    uri.push_str(PREFIX);
260    uri.push_str(mime);
261    uri.push_str(INFIX);
262
263    BASE64_STANDARD.encode_string(buf, &mut uri);
264
265    uri
266}
267
268fn font() -> crate::Result<&'static FontArc> {
269    FONT.as_ref()
270        .map_err(|e| CaptchaError::Internal(format!("load embedded font: {e}")))
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::challenge::{DecodeLimits, generate};
277    use crate::image::NoiseOptions;
278
279    const SECRET: &[u8] = b"unit-test-secret-key-32-bytes-min!!";
280
281    fn opts(cell_size: u32, sprite_format: SpriteFormat) -> GenerationOptions {
282        GenerationOptions {
283            cell_size,
284            sprite_format,
285            rounds: 1,
286            limits: DecodeLimits::default(),
287        }
288    }
289
290    fn load_base(opts: &GenerationOptions) -> RgbaImage {
291        decode_base(
292            include_bytes!("../assets/sample1.jpg"),
293            image::ImageFormat::Jpeg,
294            opts,
295        )
296        .expect("decode sample")
297    }
298
299    fn decode(bytes: &[u8]) {
300        ImageReader::new(Cursor::new(bytes))
301            .with_guessed_format()
302            .expect("guess format")
303            .decode()
304            .expect("decode sprite");
305    }
306
307    fn payload(uri: &str) -> Vec<u8> {
308        let b64 = uri.split_once(',').map(|x| x.1).expect("data uri payload");
309        BASE64_STANDARD.decode(b64).expect("base64 decode")
310    }
311
312    #[test]
313    fn sprite_prefix_jpeg_and_decode() {
314        let o = opts(120, SpriteFormat::Jpeg { quality: 60 });
315        let base = load_base(&o);
316
317        let ch = generate::<SpriteUri>(
318            std::slice::from_ref(&base),
319            SECRET,
320            &o,
321            NoiseOptions::default(),
322        )
323        .expect("jpeg generation failed");
324
325        assert_eq!(ch.sprites.len(), 1);
326        assert!(ch.sprites[0].0.starts_with("data:image/jpeg;base64,"));
327
328        decode(&payload(&ch.sprites[0].0));
329    }
330
331    #[test]
332    fn sprite_prefix_webp_and_decode() {
333        let o = opts(
334            120,
335            SpriteFormat::Webp {
336                quality: 75,
337                lossless: false,
338            },
339        );
340
341        let base = load_base(&o);
342        let ch = generate::<SpriteUri>(
343            std::slice::from_ref(&base),
344            SECRET,
345            &o,
346            NoiseOptions::default(),
347        )
348        .expect("webp generation failed");
349
350        assert!(ch.sprites[0].0.starts_with("data:image/webp;base64,"));
351
352        decode(&payload(&ch.sprites[0].0));
353    }
354
355    #[test]
356    fn sprite_binary_jpeg_and_decode() {
357        let o = opts(150, SpriteFormat::Jpeg { quality: 70 });
358        let base = load_base(&o);
359
360        let ch = generate::<SpriteBinary>(
361            std::slice::from_ref(&base),
362            SECRET,
363            &o,
364            NoiseOptions::default(),
365        )
366        .expect("jpeg binary generation failed");
367
368        assert_eq!(ch.sprites[0].mime, "image/jpeg");
369        assert!(!ch.sprites[0].bytes.is_empty());
370
371        decode(&ch.sprites[0].bytes);
372    }
373
374    #[test]
375    fn sprite_binary_webp_and_decode() {
376        let o = opts(
377            150,
378            SpriteFormat::Webp {
379                quality: 70,
380                lossless: false,
381            },
382        );
383
384        let base = load_base(&o);
385        let ch = generate::<SpriteBinary>(
386            std::slice::from_ref(&base),
387            SECRET,
388            &o,
389            NoiseOptions::default(),
390        )
391        .expect("webp binary generation failed");
392
393        assert_eq!(ch.sprites[0].mime, "image/webp");
394        assert!(!ch.sprites[0].bytes.is_empty());
395
396        decode(&ch.sprites[0].bytes);
397    }
398
399    #[test]
400    fn tile_silhouette_is_angle_independent() {
401        let base_size = 64;
402        let fill = Rgba([10, 20, 30, 255]);
403        let base = RgbaImage::from_pixel(base_size, base_size, fill);
404
405        let size = 41;
406        let corners = [
407            (0, 0),
408            (size - 1, 0),
409            (0, size - 1),
410            (size - 1, size - 1),
411            (size / 2, 0),
412        ];
413
414        let mut areas = Vec::new();
415        for angle in [0.0, 3.0, -4.9, 38.0, 88.0, 138.0, 176.0, 278.0, 320.0] {
416            for flip in [false, true] {
417                let mut tile = RgbaImage::new(size, size);
418                warp_tile(&base, angle, flip, SPRITE_BG, &mut tile);
419
420                for (x, y) in corners {
421                    assert_eq!(
422                        *tile.get_pixel(x, y),
423                        SPRITE_BG,
424                        "angle {angle} flip {flip} leaves tile content at ({x}, {y})"
425                    );
426                }
427
428                assert_eq!(*tile.get_pixel(size / 2, size / 2), fill);
429
430                areas.push(tile.pixels().filter(|p| **p != SPRITE_BG).count());
431            }
432        }
433
434        assert!(
435            areas.windows(2).all(|w| w[0] == w[1]),
436            "tile area varies with angle or flip, silhouette leaks the answer: {areas:?}"
437        );
438    }
439
440    #[test]
441    fn render_tile_reuses_scratch_capacity() {
442        let o = opts(80, SpriteFormat::Jpeg { quality: 70 });
443        let base = load_base(&o);
444
445        let mut scratch = RgbaImage::new(0, 0);
446        let mut capacities = Vec::new();
447
448        for scale in [MAX_TILE_SCALE, MIN_TILE_SCALE, 0.65, MAX_TILE_SCALE] {
449            let tile = Tile {
450                angle: 38.0,
451                scale,
452                flip: false,
453                correct: false,
454            };
455
456            render_tile(&base, tile, o.cell_size, &mut scratch).expect("render");
457
458            let expected = (o.cell_size as f32 * scale) as u32;
459            assert_eq!(scratch.dimensions(), (expected, expected));
460
461            capacities.push(scratch.as_raw().capacity());
462        }
463
464        assert!(
465            capacities.windows(2).all(|w| w[0] == w[1]),
466            "scratch reallocated between tiles: {capacities:?}"
467        );
468    }
469
470    #[test]
471    fn noise_blends_without_writing_alpha() {
472        let o = opts(80, SpriteFormat::Jpeg { quality: 70 });
473        let base = load_base(&o);
474
475        let (sprite, _) = create_sprite(&base, &o).expect("sprite");
476
477        let mut noised = sprite.clone();
478        crate::image::watermark_with_noise(&mut noised, NoiseOptions::default());
479
480        let alpha_before: Vec<u8> = sprite.pixels().map(|p| p.0[3]).collect();
481        let alpha_after: Vec<u8> = noised.pixels().map(|p| p.0[3]).collect();
482
483        assert_eq!(
484            alpha_before, alpha_after,
485            "noise must blend, never write into the alpha channel"
486        );
487        assert_ne!(
488            sprite.as_raw(),
489            noised.as_raw(),
490            "noise must actually modify the sprite"
491        );
492    }
493
494    #[test]
495    fn correct_number_is_within_grid() {
496        let o = opts(80, SpriteFormat::Jpeg { quality: 70 });
497        let base = load_base(&o);
498
499        for _ in 0..16 {
500            let (_, correct) = create_sprite(&base, &o).expect("sprite");
501            assert!(
502                (1..=9).contains(&correct),
503                "correct number {correct} out of grid"
504            );
505        }
506    }
507}