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
36pub trait SpriteTarget: Sized {
38 fn from_bytes(bytes: Vec<u8>, mime: &'static str) -> Self;
39}
40
41pub 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
50pub 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}