1use crate::capture::ImageStore;
2use ab_glyph::{Font as _, FontVec, PxScale, ScaleFont as _};
3use base64::Engine;
4use chrono::Local;
5use directories::UserDirs;
6use image::Rgba;
7use imageproc::drawing::{
8 draw_filled_circle_mut, draw_filled_ellipse_mut, draw_hollow_ellipse_mut, draw_hollow_rect_mut,
9 draw_line_segment_mut, draw_text_mut,
10};
11use imageproc::rect::Rect;
12use serde::{Deserialize, Serialize};
13use std::fs::File;
14use std::io::{BufWriter, Cursor};
15use std::path::PathBuf;
16use uuid::Uuid;
17
18#[derive(Deserialize, Serialize)]
19pub struct Point {
20 pub x: f64,
21 pub y: f64,
22}
23
24#[derive(Deserialize, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Annotation {
27 pub id: String,
28 #[serde(rename = "type")]
29 pub annotation_type: String,
30 pub x: f64,
31 pub y: f64,
32 pub width: Option<f64>,
33 pub height: Option<f64>,
34 pub stroke_color: Option<String>,
35 pub fill_color: Option<String>,
36 pub stroke_width: Option<f64>,
37 pub opacity: Option<f64>,
38 pub text: Option<String>,
39 pub font_size: Option<f64>,
40 pub font_family: Option<String>,
41 pub points: Option<Vec<Point>>,
42 pub step_number: Option<u32>,
43 pub blur_radius: Option<f64>,
44 pub highlight_color: Option<String>,
45 pub created_at: Option<String>,
46 pub locked: Option<bool>,
47}
48
49#[tauri::command]
50pub fn save_image(
51 image_id: String,
52 annotations: Vec<Annotation>,
53 format: String,
54 path: String,
55 store: tauri::State<'_, ImageStore>,
56) -> Result<String, String> {
57 let uuid = Uuid::parse_str(&image_id).map_err(|e| format!("Invalid image ID: {}", e))?;
58
59 let base_image = store
60 .get(&uuid)
61 .ok_or_else(|| format!("Image not found: {}", image_id))?;
62
63 let mut composite = base_image.to_rgba8();
64
65 for anno in &annotations {
66 composite_annotation(&mut composite, anno);
67 }
68
69 let (save_path, user_chosen) = if path.is_empty() {
70 (generate_default_path()?, false)
71 } else {
72 (expand_tilde(&path)?, true)
73 };
74
75 if !user_chosen {
81 let home = UserDirs::new()
82 .map(|d| d.home_dir().to_path_buf())
83 .ok_or("Could not determine home directory for path validation")?;
84
85 let canonical = save_path
86 .canonicalize()
87 .unwrap_or_else(|_| save_path.clone());
88 if !canonical.starts_with(&home) {
89 return Err(format!(
90 "Save path '{}' is outside the home directory",
91 save_path.display()
92 ));
93 }
94 }
95
96 if let Some(parent) = save_path.parent() {
97 std::fs::create_dir_all(parent)
98 .map_err(|e| format!("Failed to create directory: {}", e))?;
99 }
100
101 let fmt = detect_format(&save_path, &format);
102 write_image_to_file(composite, &save_path, fmt, JPEG_QUALITY_DEFAULT)?;
103
104 Ok(save_path.to_string_lossy().to_string())
105}
106
107#[tauri::command]
111pub fn composite_image(
112 image_id: String,
113 annotations: Vec<Annotation>,
114 format: Option<String>,
115 store: tauri::State<'_, ImageStore>,
116) -> Result<String, String> {
117 let uuid = Uuid::parse_str(&image_id).map_err(|e| format!("Invalid image ID: {}", e))?;
118
119 let base_image = store
120 .get(&uuid)
121 .ok_or_else(|| format!("Image not found: {}", image_id))?;
122
123 let mut composite = base_image.to_rgba8();
124
125 for anno in &annotations {
126 composite_annotation(&mut composite, anno);
127 }
128
129 let hint = format.as_deref().unwrap_or("png");
130 let fmt = format_from_hint(hint);
131 let bytes = encode_to_bytes(composite, fmt, JPEG_QUALITY_DEFAULT)?;
132
133 Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
134}
135
136#[tauri::command]
137pub fn copy_to_clipboard(
138 app: tauri::AppHandle,
139 image_id: String,
140 annotations: Vec<Annotation>,
141 store: tauri::State<'_, ImageStore>,
142) -> Result<(), String> {
143 use tauri_plugin_clipboard_manager::ClipboardExt;
144
145 let uuid = Uuid::parse_str(&image_id).map_err(|e| format!("Invalid image ID: {}", e))?;
146
147 let base_image = store
148 .get(&uuid)
149 .ok_or_else(|| format!("Image not found: {}", image_id))?;
150
151 let mut composite = base_image.to_rgba8();
152 for anno in &annotations {
153 composite_annotation(&mut composite, anno);
154 }
155
156 let (width, height) = composite.dimensions();
157 let rgba_bytes = composite.into_raw();
158 let image = tauri::image::Image::new_owned(rgba_bytes, width, height);
159
160 app.clipboard()
161 .write_image(&image)
162 .map_err(|e| format!("Failed to copy to clipboard: {}", e))
163}
164
165#[tauri::command]
166pub async fn export_annotations(
167 app: tauri::AppHandle,
168 image_id: String,
169 annotations: Vec<Annotation>,
170) -> Result<String, String> {
171 use tauri_plugin_dialog::DialogExt;
172 use tokio::sync::oneshot;
173
174 let (tx, rx) = oneshot::channel();
175 let default_name = format!("annotations-{}.json", &image_id[..image_id.len().min(8)]);
176 app.dialog()
177 .file()
178 .add_filter("JSON", &["json"])
179 .set_file_name(&default_name)
180 .save_file(move |path| {
181 let _ = tx.send(path);
182 });
183
184 let path = rx
185 .await
186 .map_err(|_| "dialog error".to_string())?
187 .ok_or_else(|| "cancelled".to_string())?
188 .into_path()
189 .map_err(|e| format!("invalid path: {e}"))?;
190
191 let json = serde_json::to_string_pretty(&annotations)
192 .map_err(|e| format!("serialization error: {e}"))?;
193
194 std::fs::write(&path, json).map_err(|e| format!("write error: {e}"))?;
195
196 Ok(path.to_string_lossy().to_string())
197}
198
199#[tauri::command]
200pub async fn import_annotations(app: tauri::AppHandle) -> Result<Vec<Annotation>, String> {
201 use tauri_plugin_dialog::DialogExt;
202 use tokio::sync::oneshot;
203
204 let (tx, rx) = oneshot::channel();
205 app.dialog()
206 .file()
207 .add_filter("JSON", &["json"])
208 .pick_file(move |path| {
209 let _ = tx.send(path);
210 });
211
212 let path = rx
213 .await
214 .map_err(|_| "dialog error".to_string())?
215 .ok_or_else(|| "cancelled".to_string())?
216 .into_path()
217 .map_err(|e| format!("invalid path: {e}"))?;
218
219 let content = std::fs::read_to_string(&path).map_err(|e| format!("read error: {e}"))?;
220
221 serde_json::from_str(&content).map_err(|e| format!("invalid JSON: {e}"))
222}
223
224const JPEG_QUALITY_DEFAULT: u8 = 85;
228
229fn format_from_hint(hint: &str) -> image::ImageFormat {
231 match hint.to_ascii_lowercase().as_str() {
232 "jpeg" | "jpg" => image::ImageFormat::Jpeg,
233 "webp" => image::ImageFormat::WebP,
234 _ => image::ImageFormat::Png,
235 }
236}
237
238fn detect_format(path: &PathBuf, hint: &str) -> image::ImageFormat {
240 image::ImageFormat::from_path(path).unwrap_or_else(|_| format_from_hint(hint))
241}
242
243fn encode_to_bytes(
247 img: image::RgbaImage,
248 fmt: image::ImageFormat,
249 jpeg_quality: u8,
250) -> Result<Vec<u8>, String> {
251 let mut buf = Cursor::new(Vec::new());
252 match fmt {
253 image::ImageFormat::Jpeg => {
254 let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
255 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, jpeg_quality)
256 .encode_image(&rgb)
257 .map_err(|e| format!("Failed to encode JPEG: {e}"))?;
258 }
259 _ => {
260 image::DynamicImage::ImageRgba8(img)
261 .write_to(&mut buf, fmt)
262 .map_err(|e| format!("Failed to encode image: {e}"))?;
263 }
264 }
265 Ok(buf.into_inner())
266}
267
268fn write_image_to_file(
270 img: image::RgbaImage,
271 path: &PathBuf,
272 fmt: image::ImageFormat,
273 jpeg_quality: u8,
274) -> Result<(), String> {
275 let file = File::create(path).map_err(|e| format!("Failed to create file: {e}"))?;
276 let mut w = BufWriter::new(file);
277 match fmt {
278 image::ImageFormat::Jpeg => {
279 let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
280 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut w, jpeg_quality)
281 .encode_image(&rgb)
282 .map_err(|e| format!("Failed to encode JPEG: {e}"))
283 }
284 _ => image::DynamicImage::ImageRgba8(img)
285 .write_to(&mut w, fmt)
286 .map_err(|e| format!("Failed to encode image: {e}")),
287 }
288}
289
290fn composite_annotation(composite: &mut image::RgbaImage, anno: &Annotation) {
293 match anno.annotation_type.as_str() {
294 "rect" => composite_rectangle(composite, anno),
295 "arrow" => composite_arrow(composite, anno),
296 "ellipse" => composite_ellipse(composite, anno),
297 "freehand" => composite_freehand(composite, anno),
298 "highlight" => composite_highlight(composite, anno),
299 "blur" => composite_blur(composite, anno),
300 "step" => composite_step(composite, anno),
301 "text" => composite_text(composite, anno),
302 other => tracing::debug!("unknown annotation type: {other}"),
303 }
304}
305
306fn composite_rectangle(composite: &mut image::RgbaImage, anno: &Annotation) {
309 let x = anno.x as i32;
310 let y = anno.y as i32;
311 let width = anno.width.unwrap_or(0.0) as i32;
312 let height = anno.height.unwrap_or(0.0) as i32;
313
314 if width <= 0 || height <= 0 {
315 return;
316 }
317
318 let opacity = (anno.opacity.unwrap_or(1.0).clamp(0.0, 1.0) * 255.0) as u8;
319
320 let fill_str = anno.fill_color.as_deref().unwrap_or("transparent");
322 if let Ok(mut fill) = parse_color(fill_str) {
323 if fill[3] > 0 {
324 fill[3] = ((fill[3] as f64 / 255.0) * opacity as f64) as u8;
325 for py in y..(y + height) {
326 for px in x..(x + width) {
327 if px >= 0
328 && py >= 0
329 && (px as u32) < composite.width()
330 && (py as u32) < composite.height()
331 {
332 let base = composite.get_pixel_mut(px as u32, py as u32);
333 blend_pixel(base, fill);
334 }
335 }
336 }
337 }
338 }
339
340 let stroke_color_str = anno.stroke_color.as_deref().unwrap_or("#FF0000");
342 let Ok(stroke_color) = parse_color(stroke_color_str) else {
343 return;
344 };
345 if stroke_color[3] == 0 {
346 return;
347 }
348
349 let sw = anno.stroke_width.unwrap_or(2.0).max(0.0) as i32;
350 let half = sw / 2;
351
352 for i in 0..sw {
353 let offset = i - half;
354 let rx = x - offset;
355 let ry = y - offset;
356 let rw = width + offset * 2;
357 let rh = height + offset * 2;
358
359 if rw <= 0 || rh <= 0 {
360 continue;
361 }
362
363 let rect = Rect::at(rx, ry).of_size(rw as u32, rh as u32);
364 draw_hollow_rect_mut(composite, rect, stroke_color);
365 }
366}
367
368fn composite_arrow(composite: &mut image::RgbaImage, anno: &Annotation) {
369 let points = match &anno.points {
370 Some(pts) if pts.len() >= 2 => pts,
371 _ => return,
372 };
373 let stroke_str = anno.stroke_color.as_deref().unwrap_or("#FF0000");
374 let Ok(color) = parse_color(stroke_str) else {
375 return;
376 };
377 let sw = anno.stroke_width.unwrap_or(2.0).max(1.0);
378
379 let p1 = &points[0];
380 let p2 = &points[1];
381
382 draw_thick_line(
384 composite,
385 p1.x as f32,
386 p1.y as f32,
387 p2.x as f32,
388 p2.y as f32,
389 color,
390 sw as f32,
391 );
392
393 let head_len = (sw * 5.0).max(12.0);
395 let dx = p2.x - p1.x;
396 let dy = p2.y - p1.y;
397 let len = (dx * dx + dy * dy).sqrt();
398 if len < 0.001 {
399 return;
400 }
401 let angle = dy.atan2(dx);
402 let wing = std::f64::consts::PI / 6.0;
403
404 let wx1 = (p2.x - head_len * (angle - wing).cos()) as f32;
405 let wy1 = (p2.y - head_len * (angle - wing).sin()) as f32;
406 let wx2 = (p2.x - head_len * (angle + wing).cos()) as f32;
407 let wy2 = (p2.y - head_len * (angle + wing).sin()) as f32;
408
409 draw_thick_line(
410 composite,
411 p2.x as f32,
412 p2.y as f32,
413 wx1,
414 wy1,
415 color,
416 sw as f32,
417 );
418 draw_thick_line(
419 composite,
420 p2.x as f32,
421 p2.y as f32,
422 wx2,
423 wy2,
424 color,
425 sw as f32,
426 );
427}
428
429fn composite_ellipse(composite: &mut image::RgbaImage, anno: &Annotation) {
430 let x = anno.x as i32;
431 let y = anno.y as i32;
432 let width = anno.width.unwrap_or(0.0) as i32;
433 let height = anno.height.unwrap_or(0.0) as i32;
434
435 if width <= 0 || height <= 0 {
436 return;
437 }
438
439 let cx = x + width / 2;
440 let cy = y + height / 2;
441 let rx = width / 2;
442 let ry = height / 2;
443
444 let opacity = anno.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
445
446 let fill_str = anno.fill_color.as_deref().unwrap_or("transparent");
448 if let Ok(mut fill) = parse_color(fill_str) {
449 if fill[3] > 0 {
450 fill[3] = (fill[3] as f64 * opacity) as u8;
451 draw_filled_ellipse_mut(composite, (cx, cy), rx, ry, fill);
452 }
453 }
454
455 let stroke_str = anno.stroke_color.as_deref().unwrap_or("#FF0000");
457 if let Ok(mut stroke) = parse_color(stroke_str) {
458 if stroke[3] > 0 {
459 stroke[3] = (stroke[3] as f64 * opacity) as u8;
460 draw_hollow_ellipse_mut(composite, (cx, cy), rx, ry, stroke);
461 }
462 }
463}
464
465fn composite_freehand(composite: &mut image::RgbaImage, anno: &Annotation) {
466 let points = match &anno.points {
467 Some(pts) if pts.len() >= 2 => pts,
468 _ => return,
469 };
470 let stroke_str = anno.stroke_color.as_deref().unwrap_or("#FF0000");
471 let Ok(color) = parse_color(stroke_str) else {
472 return;
473 };
474 let sw = anno.stroke_width.unwrap_or(2.0).max(1.0);
475
476 for i in 0..points.len() - 1 {
477 let p1 = &points[i];
478 let p2 = &points[i + 1];
479 draw_thick_line(
480 composite,
481 p1.x as f32,
482 p1.y as f32,
483 p2.x as f32,
484 p2.y as f32,
485 color,
486 sw as f32,
487 );
488 }
489}
490
491fn composite_highlight(composite: &mut image::RgbaImage, anno: &Annotation) {
492 let x = anno.x as i32;
493 let y = anno.y as i32;
494 let width = anno.width.unwrap_or(0.0) as i32;
495 let height = anno.height.unwrap_or(0.0) as i32;
496
497 if width <= 0 || height <= 0 {
498 return;
499 }
500
501 let color_str = anno.highlight_color.as_deref().unwrap_or("#FFFF00");
502 let Ok(mut color) = parse_color(color_str) else {
503 return;
504 };
505 color[3] = (0.4 * 255.0) as u8;
507
508 let img_w = composite.width() as i32;
509 let img_h = composite.height() as i32;
510
511 for py in y..(y + height) {
512 for px in x..(x + width) {
513 if px >= 0 && py >= 0 && px < img_w && py < img_h {
514 let base = composite.get_pixel_mut(px as u32, py as u32);
515 blend_pixel(base, color);
516 }
517 }
518 }
519}
520
521fn composite_blur(composite: &mut image::RgbaImage, anno: &Annotation) {
522 let x = anno.x as u32;
523 let y = anno.y as u32;
524 let w = anno.width.unwrap_or(0.0) as u32;
525 let h = anno.height.unwrap_or(0.0) as u32;
526 let block_size = anno.blur_radius.unwrap_or(10.0).max(1.0) as u32;
527
528 if w == 0 || h == 0 {
529 return;
530 }
531
532 let img_w = composite.width();
533 let img_h = composite.height();
534
535 let x2 = (x + w).min(img_w);
536 let y2 = (y + h).min(img_h);
537 let x1 = x.min(x2);
538 let y1 = y.min(y2);
539
540 let mut bx = x1;
541 while bx < x2 {
542 let bx2 = (bx + block_size).min(x2);
543 let mut by = y1;
544 while by < y2 {
545 let by2 = (by + block_size).min(y2);
546 let count = (bx2 - bx) * (by2 - by);
547
548 let mut r_sum = 0u32;
549 let mut g_sum = 0u32;
550 let mut b_sum = 0u32;
551 let mut a_sum = 0u32;
552
553 for py in by..by2 {
554 for px in bx..bx2 {
555 let p = composite.get_pixel(px, py);
556 r_sum += p[0] as u32;
557 g_sum += p[1] as u32;
558 b_sum += p[2] as u32;
559 a_sum += p[3] as u32;
560 }
561 }
562
563 let avg = Rgba([
564 (r_sum / count) as u8,
565 (g_sum / count) as u8,
566 (b_sum / count) as u8,
567 (a_sum / count) as u8,
568 ]);
569
570 for py in by..by2 {
571 for px in bx..bx2 {
572 composite.put_pixel(px, py, avg);
573 }
574 }
575
576 by += block_size;
577 }
578 bx += block_size;
579 }
580}
581
582fn composite_step(composite: &mut image::RgbaImage, anno: &Annotation) {
583 let cx = anno.x as i32;
584 let cy = anno.y as i32;
585 let size = anno.font_size.unwrap_or(24.0) as i32;
586 let radius = size / 2;
587
588 let stroke_str = anno.stroke_color.as_deref().unwrap_or("#FF0000");
589 let Ok(color) = parse_color(stroke_str) else {
590 return;
591 };
592
593 draw_filled_circle_mut(composite, (cx, cy), radius, color);
594
595 let step = match anno.step_number {
597 Some(n) => n,
598 None => return,
599 };
600 let font = embedded_font();
601 let text = step.to_string();
602 let font_size = (size as f32 * 0.6).max(8.0);
603 let scale = PxScale {
604 x: font_size,
605 y: font_size,
606 };
607 let scaled = font.as_scaled(scale);
608 let text_width: f32 = text
609 .chars()
610 .map(|c| scaled.h_advance(font.glyph_id(c)))
611 .sum();
612 let ascent = scaled.ascent();
613 let tx = cx - (text_width / 2.0) as i32;
614 let ty = cy - (ascent / 2.0) as i32;
615 draw_text_mut(
616 composite,
617 Rgba([255, 255, 255, 255]),
618 tx,
619 ty,
620 scale,
621 &font,
622 &text,
623 );
624}
625
626fn embedded_font() -> FontVec {
627 static BYTES: &[u8] = include_bytes!("../../fonts/LiberationSans-Regular.ttf");
628 FontVec::try_from_vec(BYTES.to_vec()).expect("embedded font is valid")
629}
630
631fn composite_text(composite: &mut image::RgbaImage, anno: &Annotation) {
632 let text = match &anno.text {
633 Some(t) if !t.is_empty() => t,
634 _ => return,
635 };
636
637 let font = embedded_font();
638 let x = anno.x as i32;
639 let y = anno.y as i32;
640 let font_size = anno.font_size.unwrap_or(20.0) as f32;
641 let scale = PxScale {
642 x: font_size,
643 y: font_size,
644 };
645
646 let stroke_str = anno.stroke_color.as_deref().unwrap_or("#FF0000");
647 let Ok(color) = parse_color(stroke_str) else {
648 return;
649 };
650
651 let line_height = (font_size * 1.4) as i32;
652 for (i, line) in text.lines().enumerate() {
653 if line.is_empty() {
654 continue;
655 }
656 let line_y = y + (i as i32 * line_height);
657 draw_text_mut(composite, color, x, line_y, scale, &font, line);
658 }
659}
660
661fn draw_thick_line(
665 composite: &mut image::RgbaImage,
666 x1: f32,
667 y1: f32,
668 x2: f32,
669 y2: f32,
670 color: Rgba<u8>,
671 width: f32,
672) {
673 if width <= 1.0 {
674 draw_line_segment_mut(composite, (x1, y1), (x2, y2), color);
675 return;
676 }
677
678 let dx = x2 - x1;
679 let dy = y2 - y1;
680 let len = (dx * dx + dy * dy).sqrt();
681 if len < 0.001 {
682 return;
683 }
684 let px = -dy / len;
686 let py = dx / len;
687
688 let half = width / 2.0;
689 let steps = width.ceil() as i32;
690
691 for i in 0..=steps {
692 let t = ((i as f32 / steps.max(1) as f32) - 0.5) * width;
693 let t = t.clamp(-half, half);
694 draw_line_segment_mut(
695 composite,
696 (x1 + px * t, y1 + py * t),
697 (x2 + px * t, y2 + py * t),
698 color,
699 );
700 }
701}
702
703#[inline]
707fn blend_pixel(dst: &mut Rgba<u8>, src: Rgba<u8>) {
708 let sa = src[3] as f64 / 255.0;
709 let da = dst[3] as f64 / 255.0;
710 let out_a = sa + da * (1.0 - sa);
711 if out_a < f64::EPSILON {
712 *dst = Rgba([0, 0, 0, 0]);
713 return;
714 }
715 dst[0] = ((src[0] as f64 * sa + dst[0] as f64 * da * (1.0 - sa)) / out_a) as u8;
716 dst[1] = ((src[1] as f64 * sa + dst[1] as f64 * da * (1.0 - sa)) / out_a) as u8;
717 dst[2] = ((src[2] as f64 * sa + dst[2] as f64 * da * (1.0 - sa)) / out_a) as u8;
718 dst[3] = (out_a * 255.0) as u8;
719}
720
721fn parse_color(color: &str) -> Result<Rgba<u8>, String> {
730 if color.eq_ignore_ascii_case("transparent") {
731 return Ok(Rgba([0, 0, 0, 0]));
732 }
733
734 let hex = color
735 .strip_prefix('#')
736 .ok_or_else(|| format!("Unsupported color format: {color}"))?;
737
738 let parse_byte = |s: &str| {
739 u8::from_str_radix(s, 16).map_err(|_| format!("Invalid hex byte '{s}' in color {color}"))
740 };
741
742 match hex.len() {
743 6 => Ok(Rgba([
744 parse_byte(&hex[0..2])?,
745 parse_byte(&hex[2..4])?,
746 parse_byte(&hex[4..6])?,
747 255,
748 ])),
749 8 => Ok(Rgba([
750 parse_byte(&hex[0..2])?,
751 parse_byte(&hex[2..4])?,
752 parse_byte(&hex[4..6])?,
753 parse_byte(&hex[6..8])?,
754 ])),
755 _ => Err(format!("Unsupported color format: {color}")),
756 }
757}
758
759fn generate_default_path() -> Result<PathBuf, String> {
760 let user_dirs = UserDirs::new().ok_or("Could not find user directories")?;
761 let pictures = user_dirs
762 .picture_dir()
763 .ok_or("Could not find Pictures directory")?;
764
765 let fotos_dir = pictures.join("Fotos");
766 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
767 let filename = format!("fotos-{}.png", timestamp);
768
769 Ok(fotos_dir.join(filename))
770}
771
772fn expand_tilde(path: &str) -> Result<PathBuf, String> {
773 if let Some(stripped) = path.strip_prefix("~/") {
774 let home = UserDirs::new()
775 .map(|dirs| dirs.home_dir().to_path_buf())
776 .ok_or("Could not resolve home directory for tilde expansion")?;
777 return Ok(home.join(stripped));
778 }
779 Ok(PathBuf::from(path))
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use image::{Rgba, RgbaImage};
786
787 fn make_annotation(
788 ann_type: &str,
789 x: f64,
790 y: f64,
791 w: f64,
792 h: f64,
793 stroke_color: Option<&str>,
794 fill_color: Option<&str>,
795 stroke_width: Option<f64>,
796 opacity: Option<f64>,
797 ) -> Annotation {
798 Annotation {
799 id: "test".into(),
800 annotation_type: ann_type.into(),
801 x,
802 y,
803 width: Some(w),
804 height: Some(h),
805 stroke_color: stroke_color.map(String::from),
806 fill_color: fill_color.map(String::from),
807 stroke_width,
808 opacity,
809 text: None,
810 font_size: None,
811 font_family: None,
812 points: None,
813 step_number: None,
814 blur_radius: None,
815 highlight_color: None,
816 created_at: None,
817 locked: None,
818 }
819 }
820
821 #[test]
824 fn parse_color_transparent() {
825 assert_eq!(parse_color("transparent").unwrap(), Rgba([0, 0, 0, 0]));
826 assert_eq!(parse_color("TRANSPARENT").unwrap(), Rgba([0, 0, 0, 0]));
827 }
828
829 #[test]
830 fn parse_color_rrggbb() {
831 assert_eq!(parse_color("#ff0000").unwrap(), Rgba([255, 0, 0, 255]));
832 assert_eq!(parse_color("#00FF00").unwrap(), Rgba([0, 255, 0, 255]));
833 }
834
835 #[test]
836 fn parse_color_rrggbbaa() {
837 assert_eq!(parse_color("#ff000080").unwrap(), Rgba([255, 0, 0, 128]));
838 }
839
840 #[test]
841 fn parse_color_invalid_returns_err() {
842 assert!(parse_color("red").is_err());
843 assert!(parse_color("#zzz").is_err());
844 assert!(parse_color("").is_err());
845 }
846
847 #[test]
850 fn compositing_stroke_pixel_is_stroke_color() {
851 let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
852 let anno = make_annotation(
853 "rect",
854 10.0,
855 10.0,
856 30.0,
857 20.0,
858 Some("#ff0000"),
859 Some("transparent"),
860 Some(1.0),
861 Some(1.0),
862 );
863 composite_rectangle(&mut img, &anno);
864 assert_eq!(*img.get_pixel(10, 10), Rgba([255, 0, 0, 255]));
865 }
866
867 #[test]
868 fn compositing_outside_pixel_unchanged() {
869 let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
870 let anno = make_annotation(
871 "rect",
872 10.0,
873 10.0,
874 30.0,
875 20.0,
876 Some("#ff0000"),
877 Some("transparent"),
878 Some(1.0),
879 Some(1.0),
880 );
881 composite_rectangle(&mut img, &anno);
882 assert_eq!(*img.get_pixel(0, 0), Rgba([255, 255, 255, 255]));
883 assert_eq!(*img.get_pixel(99, 99), Rgba([255, 255, 255, 255]));
884 }
885
886 #[test]
887 fn compositing_fill_pixel_is_fill_color() {
888 let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
889 let anno = make_annotation(
890 "rect",
891 10.0,
892 10.0,
893 30.0,
894 20.0,
895 Some("transparent"),
896 Some("#0000ff"),
897 Some(0.0),
898 Some(1.0),
899 );
900 composite_rectangle(&mut img, &anno);
901 let px = img.get_pixel(20, 15);
902 assert_eq!(px[2], 255);
903 assert_eq!(px[0], 0);
904 }
905
906 #[test]
907 fn compositing_highlight_uses_fixed_opacity() {
908 let mut img = RgbaImage::from_pixel(100, 100, Rgba([0, 0, 0, 255]));
909 let mut anno = make_annotation(
910 "highlight",
911 10.0,
912 10.0,
913 20.0,
914 20.0,
915 None,
916 None,
917 None,
918 Some(1.0),
919 );
920 anno.highlight_color = Some("#FFFF00".into());
921 composite_highlight(&mut img, &anno);
922 let px = img.get_pixel(20, 20);
924 assert!(
925 px[0] > 0,
926 "yellow channel should be non-zero after highlight"
927 );
928 assert!(px[0] < 255, "should be blended, not fully opaque");
929 }
930
931 #[test]
932 fn compositing_blur_pixelates_region() {
933 let mut img = RgbaImage::from_pixel(100, 100, Rgba([0, 0, 255, 255]));
935 for y in 0..20u32 {
936 for x in 0..20u32 {
937 img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
938 }
939 }
940 let mut anno = make_annotation("blur", 0.0, 0.0, 20.0, 20.0, None, None, None, None);
941 anno.blur_radius = Some(10.0);
942 composite_blur(&mut img, &anno);
943 let block1 = *img.get_pixel(0, 0);
945 let block2 = *img.get_pixel(5, 5);
946 assert_eq!(
947 block1, block2,
948 "pixels within a blur block should be identical"
949 );
950 }
951
952 #[test]
953 fn compositing_arrow_draws_something() {
954 let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
955 let mut anno = make_annotation(
956 "arrow",
957 0.0,
958 0.0,
959 0.0,
960 0.0,
961 Some("#ff0000"),
962 None,
963 Some(2.0),
964 Some(1.0),
965 );
966 anno.points = Some(vec![Point { x: 10.0, y: 50.0 }, Point { x: 80.0, y: 50.0 }]);
967 composite_arrow(&mut img, &anno);
968 let mid = img.get_pixel(45, 50);
970 assert_eq!(mid[0], 255, "arrow shaft should be red");
971 assert_eq!(mid[1], 0);
972 }
973
974 #[test]
977 fn annotation_serde_camel_case_round_trip() {
978 let json = r##"{
979 "id": "abc",
980 "type": "rect",
981 "x": 5.0,
982 "y": 10.0,
983 "width": 100.0,
984 "height": 50.0,
985 "strokeColor": "#ff0000",
986 "fillColor": "transparent",
987 "strokeWidth": 2.0,
988 "opacity": 0.8
989 }"##;
990
991 let anno: Annotation = serde_json::from_str(json).expect("deserialization failed");
992 assert_eq!(anno.annotation_type, "rect");
993 assert_eq!(anno.stroke_color.as_deref(), Some("#ff0000"));
994 assert_eq!(anno.fill_color.as_deref(), Some("transparent"));
995 assert_eq!(anno.stroke_width, Some(2.0));
996 assert_eq!(anno.opacity, Some(0.8));
997 }
998
999 #[test]
1002 fn format_from_hint_jpeg() {
1003 assert_eq!(format_from_hint("jpeg"), image::ImageFormat::Jpeg);
1004 assert_eq!(format_from_hint("jpg"), image::ImageFormat::Jpeg);
1005 assert_eq!(format_from_hint("JPEG"), image::ImageFormat::Jpeg);
1006 }
1007
1008 #[test]
1009 fn format_from_hint_webp() {
1010 assert_eq!(format_from_hint("webp"), image::ImageFormat::WebP);
1011 }
1012
1013 #[test]
1014 fn format_from_hint_png_fallback() {
1015 assert_eq!(format_from_hint("png"), image::ImageFormat::Png);
1016 assert_eq!(format_from_hint("unknown"), image::ImageFormat::Png);
1017 }
1018
1019 #[test]
1020 fn detect_format_prefers_extension() {
1021 let p = PathBuf::from("/tmp/foo.jpg");
1022 assert_eq!(detect_format(&p, "png"), image::ImageFormat::Jpeg);
1023 let p = PathBuf::from("/tmp/bar.webp");
1024 assert_eq!(detect_format(&p, "png"), image::ImageFormat::WebP);
1025 }
1026
1027 #[test]
1028 fn detect_format_falls_back_to_hint() {
1029 let p = PathBuf::from("/tmp/no_extension");
1030 assert_eq!(detect_format(&p, "jpeg"), image::ImageFormat::Jpeg);
1031 assert_eq!(detect_format(&p, "webp"), image::ImageFormat::WebP);
1032 }
1033
1034 #[test]
1037 fn encode_to_bytes_png_has_png_magic() {
1038 let img = RgbaImage::from_pixel(8, 8, Rgba([255, 0, 0, 255]));
1039 let bytes = encode_to_bytes(img, image::ImageFormat::Png, 85).unwrap();
1040 assert_eq!(
1041 &bytes[..8],
1042 b"\x89PNG\r\n\x1a\n",
1043 "should start with PNG magic"
1044 );
1045 }
1046
1047 #[test]
1048 fn encode_to_bytes_jpeg_has_jpeg_magic() {
1049 let img = RgbaImage::from_pixel(8, 8, Rgba([255, 0, 0, 255]));
1050 let bytes = encode_to_bytes(img, image::ImageFormat::Jpeg, 85).unwrap();
1051 assert_eq!(
1052 &bytes[..2],
1053 b"\xff\xd8",
1054 "should start with JPEG SOI marker"
1055 );
1056 }
1057
1058 #[test]
1059 fn encode_to_bytes_webp_has_riff_magic() {
1060 let img = RgbaImage::from_pixel(8, 8, Rgba([0, 128, 0, 255]));
1061 let bytes = encode_to_bytes(img, image::ImageFormat::WebP, 85).unwrap();
1062 assert_eq!(&bytes[..4], b"RIFF", "should start with RIFF header");
1063 }
1064
1065 #[test]
1066 fn annotation_serde_new_fields() {
1067 let json = r##"{
1068 "id": "def",
1069 "type": "step",
1070 "x": 50.0,
1071 "y": 50.0,
1072 "strokeColor": "#ff0000",
1073 "stepNumber": 3,
1074 "fontSize": 24.0,
1075 "highlightColor": "#FFFF00",
1076 "blurRadius": 10.0
1077 }"##;
1078
1079 let anno: Annotation = serde_json::from_str(json).expect("deserialization failed");
1080 assert_eq!(anno.step_number, Some(3));
1081 assert_eq!(anno.blur_radius, Some(10.0));
1082 assert_eq!(anno.highlight_color.as_deref(), Some("#FFFF00"));
1083 }
1084}