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

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