Skip to main content

fotos_lib/commands/
files.rs

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    // For auto-generated paths, guard against path traversal by requiring the path
76    // stays within the home directory.  User-chosen paths (from the save dialog) are
77    // already authorised by the OS portal/dialog, so we skip this check for them —
78    // the dialog may return portal-translated paths like /run/user/<uid>/doc/… which
79    // are outside the home directory but perfectly valid.
80    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/// Composite annotations onto an image and return as a base64-encoded image.
108///
109/// `format` controls the output encoding: `"png"` (default), `"jpeg"`, or `"webp"`.
110#[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
224// ── Image encoding helpers ────────────────────────────────────────────────────
225
226/// Default JPEG quality (matches `CaptureSettings::jpeg_quality` default).
227const JPEG_QUALITY_DEFAULT: u8 = 85;
228
229/// Map a format hint string to an `ImageFormat`.
230fn 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
238/// Detect the output format from the file extension, falling back to `hint`.
239fn detect_format(path: &PathBuf, hint: &str) -> image::ImageFormat {
240    image::ImageFormat::from_path(path).unwrap_or_else(|_| format_from_hint(hint))
241}
242
243/// Encode `img` to bytes in the requested format.
244///
245/// JPEG strips the alpha channel (JPEG does not support transparency).
246fn 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
268/// Write `img` directly to a file with the given format and JPEG quality.
269fn 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
290// ── Compositing dispatch ──────────────────────────────────────────────────────
291
292fn 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
306// ── Per-type compositing ──────────────────────────────────────────────────────
307
308fn 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    // Fill (drawn first so stroke overlays it).
321    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    // Stroke — centered on the rect edge (half inside, half outside).
341    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    // Shaft.
383    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    // Arrowhead — two lines from tip.
394    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    // Fill.
447    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    // Stroke.
456    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    // Always 0.4 opacity per spec.
506    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    // Draw the step number centered inside the circle.
596    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
661// ── Line helpers ──────────────────────────────────────────────────────────────
662
663/// Draw a line with approximate stroke width by drawing parallel offset lines.
664fn 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    // Perpendicular unit vector.
685    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// ── Shared pixel helpers ──────────────────────────────────────────────────────
704
705/// Alpha-composite `src` over `dst` in place (src-over).
706#[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
721/// Parse a CSS color string into `Rgba<u8>`.
722///
723/// Supports:
724/// - `"transparent"` → `[0, 0, 0, 0]`
725/// - `#RRGGBB` → alpha 255
726/// - `#RRGGBBAA` → explicit alpha
727///
728/// Returns `Err` for unrecognized formats.
729fn 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    // ── parse_color ──────────────────────────────────────────────────────────
822
823    #[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    // ── compositing ──────────────────────────────────────────────────────────
848
849    #[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        // The yellow should be blended at 0.4 opacity over black.
923        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        // Fill top-left 20×20 with red, rest with blue.
934        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        // After blur, the 10×10 blocks should all have the same average red color.
944        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        // At least the shaft midpoint should be red.
969        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    // ── Annotation serde (camelCase round-trip) ──────────────────────────────
975
976    #[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    // ── Format detection ──────────────────────────────────────────────────────
1000
1001    #[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    // ── encode_to_bytes ───────────────────────────────────────────────────────
1035
1036    #[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}