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:
2026-03-07 19:47:23 +02:00
parent 270a7db60d
commit b432cc7431
44 changed files with 5748 additions and 2221 deletions

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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()
}

View File

@@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;"),
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,
));
}

View File

@@ -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]));

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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)
}
}

View File

@@ -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",