Files
pixstrip/pixstrip-core/src/encoder.rs
lashman d1cab8a691 Fix 40+ bugs from audit passes 9-12
- PNG chunk parsing overflow protection with checked arithmetic
- Font directory traversal bounded with global result limit
- find_unique_path TOCTOU race fixed with create_new + marker byte
- Watch mode "processed" dir exclusion narrowed to prevent false skips
- Metadata copy now checks format support before little_exif calls
- Clipboard temp files cleaned up on app exit
- Atomic writes for file manager integration scripts
- BMP format support added to encoder and convert step
- Regex DoS protection with DFA size limit
- Watermark NaN/negative scale guard
- Selective EXIF stripping for privacy/custom metadata modes
- CLI watch mode: file stability checks, per-file history saves
- High contrast toggle preserves and restores original theme
- Image list deduplication uses O(1) HashSet lookups
- Saturation/trim/padding overflow guards in adjustments
2026-03-07 22:14:48 +02:00

304 lines
10 KiB
Rust

use std::io::Cursor;
use std::path::Path;
use crate::error::{PixstripError, Result};
use crate::types::{ImageFormat, QualityPreset};
#[derive(Debug, Clone, Default)]
pub struct EncoderOptions {
pub progressive_jpeg: bool,
pub avif_speed: u8,
/// Output DPI (0 means don't set / use default)
pub output_dpi: u32,
}
pub struct OutputEncoder {
pub options: EncoderOptions,
}
impl OutputEncoder {
pub fn new() -> Self {
Self {
options: EncoderOptions {
progressive_jpeg: false,
avif_speed: 6,
output_dpi: 0,
},
}
}
pub fn with_options(options: EncoderOptions) -> Self {
Self { options }
}
pub fn encode(
&self,
img: &image::DynamicImage,
format: ImageFormat,
quality: Option<u8>,
) -> Result<Vec<u8>> {
match format {
ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)),
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),
ImageFormat::Tiff => self.encode_fallback(img, image::ImageFormat::Tiff),
ImageFormat::Bmp => self.encode_fallback(img, image::ImageFormat::Bmp),
}
}
pub fn encode_to_file(
&self,
img: &image::DynamicImage,
path: &Path,
format: ImageFormat,
quality: Option<u8>,
) -> Result<()> {
let bytes = self.encode(img, format, quality)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
}
std::fs::write(path, &bytes).map_err(PixstripError::Io)?;
Ok(())
}
pub fn quality_for_format(&self, format: ImageFormat, preset: &QualityPreset) -> u8 {
match format {
ImageFormat::Jpeg => preset.jpeg_quality(),
ImageFormat::WebP => preset.webp_quality() as u8,
ImageFormat::Avif => preset.avif_quality() as u8,
ImageFormat::Png => preset.png_level(),
ImageFormat::Gif | ImageFormat::Tiff | ImageFormat::Bmp => preset.jpeg_quality(),
}
}
fn encode_jpeg(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
let pixels = rgb.as_raw();
let mut comp = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_RGB);
comp.set_size(width, height);
comp.set_quality(quality as f32);
if self.options.progressive_jpeg {
comp.set_progressive_mode();
}
if self.options.output_dpi > 0 {
comp.set_pixel_density(mozjpeg::PixelDensity {
unit: mozjpeg::PixelDensityUnit::Inches,
x: self.options.output_dpi.min(65535) as u16,
y: self.options.output_dpi.min(65535) as u16,
});
}
let mut output = Vec::new();
let mut started = comp.start_compress(&mut output).map_err(|e| PixstripError::Processing {
operation: "jpeg_encode".into(),
reason: e.to_string(),
})?;
let row_stride = width * 3;
for y in 0..height {
let start = y * row_stride;
let end = start + row_stride;
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 {
operation: "jpeg_encode".into(),
reason: e.to_string(),
})?;
Ok(output)
}
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();
let encoder = image::codecs::png::PngEncoder::new(cursor);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|e| PixstripError::Processing {
operation: "png_encode".into(),
reason: e.to_string(),
})?;
// 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 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(),
})?;
Ok(optimized)
}
fn encode_webp(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let encoder = webp::Encoder::from_image(img).map_err(|e| PixstripError::Processing {
operation: "webp_encode".into(),
reason: e.to_string(),
})?;
let mem = encoder.encode(quality as f32);
Ok(mem.to_vec())
}
fn encode_avif(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let cursor = Cursor::new(&mut buf);
let rgba = img.to_rgba8();
let speed = self.options.avif_speed.clamp(0, 10);
let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
cursor,
speed,
quality,
);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|e| PixstripError::Processing {
operation: "avif_encode".into(),
reason: e.to_string(),
})?;
Ok(buf)
}
fn encode_fallback(
&self,
img: &image::DynamicImage,
format: image::ImageFormat,
) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let cursor = Cursor::new(&mut buf);
img.write_to(cursor, format).map_err(|e| PixstripError::Processing {
operation: "encode".into(),
reason: e.to_string(),
})?;
Ok(buf)
}
}
/// Insert a pHYs chunk into PNG data to set DPI.
/// The pHYs chunk must appear before the first IDAT chunk.
/// DPI is converted to pixels per meter (1 inch = 0.0254 meters).
fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
// Not a valid PNG (too short for signature) - return as-is
if png_data.len() < 8 {
return png_data.to_vec();
}
// PNG pixels per meter = DPI / 0.0254
let ppm = (dpi as f64 / 0.0254).round() as u32;
// Build the pHYs chunk:
// 4 bytes: data length (9)
// 4 bytes: chunk type "pHYs"
// 4 bytes: pixels per unit X
// 4 bytes: pixels per unit Y
// 1 byte: unit (1 = meter)
// 4 bytes: CRC32 of type + data
let mut chunk_data = Vec::with_capacity(9);
chunk_data.extend_from_slice(&ppm.to_be_bytes()); // X pixels per unit
chunk_data.extend_from_slice(&ppm.to_be_bytes()); // Y pixels per unit
chunk_data.push(1); // unit = meter
let mut crc_input = Vec::with_capacity(13);
crc_input.extend_from_slice(b"pHYs");
crc_input.extend_from_slice(&chunk_data);
let crc = crc32_png(&crc_input);
let mut phys_chunk = Vec::with_capacity(21);
phys_chunk.extend_from_slice(&9u32.to_be_bytes()); // length
phys_chunk.extend_from_slice(b"pHYs"); // type
phys_chunk.extend_from_slice(&chunk_data); // data
phys_chunk.extend_from_slice(&crc.to_be_bytes()); // CRC
// Find the first IDAT chunk and insert pHYs before it.
// 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() {
let chunk_len = u32::from_be_bytes([
png_data[pos], png_data[pos + 1], png_data[pos + 2], png_data[pos + 3],
]) as usize;
let chunk_type = &png_data[pos + 4..pos + 8];
// Use checked arithmetic to prevent overflow on malformed PNGs
let Some(total_chunk_size) = chunk_len.checked_add(12) else {
break; // chunk_len so large it overflows - malformed PNG
};
if pos.checked_add(total_chunk_size).map_or(true, |end| end > 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]);
pos += total_chunk_size;
}
// Copy any remaining bytes (e.g. trailing data after a truncated chunk)
if pos < png_data.len() {
result.extend_from_slice(&png_data[pos..]);
}
result
}
/// Simple CRC32 for PNG chunks (uses the standard PNG CRC polynomial)
fn crc32_png(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
crc ^ 0xFFFF_FFFF
}
impl Default for OutputEncoder {
fn default() -> Self {
Self::new()
}
}