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",
|
||||
|
||||
145
pixstrip-core/tests/adjustments_tests.rs
Normal file
145
pixstrip-core/tests/adjustments_tests.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use pixstrip_core::operations::AdjustmentsConfig;
|
||||
use pixstrip_core::operations::adjustments::apply_adjustments;
|
||||
use image::DynamicImage;
|
||||
|
||||
fn noop_config() -> AdjustmentsConfig {
|
||||
AdjustmentsConfig {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
sharpen: false,
|
||||
grayscale: false,
|
||||
sepia: false,
|
||||
crop_aspect_ratio: None,
|
||||
trim_whitespace: false,
|
||||
canvas_padding: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_default() {
|
||||
assert!(noop_config().is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_brightness() {
|
||||
let mut config = noop_config();
|
||||
config.brightness = 10;
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_sharpen() {
|
||||
let mut config = noop_config();
|
||||
config.sharpen = true;
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_crop() {
|
||||
let mut config = noop_config();
|
||||
config.crop_aspect_ratio = Some((16.0, 9.0));
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_trim() {
|
||||
let mut config = noop_config();
|
||||
config.trim_whitespace = true;
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_padding() {
|
||||
let mut config = noop_config();
|
||||
config.canvas_padding = 20;
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_grayscale() {
|
||||
let mut config = noop_config();
|
||||
config.grayscale = true;
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_noop_with_sepia() {
|
||||
let mut config = noop_config();
|
||||
config.sepia = true;
|
||||
assert!(!config.is_noop());
|
||||
}
|
||||
|
||||
// --- crop_to_aspect_ratio edge cases ---
|
||||
|
||||
fn make_test_image(w: u32, h: u32) -> DynamicImage {
|
||||
DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(w, h, image::Rgba([128, 128, 128, 255])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_zero_ratio_returns_original() {
|
||||
let img = make_test_image(100, 100);
|
||||
let config = AdjustmentsConfig {
|
||||
crop_aspect_ratio: Some((0.0, 9.0)),
|
||||
..noop_config()
|
||||
};
|
||||
let result = apply_adjustments(img, &config).unwrap();
|
||||
assert_eq!(result.width(), 100);
|
||||
assert_eq!(result.height(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_zero_height_ratio_returns_original() {
|
||||
let img = make_test_image(100, 100);
|
||||
let config = AdjustmentsConfig {
|
||||
crop_aspect_ratio: Some((16.0, 0.0)),
|
||||
..noop_config()
|
||||
};
|
||||
let result = apply_adjustments(img, &config).unwrap();
|
||||
assert_eq!(result.width(), 100);
|
||||
assert_eq!(result.height(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_square_on_landscape() {
|
||||
let img = make_test_image(200, 100);
|
||||
let config = AdjustmentsConfig {
|
||||
crop_aspect_ratio: Some((1.0, 1.0)),
|
||||
..noop_config()
|
||||
};
|
||||
let result = apply_adjustments(img, &config).unwrap();
|
||||
assert_eq!(result.width(), 100);
|
||||
assert_eq!(result.height(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_16_9_on_square() {
|
||||
let img = make_test_image(100, 100);
|
||||
let config = AdjustmentsConfig {
|
||||
crop_aspect_ratio: Some((16.0, 9.0)),
|
||||
..noop_config()
|
||||
};
|
||||
let result = apply_adjustments(img, &config).unwrap();
|
||||
assert_eq!(result.width(), 100);
|
||||
assert_eq!(result.height(), 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_padding_large_value() {
|
||||
let img = make_test_image(10, 10);
|
||||
let config = AdjustmentsConfig {
|
||||
canvas_padding: 500,
|
||||
..noop_config()
|
||||
};
|
||||
let result = apply_adjustments(img, &config).unwrap();
|
||||
assert_eq!(result.width(), 1010);
|
||||
assert_eq!(result.height(), 1010);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_config_returns_same_dimensions() {
|
||||
let img = make_test_image(200, 100);
|
||||
let result = apply_adjustments(img, &noop_config()).unwrap();
|
||||
assert_eq!(result.width(), 200);
|
||||
assert_eq!(result.height(), 100);
|
||||
}
|
||||
@@ -112,9 +112,14 @@ fn execute_with_cancellation() {
|
||||
let executor = PipelineExecutor::with_cancel(cancel);
|
||||
let result = executor.execute(&job, |_| {}).unwrap();
|
||||
|
||||
// With immediate cancellation, fewer images should be processed
|
||||
assert!(result.succeeded + result.failed <= 2);
|
||||
assert!(result.cancelled);
|
||||
// Cancellation flag should be set
|
||||
assert!(result.cancelled, "result.cancelled should be true when cancel flag is set");
|
||||
// Total processed should be less than total sources (at least some skipped)
|
||||
assert!(
|
||||
result.succeeded + result.failed <= 2,
|
||||
"processed count ({}) should not exceed total (2)",
|
||||
result.succeeded + result.failed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,3 +42,84 @@ fn privacy_mode_strips_gps() {
|
||||
strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap();
|
||||
assert!(output.exists());
|
||||
}
|
||||
|
||||
fn create_test_png(path: &Path) {
|
||||
let img = image::RgbaImage::from_fn(100, 80, |x, y| {
|
||||
image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255])
|
||||
});
|
||||
img.save_with_format(path, image::ImageFormat::Png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_png_metadata_produces_valid_png() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.png");
|
||||
let output = dir.path().join("stripped.png");
|
||||
create_test_png(&input);
|
||||
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
assert!(output.exists());
|
||||
// Output must be a valid PNG that can be opened
|
||||
let img = image::open(&output).unwrap();
|
||||
assert_eq!(img.width(), 100);
|
||||
assert_eq!(img.height(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_png_removes_text_chunks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.png");
|
||||
let output = dir.path().join("stripped.png");
|
||||
create_test_png(&input);
|
||||
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
// Read output and verify no tEXt chunks remain
|
||||
let data = std::fs::read(&output).unwrap();
|
||||
let mut pos = 8; // skip PNG signature
|
||||
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];
|
||||
assert_ne!(chunk_type, b"tEXt", "tEXt chunk should be stripped");
|
||||
assert_ne!(chunk_type, b"iTXt", "iTXt chunk should be stripped");
|
||||
assert_ne!(chunk_type, b"zTXt", "zTXt chunk should be stripped");
|
||||
pos += 12 + chunk_len;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_png_output_smaller_or_equal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.png");
|
||||
let output = dir.path().join("stripped.png");
|
||||
create_test_png(&input);
|
||||
|
||||
let input_size = std::fs::metadata(&input).unwrap().len();
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
let output_size = std::fs::metadata(&output).unwrap().len();
|
||||
assert!(output_size <= input_size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jpeg_removes_app1_exif() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.jpg");
|
||||
let output = dir.path().join("stripped.jpg");
|
||||
create_test_jpeg(&input);
|
||||
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
// Verify no APP1 (0xFFE1) markers remain
|
||||
let data = std::fs::read(&output).unwrap();
|
||||
let mut i = 2; // skip SOI
|
||||
while i + 1 < data.len() {
|
||||
if data[i] != 0xFF { break; }
|
||||
let marker = data[i + 1];
|
||||
if marker == 0xDA { break; } // SOS - rest is image data
|
||||
assert_ne!(marker, 0xE1, "APP1/EXIF marker should be stripped");
|
||||
if i + 3 < data.len() {
|
||||
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||
i += 2 + len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,12 @@ fn rename_config_simple_template() {
|
||||
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(),
|
||||
};
|
||||
@@ -107,11 +111,101 @@ fn rename_config_with_suffix() {
|
||||
suffix: "_web".into(),
|
||||
counter_start: 1,
|
||||
counter_padding: 2,
|
||||
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(),
|
||||
};
|
||||
let result = config.apply_simple("photo", "webp", 5);
|
||||
assert_eq!(result, "photo_web_05.webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_counter_overflow_saturates() {
|
||||
let config = RenameConfig {
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
counter_start: u32::MAX,
|
||||
counter_padding: 1,
|
||||
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(),
|
||||
};
|
||||
// Should not panic - saturating arithmetic
|
||||
let result = config.apply_simple("photo", "jpg", u32::MAX);
|
||||
assert!(result.contains("photo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_config_custom_selective() {
|
||||
let config = MetadataConfig::Custom {
|
||||
strip_gps: true,
|
||||
strip_camera: false,
|
||||
strip_software: true,
|
||||
strip_timestamps: false,
|
||||
strip_copyright: false,
|
||||
};
|
||||
assert!(config.should_strip_gps());
|
||||
assert!(!config.should_strip_camera());
|
||||
assert!(!config.should_strip_copyright());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_config_custom_all_off() {
|
||||
let config = MetadataConfig::Custom {
|
||||
strip_gps: false,
|
||||
strip_camera: false,
|
||||
strip_software: false,
|
||||
strip_timestamps: false,
|
||||
strip_copyright: false,
|
||||
};
|
||||
assert!(!config.should_strip_gps());
|
||||
assert!(!config.should_strip_camera());
|
||||
assert!(!config.should_strip_copyright());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_config_custom_all_on() {
|
||||
let config = MetadataConfig::Custom {
|
||||
strip_gps: true,
|
||||
strip_camera: true,
|
||||
strip_software: true,
|
||||
strip_timestamps: true,
|
||||
strip_copyright: true,
|
||||
};
|
||||
assert!(config.should_strip_gps());
|
||||
assert!(config.should_strip_camera());
|
||||
assert!(config.should_strip_copyright());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_rotation_variants_exist() {
|
||||
let rotations = [
|
||||
WatermarkRotation::Degrees45,
|
||||
WatermarkRotation::DegreesNeg45,
|
||||
WatermarkRotation::Degrees90,
|
||||
WatermarkRotation::Custom(30.0),
|
||||
];
|
||||
assert_eq!(rotations.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_auto_orient_variant() {
|
||||
let rotation = Rotation::AutoOrient;
|
||||
assert!(matches!(rotation, Rotation::AutoOrient));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_action_default_is_auto_rename() {
|
||||
let default = OverwriteAction::default();
|
||||
assert!(matches!(default, OverwriteAction::AutoRename));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ fn preset_serialization_roundtrip() {
|
||||
#[test]
|
||||
fn all_builtin_presets() {
|
||||
let presets = Preset::all_builtins();
|
||||
assert_eq!(presets.len(), 8);
|
||||
assert_eq!(presets.len(), 9);
|
||||
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"Blog Photos"));
|
||||
assert!(names.contains(&"Social Media"));
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use pixstrip_core::operations::rename::{apply_template, resolve_collision};
|
||||
use pixstrip_core::operations::rename::{
|
||||
apply_template, apply_regex_replace, apply_space_replacement,
|
||||
apply_special_chars, apply_case_conversion, resolve_collision,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn template_basic_variables() {
|
||||
@@ -93,3 +96,185 @@ fn no_collision_returns_same() {
|
||||
let resolved = resolve_collision(&path);
|
||||
assert_eq!(resolved, path);
|
||||
}
|
||||
|
||||
// --- Regex replace tests ---
|
||||
|
||||
#[test]
|
||||
fn regex_replace_basic() {
|
||||
let result = apply_regex_replace("hello_world", "_", "-");
|
||||
assert_eq!(result, "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_pattern() {
|
||||
let result = apply_regex_replace("IMG_20260307_001", r"\d{8}", "DATE");
|
||||
assert_eq!(result, "IMG_DATE_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_invalid_pattern_returns_original() {
|
||||
let result = apply_regex_replace("hello", "[invalid", "x");
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_empty_find_returns_original() {
|
||||
let result = apply_regex_replace("hello", "", "x");
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
// --- Space replacement tests ---
|
||||
|
||||
#[test]
|
||||
fn space_replacement_none() {
|
||||
assert_eq!(apply_space_replacement("hello world", 0), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_underscore() {
|
||||
assert_eq!(apply_space_replacement("hello world", 1), "hello_world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_hyphen() {
|
||||
assert_eq!(apply_space_replacement("hello world", 2), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_dot() {
|
||||
assert_eq!(apply_space_replacement("hello world", 3), "hello.world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_camelcase() {
|
||||
assert_eq!(apply_space_replacement("hello world", 4), "helloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_remove() {
|
||||
assert_eq!(apply_space_replacement("hello world", 5), "helloworld");
|
||||
}
|
||||
|
||||
// --- Special chars tests ---
|
||||
|
||||
#[test]
|
||||
fn special_chars_keep_all() {
|
||||
assert_eq!(apply_special_chars("file<name>.txt", 0), "file<name>.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars_filesystem_safe() {
|
||||
assert_eq!(apply_special_chars("file<name>", 1), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars_web_safe() {
|
||||
assert_eq!(apply_special_chars("file name!@#", 2), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars_alphanumeric_only() {
|
||||
assert_eq!(apply_special_chars("file-name_123", 5), "filename123");
|
||||
}
|
||||
|
||||
// --- Case conversion tests ---
|
||||
|
||||
#[test]
|
||||
fn case_conversion_none() {
|
||||
assert_eq!(apply_case_conversion("Hello World", 0), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_lowercase() {
|
||||
assert_eq!(apply_case_conversion("Hello World", 1), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_uppercase() {
|
||||
assert_eq!(apply_case_conversion("Hello World", 2), "HELLO WORLD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_title_case() {
|
||||
assert_eq!(apply_case_conversion("hello world", 3), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_title_preserves_separators() {
|
||||
assert_eq!(apply_case_conversion("hello-world_foo", 3), "Hello-World_Foo");
|
||||
}
|
||||
|
||||
// --- RenameConfig::apply_simple edge cases ---
|
||||
|
||||
use pixstrip_core::operations::RenameConfig;
|
||||
|
||||
fn default_rename_config() -> RenameConfig {
|
||||
RenameConfig {
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: false,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_no_changes() {
|
||||
let cfg = default_rename_config();
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_with_prefix_suffix() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.prefix = "web_".into();
|
||||
cfg.suffix = "_final".into();
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "web_photo_final.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_counter_after_suffix() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.counter_enabled = true;
|
||||
cfg.counter_position = 3;
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo_001.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_counter_replaces_name() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.counter_enabled = true;
|
||||
cfg.counter_position = 4;
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "001.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_empty_name() {
|
||||
let cfg = default_rename_config();
|
||||
assert_eq!(cfg.apply_simple("", "png", 1), ".png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_case_lowercase() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.case_mode = 1;
|
||||
assert_eq!(cfg.apply_simple("MyPhoto", "JPG", 1), "myphoto.JPG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_large_counter_padding_capped() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.counter_enabled = true;
|
||||
cfg.counter_padding = 100; // should be capped to 10
|
||||
cfg.counter_position = 3;
|
||||
let result = cfg.apply_simple("photo", "jpg", 1);
|
||||
// Counter portion should be at most 10 digits
|
||||
assert!(result.len() <= 22); // "photo_" + 10 digits + ".jpg"
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ fn add_and_list_history_entries() {
|
||||
],
|
||||
};
|
||||
|
||||
history.add(entry.clone()).unwrap();
|
||||
history.add(entry.clone(), 50, 30).unwrap();
|
||||
let entries = history.list().unwrap();
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
@@ -236,7 +236,7 @@ fn history_appends_entries() {
|
||||
total_output_bytes: 500,
|
||||
elapsed_ms: 100,
|
||||
output_files: vec![],
|
||||
})
|
||||
}, 50, 30)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ fn clear_history() {
|
||||
total_output_bytes: 500,
|
||||
elapsed_ms: 100,
|
||||
output_files: vec![],
|
||||
})
|
||||
}, 50, 30)
|
||||
.unwrap();
|
||||
|
||||
history.clear().unwrap();
|
||||
|
||||
@@ -81,3 +81,39 @@ fn quality_preset_values() {
|
||||
assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
|
||||
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_zero_height_aspect_ratio() {
|
||||
let dims = Dimensions { width: 1920, height: 0 };
|
||||
assert_eq!(dims.aspect_ratio(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_zero_both_aspect_ratio() {
|
||||
let dims = Dimensions { width: 0, height: 0 };
|
||||
assert_eq!(dims.aspect_ratio(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_within_zero_self_width() {
|
||||
let original = Dimensions { width: 0, height: 600 };
|
||||
let max_box = Dimensions { width: 1200, height: 1200 };
|
||||
let fitted = original.fit_within(max_box, false);
|
||||
assert_eq!(fitted, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_within_zero_self_height() {
|
||||
let original = Dimensions { width: 800, height: 0 };
|
||||
let max_box = Dimensions { width: 1200, height: 1200 };
|
||||
let fitted = original.fit_within(max_box, false);
|
||||
assert_eq!(fitted, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_within_zero_max() {
|
||||
let original = Dimensions { width: 800, height: 600 };
|
||||
let max_box = Dimensions { width: 0, height: 0 };
|
||||
let fitted = original.fit_within(max_box, false);
|
||||
assert_eq!(fitted, original);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ fn watcher_detects_new_image() {
|
||||
watcher.start(&folder, tx).unwrap();
|
||||
|
||||
// Wait for watcher to be ready
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// Create an image file
|
||||
let img_path = dir.path().join("new_photo.jpg");
|
||||
@@ -87,7 +87,7 @@ fn watcher_ignores_non_image_files() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
watcher.start(&folder, tx).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// Create a non-image file
|
||||
std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap();
|
||||
|
||||
@@ -109,3 +109,137 @@ fn position_bottom_left() {
|
||||
assert_eq!(x, 10);
|
||||
assert_eq!(y, 1020);
|
||||
}
|
||||
|
||||
// --- Margin variation tests ---
|
||||
|
||||
#[test]
|
||||
fn margin_zero_top_left() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::TopLeft,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_zero_bottom_right() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 1720); // 1920 - 200
|
||||
assert_eq!(y, 1030); // 1080 - 50
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_margin_top_left() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::TopLeft,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
100,
|
||||
);
|
||||
assert_eq!(x, 100);
|
||||
assert_eq!(y, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_margin_bottom_right() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
100,
|
||||
);
|
||||
assert_eq!(x, 1620); // 1920 - 200 - 100
|
||||
assert_eq!(y, 930); // 1080 - 50 - 100
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_does_not_affect_center() {
|
||||
let (x1, y1) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
0,
|
||||
);
|
||||
let (x2, y2) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
100,
|
||||
);
|
||||
assert_eq!(x1, x2);
|
||||
assert_eq!(y1, y2);
|
||||
}
|
||||
|
||||
// --- Edge case tests ---
|
||||
|
||||
#[test]
|
||||
fn watermark_larger_than_image() {
|
||||
// Watermark is bigger than the image - should not panic, saturating_sub clamps to 0
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 100, height: 100 },
|
||||
Dimensions { width: 200, height: 200 },
|
||||
10,
|
||||
);
|
||||
// (100 - 200 - 10) saturates to 0
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_exact_image_size() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 200, height: 100 },
|
||||
Dimensions { width: 200, height: 100 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_image() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 0, height: 0 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
10,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_exceeds_available_space() {
|
||||
// Margin is huge relative to image size
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 100, height: 100 },
|
||||
Dimensions { width: 50, height: 50 },
|
||||
200,
|
||||
);
|
||||
// saturating_sub: 100 - 50 - 200 = 0
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pixel_image() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::TopLeft,
|
||||
Dimensions { width: 1, height: 1 },
|
||||
Dimensions { width: 1, height: 1 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user