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 if let Ok(img) = image::open(original_path) { let thumb = img.thumbnail(u32::MAX, 200); let _file = fs::File::create(&thumb_path).ok()?; 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, manual_position: Option, scale: Option, opacity: Option, color: Option, } #[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) // Global manual override is deprecated in favor of per-task control, but kept for struct compatibility if needed _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))?; // Note: Settings are now resolved per-task 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(); // Determine effective settings (Task > Global) let eff_scale = task.scale.unwrap_or(watermark.scale); let eff_opacity = task.opacity.unwrap_or(watermark.opacity); let eff_color_hex = task.color.as_ref().unwrap_or(&watermark.color); // Calculate final color let base_color = parse_hex_color(eff_color_hex); let alpha = (eff_opacity * 255.0) as u8; let text_color = image::Rgba([base_color[0], base_color[1], base_color[2], alpha]); // 1. Calculate Font Scale based on Image Height let mut scale_px = height as f32 * eff_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 10%) let max_width = (width as f32 * 0.90) as u32; if t_width > max_width { let ratio = max_width as f32 / t_width as f32; scale_px *= ratio; } 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 (Task Specific > ZCA) // If task has manual_position, use it. Otherwise calculate ZCA. let (pos_x_pct, pos_y_pct) = if let Some(pos) = &task.manual_position { (pos.x, pos.y) } else { match calculate_zca_internal(&dynamic_img) { Ok(res) => (res.x, res.y), Err(_) => (0.5, 0.97), } }; // Calculate initial top-left based on 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. Strict Boundary Clamping // We ensure the text box (final_t_width, final_t_height) is always inside (0, 0, width, height) let min_padding = 2; // Absolute minimum pixels from edge if x < min_padding { x = min_padding; } if y < min_padding { y = min_padding; } if x + final_t_width as i32 > width as i32 - min_padding { x = width as i32 - final_t_width as i32 - min_padding; } if y + final_t_height as i32 > height as i32 - min_padding { y = height as i32 - final_t_height as i32 - min_padding; } // Re-clamp just in case of very small images where text is larger than image x = x.max(0); y = y.max(0); // 6. Draw Stroke (Simple 4-direction offset for black outline) // Stroke alpha should match text alpha let stroke_color = image::Rgba([0, 0, 0, text_color[3]]); for offset in [(-1, -1), (-1, 1), (1, -1), (1, 1)] { draw_text_mut( &mut base_img, stroke_color, x + offset.0, y + offset.1, final_scale, &font, &watermark.text, ); } // 7. Draw Main Text 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); // Handle format specific saving // JPEG does not support Alpha channel. If we save Rgba8 to Jpeg, it might fail or look wrong. let ext = output_path.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); if ext == "jpg" || ext == "jpeg" { // Convert to RGB8 (dropping alpha) // Note: This simply drops alpha. If background was transparent, it becomes black. // For photos (JPEGs) this is usually fine as they don't have alpha. let rgb_img = image::DynamicImage::ImageRgba8(base_img).to_rgb8(); rgb_img.save(&output_path).map_err(|e| e.to_string())?; } else { // For PNG/WebP etc, keep RGBA 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(); // Greedy Layered Search // Priority: Bottom -> Up let y_levels = [0.97, 0.94, 0.91, 0.88]; let x_cols = [1.0/6.0, 3.0/6.0, 5.0/6.0]; // Left, Center, Right centers let col_names = ["Left", "Center", "Right"]; // Box Size for analysis (approx watermark size) let box_w = (width as f64 * 0.30) as u32; let box_h = (height as f64 * 0.05) as u32; let half_box_w = box_w / 2; let half_box_h = box_h / 2; let mut global_best_score = f64::MAX; let mut global_best_result = ZcaResult { x: 0.5, y: 0.97, zone: "Center".to_string() }; for &y_pct in y_levels.iter() { let mut row_best_score = f64::MAX; let mut row_best_idx = 1; // Default Center let mut row_stats = Vec::new(); // (mean, std_dev) for (col_idx, &x_pct) in x_cols.iter().enumerate() { let cx = (width as f64 * x_pct) as u32; let cy = (height as f64 * y_pct) as u32; let start_x = if cx > half_box_w { cx - half_box_w } else { 0 }; let start_y = if cy > half_box_h { cy - half_box_h } else { 0 }; let end_x = (start_x + box_w).min(width); let end_y = (start_y + box_h).min(height); let mut luma_values = Vec::with_capacity((box_w * box_h) as usize); for y in start_y..end_y { for x in start_x..end_x { 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 { row_stats.push((0.0, f64::MAX)); 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(); row_stats.push((mean, std_dev)); // For choosing "Best in Row", we strictly prefer Flatness (StdDev) if std_dev < row_best_score { row_best_score = std_dev; row_best_idx = col_idx; } // Update Global Best (fallback) if std_dev < global_best_score { global_best_score = std_dev; global_best_result = ZcaResult { x: x_pct, y: y_pct, zone: col_names[col_idx].to_string(), }; } } // Analyze the Best Zone in this Row let (mean, std_dev) = row_stats[row_best_idx]; // Safety Check: Is this zone "White Text"? // Condition: Mean > 180 (Bright-ish) AND StdDev > 20 (Busy/Text) let is_unsafe_white_text = mean > 180.0 && std_dev > 20.0; let is_unsafe_bright = mean > 230.0; if !is_unsafe_white_text && !is_unsafe_bright { // Safe! return Ok(ZcaResult { x: x_cols[row_best_idx], y: y_pct, zone: col_names[row_best_idx].to_string(), }); } } Ok(global_best_result) } #[tauri::command] fn get_zca_suggestion(path: String) -> Result { let img = image::open(&path).map_err(|e| e.to_string())?; calculate_zca_internal(&img) } #[derive(serde::Serialize)] struct LayoutResult { x: f64, y: f64, scale: f64, } #[tauri::command] async fn layout_watermark(path: String, text: String, base_scale: f64) -> Result { let img = image::open(&path).map_err(|e| e.to_string())?; let (width, height) = img.dimensions(); let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?; // 1. Run ZCA to find best zone center (now with dark preference) let zca = calculate_zca_internal(&img)?; // 2. Calculate Text Dimensions at Base Scale let mut scale_val = base_scale; let mut scale_px = height as f32 * scale_val as f32; let mut font_scale = PxScale::from(scale_px); let (mut t_width, mut t_height) = imageproc::drawing::text_size(font_scale, &font, &text); // 3. Auto-Fit Width (Limit to 90% of image width) let max_width = (width as f32 * 0.90) as u32; if t_width > max_width { let ratio = max_width as f32 / t_width as f32; scale_val *= ratio as f64; scale_px *= ratio; font_scale = PxScale::from(scale_px); let dims = imageproc::drawing::text_size(font_scale, &font, &text); t_width = dims.0; t_height = dims.1; } // 4. Smart Clamping let center_x = zca.x * width as f64; let center_y = zca.y * height as f64; let half_w = t_width as f64 / 2.0; let half_h = t_height as f64 / 2.0; let padding = width as f64 * 0.02; let min_x = half_w + padding; let max_x = width as f64 - half_w - padding; let final_x = center_x.clamp(min_x, max_x); let min_y = half_h + padding; let max_y = height as f64 - half_h - padding; let final_y = center_y.clamp(min_y, max_y); Ok(LayoutResult { x: final_x / width as f64, y: final_y / height as f64, scale: scale_val, }) } #[derive(serde::Serialize)] struct DetectionResult { rects: Vec, } #[derive(serde::Serialize, Clone)] struct Rect { x: f64, y: f64, width: f64, height: f64, } #[tauri::command] async fn detect_watermark(path: String) -> Result { let img = image::open(&path).map_err(|e| e.to_string())?; let (width, height) = img.dimensions(); let gray = img.to_luma8(); // Heuristic: // 1. Scan Top 20% and Bottom 20% // 2. Look for high brightness pixels (> 230) // 3. Grid based clustering let cell_size = 10; let grid_w = (width + cell_size - 1) / cell_size; let grid_h = (height + cell_size - 1) / cell_size; let mut grid = vec![false; (grid_w * grid_h) as usize]; let top_limit = (height as f64 * 0.2) as u32; let bottom_start = (height as f64 * 0.8) as u32; for y in 0..height { // Skip middle section if y > top_limit && y < bottom_start { continue; } for x in 0..width { let p = gray.get_pixel(x, y); if p[0] > 230 { // High brightness threshold // Check local contrast/edges? // For now, simple brightness is a good proxy for "white text" // Mark grid cell let gx = x / cell_size; let gy = y / cell_size; grid[(gy * grid_w + gx) as usize] = true; } } } // Connected Components on Grid (Simple merging) let mut rects = Vec::new(); let mut visited = vec![false; grid.len()]; for gy in 0..grid_h { for gx in 0..grid_w { let idx = (gy * grid_w + gx) as usize; if grid[idx] && !visited[idx] { // Start a new component // Simple Flood Fill or just greedy expansion // Let's do a simple greedy expansion for rectangles let mut min_gx = gx; let mut max_gx = gx; let mut min_gy = gy; let mut max_gy = gy; let mut stack = vec![(gx, gy)]; visited[idx] = true; while let Some((cx, cy)) = stack.pop() { if cx < min_gx { min_gx = cx; } if cx > max_gx { max_gx = cx; } if cy < min_gy { min_gy = cy; } if cy > max_gy { max_gy = cy; } // Neighbors let neighbors = [ (cx.wrapping_sub(1), cy), (cx + 1, cy), (cx, cy.wrapping_sub(1)), (cx, cy + 1) ]; for (nx, ny) in neighbors { if nx < grid_w && ny < grid_h { let nidx = (ny * grid_w + nx) as usize; if grid[nidx] && !visited[nidx] { visited[nidx] = true; stack.push((nx, ny)); } } } } // Convert grid rect to normalized image rect // Add padding (1 cell) let px = (min_gx * cell_size) as f64; let py = (min_gy * cell_size) as f64; let pw = ((max_gx - min_gx + 1) * cell_size) as f64; let ph = ((max_gy - min_gy + 1) * cell_size) as f64; rects.push(Rect { x: px / width as f64, y: py / height as f64, width: pw / width as f64, height: ph / height as f64, }); } } } Ok(DetectionResult { rects }) } #[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, detect_watermark, layout_watermark]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }