Files
watermark-wizard/src-tauri/src/lib.rs
Julian Freeman e1f2c8efc8 apply custom text
2026-01-19 08:41:45 -04:00

555 lines
19 KiB
Rust

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<String> {
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<Vec<ImageItem>, 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<ImageItem> = 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<ManualPosition>,
scale: Option<f64>,
opacity: Option<f64>,
color: Option<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)
// 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<u8> {
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<ExportImageTask>, watermark: WatermarkSettings, output_dir: String) -> Result<String, String> {
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<Result<(), String>> = 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<String> = 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<ZcaResult, String> {
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::<f64>() / count;
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / 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<ZcaResult, String> {
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<LayoutResult, String> {
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<Rect>,
}
#[derive(serde::Serialize, Clone)]
struct Rect {
x: f64,
y: f64,
width: f64,
height: f64,
}
#[tauri::command]
async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
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");
}