1use crate::SpriteFormat;
2use crate::error::{CaptchaError, Result};
3
4use image::{ExtendedColorType, RgbImage, Rgba, RgbaImage, imageops};
5use imageproc::geometric_transformations::{Border, Interpolation, Projection, warp_into};
6use rand::RngExt;
7use webp::{Encoder as WebPEncoder, WebPConfig};
8
9const MAX_NOISE_COUNT: u32 = 1_000_000;
10const MAX_NOISE_SIZE: u32 = 64;
11const MAX_NOISE_WORK: u64 = 4_000_000;
12const MAX_BLUR_SIGMA: f32 = 32.0;
13
14const WEBP_METHOD: i32 = 1;
16
17const JPEG_BYTES_PER_PIXEL_SHIFT: usize = 4;
19
20#[derive(Clone, Copy, Debug, Default)]
21pub enum NoisePattern {
22 Dots,
23 Lines,
24 #[default]
25 Grid,
26}
27
28#[derive(Clone, Copy, Debug)]
31pub struct NoiseOptions {
32 pub count: u32,
37
38 pub size: u32,
43
44 pub blur_sigma: f32,
47
48 pub alpha: u8,
51
52 pub color_range: (u8, u8),
55 pub shape: NoisePattern,
56 pub red: bool,
57 pub green: bool,
58 pub blue: bool,
59}
60
61impl Default for NoiseOptions {
62 fn default() -> Self {
63 NoiseOptions {
64 count: 300 * 9,
65 size: 2,
66 alpha: 100,
67 color_range: (0, 255),
68 shape: NoisePattern::default(),
69 red: true,
70 green: true,
71 blue: true,
72 blur_sigma: 0.7,
73 }
74 }
75}
76
77impl NoiseOptions {
78 pub fn validate(&self) -> Result<()> {
83 if self.color_range.0 > self.color_range.1 {
84 return Err(CaptchaError::InvalidInput(format!(
85 "noise color_range is inverted: {} > {}",
86 self.color_range.0, self.color_range.1
87 )));
88 }
89
90 if self.count > MAX_NOISE_COUNT {
91 return Err(CaptchaError::InvalidInput(format!(
92 "noise count {} exceeds maximum {MAX_NOISE_COUNT}",
93 self.count
94 )));
95 }
96
97 if self.size == 0 || self.size > MAX_NOISE_SIZE {
98 return Err(CaptchaError::InvalidInput(format!(
99 "noise size {} outside 1..={MAX_NOISE_SIZE}",
100 self.size
101 )));
102 }
103
104 if !self.blur_sigma.is_finite() || self.blur_sigma < 0.0 || self.blur_sigma > MAX_BLUR_SIGMA
105 {
106 return Err(CaptchaError::InvalidInput(format!(
107 "noise blur_sigma {} outside 0.0..={MAX_BLUR_SIGMA}",
108 self.blur_sigma
109 )));
110 }
111
112 let work = u64::from(self.count) * self.pixels_per_mark();
113 if work > MAX_NOISE_WORK {
114 return Err(CaptchaError::InvalidInput(format!(
115 "noise count {} at size {} blends {work} pixels, maximum is {MAX_NOISE_WORK}",
116 self.count, self.size
117 )));
118 }
119
120 Ok(())
121 }
122
123 fn pixels_per_mark(&self) -> u64 {
124 match self.shape {
125 NoisePattern::Dots => 1,
126 NoisePattern::Lines => u64::from(self.size),
127 NoisePattern::Grid => u64::from(self.size) * u64::from(self.size),
128 }
129 }
130}
131
132pub fn warp_tile(base: &RgbaImage, angle_deg: f32, flip: bool, bg: Rgba<u8>, out: &mut RgbaImage) {
134 let (in_w, in_h) = base.dimensions();
135 let (out_w, out_h) = out.dimensions();
136
137 let scale = out_w as f32 / in_w as f32;
138 let scale_x = match flip {
139 true => -scale,
140 false => scale,
141 };
142
143 let projection = Projection::translate(out_w as f32 / 2.0, out_h as f32 / 2.0)
144 * Projection::scale(scale_x, scale)
145 * Projection::rotate(angle_deg.to_radians())
146 * Projection::translate(-(in_w as f32) / 2.0, -(in_h as f32) / 2.0);
147
148 warp_into(
149 base,
150 projection,
151 Interpolation::Bilinear,
152 Border::Constant(bg),
153 out,
154 );
155
156 mask_outside_circle(out, bg);
157}
158
159pub fn watermark_with_noise(img: &mut RgbaImage, opts: NoiseOptions) {
160 let mut rng = rand::rng();
161 let (width, height) = img.dimensions();
162
163 for _ in 0..opts.count {
164 let x = rng.random_range(0..width);
165 let y = rng.random_range(0..height);
166
167 let channel = |enabled: bool, rng: &mut _| match enabled {
168 true => RngExt::random_range(rng, opts.color_range.0..=opts.color_range.1),
169 false => 0,
170 };
171
172 let color = Rgba([
173 channel(opts.red, &mut rng),
174 channel(opts.green, &mut rng),
175 channel(opts.blue, &mut rng),
176 opts.alpha,
177 ]);
178
179 match opts.shape {
180 NoisePattern::Dots => blend_pixel(img, x, y, color),
181 NoisePattern::Lines => {
182 for i in 0..opts.size.min(width - x) {
183 blend_pixel(img, x + i, y, color);
184 }
185 }
186 NoisePattern::Grid => {
187 for dx in 0..opts.size.min(width - x) {
188 for dy in 0..opts.size.min(height - y) {
189 blend_pixel(img, x + dx, y + dy, color);
190 }
191 }
192 }
193 }
194 }
195}
196
197pub fn blur_rgb(img: RgbImage, sigma: f32) -> RgbImage {
198 match sigma > 0.0 {
199 true => imageops::fast_blur(&img, sigma),
200 false => img,
201 }
202}
203
204pub fn encode_image(img: &RgbImage, fmt: &SpriteFormat) -> Result<(Vec<u8>, &'static str)> {
205 match *fmt {
206 SpriteFormat::Jpeg { quality } => {
207 let mut buf = Vec::with_capacity(img.as_raw().len() >> JPEG_BYTES_PER_PIXEL_SHIFT);
208 let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
209
210 enc.encode(
211 img.as_raw(),
212 img.width(),
213 img.height(),
214 ExtendedColorType::Rgb8,
215 )
216 .map_err(CaptchaError::Encode)?;
217
218 Ok((buf, "image/jpeg"))
219 }
220 SpriteFormat::Webp { quality, lossless } => {
221 let enc = WebPEncoder::from_rgb(img.as_raw(), img.width(), img.height());
222
223 let webp = match lossless {
224 true => enc.encode_lossless(),
225 false => {
226 let mut config = WebPConfig::new()
227 .map_err(|()| CaptchaError::Internal("build webp config".into()))?;
228 config.quality = quality as f32;
229 config.method = WEBP_METHOD;
230
231 enc.encode_advanced(&config)
232 .map_err(|e| CaptchaError::Internal(format!("webp encode: {e:?}")))?
233 }
234 };
235
236 Ok((webp.to_vec(), "image/webp"))
237 }
238 }
239}
240
241fn mask_outside_circle(img: &mut RgbaImage, fill: Rgba<u8>) {
242 let (width, height) = img.dimensions();
243
244 let radius = width.min(height) as f32 / 2.0 - 1.0;
245 if radius <= 0.0 {
246 return;
247 }
248
249 let (cx, cy) = (width as f32 / 2.0, height as f32 / 2.0);
250 let r2 = radius * radius;
251
252 for y in 0..height {
253 let dy = y as f32 + 0.5 - cy;
254 let span = r2 - dy * dy;
255
256 let (lo, hi) = match span > 0.0 {
257 true => {
258 let dx = span.sqrt();
259 (
260 (cx - dx).ceil().clamp(0.0, width as f32) as u32,
261 (cx + dx).floor().clamp(0.0, width as f32) as u32,
262 )
263 }
264 false => (width, width),
265 };
266
267 for x in (0..lo).chain(hi..width) {
268 img.put_pixel(x, y, fill);
269 }
270 }
271}
272
273fn blend_pixel(img: &mut RgbaImage, x: u32, y: u32, src: Rgba<u8>) {
274 let dst = img.get_pixel_mut(x, y);
275 let a = u32::from(src.0[3]);
276 let inv = 255 - a;
277
278 for c in 0..3 {
279 dst.0[c] = ((u32::from(src.0[c]) * a + u32::from(dst.0[c]) * inv) / 255) as u8;
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn noise_validation_rejects_out_of_range() {
289 let cases = [
290 (
291 "inverted color_range",
292 NoiseOptions {
293 color_range: (200, 100),
294 ..NoiseOptions::default()
295 },
296 ),
297 (
298 "count over cap",
299 NoiseOptions {
300 count: MAX_NOISE_COUNT + 1,
301 shape: NoisePattern::Dots,
302 ..NoiseOptions::default()
303 },
304 ),
305 (
306 "size zero",
307 NoiseOptions {
308 size: 0,
309 ..NoiseOptions::default()
310 },
311 ),
312 (
313 "size over cap",
314 NoiseOptions {
315 size: MAX_NOISE_SIZE + 1,
316 ..NoiseOptions::default()
317 },
318 ),
319 (
320 "negative blur",
321 NoiseOptions {
322 blur_sigma: -0.1,
323 ..NoiseOptions::default()
324 },
325 ),
326 (
327 "blur over cap",
328 NoiseOptions {
329 blur_sigma: MAX_BLUR_SIGMA + 0.1,
330 ..NoiseOptions::default()
331 },
332 ),
333 (
334 "blur nan",
335 NoiseOptions {
336 blur_sigma: f32::NAN,
337 ..NoiseOptions::default()
338 },
339 ),
340 (
341 "blur infinite",
342 NoiseOptions {
343 blur_sigma: f32::INFINITY,
344 ..NoiseOptions::default()
345 },
346 ),
347 (
348 "grid work over cap",
349 NoiseOptions {
350 count: MAX_NOISE_COUNT,
351 size: MAX_NOISE_SIZE,
352 shape: NoisePattern::Grid,
353 ..NoiseOptions::default()
354 },
355 ),
356 (
357 "lines work over cap",
358 NoiseOptions {
359 count: MAX_NOISE_COUNT,
360 size: MAX_NOISE_SIZE,
361 shape: NoisePattern::Lines,
362 ..NoiseOptions::default()
363 },
364 ),
365 ];
366
367 for (name, noise) in cases {
368 assert!(
369 matches!(noise.validate(), Err(CaptchaError::InvalidInput(_))),
370 "{name} should be rejected"
371 );
372 }
373 }
374
375 #[test]
376 fn noise_validation_accepts_bounds() {
377 let accepted = [
378 NoiseOptions::default(),
379 NoiseOptions {
380 count: 0,
381 ..NoiseOptions::default()
382 },
383 NoiseOptions {
384 count: MAX_NOISE_COUNT,
385 shape: NoisePattern::Dots,
386 ..NoiseOptions::default()
387 },
388 NoiseOptions {
389 size: MAX_NOISE_SIZE,
390 count: 1,
391 ..NoiseOptions::default()
392 },
393 NoiseOptions {
394 blur_sigma: 0.0,
395 ..NoiseOptions::default()
396 },
397 NoiseOptions {
398 blur_sigma: MAX_BLUR_SIGMA,
399 ..NoiseOptions::default()
400 },
401 NoiseOptions {
402 color_range: (7, 7),
403 ..NoiseOptions::default()
404 },
405 ];
406
407 for noise in accepted {
408 assert!(noise.validate().is_ok(), "{noise:?} should be accepted");
409 }
410 }
411}