Fix 26 bugs, edge cases, and consistency issues from fifth audit pass
Critical: undo toast now trashes only batch output files (not entire dir), JPEG scanline write errors propagated, selective metadata write result returned. High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio rejection, FM integration toggle infinite recursion guard, saturating counter arithmetic in executor. Medium: PNG compression level passed to oxipng, pct mode updates job_config, external file loading updates step indicator, CLI undo removes history entries, watch config write failures reported, fast-copy path reads image dimensions for rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl), CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot fix, generation guards on all preview threads to cancel stale results, default DPI aligned to 0, watermark text width uses char count not byte length. Low: binary path escaped in Nautilus extension, file dialog filter aligned with discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
@@ -3,8 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const IMAGE_EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", "heic", "heif", "jxl",
|
||||
"svg", "ico",
|
||||
"jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp",
|
||||
];
|
||||
|
||||
fn is_image_extension(ext: &str) -> bool {
|
||||
|
||||
@@ -39,7 +39,7 @@ impl OutputEncoder {
|
||||
) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)),
|
||||
ImageFormat::Png => self.encode_png(img),
|
||||
ImageFormat::Png => self.encode_png(img, quality.unwrap_or(3)),
|
||||
ImageFormat::WebP => self.encode_webp(img, quality.unwrap_or(80)),
|
||||
ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)),
|
||||
ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif),
|
||||
@@ -66,7 +66,7 @@ impl OutputEncoder {
|
||||
match format {
|
||||
ImageFormat::Jpeg => preset.jpeg_quality(),
|
||||
ImageFormat::WebP => preset.webp_quality() as u8,
|
||||
ImageFormat::Avif => preset.webp_quality() as u8,
|
||||
ImageFormat::Avif => preset.avif_quality() as u8,
|
||||
ImageFormat::Png => preset.png_level(),
|
||||
_ => preset.jpeg_quality(),
|
||||
}
|
||||
@@ -101,7 +101,10 @@ impl OutputEncoder {
|
||||
for y in 0..height {
|
||||
let start = y * row_stride;
|
||||
let end = start + row_stride;
|
||||
let _ = started.write_scanlines(&pixels[start..end]);
|
||||
started.write_scanlines(&pixels[start..end]).map_err(|e| PixstripError::Processing {
|
||||
operation: "jpeg_scanline".into(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
|
||||
started.finish().map_err(|e| PixstripError::Processing {
|
||||
@@ -112,7 +115,7 @@ impl OutputEncoder {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_png(&self, img: &image::DynamicImage) -> Result<Vec<u8>> {
|
||||
fn encode_png(&self, img: &image::DynamicImage, level: u8) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let cursor = Cursor::new(&mut buf);
|
||||
let rgba = img.to_rgba8();
|
||||
@@ -129,12 +132,16 @@ impl OutputEncoder {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Insert pHYs chunk for DPI if requested
|
||||
// Insert pHYs chunk for DPI if requested (before oxipng, which preserves it)
|
||||
if self.options.output_dpi > 0 {
|
||||
buf = insert_png_phys_chunk(&buf, self.options.output_dpi);
|
||||
}
|
||||
|
||||
let optimized = oxipng::optimize_from_memory(&buf, &oxipng::Options::default())
|
||||
let mut opts = oxipng::Options::default();
|
||||
opts.optimize_alpha = true;
|
||||
opts.deflater = oxipng::Deflater::Libdeflater { compression: level.clamp(1, 12) };
|
||||
|
||||
let optimized = oxipng::optimize_from_memory(&buf, &opts)
|
||||
.map_err(|e| PixstripError::Processing {
|
||||
operation: "png_optimize".into(),
|
||||
reason: e.to_string(),
|
||||
@@ -156,7 +163,7 @@ impl OutputEncoder {
|
||||
let mut buf = Vec::new();
|
||||
let cursor = Cursor::new(&mut buf);
|
||||
let rgba = img.to_rgba8();
|
||||
let speed = self.options.avif_speed.clamp(1, 10);
|
||||
let speed = self.options.avif_speed.clamp(0, 10);
|
||||
let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
|
||||
cursor,
|
||||
speed,
|
||||
@@ -226,6 +233,7 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
|
||||
// PNG structure: 8-byte signature, then chunks (each: 4 len + 4 type + data + 4 crc)
|
||||
let mut result = Vec::with_capacity(png_data.len() + phys_chunk.len());
|
||||
let mut pos = 8; // skip PNG signature
|
||||
let mut phys_inserted = false;
|
||||
result.extend_from_slice(&png_data[..8]);
|
||||
|
||||
while pos + 8 <= png_data.len() {
|
||||
@@ -235,16 +243,20 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
|
||||
let chunk_type = &png_data[pos + 4..pos + 8];
|
||||
let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc
|
||||
|
||||
if chunk_type == b"IDAT" || chunk_type == b"pHYs" {
|
||||
if chunk_type == b"IDAT" {
|
||||
// Insert pHYs before first IDAT
|
||||
result.extend_from_slice(&phys_chunk);
|
||||
}
|
||||
// If existing pHYs, skip it (we're replacing it)
|
||||
if chunk_type == b"pHYs" {
|
||||
pos += total_chunk_size;
|
||||
continue;
|
||||
}
|
||||
if pos + total_chunk_size > png_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip any existing pHYs (we're replacing it)
|
||||
if chunk_type == b"pHYs" {
|
||||
pos += total_chunk_size;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert our pHYs before the first IDAT
|
||||
if chunk_type == b"IDAT" && !phys_inserted {
|
||||
result.extend_from_slice(&phys_chunk);
|
||||
phys_inserted = true;
|
||||
}
|
||||
|
||||
result.extend_from_slice(&png_data[pos..pos + total_chunk_size]);
|
||||
|
||||
@@ -321,6 +321,79 @@ impl PipelineExecutor {
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Fast path: if no pixel processing needed (rename-only or rename+metadata),
|
||||
// just copy the file instead of decoding/re-encoding.
|
||||
if !job.needs_pixel_processing() {
|
||||
let output_path = if let Some(ref rename) = job.rename {
|
||||
let stem = source.path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
|
||||
let ext = source.path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||
if let Some(ref template) = rename.template {
|
||||
// Read dimensions without full decode for {width}/{height} templates
|
||||
let dims = image::ImageReader::open(&source.path)
|
||||
.ok()
|
||||
.and_then(|r| r.with_guessed_format().ok())
|
||||
.and_then(|r| r.into_dimensions().ok());
|
||||
let new_name = crate::operations::rename::apply_template_full(
|
||||
template, stem, ext,
|
||||
rename.counter_start.saturating_add(index as u32),
|
||||
dims, Some(ext), Some(&source.path), None,
|
||||
);
|
||||
let new_name = if rename.case_mode > 0 {
|
||||
if let Some(dot_pos) = new_name.rfind('.') {
|
||||
let (name_part, ext_part) = new_name.split_at(dot_pos);
|
||||
format!("{}{}", crate::operations::rename::apply_case_conversion(name_part, rename.case_mode), ext_part)
|
||||
} else {
|
||||
crate::operations::rename::apply_case_conversion(&new_name, rename.case_mode)
|
||||
}
|
||||
} else {
|
||||
new_name
|
||||
};
|
||||
job.output_dir.join(new_name)
|
||||
} else {
|
||||
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
|
||||
job.output_dir.join(new_name)
|
||||
}
|
||||
} else {
|
||||
job.output_path_for(source, None)
|
||||
};
|
||||
|
||||
let output_path = match job.overwrite_behavior {
|
||||
crate::operations::OverwriteAction::Skip if output_path.exists() => {
|
||||
return Ok((input_size, 0));
|
||||
}
|
||||
crate::operations::OverwriteAction::AutoRename if output_path.exists() => {
|
||||
find_unique_path(&output_path)
|
||||
}
|
||||
_ => output_path,
|
||||
};
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||
}
|
||||
|
||||
std::fs::copy(&source.path, &output_path).map_err(PixstripError::Io)?;
|
||||
|
||||
// Metadata handling on the copy
|
||||
if let Some(ref meta_config) = job.metadata {
|
||||
match meta_config {
|
||||
crate::operations::MetadataConfig::KeepAll => {
|
||||
// Already a copy - metadata preserved
|
||||
}
|
||||
crate::operations::MetadataConfig::StripAll => {
|
||||
if !strip_all_metadata(&output_path) {
|
||||
eprintln!("Warning: failed to strip metadata from {}", output_path.display());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
strip_selective_metadata(&output_path, meta_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output_size = std::fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0);
|
||||
return Ok((input_size, output_size));
|
||||
}
|
||||
|
||||
// Load image
|
||||
let mut img = loader.load_pixels(&source.path)?;
|
||||
|
||||
@@ -404,7 +477,7 @@ impl PipelineExecutor {
|
||||
template,
|
||||
&working_stem,
|
||||
ext,
|
||||
rename.counter_start + index as u32,
|
||||
rename.counter_start.saturating_add(index as u32),
|
||||
dims,
|
||||
original_ext,
|
||||
Some(&source.path),
|
||||
@@ -423,7 +496,7 @@ impl PipelineExecutor {
|
||||
};
|
||||
job.output_dir.join(new_name)
|
||||
} else {
|
||||
let new_name = rename.apply_simple(stem, ext, index as u32 + 1);
|
||||
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
|
||||
job.output_dir.join(new_name)
|
||||
}
|
||||
} else {
|
||||
@@ -432,21 +505,21 @@ impl PipelineExecutor {
|
||||
|
||||
// Handle overwrite behavior
|
||||
let output_path = match job.overwrite_behavior {
|
||||
crate::operations::OverwriteBehavior::Skip => {
|
||||
crate::operations::OverwriteAction::Skip => {
|
||||
if output_path.exists() {
|
||||
// Return 0 bytes written - file was skipped
|
||||
return Ok((input_size, 0));
|
||||
}
|
||||
output_path
|
||||
}
|
||||
crate::operations::OverwriteBehavior::AutoRename => {
|
||||
crate::operations::OverwriteAction::AutoRename => {
|
||||
if output_path.exists() {
|
||||
find_unique_path(&output_path)
|
||||
} else {
|
||||
output_path
|
||||
}
|
||||
}
|
||||
crate::operations::OverwriteBehavior::Overwrite => output_path,
|
||||
crate::operations::OverwriteAction::Overwrite => output_path,
|
||||
};
|
||||
|
||||
// Ensure output directory exists
|
||||
@@ -469,14 +542,18 @@ impl PipelineExecutor {
|
||||
if let Some(ref meta_config) = job.metadata {
|
||||
match meta_config {
|
||||
crate::operations::MetadataConfig::KeepAll => {
|
||||
copy_metadata_from_source(&source.path, &output_path);
|
||||
if !copy_metadata_from_source(&source.path, &output_path) {
|
||||
eprintln!("Warning: failed to copy metadata to {}", output_path.display());
|
||||
}
|
||||
}
|
||||
crate::operations::MetadataConfig::StripAll => {
|
||||
// Already stripped by re-encoding - nothing to do
|
||||
}
|
||||
_ => {
|
||||
// Privacy or Custom: copy all metadata back, then strip unwanted tags
|
||||
copy_metadata_from_source(&source.path, &output_path);
|
||||
if !copy_metadata_from_source(&source.path, &output_path) {
|
||||
eprintln!("Warning: failed to copy metadata to {}", output_path.display());
|
||||
}
|
||||
strip_selective_metadata(&output_path, meta_config);
|
||||
}
|
||||
}
|
||||
@@ -537,9 +614,9 @@ fn auto_orient_from_exif(
|
||||
2 => img.fliph(), // Flipped horizontal
|
||||
3 => img.rotate180(), // Rotated 180
|
||||
4 => img.flipv(), // Flipped vertical
|
||||
5 => img.fliph().rotate270(), // Transposed
|
||||
5 => img.rotate90().fliph(), // Transposed
|
||||
6 => img.rotate90(), // Rotated 90 CW
|
||||
7 => img.fliph().rotate90(), // Transverse
|
||||
7 => img.rotate270().fliph(), // Transverse
|
||||
8 => img.rotate270(), // Rotated 270 CW
|
||||
_ => img,
|
||||
}
|
||||
@@ -569,25 +646,28 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
.unwrap_or(0), ext))
|
||||
}
|
||||
|
||||
fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) {
|
||||
// Best-effort: try to copy EXIF from source to output using little_exif.
|
||||
// If it fails (e.g. non-JPEG, no EXIF), silently continue.
|
||||
fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) -> bool {
|
||||
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output);
|
||||
metadata.write_to_file(output).is_ok()
|
||||
}
|
||||
|
||||
fn strip_all_metadata(path: &std::path::Path) -> bool {
|
||||
let empty = little_exif::metadata::Metadata::new();
|
||||
empty.write_to_file(path).is_ok()
|
||||
}
|
||||
|
||||
fn strip_selective_metadata(
|
||||
path: &std::path::Path,
|
||||
config: &crate::operations::MetadataConfig,
|
||||
) {
|
||||
) -> bool {
|
||||
use little_exif::exif_tag::ExifTag;
|
||||
use little_exif::metadata::Metadata;
|
||||
|
||||
// Read the metadata we just wrote back
|
||||
let Ok(source_meta) = Metadata::new_from_path(path) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Build a set of tag IDs to strip
|
||||
@@ -639,5 +719,5 @@ fn strip_selective_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
let _: std::result::Result<(), std::io::Error> = new_meta.write_to_file(path);
|
||||
new_meta.write_to_file(path).is_ok()
|
||||
}
|
||||
|
||||
@@ -76,6 +76,14 @@ pub fn regenerate_all() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanitize a string for safe use in shell Exec= lines and XML command elements.
|
||||
/// Removes or replaces characters that could cause shell injection.
|
||||
fn shell_safe(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '(' | ')' | ','))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pixstrip_bin() -> String {
|
||||
// Try to find the pixstrip binary path
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
@@ -116,7 +124,7 @@ fn get_preset_names() -> Vec<String> {
|
||||
fn nautilus_extension_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.local/share", home)
|
||||
});
|
||||
PathBuf::from(data).join("nautilus-python").join("extensions")
|
||||
@@ -143,11 +151,12 @@ fn install_nautilus() -> Result<()> {
|
||||
\x20 item.connect('activate', self._on_preset, '{}', files)\n\
|
||||
\x20 submenu.append_item(item)\n\n",
|
||||
name.replace(' ', "_"),
|
||||
name,
|
||||
name,
|
||||
name.replace('\'', "\\'"),
|
||||
name.replace('\'', "\\'"),
|
||||
));
|
||||
}
|
||||
|
||||
let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let script = format!(
|
||||
r#"import subprocess
|
||||
from gi.repository import Nautilus, GObject
|
||||
@@ -202,7 +211,7 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider):
|
||||
subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths)
|
||||
"#,
|
||||
preset_items = preset_items,
|
||||
bin = bin,
|
||||
bin = escaped_bin,
|
||||
);
|
||||
|
||||
std::fs::write(nautilus_extension_path(), script)?;
|
||||
@@ -222,7 +231,7 @@ fn uninstall_nautilus() -> Result<()> {
|
||||
fn nemo_action_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.local/share", home)
|
||||
});
|
||||
PathBuf::from(data).join("nemo").join("actions")
|
||||
@@ -261,12 +270,13 @@ fn install_nemo() -> Result<()> {
|
||||
"[Nemo Action]\n\
|
||||
Name=Pixstrip: {name}\n\
|
||||
Comment=Process with {name} preset\n\
|
||||
Exec={bin} --preset \"{name}\" --files %F\n\
|
||||
Exec={bin} --preset \"{safe_label}\" --files %F\n\
|
||||
Icon-Name=applications-graphics-symbolic\n\
|
||||
Selection=Any\n\
|
||||
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
||||
Mimetypes=image/*;\n",
|
||||
name = name,
|
||||
safe_label = shell_safe(name),
|
||||
bin = bin,
|
||||
);
|
||||
std::fs::write(action_path, action)?;
|
||||
@@ -300,7 +310,7 @@ fn uninstall_nemo() -> Result<()> {
|
||||
fn thunar_action_dir() -> PathBuf {
|
||||
let config = std::env::var("XDG_CONFIG_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.config", home)
|
||||
});
|
||||
PathBuf::from(config).join("Thunar")
|
||||
@@ -337,14 +347,15 @@ fn install_thunar() -> Result<()> {
|
||||
actions.push_str(&format!(
|
||||
" <action>\n\
|
||||
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
||||
\x20 <name>Pixstrip: {name}</name>\n\
|
||||
\x20 <command>{bin} --preset \"{name}\" --files %F</command>\n\
|
||||
\x20 <description>Process with {name} preset</description>\n\
|
||||
\x20 <name>Pixstrip: {xml_name}</name>\n\
|
||||
\x20 <command>{bin} --preset \"{safe_label}\" --files %F</command>\n\
|
||||
\x20 <description>Process with {xml_name} preset</description>\n\
|
||||
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
||||
\x20 <image-files/>\n\
|
||||
\x20 <directories/>\n\
|
||||
</action>\n",
|
||||
name = name,
|
||||
xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """),
|
||||
safe_label = shell_safe(name),
|
||||
bin = bin,
|
||||
));
|
||||
}
|
||||
@@ -367,7 +378,7 @@ fn uninstall_thunar() -> Result<()> {
|
||||
fn dolphin_service_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.local/share", home)
|
||||
});
|
||||
PathBuf::from(data).join("kio").join("servicemenus")
|
||||
@@ -410,9 +421,10 @@ fn install_dolphin() -> Result<()> {
|
||||
"[Desktop Action Preset{i}]\n\
|
||||
Name={name}\n\
|
||||
Icon=applications-graphics-symbolic\n\
|
||||
Exec={bin} --preset \"{name}\" --files %F\n\n",
|
||||
Exec={bin} --preset \"{safe_label}\" --files %F\n\n",
|
||||
i = i,
|
||||
name = name,
|
||||
safe_label = shell_safe(name),
|
||||
bin = bin,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -57,21 +57,27 @@ pub fn apply_adjustments(
|
||||
|
||||
fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage {
|
||||
let (iw, ih) = (img.width(), img.height());
|
||||
if !w_ratio.is_finite() || !h_ratio.is_finite()
|
||||
|| w_ratio <= 0.0 || h_ratio <= 0.0
|
||||
|| iw == 0 || ih == 0
|
||||
{
|
||||
return img;
|
||||
}
|
||||
let target_ratio = w_ratio / h_ratio;
|
||||
let current_ratio = iw as f64 / ih as f64;
|
||||
|
||||
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||
// Image is wider than target, crop width
|
||||
let new_w = (ih as f64 * target_ratio) as u32;
|
||||
(new_w, ih)
|
||||
(new_w.min(iw), ih)
|
||||
} else {
|
||||
// Image is taller than target, crop height
|
||||
let new_h = (iw as f64 / target_ratio) as u32;
|
||||
(iw, new_h)
|
||||
(iw, new_h.min(ih))
|
||||
};
|
||||
|
||||
let x = (iw - crop_w) / 2;
|
||||
let y = (ih - crop_h) / 2;
|
||||
let x = iw.saturating_sub(crop_w) / 2;
|
||||
let y = ih.saturating_sub(crop_h) / 2;
|
||||
|
||||
img.crop_imm(x, y, crop_w, crop_h)
|
||||
}
|
||||
@@ -140,8 +146,8 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage {
|
||||
}
|
||||
}
|
||||
|
||||
let crop_w = right.saturating_sub(left) + 1;
|
||||
let crop_h = bottom.saturating_sub(top) + 1;
|
||||
let crop_w = right.saturating_sub(left).saturating_add(1);
|
||||
let crop_h = bottom.saturating_sub(top).saturating_add(1);
|
||||
|
||||
if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) {
|
||||
return img;
|
||||
@@ -151,6 +157,7 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage {
|
||||
}
|
||||
|
||||
fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage {
|
||||
let amount = amount.clamp(-100, 100);
|
||||
let mut rgba = img.into_rgba8();
|
||||
let factor = 1.0 + (amount as f64 / 100.0);
|
||||
|
||||
@@ -187,8 +194,8 @@ fn apply_sepia(img: DynamicImage) -> DynamicImage {
|
||||
|
||||
fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let new_w = w + padding * 2;
|
||||
let new_h = h + padding * 2;
|
||||
let new_w = w.saturating_add(padding.saturating_mul(2));
|
||||
let new_h = h.saturating_add(padding.saturating_mul(2));
|
||||
|
||||
let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255]));
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ pub fn strip_metadata(
|
||||
|
||||
fn strip_all_exif(input: &Path, output: &Path) -> Result<()> {
|
||||
let data = std::fs::read(input).map_err(PixstripError::Io)?;
|
||||
let cleaned = remove_exif_from_jpeg(&data);
|
||||
let cleaned = if data.len() >= 8 && &data[..8] == b"\x89PNG\r\n\x1a\n" {
|
||||
remove_metadata_from_png(&data)
|
||||
} else {
|
||||
remove_exif_from_jpeg(&data)
|
||||
};
|
||||
std::fs::write(output, cleaned).map_err(PixstripError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -60,7 +64,12 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
|
||||
// APP1 (0xE1) contains EXIF - skip it
|
||||
if marker == 0xE1 && i + 3 < data.len() {
|
||||
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||
i += 2 + len;
|
||||
let skip = 2 + len;
|
||||
if i + skip <= data.len() {
|
||||
i += skip;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -89,3 +98,41 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn remove_metadata_from_png(data: &[u8]) -> Vec<u8> {
|
||||
// PNG: 8-byte signature + chunks (4 len + 4 type + data + 4 CRC)
|
||||
// Keep only rendering-essential chunks, strip textual/EXIF metadata.
|
||||
const KEEP_CHUNKS: &[&[u8; 4]] = &[
|
||||
b"IHDR", b"PLTE", b"IDAT", b"IEND",
|
||||
b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"iCCP", b"sBIT",
|
||||
b"pHYs", b"bKGD", b"hIST", b"sPLT",
|
||||
b"acTL", b"fcTL", b"fdAT",
|
||||
];
|
||||
|
||||
if data.len() < 8 {
|
||||
return data.to_vec();
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(data.len());
|
||||
result.extend_from_slice(&data[..8]); // PNG signature
|
||||
|
||||
let mut pos = 8;
|
||||
while pos + 12 <= data.len() {
|
||||
let chunk_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
|
||||
let chunk_type = &data[pos + 4..pos + 8];
|
||||
let total = 12 + chunk_len; // 4 len + 4 type + data + 4 CRC
|
||||
|
||||
if pos + total > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let keep = KEEP_CHUNKS.iter().any(|k| *k == chunk_type);
|
||||
if keep {
|
||||
result.extend_from_slice(&data[pos..pos + total]);
|
||||
}
|
||||
|
||||
pos += total;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -23,25 +23,43 @@ pub enum ResizeConfig {
|
||||
|
||||
impl ResizeConfig {
|
||||
pub fn target_for(&self, original: Dimensions) -> Dimensions {
|
||||
match self {
|
||||
if original.width == 0 || original.height == 0 {
|
||||
return original;
|
||||
}
|
||||
let result = match self {
|
||||
Self::ByWidth(w) => {
|
||||
if *w == 0 {
|
||||
return original;
|
||||
}
|
||||
let scale = *w as f64 / original.width as f64;
|
||||
Dimensions {
|
||||
width: *w,
|
||||
height: (original.height as f64 * scale).round() as u32,
|
||||
height: (original.height as f64 * scale).round().max(1.0) as u32,
|
||||
}
|
||||
}
|
||||
Self::ByHeight(h) => {
|
||||
if *h == 0 {
|
||||
return original;
|
||||
}
|
||||
let scale = *h as f64 / original.height as f64;
|
||||
Dimensions {
|
||||
width: (original.width as f64 * scale).round() as u32,
|
||||
width: (original.width as f64 * scale).round().max(1.0) as u32,
|
||||
height: *h,
|
||||
}
|
||||
}
|
||||
Self::FitInBox { max, allow_upscale } => {
|
||||
original.fit_within(*max, *allow_upscale)
|
||||
}
|
||||
Self::Exact(dims) => *dims,
|
||||
Self::Exact(dims) => {
|
||||
if dims.width == 0 || dims.height == 0 {
|
||||
return original;
|
||||
}
|
||||
*dims
|
||||
}
|
||||
};
|
||||
Dimensions {
|
||||
width: result.width.max(1),
|
||||
height: result.height.max(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +242,7 @@ pub enum WatermarkRotation {
|
||||
Degrees45,
|
||||
DegreesNeg45,
|
||||
Degrees90,
|
||||
Custom(f32),
|
||||
}
|
||||
|
||||
// --- Adjustments ---
|
||||
@@ -255,16 +274,16 @@ impl AdjustmentsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Overwrite Behavior ---
|
||||
// --- Overwrite Action (concrete action, no "Ask" variant) ---
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum OverwriteBehavior {
|
||||
pub enum OverwriteAction {
|
||||
AutoRename,
|
||||
Overwrite,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Default for OverwriteBehavior {
|
||||
impl Default for OverwriteAction {
|
||||
fn default() -> Self {
|
||||
Self::AutoRename
|
||||
}
|
||||
@@ -278,39 +297,85 @@ pub struct RenameConfig {
|
||||
pub suffix: String,
|
||||
pub counter_start: u32,
|
||||
pub counter_padding: u32,
|
||||
#[serde(default)]
|
||||
pub counter_enabled: bool,
|
||||
/// 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
||||
#[serde(default = "default_counter_position")]
|
||||
pub counter_position: u32,
|
||||
pub template: Option<String>,
|
||||
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||
pub case_mode: u32,
|
||||
/// 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
||||
#[serde(default)]
|
||||
pub replace_spaces: u32,
|
||||
/// 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
||||
#[serde(default)]
|
||||
pub special_chars: u32,
|
||||
pub regex_find: String,
|
||||
pub regex_replace: String,
|
||||
}
|
||||
|
||||
fn default_counter_position() -> u32 { 3 }
|
||||
|
||||
impl RenameConfig {
|
||||
pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String {
|
||||
let counter = self.counter_start + index - 1;
|
||||
let counter_str = format!(
|
||||
"{:0>width$}",
|
||||
counter,
|
||||
width = self.counter_padding as usize
|
||||
);
|
||||
|
||||
// Apply regex find-and-replace on the original name
|
||||
// 1. Apply regex find-and-replace on the original name
|
||||
let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
|
||||
|
||||
let mut name = String::new();
|
||||
if !self.prefix.is_empty() {
|
||||
name.push_str(&self.prefix);
|
||||
}
|
||||
name.push_str(&working_name);
|
||||
if !self.suffix.is_empty() {
|
||||
name.push_str(&self.suffix);
|
||||
}
|
||||
name.push('_');
|
||||
name.push_str(&counter_str);
|
||||
// 2. Apply space replacement
|
||||
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
|
||||
|
||||
// Apply case conversion
|
||||
let name = rename::apply_case_conversion(&name, self.case_mode);
|
||||
// 3. Apply special character filtering
|
||||
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
|
||||
|
||||
format!("{}.{}", name, extension)
|
||||
// 4. Build counter string
|
||||
let counter_str = if self.counter_enabled {
|
||||
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
|
||||
let padding = (self.counter_padding as usize).min(10);
|
||||
format!("{:0>width$}", counter, width = padding)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let has_counter = self.counter_enabled && !counter_str.is_empty();
|
||||
|
||||
// 5. Assemble parts based on counter position
|
||||
// Positions: 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
||||
let mut result = String::new();
|
||||
|
||||
if has_counter && self.counter_position == 0 {
|
||||
result.push_str(&counter_str);
|
||||
result.push('_');
|
||||
}
|
||||
|
||||
result.push_str(&self.prefix);
|
||||
|
||||
if has_counter && self.counter_position == 1 {
|
||||
result.push_str(&counter_str);
|
||||
result.push('_');
|
||||
}
|
||||
|
||||
if has_counter && self.counter_position == 4 {
|
||||
result.push_str(&counter_str);
|
||||
} else {
|
||||
result.push_str(&working_name);
|
||||
}
|
||||
|
||||
if has_counter && self.counter_position == 2 {
|
||||
result.push('_');
|
||||
result.push_str(&counter_str);
|
||||
}
|
||||
|
||||
result.push_str(&self.suffix);
|
||||
|
||||
if has_counter && self.counter_position == 3 {
|
||||
result.push('_');
|
||||
result.push_str(&counter_str);
|
||||
}
|
||||
|
||||
// 6. Apply case conversion
|
||||
let result = rename::apply_case_conversion(&result, self.case_mode);
|
||||
|
||||
format!("{}.{}", result, extension)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ fn days_to_ymd(total_days: u64) -> (u64, u64, u64) {
|
||||
let mut days = total_days;
|
||||
let mut year = 1970u64;
|
||||
loop {
|
||||
if year > 9999 {
|
||||
break;
|
||||
}
|
||||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||
if days < days_in_year {
|
||||
break;
|
||||
@@ -149,6 +152,46 @@ fn is_leap(year: u64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
/// Apply space replacement on a filename
|
||||
/// mode: 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
||||
pub fn apply_space_replacement(name: &str, mode: u32) -> String {
|
||||
match mode {
|
||||
1 => name.replace(' ', "_"),
|
||||
2 => name.replace(' ', "-"),
|
||||
3 => name.replace(' ', "."),
|
||||
4 => {
|
||||
let mut result = String::with_capacity(name.len());
|
||||
let mut capitalize_next = false;
|
||||
for ch in name.chars() {
|
||||
if ch == ' ' {
|
||||
capitalize_next = true;
|
||||
} else if capitalize_next {
|
||||
result.extend(ch.to_uppercase());
|
||||
capitalize_next = false;
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
5 => name.replace(' ', ""),
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply special character filtering on a filename
|
||||
/// mode: 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
||||
pub fn apply_special_chars(name: &str, mode: u32) -> String {
|
||||
match mode {
|
||||
1 => name.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(),
|
||||
2 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(),
|
||||
3 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(),
|
||||
4 => name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect(),
|
||||
5 => name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(),
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply case conversion to a filename (without extension)
|
||||
/// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||
pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
|
||||
@@ -156,20 +199,25 @@ pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
|
||||
1 => name.to_lowercase(),
|
||||
2 => name.to_uppercase(),
|
||||
3 => {
|
||||
// Title case: capitalize first letter of each word (split on _ - space)
|
||||
name.split(|c: char| c == '_' || c == '-' || c == ' ')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
upper + &chars.as_str().to_lowercase()
|
||||
}
|
||||
None => String::new(),
|
||||
// Title case: capitalize first letter of each word, preserve original separators
|
||||
let mut result = String::with_capacity(name.len());
|
||||
let mut capitalize_next = true;
|
||||
for c in name.chars() {
|
||||
if c == '_' || c == '-' || c == ' ' {
|
||||
result.push(c);
|
||||
capitalize_next = true;
|
||||
} else if capitalize_next {
|
||||
for uc in c.to_uppercase() {
|
||||
result.push(uc);
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("_")
|
||||
capitalize_next = false;
|
||||
} else {
|
||||
for lc in c.to_lowercase() {
|
||||
result.push(lc);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
@@ -180,7 +228,10 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
|
||||
if find.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
match regex::Regex::new(find) {
|
||||
match regex::RegexBuilder::new(find)
|
||||
.size_limit(1 << 16)
|
||||
.build()
|
||||
{
|
||||
Ok(re) => re.replace_all(name, replace).into_owned(),
|
||||
Err(_) => name.to_string(),
|
||||
}
|
||||
@@ -213,5 +264,9 @@ pub fn resolve_collision(path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
// Fallback - should never happen with 1000 attempts
|
||||
parent.join(format!("{}_{}.{}", stem, "overflow", ext))
|
||||
if ext.is_empty() {
|
||||
parent.join(format!("{}_overflow", stem))
|
||||
} else {
|
||||
parent.join(format!("{}_overflow.{}", stem, ext))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ pub fn calculate_position(
|
||||
|
||||
let center_x = iw.saturating_sub(ww) / 2;
|
||||
let center_y = ih.saturating_sub(wh) / 2;
|
||||
let right_x = iw.saturating_sub(ww + margin);
|
||||
let bottom_y = ih.saturating_sub(wh + margin);
|
||||
let right_x = iw.saturating_sub(ww).saturating_sub(margin);
|
||||
let bottom_y = ih.saturating_sub(wh).saturating_sub(margin);
|
||||
|
||||
match position {
|
||||
WatermarkPosition::TopLeft => (margin, margin),
|
||||
@@ -69,7 +69,7 @@ pub fn apply_watermark(
|
||||
if *tiled {
|
||||
apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin)
|
||||
} else {
|
||||
apply_image_watermark(img, path, *position, *opacity, *scale, *rotation)
|
||||
apply_image_watermark(img, path, *position, *opacity, *scale, *rotation, *margin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,20 +128,29 @@ fn find_system_font(family: Option<&str>) -> Result<Vec<u8>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively walk a directory and collect file paths
|
||||
/// Recursively walk a directory and collect file paths (max depth 5)
|
||||
fn walkdir(dir: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||
walkdir_depth(dir, 5)
|
||||
}
|
||||
|
||||
fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||
const MAX_RESULTS: usize = 10_000;
|
||||
let mut results = Vec::new();
|
||||
if dir.is_dir() {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Ok(sub) = walkdir(&path) {
|
||||
results.extend(sub);
|
||||
}
|
||||
} else {
|
||||
results.push(path);
|
||||
if max_depth == 0 || !dir.is_dir() {
|
||||
return Ok(results);
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
if results.len() >= MAX_RESULTS {
|
||||
break;
|
||||
}
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Ok(sub) = walkdir_depth(&path, max_depth - 1) {
|
||||
results.extend(sub);
|
||||
}
|
||||
} else {
|
||||
results.push(path);
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
@@ -156,8 +165,8 @@ fn render_text_to_image(
|
||||
opacity: f32,
|
||||
) -> image::RgbaImage {
|
||||
let scale = ab_glyph::PxScale::from(font_size);
|
||||
let text_width = (text.len() as f32 * font_size * 0.6) as u32 + 4;
|
||||
let text_height = (font_size * 1.4) as u32 + 4;
|
||||
let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192);
|
||||
let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
|
||||
|
||||
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||
@@ -167,6 +176,30 @@ fn render_text_to_image(
|
||||
buf
|
||||
}
|
||||
|
||||
/// Expand canvas so rotated content fits without clipping, then rotate
|
||||
fn rotate_on_expanded_canvas(img: &image::RgbaImage, radians: f32) -> DynamicImage {
|
||||
let w = img.width() as f32;
|
||||
let h = img.height() as f32;
|
||||
let cos = radians.abs().cos();
|
||||
let sin = radians.abs().sin();
|
||||
// Bounding box of rotated rectangle
|
||||
let new_w = (w * cos + h * sin).ceil() as u32 + 2;
|
||||
let new_h = (w * sin + h * cos).ceil() as u32 + 2;
|
||||
|
||||
// Place original in center of expanded transparent canvas
|
||||
let mut expanded = image::RgbaImage::new(new_w, new_h);
|
||||
let ox = (new_w.saturating_sub(img.width())) / 2;
|
||||
let oy = (new_h.saturating_sub(img.height())) / 2;
|
||||
image::imageops::overlay(&mut expanded, img, ox as i64, oy as i64);
|
||||
|
||||
imageproc::geometric_transformations::rotate_about_center(
|
||||
&expanded,
|
||||
radians,
|
||||
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||
Rgba([0, 0, 0, 0]),
|
||||
).into()
|
||||
}
|
||||
|
||||
/// Rotate an RGBA image by the given WatermarkRotation
|
||||
fn rotate_watermark_image(
|
||||
img: DynamicImage,
|
||||
@@ -175,20 +208,13 @@ fn rotate_watermark_image(
|
||||
match rotation {
|
||||
super::WatermarkRotation::Degrees90 => img.rotate90(),
|
||||
super::WatermarkRotation::Degrees45 => {
|
||||
imageproc::geometric_transformations::rotate_about_center(
|
||||
&img.to_rgba8(),
|
||||
std::f32::consts::FRAC_PI_4,
|
||||
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||
Rgba([0, 0, 0, 0]),
|
||||
).into()
|
||||
rotate_on_expanded_canvas(&img.to_rgba8(), std::f32::consts::FRAC_PI_4)
|
||||
}
|
||||
super::WatermarkRotation::DegreesNeg45 => {
|
||||
imageproc::geometric_transformations::rotate_about_center(
|
||||
&img.to_rgba8(),
|
||||
-std::f32::consts::FRAC_PI_4,
|
||||
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||
Rgba([0, 0, 0, 0]),
|
||||
).into()
|
||||
rotate_on_expanded_canvas(&img.to_rgba8(), -std::f32::consts::FRAC_PI_4)
|
||||
}
|
||||
super::WatermarkRotation::Custom(degrees) => {
|
||||
rotate_on_expanded_canvas(&img.to_rgba8(), degrees.to_radians())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +230,9 @@ fn apply_text_watermark(
|
||||
rotation: Option<super::WatermarkRotation>,
|
||||
margin_px: u32,
|
||||
) -> Result<DynamicImage> {
|
||||
if text.is_empty() {
|
||||
return Ok(img);
|
||||
}
|
||||
let font_data = find_system_font(font_family)?;
|
||||
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||
PixstripError::Processing {
|
||||
@@ -234,8 +263,8 @@ fn apply_text_watermark(
|
||||
} else {
|
||||
// No rotation - draw text directly (faster)
|
||||
let scale = ab_glyph::PxScale::from(font_size);
|
||||
let text_width = (text.len() as f32 * font_size * 0.6) as u32;
|
||||
let text_height = font_size as u32;
|
||||
let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192);
|
||||
let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
|
||||
let text_dims = Dimensions {
|
||||
width: text_width,
|
||||
height: text_height,
|
||||
@@ -266,6 +295,9 @@ fn apply_tiled_text_watermark(
|
||||
rotation: Option<super::WatermarkRotation>,
|
||||
margin: u32,
|
||||
) -> Result<DynamicImage> {
|
||||
if text.is_empty() {
|
||||
return Ok(img);
|
||||
}
|
||||
let font_data = find_system_font(font_family)?;
|
||||
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||
PixstripError::Processing {
|
||||
@@ -301,17 +333,17 @@ fn apply_tiled_text_watermark(
|
||||
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||
|
||||
let text_width = (text.len() as f32 * font_size * 0.6) as u32;
|
||||
let text_height = font_size as u32;
|
||||
let text_width = ((text.len().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(8192);
|
||||
let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096);
|
||||
|
||||
let mut y = spacing as i32;
|
||||
while y < ih as i32 {
|
||||
let mut x = spacing as i32;
|
||||
while x < iw as i32 {
|
||||
draw_text_mut(&mut rgba, draw_color, x, y, scale, &font, text);
|
||||
x += text_width as i32 + spacing as i32;
|
||||
let mut y = spacing as i64;
|
||||
while y < ih as i64 {
|
||||
let mut x = spacing as i64;
|
||||
while x < iw as i64 {
|
||||
draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
|
||||
x += text_width + spacing as i64;
|
||||
}
|
||||
y += text_height as i32 + spacing as i32;
|
||||
y += text_height + spacing as i64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +422,7 @@ fn apply_image_watermark(
|
||||
opacity: f32,
|
||||
scale: f32,
|
||||
rotation: Option<super::WatermarkRotation>,
|
||||
margin: u32,
|
||||
) -> Result<DynamicImage> {
|
||||
let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing {
|
||||
operation: "watermark".into(),
|
||||
@@ -423,7 +456,6 @@ fn apply_image_watermark(
|
||||
height: watermark.height(),
|
||||
};
|
||||
|
||||
let margin = 10;
|
||||
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
||||
|
||||
let mut base = img.into_rgba8();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct ProcessingJob {
|
||||
pub metadata: Option<MetadataConfig>,
|
||||
pub watermark: Option<WatermarkConfig>,
|
||||
pub rename: Option<RenameConfig>,
|
||||
pub overwrite_behavior: OverwriteBehavior,
|
||||
pub overwrite_behavior: OverwriteAction,
|
||||
pub preserve_directory_structure: bool,
|
||||
pub progressive_jpeg: bool,
|
||||
pub avif_speed: u8,
|
||||
@@ -44,11 +44,11 @@ impl ProcessingJob {
|
||||
metadata: None,
|
||||
watermark: None,
|
||||
rename: None,
|
||||
overwrite_behavior: OverwriteBehavior::default(),
|
||||
overwrite_behavior: OverwriteAction::default(),
|
||||
preserve_directory_structure: false,
|
||||
progressive_jpeg: false,
|
||||
avif_speed: 6,
|
||||
output_dpi: 72,
|
||||
output_dpi: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@ impl ProcessingJob {
|
||||
count
|
||||
}
|
||||
|
||||
/// Returns true if the job requires decoding/encoding pixel data.
|
||||
/// When false, we can use a fast copy-and-rename path.
|
||||
pub fn needs_pixel_processing(&self) -> bool {
|
||||
self.resize.is_some()
|
||||
|| matches!(self.rotation, Some(r) if !matches!(r, Rotation::None))
|
||||
|| matches!(self.flip, Some(f) if !matches!(f, Flip::None))
|
||||
|| self.adjustments.as_ref().is_some_and(|a| !a.is_noop())
|
||||
|| self.convert.is_some()
|
||||
|| self.compress.is_some()
|
||||
|| self.watermark.is_some()
|
||||
}
|
||||
|
||||
pub fn output_path_for(
|
||||
&self,
|
||||
source: &ImageSource,
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::pipeline::ProcessingJob;
|
||||
use crate::types::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Preset {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
@@ -20,6 +21,25 @@ pub struct Preset {
|
||||
pub rename: Option<RenameConfig>,
|
||||
}
|
||||
|
||||
impl Default for Preset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
icon: "image-x-generic-symbolic".into(),
|
||||
is_custom: true,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
flip: None,
|
||||
convert: None,
|
||||
compress: None,
|
||||
metadata: None,
|
||||
watermark: None,
|
||||
rename: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Preset {
|
||||
pub fn to_job(
|
||||
&self,
|
||||
@@ -40,7 +60,7 @@ impl Preset {
|
||||
metadata: self.metadata.clone(),
|
||||
watermark: self.watermark.clone(),
|
||||
rename: self.rename.clone(),
|
||||
overwrite_behavior: crate::operations::OverwriteBehavior::default(),
|
||||
overwrite_behavior: crate::operations::OverwriteAction::default(),
|
||||
preserve_directory_structure: false,
|
||||
progressive_jpeg: false,
|
||||
avif_speed: 6,
|
||||
@@ -58,6 +78,7 @@ impl Preset {
|
||||
Self::builtin_photographer_export(),
|
||||
Self::builtin_archive_compress(),
|
||||
Self::builtin_fediverse_ready(),
|
||||
Self::builtin_print_ready(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -119,8 +140,12 @@ impl Preset {
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
}),
|
||||
@@ -179,8 +204,12 @@ impl Preset {
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 4,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: Some("{exif_date}_{name}_{counter:4}".into()),
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
}),
|
||||
@@ -204,6 +233,23 @@ impl Preset {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_print_ready() -> Preset {
|
||||
Preset {
|
||||
name: "Print Ready".into(),
|
||||
description: "Maximum quality, convert to PNG, keep all metadata".into(),
|
||||
icon: "printer-symbolic".into(),
|
||||
is_custom: false,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
flip: None,
|
||||
convert: Some(ConvertConfig::SingleFormat(ImageFormat::Png)),
|
||||
compress: Some(CompressConfig::Preset(QualityPreset::Maximum)),
|
||||
metadata: Some(MetadataConfig::KeepAll),
|
||||
watermark: None,
|
||||
rename: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_fediverse_ready() -> Preset {
|
||||
Preset {
|
||||
name: "Fediverse Ready".into(),
|
||||
|
||||
@@ -8,10 +8,19 @@ use crate::preset::Preset;
|
||||
|
||||
fn default_config_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("pixstrip")
|
||||
}
|
||||
|
||||
/// Write to a temporary file then rename, for crash safety.
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, contents)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
@@ -54,7 +63,7 @@ impl PresetStore {
|
||||
let path = self.preset_path(&preset.name);
|
||||
let json = serde_json::to_string_pretty(preset)
|
||||
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||
std::fs::write(&path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn load(&self, name: &str) -> Result<Preset> {
|
||||
@@ -103,7 +112,7 @@ impl PresetStore {
|
||||
pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(preset)
|
||||
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||
std::fs::write(path, json).map_err(PixstripError::Io)
|
||||
atomic_write(path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn import_from_file(&self, path: &Path) -> Result<Preset> {
|
||||
@@ -146,7 +155,7 @@ impl ConfigStore {
|
||||
}
|
||||
let json = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||
std::fs::write(&self.config_path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&self.config_path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<AppConfig> {
|
||||
@@ -215,7 +224,7 @@ impl SessionStore {
|
||||
}
|
||||
let json = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||
std::fs::write(&self.session_path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&self.session_path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<SessionState> {
|
||||
@@ -267,10 +276,11 @@ impl HistoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&self, entry: HistoryEntry) -> Result<()> {
|
||||
pub fn add(&self, entry: HistoryEntry, max_entries: usize, max_days: u32) -> Result<()> {
|
||||
let mut entries = self.list()?;
|
||||
entries.push(entry);
|
||||
self.write_all(&entries)
|
||||
self.write_all(&entries)?;
|
||||
self.prune(max_entries, max_days)
|
||||
}
|
||||
|
||||
pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> {
|
||||
@@ -285,9 +295,9 @@ impl HistoryStore {
|
||||
.as_secs();
|
||||
let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400);
|
||||
|
||||
// Remove entries older than max_days
|
||||
// Remove entries older than max_days (keep entries with unparseable timestamps)
|
||||
entries.retain(|e| {
|
||||
e.timestamp.parse::<u64>().unwrap_or(0) >= cutoff_secs
|
||||
e.timestamp.parse::<u64>().map_or(true, |ts| ts >= cutoff_secs)
|
||||
});
|
||||
|
||||
// Trim to max_entries (keep the most recent)
|
||||
@@ -314,13 +324,13 @@ impl HistoryStore {
|
||||
self.write_all(&Vec::<HistoryEntry>::new())
|
||||
}
|
||||
|
||||
fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> {
|
||||
pub fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> {
|
||||
if let Some(parent) = self.history_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(entries)
|
||||
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||
std::fs::write(&self.history_path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&self.history_path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +66,17 @@ pub struct Dimensions {
|
||||
|
||||
impl Dimensions {
|
||||
pub fn aspect_ratio(&self) -> f64 {
|
||||
if self.height == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.width as f64 / self.height as f64
|
||||
}
|
||||
|
||||
pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions {
|
||||
if self.width == 0 || self.height == 0 || max.width == 0 || max.height == 0 {
|
||||
return self;
|
||||
}
|
||||
|
||||
if !allow_upscale && self.width <= max.width && self.height <= max.height {
|
||||
return self;
|
||||
}
|
||||
@@ -83,8 +90,8 @@ impl Dimensions {
|
||||
}
|
||||
|
||||
Dimensions {
|
||||
width: (self.width as f64 * scale).round() as u32,
|
||||
height: (self.height as f64 * scale).round() as u32,
|
||||
width: (self.width as f64 * scale).round().max(1.0) as u32,
|
||||
height: (self.height as f64 * scale).round().max(1.0) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +142,36 @@ impl QualityPreset {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webp_effort(&self) -> u8 {
|
||||
match self {
|
||||
Self::Maximum => 6,
|
||||
Self::High => 5,
|
||||
Self::Medium => 4,
|
||||
Self::Low => 3,
|
||||
Self::WebOptimized => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn avif_quality(&self) -> u8 {
|
||||
match self {
|
||||
Self::Maximum => 80,
|
||||
Self::High => 63,
|
||||
Self::Medium => 50,
|
||||
Self::Low => 35,
|
||||
Self::WebOptimized => 40,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn avif_speed(&self) -> u8 {
|
||||
match self {
|
||||
Self::Maximum => 4,
|
||||
Self::High => 6,
|
||||
Self::Medium => 6,
|
||||
Self::Low => 8,
|
||||
Self::WebOptimized => 8,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Maximum => "Maximum",
|
||||
|
||||
Reference in New Issue
Block a user