use std::fs; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::env; #[derive(serde::Serialize, Clone)] struct ImageItem { path: String, name: String, thumbnail: String, } fn get_cache_dir() -> std::path::PathBuf { let mut path = env::temp_dir(); path.push("watermark-wizard-thumbs"); if !path.exists() { let _ = fs::create_dir_all(&path); } path } fn generate_thumbnail(original_path: &Path) -> Option { let cache_dir = get_cache_dir(); // Generate simple hash for filename let mut hasher = DefaultHasher::new(); original_path.hash(&mut hasher); let hash = hasher.finish(); let file_name = format!("{}.jpg", hash); let thumb_path = cache_dir.join(file_name); // Return if exists if thumb_path.exists() { return Some(thumb_path.to_string_lossy().to_string()); } // Generate // Use image reader to be faster? open is fine. if let Ok(img) = image::open(original_path) { // Resize to height 200, preserve ratio let thumb = img.thumbnail(u32::MAX, 200); // Save as jpeg with quality 80 let mut file = fs::File::create(&thumb_path).ok()?; // thumb.write_to(&mut file, image::ImageOutputFormat::Jpeg(80)).ok()?; // write_to might be slow due to encoding, but parallel execution helps. // save directly thumb.save_with_format(&thumb_path, image::ImageFormat::Jpeg).ok()?; return Some(thumb_path.to_string_lossy().to_string()); } None } #[tauri::command] async fn scan_dir(path: String) -> Result, String> { let entries = fs::read_dir(&path).map_err(|e| e.to_string())?; // Collect valid paths first to avoid holding fs locks or iterators during parallel proc let mut valid_paths = Vec::new(); for entry in entries { if let Ok(entry) = entry { let p = entry.path(); if p.is_file() { if let Some(ext) = p.extension() { let ext_str = ext.to_string_lossy().to_lowercase(); if ["png", "jpg", "jpeg", "webp"].contains(&ext_str.as_str()) { valid_paths.push(p); } } } } } // Process in parallel let mut images: Vec = valid_paths.par_iter().filter_map(|path| { let name = path.file_name()?.to_string_lossy().to_string(); let path_str = path.to_string_lossy().to_string(); // Generate thumbnail let thumb = generate_thumbnail(path).unwrap_or_else(|| path_str.clone()); Some(ImageItem { path: path_str, name, thumbnail: thumb, }) }).collect(); // Sort by name images.sort_by(|a, b| a.name.cmp(&b.name)); Ok(images) } use image::GenericImageView; use image::Pixel; use rayon::prelude::*; use std::path::Path; use imageproc::drawing::draw_text_mut; use ab_glyph::{FontRef, PxScale}; // Embed the font to ensure it's always available without path issues const FONT_DATA: &[u8] = include_bytes!("../assets/fonts/Roboto-Regular.ttf"); #[derive(serde::Serialize)] struct ZcaResult { x: f64, y: f64, zone: String, } #[derive(serde::Deserialize)] struct ExportImageTask { path: String, } #[derive(serde::Deserialize)] struct WatermarkSettings { #[serde(rename = "type")] _w_type: String, // 'text' (image is deprecated for now per user request, but keeping struct flexible) text: String, // Was 'source' color: String, // Hex code e.g. "#FFFFFF" opacity: f64, scale: f64, // Font size relative to image height (e.g., 0.05 = 5% of height) manual_override: bool, manual_position: ManualPosition, } #[derive(serde::Deserialize)] struct ManualPosition { x: f64, y: f64, } fn parse_hex_color(hex: &str) -> image::Rgba { let hex = hex.trim_start_matches('#'); let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255); let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255); let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255); image::Rgba([r, g, b, 255]) } #[tauri::command] async fn export_batch(images: Vec, watermark: WatermarkSettings, output_dir: String) -> Result { let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?; let base_color = parse_hex_color(&watermark.color); // Calculate final color with opacity // imageproc draws with the exact color given. To support opacity, we need to handle it. // However, draw_text_mut blends. If we provide an Rgba with alpha < 255, it should blend. let alpha = (watermark.opacity * 255.0) as u8; let text_color = image::Rgba([base_color[0], base_color[1], base_color[2], alpha]); let results: Vec> = images.par_iter().map(|task| { let input_path = Path::new(&task.path); let img_result = image::open(input_path); if let Ok(dynamic_img) = img_result { let mut base_img = dynamic_img.to_rgba8(); let (width, height) = base_img.dimensions(); // 1. Calculate Font Scale // watermark.scale is percentage of image height. e.g. 0.05 let mut scale_px = height as f32 * watermark.scale as f32; // 2. Measure Text let scaled_font = PxScale::from(scale_px); let (t_width, t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text); // 3. Ensure it fits width (Padding 5%) let max_width = (width as f32 * 0.95) as u32; if t_width > max_width { let ratio = max_width as f32 / t_width as f32; scale_px *= ratio; // Re-measure isn't strictly necessary for center calc if we assume linear scaling, // but let's be safe for variable width fonts? Actually text_size scales linearly. } let final_scale = PxScale::from(scale_px); let (final_t_width, final_t_height) = imageproc::drawing::text_size(final_scale, &font, &watermark.text); // 4. Determine Position (Center based) let (mut pos_x_pct, mut pos_y_pct) = if watermark.manual_override { (watermark.manual_position.x, watermark.manual_position.y) } else { match calculate_zca_internal(&dynamic_img) { Ok(res) => (res.x, res.y), Err(_) => (0.5, 0.96), // Default fallback lowered } }; // Calculate top-left coordinate for draw_text_mut (it expects top-left, not center) let center_x = width as f64 * pos_x_pct; let center_y = height as f64 * pos_y_pct; let mut x = (center_x - (final_t_width as f64 / 2.0)) as i32; let mut y = (center_y - (final_t_height as f64 / 2.0)) as i32; // 5. Boundary Clamping (Ensure text stays inside image with padding) let padding = (height as f64 * 0.01).max(5.0) as i32; // 1% padding let max_x = (width as i32 - final_t_width as i32 - padding).max(padding); let max_y = (height as i32 - final_t_height as i32 - padding).max(padding); x = x.clamp(padding, max_x); y = y.clamp(padding, max_y); // 6. Draw draw_text_mut( &mut base_img, text_color, x, y, final_scale, &font, &watermark.text, ); // Save let file_name = input_path.file_name().unwrap_or_default(); let output_path = Path::new(&output_dir).join(file_name); base_img.save(output_path).map_err(|e| e.to_string())?; Ok(()) } else { Err(format!("Failed to open {}", task.path)) } }).collect(); let failures: Vec = results.into_iter().filter_map(|r| r.err()).collect(); if failures.is_empty() { Ok("All images processed successfully".to_string()) } else { Err(format!("Completed with errors: {:?}", failures)) } } // Helper to reuse logic (adapted from command) fn calculate_zca_internal(img: &image::DynamicImage) -> Result { let (width, height) = img.dimensions(); let bottom_start_y = (height as f64 * 0.8) as u32; let zone_height = height - bottom_start_y; let zone_width = width / 3; let zones = [ ("Left", 0, bottom_start_y), ("Center", zone_width, bottom_start_y), ("Right", zone_width * 2, bottom_start_y), ]; let mut min_std_dev = f64::MAX; let mut best_zone = "Center"; let mut best_pos = (0.5, 0.95); for (name, start_x, start_y) in zones.iter() { let mut luma_values = Vec::with_capacity((zone_width * zone_height) as usize); for y in *start_y..height { for x in *start_x..(*start_x + zone_width) { if x >= width { continue; } let pixel = img.get_pixel(x, y); let rgb = pixel.to_rgb(); let luma = 0.299 * rgb[0] as f64 + 0.587 * rgb[1] as f64 + 0.114 * rgb[2] as f64; luma_values.push(luma); } } let count = luma_values.len() as f64; if count == 0.0 { continue; } let mean = luma_values.iter().sum::() / count; let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::() / count; let std_dev = variance.sqrt(); if std_dev < min_std_dev { min_std_dev = std_dev; best_zone = name; let center_x_px = *start_x as f64 + (zone_width as f64 / 2.0); let center_y_px = *start_y as f64 + (zone_height as f64 * 0.75); best_pos = (center_x_px / width as f64, center_y_px / height as f64); } } Ok(ZcaResult { x: best_pos.0, y: best_pos.1, zone: best_zone.to_string(), }) } #[tauri::command] fn get_zca_suggestion(path: String) -> Result { let img = image::open(&path).map_err(|e| e.to_string())?; calculate_zca_internal(&img) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }