From 88bab330b64d887bfd777ead4d1ae6d50b62d418 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:49:10 +0800 Subject: [PATCH 01/31] chore: add .gitkeep file to test-files directory for organization of test assets --- test-files/.gitkeep | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 test-files/.gitkeep diff --git a/test-files/.gitkeep b/test-files/.gitkeep new file mode 100644 index 0000000..cfdb5e8 --- /dev/null +++ b/test-files/.gitkeep @@ -0,0 +1,13 @@ +# Test Files Directory + +This directory contains test files used for development and testing. + +## Structure + +- `pdf/` - PDF test files (scanned documents, multi-page PDFs, etc.) +- `images/` - Image test files (JPG, PNG, DNG, etc.) +- `output/` - Generated output files from tests (should be gitignored) + +## Usage + +Place test files here for local development and testing. These files are typically not committed to the repository. From 31a053e0eed0a8d3dde60cafc8e0f481bf37ff56 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:49:16 +0800 Subject: [PATCH 02/31] chore: update .gitignore to include test files while preserving directory structure --- .gitignore | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 21c9eac..d94ea70 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ desktop.ini *.tmp *.temp - -# PDF files -*.pdf +# Test files (but keep directory structure with .gitkeep) +test-files/**/* +!test-files/**/.gitkeep +test-files/output/ From a5d1a0bac5d3e16b9e1c30122fcb426422e2c072 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:30:51 +0800 Subject: [PATCH 03/31] feat(core): introduce image format utilities for detection and conversion --- crates/core/src/utils/image.rs | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 crates/core/src/utils/image.rs diff --git a/crates/core/src/utils/image.rs b/crates/core/src/utils/image.rs new file mode 100644 index 0000000..87e109a --- /dev/null +++ b/crates/core/src/utils/image.rs @@ -0,0 +1,122 @@ +//! # Image Format Utilities +//! +//! Types and helpers for image format detection and conversion. + +use std::path::Path; +use std::str::FromStr; + +/// Supported image formats for conversion +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageFormat { + Jpeg, + Png, + WebP, + Avif, + Tiff, + Gif, +} + +impl ImageFormat { + /// Detect format from file extension + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "jpg" | "jpeg" => Some(Self::Jpeg), + "png" => Some(Self::Png), + "webp" => Some(Self::WebP), + "avif" => Some(Self::Avif), + "tiff" | "tif" => Some(Self::Tiff), + "gif" => Some(Self::Gif), + _ => None, + } + } + + /// Get canonical file extension + pub fn extension(&self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::WebP => "webp", + Self::Avif => "avif", + Self::Tiff => "tiff", + Self::Gif => "gif", + } + } + + /// Detect format from file path + pub fn from_path(path: &Path) -> Option { + path.extension() + .and_then(|ext| ext.to_str()) + .and_then(Self::from_extension) + } + + /// Get all supported format extensions as a comma-separated string + pub fn supported_extensions() -> &'static str { + "jpg, jpeg, png, webp, avif, tiff, tif, gif" + } +} + +impl FromStr for ImageFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::from_extension(s).ok_or_else(|| { + format!( + "Unknown image format: '{}'. Supported: {}", + s, + Self::supported_extensions() + ) + }) + } +} + +impl std::fmt::Display for ImageFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.extension()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_from_extension() { + assert_eq!(ImageFormat::from_extension("jpg"), Some(ImageFormat::Jpeg)); + assert_eq!(ImageFormat::from_extension("jpeg"), Some(ImageFormat::Jpeg)); + assert_eq!(ImageFormat::from_extension("JPG"), Some(ImageFormat::Jpeg)); + assert_eq!(ImageFormat::from_extension("png"), Some(ImageFormat::Png)); + assert_eq!(ImageFormat::from_extension("webp"), Some(ImageFormat::WebP)); + assert_eq!(ImageFormat::from_extension("avif"), Some(ImageFormat::Avif)); + assert_eq!(ImageFormat::from_extension("tiff"), Some(ImageFormat::Tiff)); + assert_eq!(ImageFormat::from_extension("tif"), Some(ImageFormat::Tiff)); + assert_eq!(ImageFormat::from_extension("gif"), Some(ImageFormat::Gif)); + assert_eq!(ImageFormat::from_extension("xyz"), None); + } + + #[test] + fn test_format_extension() { + assert_eq!(ImageFormat::Jpeg.extension(), "jpg"); + assert_eq!(ImageFormat::Png.extension(), "png"); + assert_eq!(ImageFormat::WebP.extension(), "webp"); + } + + #[test] + fn test_format_from_path() { + assert_eq!( + ImageFormat::from_path(Path::new("photo.jpg")), + Some(ImageFormat::Jpeg) + ); + assert_eq!( + ImageFormat::from_path(Path::new("/path/to/image.webp")), + Some(ImageFormat::WebP) + ); + assert_eq!(ImageFormat::from_path(Path::new("file.unknown")), None); + } + + #[test] + fn test_format_from_str() { + assert_eq!("jpg".parse::().unwrap(), ImageFormat::Jpeg); + assert_eq!("webp".parse::().unwrap(), ImageFormat::WebP); + assert!("xyz".parse::().is_err()); + } +} From 6db920df470e19709ae952cd76cd93d93b22e4d6 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:30:59 +0800 Subject: [PATCH 04/31] feat(core): add image module for image format utilities --- crates/core/src/utils/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/core/src/utils/mod.rs b/crates/core/src/utils/mod.rs index f55eaa5..9a06d37 100644 --- a/crates/core/src/utils/mod.rs +++ b/crates/core/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod image; pub mod pages; pub mod platform; pub mod temp; From 619832f93e1f4f8e9f823a294aa068443551117f Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:31:13 +0800 Subject: [PATCH 05/31] feat(core): implement `ImageMagick` tool adapter for image processing --- crates/core/src/tools/imagemagick.rs | 366 +++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 crates/core/src/tools/imagemagick.rs diff --git a/crates/core/src/tools/imagemagick.rs b/crates/core/src/tools/imagemagick.rs new file mode 100644 index 0000000..82aac17 --- /dev/null +++ b/crates/core/src/tools/imagemagick.rs @@ -0,0 +1,366 @@ +//! # ImageMagick Tool Adapter +//! +//! ImageMagick is the fallback image processing tool. We use: +//! - `magick convert` (ImageMagick 7) or `convert` (ImageMagick 6) +//! +//! ## Why as Fallback? +//! +//! - More widely available, especially on Windows +//! - Full feature parity with libvips for our use cases +//! - Slower than libvips for large images +//! +//! ## Minimum Version: 7.1+ + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::tools::{Tool, ToolConfig, ToolInfo}; +use crate::utils::error::{ForgeKitError, Result}; +use crate::utils::image::ImageFormat; +use crate::utils::platform::ToolInstallHints; + +/// ImageMagick tool adapter. +pub struct ImageMagickTool; + +impl Tool for ImageMagickTool { + fn name(&self) -> &'static str { + "magick" + } + + fn probe(&self, config: &ToolConfig) -> Result { + // Check override path first + if let Some(ref path) = config.override_path { + if path.exists() { + let version = self.version(path)?; + return Ok(ToolInfo { + path: path.clone(), + version, + available: true, + }); + } + } + + // Try 'magick' first (ImageMagick 7), then 'convert' (ImageMagick 6) + // Same binaries on all platforms + let binaries = vec!["magick", "convert"]; + + for binary in binaries { + let which_output = if cfg!(target_os = "windows") { + Command::new("where").arg(binary).output() + } else { + Command::new("which").arg(binary).output() + }; + + if let Ok(output) = which_output { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + + if !path_str.is_empty() { + let path = PathBuf::from(&path_str); + + // Verify it's ImageMagick (not some other 'convert') + if let Ok(version) = self.version(&path) { + if version.to_lowercase().contains("imagemagick") { + return Ok(ToolInfo { + path, + version, + available: true, + }); + } + } + } + } + } + } + + Err(ForgeKitError::ToolNotFound { + tool: "imagemagick".to_string(), + hint: ToolInstallHints::for_tool("imagemagick"), + }) + } + + fn version(&self, path: &Path) -> Result { + // For 'magick', use 'magick --version' + // For 'convert', use 'convert --version' + let output = Command::new(path).arg("--version").output().map_err(|e| { + ForgeKitError::Other(anyhow::anyhow!("Failed to run imagemagick: {}", e)) + })?; + + if !output.status.success() { + return Err(ForgeKitError::Other(anyhow::anyhow!( + "imagemagick --version failed" + ))); + } + + // Output looks like: "Version: ImageMagick 7.1.1-..." + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .to_string(); + + Ok(version.trim().to_string()) + } +} + +impl ImageMagickTool { + /// Check if the tool path is 'magick' (v7) requiring subcommand. + fn is_magick_v7(tool_path: &Path) -> bool { + tool_path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.starts_with("magick")) + .unwrap_or(false) + } + + /// Convert image format with optional quality and strip options. + pub fn convert( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + _format: &ImageFormat, + quality: Option, + strip: bool, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + + // ImageMagick 7 uses 'magick convert', v6 uses 'convert' directly + if Self::is_magick_v7(tool_path) { + cmd.arg("convert"); + } + + cmd.arg(input); + + if let Some(q) = quality { + cmd.arg("-quality").arg(q.to_string()); + } + + if strip { + cmd.arg("-strip"); + } + + cmd.arg(output); + + let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { + tool: "imagemagick".to_string(), + stderr: format!("Failed to execute imagemagick: {}", e), + })?; + + if !result.status.success() { + return Err(ForgeKitError::ProcessingFailed { + tool: "imagemagick".to_string(), + stderr: String::from_utf8_lossy(&result.stderr).to_string(), + }); + } + + Ok(()) + } + + /// Resize image preserving aspect ratio. + pub fn resize( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + width: Option, + height: Option, + ) -> Result<()> { + let size = match (width, height) { + (Some(w), Some(h)) => format!("{}x{}", w, h), + (Some(w), None) => format!("{}x", w), + (None, Some(h)) => format!("x{}", h), + (None, None) => { + return Err(ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Width or height required for resize".to_string(), + }); + } + }; + + let mut cmd = Command::new(tool_path); + + if Self::is_magick_v7(tool_path) { + cmd.arg("convert"); + } + + cmd.arg(input); + cmd.arg("-resize").arg(&size); + cmd.arg(output); + + let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { + tool: "imagemagick".to_string(), + stderr: format!("Failed to execute imagemagick: {}", e), + })?; + + if !result.status.success() { + return Err(ForgeKitError::ProcessingFailed { + tool: "imagemagick".to_string(), + stderr: String::from_utf8_lossy(&result.stderr).to_string(), + }); + } + + Ok(()) + } + + /// Strip metadata from image. + pub fn strip_metadata(&self, tool_path: &Path, input: &Path, output: &Path) -> Result<()> { + let mut cmd = Command::new(tool_path); + + if Self::is_magick_v7(tool_path) { + cmd.arg("convert"); + } + + cmd.arg(input); + cmd.arg("-strip"); + cmd.arg(output); + + let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { + tool: "imagemagick".to_string(), + stderr: format!("Failed to execute imagemagick: {}", e), + })?; + + if !result.status.success() { + return Err(ForgeKitError::ProcessingFailed { + tool: "imagemagick".to_string(), + stderr: String::from_utf8_lossy(&result.stderr).to_string(), + }); + } + + Ok(()) + } + + /// Build plan command string for convert operation. + pub fn plan_convert(input: &Path, output: &Path, quality: Option, strip: bool) -> String { + let mut parts = vec!["magick", "convert"]; + parts.push(&*Box::leak(input.display().to_string().into_boxed_str())); + + let quality_str; + if let Some(q) = quality { + parts.push("-quality"); + quality_str = q.to_string(); + parts.push(&quality_str); + } + + if strip { + parts.push("-strip"); + } + + // Build the string manually to avoid lifetime issues + let mut result = String::from("magick convert "); + result.push_str(&input.display().to_string()); + + if let Some(q) = quality { + result.push_str(&format!(" -quality {}", q)); + } + + if strip { + result.push_str(" -strip"); + } + + result.push(' '); + result.push_str(&output.display().to_string()); + + result + } + + /// Build plan command string for resize operation. + pub fn plan_resize( + input: &Path, + output: &Path, + width: Option, + height: Option, + ) -> String { + let size = match (width, height) { + (Some(w), Some(h)) => format!("{}x{}", w, h), + (Some(w), None) => format!("{}x", w), + (None, Some(h)) => format!("x{}", h), + (None, None) => "?".to_string(), + }; + + format!( + "magick convert {} -resize {} {}", + input.display(), + size, + output.display() + ) + } + + /// Build plan command string for strip operation. + pub fn plan_strip(input: &Path, output: &Path) -> String { + format!( + "magick convert {} -strip {}", + input.display(), + output.display() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_imagemagick_name() { + let tool = ImageMagickTool; + assert_eq!(tool.name(), "magick"); + } + + #[test] + fn test_imagemagick_probe() { + let tool = ImageMagickTool; + let config = ToolConfig::default(); + match tool.probe(&config) { + Ok(info) => { + assert!(info.available); + assert!(!info.version.is_empty()); + // Verify it's ImageMagick + assert!(info.version.to_lowercase().contains("imagemagick")); + } + Err(ForgeKitError::ToolNotFound { .. }) => { + // Expected if ImageMagick is not installed + } + Err(e) => panic!("Unexpected error: {:?}", e), + } + } + + #[test] + fn test_is_magick_v7() { + assert!(ImageMagickTool::is_magick_v7(Path::new("/usr/bin/magick"))); + assert!(ImageMagickTool::is_magick_v7(Path::new("magick.exe"))); + assert!(!ImageMagickTool::is_magick_v7(Path::new( + "/usr/bin/convert" + ))); + } + + #[test] + fn test_plan_convert() { + let plan = ImageMagickTool::plan_convert( + Path::new("input.jpg"), + Path::new("output.webp"), + Some(80), + true, + ); + assert!(plan.contains("magick convert")); + assert!(plan.contains("input.jpg")); + assert!(plan.contains("-quality 80")); + assert!(plan.contains("-strip")); + } + + #[test] + fn test_plan_resize() { + let plan = ImageMagickTool::plan_resize( + Path::new("input.jpg"), + Path::new("output.jpg"), + Some(800), + None, + ); + assert!(plan.contains("magick convert")); + assert!(plan.contains("-resize 800x")); + } +} From 720e314ef01dd541244df13043209bb3045bf4db Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:31:19 +0800 Subject: [PATCH 06/31] feat(core): add `libvips` tool adapter for efficient image processing --- crates/core/src/tools/libvips.rs | 334 +++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 crates/core/src/tools/libvips.rs diff --git a/crates/core/src/tools/libvips.rs b/crates/core/src/tools/libvips.rs new file mode 100644 index 0000000..ffb8443 --- /dev/null +++ b/crates/core/src/tools/libvips.rs @@ -0,0 +1,334 @@ +//! # libvips Tool Adapter +//! +//! libvips is a fast image processing library. We use the `vips` CLI for: +//! - Format conversion (vips copy) +//! - Resizing (vipsthumbnail) +//! - Metadata stripping (vips copy with [strip] suffix) +//! +//! ## Why libvips? +//! +//! - 10-100x faster than ImageMagick for large images +//! - Streaming architecture uses constant memory +//! - Excellent quality with proper resampling +//! +//! ## Minimum Version: 8.12+ + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::tools::{Tool, ToolConfig, ToolInfo}; +use crate::utils::error::{ForgeKitError, Result}; +use crate::utils::image::ImageFormat; +use crate::utils::platform::ToolInstallHints; + +/// libvips tool adapter. +pub struct LibvipsTool; + +impl Tool for LibvipsTool { + fn name(&self) -> &'static str { + "vips" + } + + fn probe(&self, config: &ToolConfig) -> Result { + // Check override path first + if let Some(ref path) = config.override_path { + if path.exists() { + let version = self.version(path)?; + return Ok(ToolInfo { + path: path.clone(), + version, + available: true, + }); + } + } + + // Probe PATH for 'vips' command + let which_output = if cfg!(target_os = "windows") { + Command::new("where").arg("vips").output() + } else { + Command::new("which").arg("vips").output() + }; + + let path = match which_output { + Ok(output) if output.status.success() => { + let path_str = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !path_str.is_empty() { + PathBuf::from(path_str) + } else { + PathBuf::from("vips") + } + } + _ => PathBuf::from("vips"), + }; + + // Verify it works + let output = Command::new(&path).arg("--version").output().map_err(|_| { + ForgeKitError::ToolNotFound { + tool: "vips".to_string(), + hint: ToolInstallHints::for_tool("libvips"), + } + })?; + + if !output.status.success() { + return Err(ForgeKitError::ToolNotFound { + tool: "vips".to_string(), + hint: ToolInstallHints::for_tool("libvips"), + }); + } + + let version = self.version(&path)?; + + Ok(ToolInfo { + path, + version, + available: true, + }) + } + + fn version(&self, path: &Path) -> Result { + let output = Command::new(path) + .arg("--version") + .output() + .map_err(|e| ForgeKitError::Other(anyhow::anyhow!("Failed to run vips: {}", e)))?; + + if !output.status.success() { + return Err(ForgeKitError::Other(anyhow::anyhow!( + "vips --version failed" + ))); + } + + // vips --version outputs something like: "vips-8.15.0" + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .to_string(); + + Ok(version.trim().to_string()) + } +} + +impl LibvipsTool { + /// Convert image format with optional quality and strip options. + /// + /// Uses `vips copy input output[Q=quality,strip]` syntax. + pub fn convert( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + _format: &ImageFormat, + quality: Option, + strip: bool, + ) -> Result<()> { + let mut cmd = Command::new(tool_path); + cmd.arg("copy"); + cmd.arg(input); + + // Build output with options suffix + let mut options = Vec::new(); + if let Some(q) = quality { + options.push(format!("Q={}", q)); + } + if strip { + options.push("strip".to_string()); + } + + let output_spec = if options.is_empty() { + output.display().to_string() + } else { + format!("{}[{}]", output.display(), options.join(",")) + }; + cmd.arg(&output_spec); + + let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { + tool: "vips".to_string(), + stderr: format!("Failed to execute vips: {}", e), + })?; + + if !result.status.success() { + return Err(ForgeKitError::ProcessingFailed { + tool: "vips".to_string(), + stderr: String::from_utf8_lossy(&result.stderr).to_string(), + }); + } + + Ok(()) + } + + /// Resize image using vipsthumbnail. + /// + /// Preserves aspect ratio by default. + pub fn resize( + &self, + tool_path: &Path, + input: &Path, + output: &Path, + width: Option, + height: Option, + ) -> Result<()> { + // vipsthumbnail is in the same directory as vips + let thumbnail_path = tool_path.with_file_name(if cfg!(target_os = "windows") { + "vipsthumbnail.exe" + } else { + "vipsthumbnail" + }); + + let size = match (width, height) { + (Some(w), Some(h)) => format!("{}x{}", w, h), + (Some(w), None) => format!("{}x", w), + (None, Some(h)) => format!("x{}", h), + (None, None) => { + return Err(ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Width or height required for resize".to_string(), + }); + } + }; + + let mut cmd = Command::new(&thumbnail_path); + cmd.arg(input); + cmd.arg("-s").arg(&size); + cmd.arg("-o").arg(output); + + let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { + tool: "vipsthumbnail".to_string(), + stderr: format!("Failed to execute vipsthumbnail: {}", e), + })?; + + if !result.status.success() { + return Err(ForgeKitError::ProcessingFailed { + tool: "vipsthumbnail".to_string(), + stderr: String::from_utf8_lossy(&result.stderr).to_string(), + }); + } + + Ok(()) + } + + /// Strip metadata from image. + pub fn strip_metadata(&self, tool_path: &Path, input: &Path, output: &Path) -> Result<()> { + let output_spec = format!("{}[strip]", output.display()); + + let mut cmd = Command::new(tool_path); + cmd.arg("copy").arg(input).arg(&output_spec); + + let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { + tool: "vips".to_string(), + stderr: format!("Failed to execute vips: {}", e), + })?; + + if !result.status.success() { + return Err(ForgeKitError::ProcessingFailed { + tool: "vips".to_string(), + stderr: String::from_utf8_lossy(&result.stderr).to_string(), + }); + } + + Ok(()) + } + + /// Build plan command string for convert operation. + pub fn plan_convert(input: &Path, output: &Path, quality: Option, strip: bool) -> String { + let mut options = Vec::new(); + if let Some(q) = quality { + options.push(format!("Q={}", q)); + } + if strip { + options.push("strip".to_string()); + } + + let output_spec = if options.is_empty() { + output.display().to_string() + } else { + format!("{}[{}]", output.display(), options.join(",")) + }; + + format!("vips copy {} {}", input.display(), output_spec) + } + + /// Build plan command string for resize operation. + pub fn plan_resize( + input: &Path, + output: &Path, + width: Option, + height: Option, + ) -> String { + let size = match (width, height) { + (Some(w), Some(h)) => format!("{}x{}", w, h), + (Some(w), None) => format!("{}x", w), + (None, Some(h)) => format!("x{}", h), + (None, None) => "?".to_string(), + }; + + format!( + "vipsthumbnail {} -s {} -o {}", + input.display(), + size, + output.display() + ) + } + + /// Build plan command string for strip operation. + pub fn plan_strip(input: &Path, output: &Path) -> String { + format!("vips copy {} {}[strip]", input.display(), output.display()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_libvips_name() { + let tool = LibvipsTool; + assert_eq!(tool.name(), "vips"); + } + + #[test] + fn test_libvips_probe() { + let tool = LibvipsTool; + let config = ToolConfig::default(); + match tool.probe(&config) { + Ok(info) => { + assert!(info.available); + assert!(!info.version.is_empty()); + } + Err(ForgeKitError::ToolNotFound { .. }) => { + // Expected if libvips is not installed + } + Err(e) => panic!("Unexpected error: {:?}", e), + } + } + + #[test] + fn test_plan_convert() { + let plan = LibvipsTool::plan_convert( + Path::new("input.jpg"), + Path::new("output.webp"), + Some(80), + true, + ); + assert!(plan.contains("vips copy")); + assert!(plan.contains("input.jpg")); + assert!(plan.contains("Q=80")); + assert!(plan.contains("strip")); + } + + #[test] + fn test_plan_resize() { + let plan = LibvipsTool::plan_resize( + Path::new("input.jpg"), + Path::new("output.jpg"), + Some(800), + None, + ); + assert!(plan.contains("vipsthumbnail")); + assert!(plan.contains("-s 800x")); + } +} From 20415f2c3355b83f7478ae7cc7d21566ec6da0ff Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:31:29 +0800 Subject: [PATCH 07/31] feat(core): add `imagemagick` module and re-export tools for image processing --- crates/core/src/tools/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index 356c972..aa58ce3 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -1,7 +1,11 @@ pub mod exiftool; pub mod gs; +pub mod imagemagick; +pub mod libvips; pub mod ocrmypdf; pub mod qpdf; pub mod trait_def; +pub use imagemagick::ImageMagickTool; +pub use libvips::LibvipsTool; pub use trait_def::{Tool, ToolConfig, ToolInfo}; From ae33721d99fc2c028dcb63a13d2073232b9f23cd Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:31:50 +0800 Subject: [PATCH 08/31] feat(core): implement image processing operations including conversion, resizing, and metadata stripping --- crates/core/src/job/executor.rs | 273 +++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 1 deletion(-) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 625cd9c..260c8b9 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -28,10 +28,13 @@ use crate::job::JobSpec; use crate::presets::get_compression_strategy; use crate::tools::exiftool::ExiftoolTool; use crate::tools::gs::GsTool; +use crate::tools::imagemagick::ImageMagickTool; +use crate::tools::libvips::LibvipsTool; use crate::tools::ocrmypdf::OcrmypdfTool; use crate::tools::qpdf::QpdfTool; -use crate::tools::{Tool, ToolConfig}; +use crate::tools::{Tool, ToolConfig, ToolInfo}; use crate::utils::error::{ForgeKitError, Result}; +use crate::utils::image::ImageFormat; use crate::utils::pages::PageSpec; use crate::utils::temp::create_temp_file; use std::time::Instant; @@ -118,6 +121,22 @@ pub fn execute_job_with_progress( output, action, } => execute_pdf_metadata(input, output.as_deref(), action, plan_only), + + // Image operations + JobSpec::ImageConvert { + input, + output, + format, + quality, + strip_metadata, + } => execute_image_convert(input, output, format, *quality, *strip_metadata, plan_only), + JobSpec::ImageResize { + input, + output, + width, + height, + } => execute_image_resize(input, output, *width, *height, plan_only), + JobSpec::ImageStrip { input, output } => execute_image_strip(input, output, plan_only), } } @@ -1041,6 +1060,161 @@ fn execute_pdf_metadata_set( )) } +// ========== Image Operations ========== + +/// Select the best available image tool (libvips preferred, ImageMagick fallback). +/// +/// Returns the tool name ("vips" or "magick") and its path. +fn select_image_tool() -> Result<(String, ToolInfo)> { + let config = ToolConfig::default(); + + // Try libvips first (faster) + let vips = LibvipsTool; + if let Ok(info) = vips.probe(&config) { + return Ok(("vips".to_string(), info)); + } + + // Fall back to ImageMagick + let magick = ImageMagickTool; + if let Ok(info) = magick.probe(&config) { + return Ok(("magick".to_string(), info)); + } + + Err(ForgeKitError::ToolNotFound { + tool: "image processor".to_string(), + hint: "Install libvips (brew install vips) or ImageMagick (brew install imagemagick)" + .to_string(), + }) +} + +fn execute_image_convert( + input: &Path, + output: &Path, + format: &ImageFormat, + quality: Option, + strip: bool, + plan_only: bool, +) -> Result { + if plan_only { + // Generate plan for libvips (preferred) + return Ok(LibvipsTool::plan_convert(input, output, quality, strip)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let (tool_name, tool_info) = select_image_tool()?; + + match tool_name.as_str() { + "vips" => { + let tool = LibvipsTool; + tool.convert(&tool_info.path, input, output, format, quality, strip)?; + } + "magick" => { + let tool = ImageMagickTool; + tool.convert(&tool_info.path, input, output, format, quality, strip)?; + } + _ => unreachable!(), + } + + Ok(format!( + "Successfully converted image to {} ({})", + output.display(), + format.extension() + )) +} + +fn execute_image_resize( + input: &Path, + output: &Path, + width: Option, + height: Option, + plan_only: bool, +) -> Result { + if width.is_none() && height.is_none() { + return Err(ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Width or height required for resize".to_string(), + }); + } + + if plan_only { + // Generate plan for libvips (preferred) + return Ok(LibvipsTool::plan_resize(input, output, width, height)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let (tool_name, tool_info) = select_image_tool()?; + + match tool_name.as_str() { + "vips" => { + let tool = LibvipsTool; + tool.resize(&tool_info.path, input, output, width, height)?; + } + "magick" => { + let tool = ImageMagickTool; + tool.resize(&tool_info.path, input, output, width, height)?; + } + _ => unreachable!(), + } + + let size_str = match (width, height) { + (Some(w), Some(h)) => format!("{}x{}", w, h), + (Some(w), None) => format!("width {}", w), + (None, Some(h)) => format!("height {}", h), + (None, None) => "?".to_string(), + }; + + Ok(format!( + "Successfully resized image to {} ({})", + output.display(), + size_str + )) +} + +fn execute_image_strip(input: &Path, output: &Path, plan_only: bool) -> Result { + if plan_only { + // Generate plan for libvips (preferred) + return Ok(LibvipsTool::plan_strip(input, output)); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let (tool_name, tool_info) = select_image_tool()?; + + match tool_name.as_str() { + "vips" => { + let tool = LibvipsTool; + tool.strip_metadata(&tool_info.path, input, output)?; + } + "magick" => { + let tool = ImageMagickTool; + tool.strip_metadata(&tool_info.path, input, output)?; + } + _ => unreachable!(), + } + + Ok(format!( + "Successfully stripped metadata from image: {}", + output.display() + )) +} + #[cfg(test)] mod tests { use super::*; @@ -1325,3 +1499,100 @@ mod metadata_tests { } } } + +#[cfg(test)] +mod image_operation_tests { + use super::*; + use crate::utils::image::ImageFormat; + + #[test] + fn test_execute_image_convert_plan() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("photo.webp"); + + let result = + execute_image_convert(&input, &output, &ImageFormat::WebP, Some(80), true, true) + .unwrap(); + + // Plan uses libvips format + assert!(result.contains("vips copy")); + assert!(result.contains("photo.jpg")); + assert!(result.contains("Q=80")); + assert!(result.contains("strip")); + } + + #[test] + fn test_execute_image_convert_plan_no_strip() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("photo.png"); + + let result = + execute_image_convert(&input, &output, &ImageFormat::Png, None, false, true).unwrap(); + + assert!(result.contains("vips copy")); + assert!(result.contains("photo.png")); + assert!(!result.contains("Q=")); + assert!(!result.contains("strip")); + } + + #[test] + fn test_execute_image_resize_plan() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("thumb.jpg"); + + let result = execute_image_resize(&input, &output, Some(800), Some(600), true).unwrap(); + + assert!(result.contains("vipsthumbnail")); + assert!(result.contains("-s 800x600")); + } + + #[test] + fn test_execute_image_resize_plan_width_only() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("thumb.jpg"); + + let result = execute_image_resize(&input, &output, Some(800), None, true).unwrap(); + + assert!(result.contains("vipsthumbnail")); + assert!(result.contains("-s 800x")); + } + + #[test] + fn test_execute_image_resize_plan_height_only() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("thumb.jpg"); + + let result = execute_image_resize(&input, &output, None, Some(600), true).unwrap(); + + assert!(result.contains("vipsthumbnail")); + assert!(result.contains("-s x600")); + } + + #[test] + fn test_execute_image_resize_requires_dimensions() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("thumb.jpg"); + + let result = execute_image_resize(&input, &output, None, None, true); + + assert!(result.is_err()); + match result { + Err(ForgeKitError::InvalidInput { reason, .. }) => { + assert!(reason.contains("Width or height required")); + } + _ => panic!("Expected InvalidInput error"), + } + } + + #[test] + fn test_execute_image_strip_plan() { + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("clean.jpg"); + + let result = execute_image_strip(&input, &output, true).unwrap(); + + assert!(result.contains("vips copy")); + assert!(result.contains("photo.jpg")); + assert!(result.contains("[strip]")); + } +} From 1562af384bb98e4b6180cc208dfe8081fa5fc0b8 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:32:01 +0800 Subject: [PATCH 09/31] feat(core): enhance image processing capabilities with detailed operations for conversion, resizing, and metadata stripping --- crates/core/src/job/spec.rs | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index 0e32e80..e6727e7 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -11,6 +11,7 @@ //! - **Serialization**: Could save jobs to disk, queue them, etc. (future) //! - **Clarity**: The executor code is cleaner when it just focuses on "how", not "what" +use crate::utils::image::ImageFormat; use crate::utils::pages::PageSpec; use std::path::PathBuf; @@ -142,6 +143,50 @@ pub enum JobSpec { /// The metadata action to perform. action: MetadataAction, }, + + // ========== Image Operations ========== + /// Convert image to a different format. + /// + /// Uses libvips (preferred) or ImageMagick (fallback) for conversion. + /// Supports JPEG, PNG, WebP, AVIF, TIFF, and GIF formats. + ImageConvert { + /// Input image file. + input: PathBuf, + /// Output image file. + output: PathBuf, + /// Target format (auto-detected from output extension if not specified). + format: ImageFormat, + /// Quality (0-100). Format-dependent: JPEG/WebP/AVIF use this. + quality: Option, + /// Strip metadata during conversion. + strip_metadata: bool, + }, + + /// Resize image with aspect ratio preservation. + /// + /// Resizes to fit within the specified dimensions while preserving + /// the original aspect ratio. Specify width, height, or both. + ImageResize { + /// Input image file. + input: PathBuf, + /// Output image file. + output: PathBuf, + /// Target width (if only width specified, height calculated to preserve ratio). + width: Option, + /// Target height (if only height specified, width calculated to preserve ratio). + height: Option, + }, + + /// Strip EXIF and other metadata from image. + /// + /// Removes EXIF, XMP, IPTC, ICC profiles, and other metadata. + /// Useful for privacy before sharing images. + ImageStrip { + /// Input image file. + input: PathBuf, + /// Output image file. + output: PathBuf, + }, } impl JobSpec { @@ -183,6 +228,22 @@ impl JobSpec { format!("Set {} PDF metadata field(s)", fields.len()) } }, + JobSpec::ImageConvert { + format, quality, .. + } => { + if let Some(q) = quality { + format!("Convert image to {} (quality {})", format.extension(), q) + } else { + format!("Convert image to {}", format.extension()) + } + } + JobSpec::ImageResize { width, height, .. } => match (width, height) { + (Some(w), Some(h)) => format!("Resize image to {}x{}", w, h), + (Some(w), None) => format!("Resize image to width {}", w), + (None, Some(h)) => format!("Resize image to height {}", h), + (None, None) => "Resize image".to_string(), + }, + JobSpec::ImageStrip { .. } => "Strip image metadata".to_string(), } } } @@ -295,4 +356,59 @@ mod tests { }; assert_eq!(spec.description(), "Set 2 PDF metadata field(s)"); } + + #[test] + fn test_image_convert_description() { + let spec = JobSpec::ImageConvert { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("photo.webp"), + format: ImageFormat::WebP, + quality: Some(80), + strip_metadata: false, + }; + assert_eq!(spec.description(), "Convert image to webp (quality 80)"); + } + + #[test] + fn test_image_convert_description_no_quality() { + let spec = JobSpec::ImageConvert { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("photo.png"), + format: ImageFormat::Png, + quality: None, + strip_metadata: true, + }; + assert_eq!(spec.description(), "Convert image to png"); + } + + #[test] + fn test_image_resize_description() { + let spec = JobSpec::ImageResize { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("thumb.jpg"), + width: Some(800), + height: Some(600), + }; + assert_eq!(spec.description(), "Resize image to 800x600"); + } + + #[test] + fn test_image_resize_description_width_only() { + let spec = JobSpec::ImageResize { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("thumb.jpg"), + width: Some(800), + height: None, + }; + assert_eq!(spec.description(), "Resize image to width 800"); + } + + #[test] + fn test_image_strip_description() { + let spec = JobSpec::ImageStrip { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("clean.jpg"), + }; + assert_eq!(spec.description(), "Strip image metadata"); + } } From 6c14ee8229eb355df3ea312a21108585586324fa Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:32:15 +0800 Subject: [PATCH 10/31] feat(cli): add image command module for conversion, resizing, and metadata stripping operations --- crates/cli/src/commands/image.rs | 211 +++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 crates/cli/src/commands/image.rs diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs new file mode 100644 index 0000000..726e12e --- /dev/null +++ b/crates/cli/src/commands/image.rs @@ -0,0 +1,211 @@ +use clap::{Args, Subcommand}; +use forgekit_core::job::JobSpec; +use forgekit_core::utils::error::Result; +use forgekit_core::utils::image::ImageFormat; +use std::path::PathBuf; + +#[derive(Subcommand, Clone)] +pub enum ImageCommand { + /// Convert image to a different format + /// + /// Examples: + /// forgekit image convert photo.jpg --output photo.webp + /// forgekit image convert photo.jpg --output photo.webp --quality 80 + /// forgekit image convert photo.dng --output photo.jpg --strip + /// + /// Supported formats: jpeg/jpg, png, webp, avif, tiff, gif + /// Format is auto-detected from the output extension, or use --to to specify. + Convert(ConvertArgs), + + /// Resize image preserving aspect ratio + /// + /// Examples: + /// forgekit image resize photo.jpg --output thumb.jpg --width 800 + /// forgekit image resize photo.jpg --output thumb.jpg --height 600 + /// forgekit image resize photo.jpg --output thumb.jpg --width 800 --height 600 + /// + /// Specify width, height, or both. Aspect ratio is always preserved. + Resize(ResizeArgs), + + /// Strip EXIF and other metadata from image + /// + /// Examples: + /// forgekit image strip photo.jpg --output clean.jpg + /// + /// Removes EXIF, XMP, IPTC, ICC profiles, and other metadata. + /// Useful for privacy before sharing images. + Strip(StripArgs), +} + +#[derive(Args, Clone)] +pub struct ConvertArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: PathBuf, + + /// Output image file + #[arg(short, long, required = true, help = "Output image file")] + pub output: PathBuf, + + /// Target format (auto-detected from output extension if not specified) + #[arg(long, help = "Target format: jpeg, png, webp, avif, tiff, gif")] + pub to: Option, + + /// Quality (0-100). Applies to JPEG, WebP, and AVIF formats + #[arg(short, long, help = "Quality (0-100). Applies to JPEG, WebP, AVIF")] + pub quality: Option, + + /// Strip metadata during conversion + #[arg(long, help = "Strip EXIF and other metadata")] + pub strip: bool, +} + +#[derive(Args, Clone)] +pub struct ResizeArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: PathBuf, + + /// Output image file + #[arg(short, long, required = true, help = "Output image file")] + pub output: PathBuf, + + /// Target width (preserves aspect ratio if height not specified) + #[arg(short, long, help = "Target width in pixels")] + pub width: Option, + + /// Target height (preserves aspect ratio if width not specified) + #[arg(short = 'H', long, help = "Target height in pixels")] + pub height: Option, +} + +#[derive(Args, Clone)] +pub struct StripArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: PathBuf, + + /// Output image file + #[arg(short, long, required = true, help = "Output image file")] + pub output: PathBuf, +} + +pub fn handle_image_command(cmd: ImageCommand, plan_only: bool, json_output: bool) -> Result<()> { + match cmd { + ImageCommand::Convert(args) => handle_convert(args, plan_only, json_output), + ImageCommand::Resize(args) => handle_resize(args, plan_only, json_output), + ImageCommand::Strip(args) => handle_strip(args, plan_only, json_output), + } +} + +fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Result<()> { + // Determine format from --to flag or output extension + let format = if let Some(ref to) = args.to { + to.parse::().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Unknown format '{}'. Supported: jpeg, png, webp, avif, tiff, gif", + to + ), + } + })? + } else { + ImageFormat::from_path(&args.output).ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.output.clone(), + reason: + "Cannot determine format from output extension. Use --to to specify format." + .to_string(), + } + })? + }; + + // Validate quality range + if let Some(q) = args.quality { + if q > 100 { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Quality must be between 0 and 100".to_string(), + }); + } + } + + let spec = JobSpec::ImageConvert { + input: args.input, + output: args.output, + format, + quality: args.quality, + strip_metadata: args.strip, + }; + + execute_image_job(&spec, plan_only, json_output) +} + +fn handle_resize(args: ResizeArgs, plan_only: bool, json_output: bool) -> Result<()> { + // Validate that at least one dimension is specified + if args.width.is_none() && args.height.is_none() { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "At least one of --width or --height is required".to_string(), + }); + } + + let spec = JobSpec::ImageResize { + input: args.input, + output: args.output, + width: args.width, + height: args.height, + }; + + execute_image_job(&spec, plan_only, json_output) +} + +fn handle_strip(args: StripArgs, plan_only: bool, json_output: bool) -> Result<()> { + let spec = JobSpec::ImageStrip { + input: args.input, + output: args.output, + }; + + execute_image_job(&spec, plan_only, json_output) +} + +fn execute_image_job(spec: &JobSpec, plan_only: bool, json_output: bool) -> Result<()> { + if plan_only { + let plan = forgekit_core::job::executor::execute_job(spec, true)?; + if json_output { + let event = forgekit_core::job::progress::ProgressEvent::Progress { + version: 1, + job_id: forgekit_core::job::progress::new_job_id(), + progress: forgekit_core::job::progress::ProgressInfo { + current: 0, + total: 1, + percent: 0, + stage: Some("plan".to_string()), + }, + message: plan.clone(), + }; + println!("{}", serde_json::to_string(&event).unwrap()); + } else { + println!("{}", plan); + } + Ok(()) + } else { + let result = forgekit_core::job::executor::execute_job(spec, false)?; + if json_output { + let event = forgekit_core::job::progress::ProgressEvent::Complete { + version: 1, + job_id: forgekit_core::job::progress::new_job_id(), + result: forgekit_core::job::progress::JobResult { + output: result.clone(), + size_bytes: 0, + duration_ms: 0, + }, + }; + println!("{}", serde_json::to_string(&event).unwrap()); + } else { + println!("{}", result); + } + Ok(()) + } +} From 4fdd50ca2492e58157ca345d760f3167dbcbbfbb Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:32:25 +0800 Subject: [PATCH 11/31] feat(cli): register additional image processing tools (ImageMagick and Libvips) in check command --- crates/cli/src/commands/check.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/check.rs b/crates/cli/src/commands/check.rs index 565adec..11675a3 100644 --- a/crates/cli/src/commands/check.rs +++ b/crates/cli/src/commands/check.rs @@ -1,5 +1,7 @@ use forgekit_core::tools::exiftool::ExiftoolTool; use forgekit_core::tools::gs::GsTool; +use forgekit_core::tools::imagemagick::ImageMagickTool; +use forgekit_core::tools::libvips::LibvipsTool; use forgekit_core::tools::ocrmypdf::OcrmypdfTool; use forgekit_core::tools::qpdf::QpdfTool; use forgekit_core::tools::{Tool, ToolConfig}; @@ -23,7 +25,8 @@ pub fn handle_check_deps() -> Result<()> { ("gs", Box::new(GsTool)), ("ocrmypdf", Box::new(OcrmypdfTool)), ("exiftool", Box::new(ExiftoolTool)), - // TODO: Add other tools (ffmpeg, libvips, etc.) + ("vips", Box::new(LibvipsTool)), + ("imagemagick", Box::new(ImageMagickTool)), ]; let mut all_ok = true; From 60bd7ad47bf6b961e40cc3cd249411d23aa8a53b Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:32:30 +0800 Subject: [PATCH 12/31] feat(cli): add image module to CLI commands for enhanced image processing capabilities --- crates/cli/src/commands/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b35d0ec..69ad818 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod check; +pub mod image; pub mod pdf; From be1430e01c610cc5dd9dbc83db7a313f520669fa Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:32:34 +0800 Subject: [PATCH 13/31] feat(cli): integrate image command into CLI for handling image operations --- crates/cli/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a7934d4..4066947 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -27,6 +27,7 @@ mod commands; use clap::{Parser, Subcommand}; use commands::check::handle_check_deps; +use commands::image::{handle_image_command, ImageCommand}; use commands::pdf::{handle_pdf_command, PdfCommand}; use forgekit_core::utils::error::{ExitCode, ForgeKitError}; @@ -58,6 +59,10 @@ enum Commands { #[command(subcommand)] Pdf(PdfCommand), + /// Image operations (convert, resize, strip metadata) + #[command(subcommand)] + Image(ImageCommand), + /// Check if required dependencies are installed CheckDeps, } @@ -69,6 +74,7 @@ fn main() { let result = match &cli.command { Some(Commands::Pdf(ref cmd)) => handle_pdf_command(cmd.clone(), plan_only, json_output), + Some(Commands::Image(ref cmd)) => handle_image_command(cmd.clone(), plan_only, json_output), Some(Commands::CheckDeps) => handle_check_deps(), None => { println!("ForgeKit - Local-first media and PDF toolkit"); From 98caa385d6d595ac570834ec88d1431d37dd58b2 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:42:08 +0800 Subject: [PATCH 14/31] refactor(core): remove `ImageMagick` tool and streamline image processing to use only `libvips` --- crates/core/src/job/executor.rs | 78 +----- crates/core/src/tools/imagemagick.rs | 366 --------------------------- crates/core/src/tools/mod.rs | 2 - 3 files changed, 13 insertions(+), 433 deletions(-) delete mode 100644 crates/core/src/tools/imagemagick.rs diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 260c8b9..2136166 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -28,7 +28,6 @@ use crate::job::JobSpec; use crate::presets::get_compression_strategy; use crate::tools::exiftool::ExiftoolTool; use crate::tools::gs::GsTool; -use crate::tools::imagemagick::ImageMagickTool; use crate::tools::libvips::LibvipsTool; use crate::tools::ocrmypdf::OcrmypdfTool; use crate::tools::qpdf::QpdfTool; @@ -1062,29 +1061,11 @@ fn execute_pdf_metadata_set( // ========== Image Operations ========== -/// Select the best available image tool (libvips preferred, ImageMagick fallback). -/// -/// Returns the tool name ("vips" or "magick") and its path. -fn select_image_tool() -> Result<(String, ToolInfo)> { +/// Probe for libvips tool. +fn probe_libvips() -> Result { + let tool = LibvipsTool; let config = ToolConfig::default(); - - // Try libvips first (faster) - let vips = LibvipsTool; - if let Ok(info) = vips.probe(&config) { - return Ok(("vips".to_string(), info)); - } - - // Fall back to ImageMagick - let magick = ImageMagickTool; - if let Ok(info) = magick.probe(&config) { - return Ok(("magick".to_string(), info)); - } - - Err(ForgeKitError::ToolNotFound { - tool: "image processor".to_string(), - hint: "Install libvips (brew install vips) or ImageMagick (brew install imagemagick)" - .to_string(), - }) + tool.probe(&config) } fn execute_image_convert( @@ -1096,7 +1077,6 @@ fn execute_image_convert( plan_only: bool, ) -> Result { if plan_only { - // Generate plan for libvips (preferred) return Ok(LibvipsTool::plan_convert(input, output, quality, strip)); } @@ -1107,19 +1087,9 @@ fn execute_image_convert( }); } - let (tool_name, tool_info) = select_image_tool()?; - - match tool_name.as_str() { - "vips" => { - let tool = LibvipsTool; - tool.convert(&tool_info.path, input, output, format, quality, strip)?; - } - "magick" => { - let tool = ImageMagickTool; - tool.convert(&tool_info.path, input, output, format, quality, strip)?; - } - _ => unreachable!(), - } + let tool_info = probe_libvips()?; + let tool = LibvipsTool; + tool.convert(&tool_info.path, input, output, format, quality, strip)?; Ok(format!( "Successfully converted image to {} ({})", @@ -1143,7 +1113,6 @@ fn execute_image_resize( } if plan_only { - // Generate plan for libvips (preferred) return Ok(LibvipsTool::plan_resize(input, output, width, height)); } @@ -1154,19 +1123,9 @@ fn execute_image_resize( }); } - let (tool_name, tool_info) = select_image_tool()?; - - match tool_name.as_str() { - "vips" => { - let tool = LibvipsTool; - tool.resize(&tool_info.path, input, output, width, height)?; - } - "magick" => { - let tool = ImageMagickTool; - tool.resize(&tool_info.path, input, output, width, height)?; - } - _ => unreachable!(), - } + let tool_info = probe_libvips()?; + let tool = LibvipsTool; + tool.resize(&tool_info.path, input, output, width, height)?; let size_str = match (width, height) { (Some(w), Some(h)) => format!("{}x{}", w, h), @@ -1184,7 +1143,6 @@ fn execute_image_resize( fn execute_image_strip(input: &Path, output: &Path, plan_only: bool) -> Result { if plan_only { - // Generate plan for libvips (preferred) return Ok(LibvipsTool::plan_strip(input, output)); } @@ -1195,19 +1153,9 @@ fn execute_image_strip(input: &Path, output: &Path, plan_only: bool) -> Result { - let tool = LibvipsTool; - tool.strip_metadata(&tool_info.path, input, output)?; - } - "magick" => { - let tool = ImageMagickTool; - tool.strip_metadata(&tool_info.path, input, output)?; - } - _ => unreachable!(), - } + let tool_info = probe_libvips()?; + let tool = LibvipsTool; + tool.strip_metadata(&tool_info.path, input, output)?; Ok(format!( "Successfully stripped metadata from image: {}", diff --git a/crates/core/src/tools/imagemagick.rs b/crates/core/src/tools/imagemagick.rs deleted file mode 100644 index 82aac17..0000000 --- a/crates/core/src/tools/imagemagick.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! # ImageMagick Tool Adapter -//! -//! ImageMagick is the fallback image processing tool. We use: -//! - `magick convert` (ImageMagick 7) or `convert` (ImageMagick 6) -//! -//! ## Why as Fallback? -//! -//! - More widely available, especially on Windows -//! - Full feature parity with libvips for our use cases -//! - Slower than libvips for large images -//! -//! ## Minimum Version: 7.1+ - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use crate::tools::{Tool, ToolConfig, ToolInfo}; -use crate::utils::error::{ForgeKitError, Result}; -use crate::utils::image::ImageFormat; -use crate::utils::platform::ToolInstallHints; - -/// ImageMagick tool adapter. -pub struct ImageMagickTool; - -impl Tool for ImageMagickTool { - fn name(&self) -> &'static str { - "magick" - } - - fn probe(&self, config: &ToolConfig) -> Result { - // Check override path first - if let Some(ref path) = config.override_path { - if path.exists() { - let version = self.version(path)?; - return Ok(ToolInfo { - path: path.clone(), - version, - available: true, - }); - } - } - - // Try 'magick' first (ImageMagick 7), then 'convert' (ImageMagick 6) - // Same binaries on all platforms - let binaries = vec!["magick", "convert"]; - - for binary in binaries { - let which_output = if cfg!(target_os = "windows") { - Command::new("where").arg(binary).output() - } else { - Command::new("which").arg(binary).output() - }; - - if let Ok(output) = which_output { - if output.status.success() { - let path_str = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - - if !path_str.is_empty() { - let path = PathBuf::from(&path_str); - - // Verify it's ImageMagick (not some other 'convert') - if let Ok(version) = self.version(&path) { - if version.to_lowercase().contains("imagemagick") { - return Ok(ToolInfo { - path, - version, - available: true, - }); - } - } - } - } - } - } - - Err(ForgeKitError::ToolNotFound { - tool: "imagemagick".to_string(), - hint: ToolInstallHints::for_tool("imagemagick"), - }) - } - - fn version(&self, path: &Path) -> Result { - // For 'magick', use 'magick --version' - // For 'convert', use 'convert --version' - let output = Command::new(path).arg("--version").output().map_err(|e| { - ForgeKitError::Other(anyhow::anyhow!("Failed to run imagemagick: {}", e)) - })?; - - if !output.status.success() { - return Err(ForgeKitError::Other(anyhow::anyhow!( - "imagemagick --version failed" - ))); - } - - // Output looks like: "Version: ImageMagick 7.1.1-..." - let version = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .to_string(); - - Ok(version.trim().to_string()) - } -} - -impl ImageMagickTool { - /// Check if the tool path is 'magick' (v7) requiring subcommand. - fn is_magick_v7(tool_path: &Path) -> bool { - tool_path - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.starts_with("magick")) - .unwrap_or(false) - } - - /// Convert image format with optional quality and strip options. - pub fn convert( - &self, - tool_path: &Path, - input: &Path, - output: &Path, - _format: &ImageFormat, - quality: Option, - strip: bool, - ) -> Result<()> { - let mut cmd = Command::new(tool_path); - - // ImageMagick 7 uses 'magick convert', v6 uses 'convert' directly - if Self::is_magick_v7(tool_path) { - cmd.arg("convert"); - } - - cmd.arg(input); - - if let Some(q) = quality { - cmd.arg("-quality").arg(q.to_string()); - } - - if strip { - cmd.arg("-strip"); - } - - cmd.arg(output); - - let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { - tool: "imagemagick".to_string(), - stderr: format!("Failed to execute imagemagick: {}", e), - })?; - - if !result.status.success() { - return Err(ForgeKitError::ProcessingFailed { - tool: "imagemagick".to_string(), - stderr: String::from_utf8_lossy(&result.stderr).to_string(), - }); - } - - Ok(()) - } - - /// Resize image preserving aspect ratio. - pub fn resize( - &self, - tool_path: &Path, - input: &Path, - output: &Path, - width: Option, - height: Option, - ) -> Result<()> { - let size = match (width, height) { - (Some(w), Some(h)) => format!("{}x{}", w, h), - (Some(w), None) => format!("{}x", w), - (None, Some(h)) => format!("x{}", h), - (None, None) => { - return Err(ForgeKitError::InvalidInput { - path: PathBuf::new(), - reason: "Width or height required for resize".to_string(), - }); - } - }; - - let mut cmd = Command::new(tool_path); - - if Self::is_magick_v7(tool_path) { - cmd.arg("convert"); - } - - cmd.arg(input); - cmd.arg("-resize").arg(&size); - cmd.arg(output); - - let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { - tool: "imagemagick".to_string(), - stderr: format!("Failed to execute imagemagick: {}", e), - })?; - - if !result.status.success() { - return Err(ForgeKitError::ProcessingFailed { - tool: "imagemagick".to_string(), - stderr: String::from_utf8_lossy(&result.stderr).to_string(), - }); - } - - Ok(()) - } - - /// Strip metadata from image. - pub fn strip_metadata(&self, tool_path: &Path, input: &Path, output: &Path) -> Result<()> { - let mut cmd = Command::new(tool_path); - - if Self::is_magick_v7(tool_path) { - cmd.arg("convert"); - } - - cmd.arg(input); - cmd.arg("-strip"); - cmd.arg(output); - - let result = cmd.output().map_err(|e| ForgeKitError::ProcessingFailed { - tool: "imagemagick".to_string(), - stderr: format!("Failed to execute imagemagick: {}", e), - })?; - - if !result.status.success() { - return Err(ForgeKitError::ProcessingFailed { - tool: "imagemagick".to_string(), - stderr: String::from_utf8_lossy(&result.stderr).to_string(), - }); - } - - Ok(()) - } - - /// Build plan command string for convert operation. - pub fn plan_convert(input: &Path, output: &Path, quality: Option, strip: bool) -> String { - let mut parts = vec!["magick", "convert"]; - parts.push(&*Box::leak(input.display().to_string().into_boxed_str())); - - let quality_str; - if let Some(q) = quality { - parts.push("-quality"); - quality_str = q.to_string(); - parts.push(&quality_str); - } - - if strip { - parts.push("-strip"); - } - - // Build the string manually to avoid lifetime issues - let mut result = String::from("magick convert "); - result.push_str(&input.display().to_string()); - - if let Some(q) = quality { - result.push_str(&format!(" -quality {}", q)); - } - - if strip { - result.push_str(" -strip"); - } - - result.push(' '); - result.push_str(&output.display().to_string()); - - result - } - - /// Build plan command string for resize operation. - pub fn plan_resize( - input: &Path, - output: &Path, - width: Option, - height: Option, - ) -> String { - let size = match (width, height) { - (Some(w), Some(h)) => format!("{}x{}", w, h), - (Some(w), None) => format!("{}x", w), - (None, Some(h)) => format!("x{}", h), - (None, None) => "?".to_string(), - }; - - format!( - "magick convert {} -resize {} {}", - input.display(), - size, - output.display() - ) - } - - /// Build plan command string for strip operation. - pub fn plan_strip(input: &Path, output: &Path) -> String { - format!( - "magick convert {} -strip {}", - input.display(), - output.display() - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_imagemagick_name() { - let tool = ImageMagickTool; - assert_eq!(tool.name(), "magick"); - } - - #[test] - fn test_imagemagick_probe() { - let tool = ImageMagickTool; - let config = ToolConfig::default(); - match tool.probe(&config) { - Ok(info) => { - assert!(info.available); - assert!(!info.version.is_empty()); - // Verify it's ImageMagick - assert!(info.version.to_lowercase().contains("imagemagick")); - } - Err(ForgeKitError::ToolNotFound { .. }) => { - // Expected if ImageMagick is not installed - } - Err(e) => panic!("Unexpected error: {:?}", e), - } - } - - #[test] - fn test_is_magick_v7() { - assert!(ImageMagickTool::is_magick_v7(Path::new("/usr/bin/magick"))); - assert!(ImageMagickTool::is_magick_v7(Path::new("magick.exe"))); - assert!(!ImageMagickTool::is_magick_v7(Path::new( - "/usr/bin/convert" - ))); - } - - #[test] - fn test_plan_convert() { - let plan = ImageMagickTool::plan_convert( - Path::new("input.jpg"), - Path::new("output.webp"), - Some(80), - true, - ); - assert!(plan.contains("magick convert")); - assert!(plan.contains("input.jpg")); - assert!(plan.contains("-quality 80")); - assert!(plan.contains("-strip")); - } - - #[test] - fn test_plan_resize() { - let plan = ImageMagickTool::plan_resize( - Path::new("input.jpg"), - Path::new("output.jpg"), - Some(800), - None, - ); - assert!(plan.contains("magick convert")); - assert!(plan.contains("-resize 800x")); - } -} diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index aa58ce3..a96e601 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -1,11 +1,9 @@ pub mod exiftool; pub mod gs; -pub mod imagemagick; pub mod libvips; pub mod ocrmypdf; pub mod qpdf; pub mod trait_def; -pub use imagemagick::ImageMagickTool; pub use libvips::LibvipsTool; pub use trait_def::{Tool, ToolConfig, ToolInfo}; From 04c0f6658be4e31d49b2d49a788c3ed3e6518e1a Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:42:14 +0800 Subject: [PATCH 15/31] refactor(cli): remove `ImageMagick` tool from check command to simplify image processing --- crates/cli/src/commands/check.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/cli/src/commands/check.rs b/crates/cli/src/commands/check.rs index 11675a3..21803be 100644 --- a/crates/cli/src/commands/check.rs +++ b/crates/cli/src/commands/check.rs @@ -1,6 +1,5 @@ use forgekit_core::tools::exiftool::ExiftoolTool; use forgekit_core::tools::gs::GsTool; -use forgekit_core::tools::imagemagick::ImageMagickTool; use forgekit_core::tools::libvips::LibvipsTool; use forgekit_core::tools::ocrmypdf::OcrmypdfTool; use forgekit_core::tools::qpdf::QpdfTool; @@ -26,7 +25,6 @@ pub fn handle_check_deps() -> Result<()> { ("ocrmypdf", Box::new(OcrmypdfTool)), ("exiftool", Box::new(ExiftoolTool)), ("vips", Box::new(LibvipsTool)), - ("imagemagick", Box::new(ImageMagickTool)), ]; let mut all_ok = true; From de0abdae20589039fc6fea531c62df7495391f86 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:18:37 +0800 Subject: [PATCH 16/31] feat(core): add compression parameter to image conversion function for enhanced control over output quality --- crates/core/src/job/executor.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 2136166..160a9e9 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -127,8 +127,9 @@ pub fn execute_job_with_progress( output, format, quality, + compression, strip_metadata, - } => execute_image_convert(input, output, format, *quality, *strip_metadata, plan_only), + } => execute_image_convert(input, output, format, *quality, *compression, *strip_metadata, plan_only), JobSpec::ImageResize { input, output, @@ -1073,11 +1074,12 @@ fn execute_image_convert( output: &Path, format: &ImageFormat, quality: Option, + compression: Option, strip: bool, plan_only: bool, ) -> Result { if plan_only { - return Ok(LibvipsTool::plan_convert(input, output, quality, strip)); + return Ok(LibvipsTool::plan_convert(input, output, quality, compression, strip)); } if !input.exists() { @@ -1089,7 +1091,7 @@ fn execute_image_convert( let tool_info = probe_libvips()?; let tool = LibvipsTool; - tool.convert(&tool_info.path, input, output, format, quality, strip)?; + tool.convert(&tool_info.path, input, output, format, quality, compression, strip)?; Ok(format!( "Successfully converted image to {} ({})", @@ -1459,7 +1461,7 @@ mod image_operation_tests { let output = PathBuf::from("photo.webp"); let result = - execute_image_convert(&input, &output, &ImageFormat::WebP, Some(80), true, true) + execute_image_convert(&input, &output, &ImageFormat::WebP, Some(80), None, true, true) .unwrap(); // Plan uses libvips format @@ -1475,11 +1477,13 @@ mod image_operation_tests { let output = PathBuf::from("photo.png"); let result = - execute_image_convert(&input, &output, &ImageFormat::Png, None, false, true).unwrap(); + execute_image_convert(&input, &output, &ImageFormat::Png, None, Some(0), false, true) + .unwrap(); assert!(result.contains("vips copy")); assert!(result.contains("photo.png")); assert!(!result.contains("Q=")); + assert!(result.contains("compression=0")); assert!(!result.contains("strip")); } From 6b8a71c77eb0425e46c5a62112bb7660bc37f06e Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:18:48 +0800 Subject: [PATCH 17/31] docs(core): update image conversion documentation to reflect removal of `ImageMagick` and add compression parameter --- crates/core/src/job/spec.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index e6727e7..6f0ee43 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -147,7 +147,7 @@ pub enum JobSpec { // ========== Image Operations ========== /// Convert image to a different format. /// - /// Uses libvips (preferred) or ImageMagick (fallback) for conversion. + /// Uses libvips for conversion. /// Supports JPEG, PNG, WebP, AVIF, TIFF, and GIF formats. ImageConvert { /// Input image file. @@ -158,6 +158,8 @@ pub enum JobSpec { format: ImageFormat, /// Quality (0-100). Format-dependent: JPEG/WebP/AVIF use this. quality: Option, + /// Compression level (0-9). For PNG: 0=fastest, 9=smallest. + compression: Option, /// Strip metadata during conversion. strip_metadata: bool, }, @@ -364,6 +366,7 @@ mod tests { output: PathBuf::from("photo.webp"), format: ImageFormat::WebP, quality: Some(80), + compression: None, strip_metadata: false, }; assert_eq!(spec.description(), "Convert image to webp (quality 80)"); @@ -376,6 +379,7 @@ mod tests { output: PathBuf::from("photo.png"), format: ImageFormat::Png, quality: None, + compression: Some(0), strip_metadata: true, }; assert_eq!(spec.description(), "Convert image to png"); From 4bef7f06f80aefa83b2e683d9e20033128ccaf84 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:19:00 +0800 Subject: [PATCH 18/31] feat(core): enhance `LibvipsTool` with compression parameter for image conversion and update documentation --- crates/core/src/tools/libvips.rs | 34 +++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/core/src/tools/libvips.rs b/crates/core/src/tools/libvips.rs index ffb8443..9b8e246 100644 --- a/crates/core/src/tools/libvips.rs +++ b/crates/core/src/tools/libvips.rs @@ -114,9 +114,9 @@ impl Tool for LibvipsTool { } impl LibvipsTool { - /// Convert image format with optional quality and strip options. + /// Convert image format with optional quality, compression, and strip options. /// - /// Uses `vips copy input output[Q=quality,strip]` syntax. + /// Uses `vips copy input output[Q=quality,compression=level,strip]` syntax. pub fn convert( &self, tool_path: &Path, @@ -124,6 +124,7 @@ impl LibvipsTool { output: &Path, _format: &ImageFormat, quality: Option, + compression: Option, strip: bool, ) -> Result<()> { let mut cmd = Command::new(tool_path); @@ -135,6 +136,9 @@ impl LibvipsTool { if let Some(q) = quality { options.push(format!("Q={}", q)); } + if let Some(c) = compression { + options.push(format!("compression={}", c)); + } if strip { options.push("strip".to_string()); } @@ -234,11 +238,20 @@ impl LibvipsTool { } /// Build plan command string for convert operation. - pub fn plan_convert(input: &Path, output: &Path, quality: Option, strip: bool) -> String { + pub fn plan_convert( + input: &Path, + output: &Path, + quality: Option, + compression: Option, + strip: bool, + ) -> String { let mut options = Vec::new(); if let Some(q) = quality { options.push(format!("Q={}", q)); } + if let Some(c) = compression { + options.push(format!("compression={}", c)); + } if strip { options.push("strip".to_string()); } @@ -312,6 +325,7 @@ mod tests { Path::new("input.jpg"), Path::new("output.webp"), Some(80), + None, true, ); assert!(plan.contains("vips copy")); @@ -320,6 +334,20 @@ mod tests { assert!(plan.contains("strip")); } + #[test] + fn test_plan_convert_with_compression() { + let plan = LibvipsTool::plan_convert( + Path::new("input.jpg"), + Path::new("output.png"), + None, + Some(0), + false, + ); + assert!(plan.contains("vips copy")); + assert!(plan.contains("compression=0")); + assert!(!plan.contains("strip")); + } + #[test] fn test_plan_resize() { let plan = LibvipsTool::plan_resize( From fc6d9313bf533f0da3f31a6f969ff87107222ca7 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:19:07 +0800 Subject: [PATCH 19/31] feat(cli): update image conversion arguments to allow optional output path and add compression parameter --- crates/cli/src/commands/image.rs | 78 +++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs index 726e12e..f15d038 100644 --- a/crates/cli/src/commands/image.rs +++ b/crates/cli/src/commands/image.rs @@ -43,11 +43,11 @@ pub struct ConvertArgs { #[arg(required = true, help = "Input image file")] pub input: PathBuf, - /// Output image file - #[arg(short, long, required = true, help = "Output image file")] - pub output: PathBuf, + /// Output image file (defaults to input name with new extension in current dir) + #[arg(short, long, help = "Output image file")] + pub output: Option, - /// Target format (auto-detected from output extension if not specified) + /// Target format (required if --output not specified) #[arg(long, help = "Target format: jpeg, png, webp, avif, tiff, gif")] pub to: Option, @@ -55,6 +55,10 @@ pub struct ConvertArgs { #[arg(short, long, help = "Quality (0-100). Applies to JPEG, WebP, AVIF")] pub quality: Option, + /// Compression level (1-9). Higher = smaller file, slower. For PNG + #[arg(short, long, help = "Compression (1-9). Default: none (fastest)")] + pub compression: Option, + /// Strip metadata during conversion #[arg(long, help = "Strip EXIF and other metadata")] pub strip: bool, @@ -99,9 +103,38 @@ pub fn handle_image_command(cmd: ImageCommand, plan_only: bool, json_output: boo } fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Result<()> { - // Determine format from --to flag or output extension - let format = if let Some(ref to) = args.to { - to.parse::().map_err(|_| { + // Determine format and output path + let (format, output) = if let Some(output) = args.output { + // Output provided - get format from --to or output extension + let fmt = if let Some(ref to) = args.to { + to.parse::().map_err(|_| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: format!( + "Unknown format '{}'. Supported: jpeg, png, webp, avif, tiff, gif", + to + ), + } + })? + } else { + ImageFormat::from_path(&output).ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: output.clone(), + reason: "Cannot determine format from output extension. Use --to to specify." + .to_string(), + } + })? + }; + (fmt, output) + } else { + // No output - require --to flag and derive output path + let to = args.to.ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Either --output or --to is required".to_string(), + } + })?; + let fmt = to.parse::().map_err(|_| { forgekit_core::utils::error::ForgeKitError::InvalidInput { path: PathBuf::new(), reason: format!( @@ -109,16 +142,16 @@ fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Resu to ), } - })? - } else { - ImageFormat::from_path(&args.output).ok_or_else(|| { + })?; + // Derive output: input stem + new extension in current directory + let stem = args.input.file_stem().ok_or_else(|| { forgekit_core::utils::error::ForgeKitError::InvalidInput { - path: args.output.clone(), - reason: - "Cannot determine format from output extension. Use --to to specify format." - .to_string(), + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), } - })? + })?; + let output = PathBuf::from(stem).with_extension(fmt.extension()); + (fmt, output) }; // Validate quality range @@ -131,11 +164,24 @@ fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Resu } } + // Validate compression range (1-9) and default to 0 (no compression) if not specified + let compression = match args.compression { + Some(c) if c >= 1 && c <= 9 => Some(c), + Some(_) => { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Compression must be between 1 and 9".to_string(), + }); + } + None => Some(0), // Default: no compression (fastest) + }; + let spec = JobSpec::ImageConvert { input: args.input, - output: args.output, + output, format, quality: args.quality, + compression, strip_metadata: args.strip, }; From f7098ec02f4cc835a63bcaebf6af3314b69dd830 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:25:29 +0800 Subject: [PATCH 20/31] feat(cli): make output path optional for image resize and strip commands, derive default names --- crates/cli/src/commands/image.rs | 54 ++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs index f15d038..2abbc43 100644 --- a/crates/cli/src/commands/image.rs +++ b/crates/cli/src/commands/image.rs @@ -48,7 +48,7 @@ pub struct ConvertArgs { pub output: Option, /// Target format (required if --output not specified) - #[arg(long, help = "Target format: jpeg, png, webp, avif, tiff, gif")] + #[arg(short = 't', long, help = "Target format: jpeg, png, webp, avif, tiff, gif")] pub to: Option, /// Quality (0-100). Applies to JPEG, WebP, and AVIF formats @@ -70,9 +70,9 @@ pub struct ResizeArgs { #[arg(required = true, help = "Input image file")] pub input: PathBuf, - /// Output image file - #[arg(short, long, required = true, help = "Output image file")] - pub output: PathBuf, + /// Output image file (defaults to input_WIDTHxHEIGHT.ext in current dir) + #[arg(short, long, help = "Output image file")] + pub output: Option, /// Target width (preserves aspect ratio if height not specified) #[arg(short, long, help = "Target width in pixels")] @@ -89,9 +89,9 @@ pub struct StripArgs { #[arg(required = true, help = "Input image file")] pub input: PathBuf, - /// Output image file - #[arg(short, long, required = true, help = "Output image file")] - pub output: PathBuf, + /// Output image file (defaults to input_stripped.ext in current dir) + #[arg(short, long, help = "Output image file")] + pub output: Option, } pub fn handle_image_command(cmd: ImageCommand, plan_only: bool, json_output: bool) -> Result<()> { @@ -197,9 +197,30 @@ fn handle_resize(args: ResizeArgs, plan_only: bool, json_output: bool) -> Result }); } + // Derive output path if not provided + let output = if let Some(output) = args.output { + output + } else { + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let ext = args.input.extension().unwrap_or_default(); + let suffix = match (args.width, args.height) { + (Some(w), Some(h)) => format!("_{}x{}", w, h), + (Some(w), None) => format!("_{}w", w), + (None, Some(h)) => format!("_{}h", h), + (None, None) => unreachable!(), + }; + let new_name = format!("{}{}.{}", stem.to_string_lossy(), suffix, ext.to_string_lossy()); + PathBuf::from(new_name) + }; + let spec = JobSpec::ImageResize { input: args.input, - output: args.output, + output, width: args.width, height: args.height, }; @@ -208,9 +229,24 @@ fn handle_resize(args: ResizeArgs, plan_only: bool, json_output: bool) -> Result } fn handle_strip(args: StripArgs, plan_only: bool, json_output: bool) -> Result<()> { + // Derive output path if not provided + let output = if let Some(output) = args.output { + output + } else { + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let ext = args.input.extension().unwrap_or_default(); + let new_name = format!("{}_stripped.{}", stem.to_string_lossy(), ext.to_string_lossy()); + PathBuf::from(new_name) + }; + let spec = JobSpec::ImageStrip { input: args.input, - output: args.output, + output, }; execute_image_job(&spec, plan_only, json_output) From 15aa2d5100f783f4f01ea2dad9b2deed366c83c4 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:34:42 +0800 Subject: [PATCH 21/31] feat(cli): add image compression command to reduce file size with quality control --- crates/cli/src/commands/image.rs | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs index 2abbc43..7c1e07a 100644 --- a/crates/cli/src/commands/image.rs +++ b/crates/cli/src/commands/image.rs @@ -35,6 +35,16 @@ pub enum ImageCommand { /// Removes EXIF, XMP, IPTC, ICC profiles, and other metadata. /// Useful for privacy before sharing images. Strip(StripArgs), + + /// Compress image to reduce file size + /// + /// Examples: + /// forgekit image compress photo.jpg + /// forgekit image compress photo.jpg --quality 60 + /// + /// For JPEG/WebP/AVIF: reduces quality (default 80) + strips metadata. + /// For PNG: uses max compression (level 9) + strips metadata. + Compress(CompressArgs), } #[derive(Args, Clone)] @@ -94,11 +104,27 @@ pub struct StripArgs { pub output: Option, } +#[derive(Args, Clone)] +pub struct CompressArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: PathBuf, + + /// Output image file (defaults to input_compressed.ext in current dir) + #[arg(short, long, help = "Output image file")] + pub output: Option, + + /// Quality (0-100). Default: 80. For JPEG, WebP, AVIF, and RAW conversion + #[arg(short, long, default_value = "80", help = "Quality (0-100). Default: 80")] + pub quality: u8, +} + pub fn handle_image_command(cmd: ImageCommand, plan_only: bool, json_output: bool) -> Result<()> { match cmd { ImageCommand::Convert(args) => handle_convert(args, plan_only, json_output), ImageCommand::Resize(args) => handle_resize(args, plan_only, json_output), ImageCommand::Strip(args) => handle_strip(args, plan_only, json_output), + ImageCommand::Compress(args) => handle_compress(args, plan_only, json_output), } } @@ -252,6 +278,74 @@ fn handle_strip(args: StripArgs, plan_only: bool, json_output: bool) -> Result<( execute_image_job(&spec, plan_only, json_output) } +fn handle_compress(args: CompressArgs, plan_only: bool, json_output: bool) -> Result<()> { + // Validate quality + if args.quality > 100 { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Quality must be between 0 and 100".to_string(), + }); + } + + // Detect file type + let input_ext = args + .input + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + + // Reject RAW files + let is_raw = matches!( + input_ext.as_str(), + "dng" | "cr2" | "cr3" | "nef" | "arw" | "orf" | "rw2" | "raf" | "pef" | "srw" | "raw" + ); + if is_raw { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input, + reason: "RAW files cannot be compressed. Use 'image convert' to convert to JPEG/WebP first.".to_string(), + }); + } + + let is_png = input_ext == "png"; + + // Determine output format and path + let (output, format, quality, compression) = if let Some(output) = args.output { + let fmt = ImageFormat::from_path(&output).unwrap_or(ImageFormat::Jpeg); + let comp = if fmt == ImageFormat::Png { Some(9) } else { None }; + (output, fmt, Some(args.quality), comp) + } else { + let stem = args.input.file_stem().ok_or_else(|| { + forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + + if is_png { + // PNG → PNG with max compression + let new_name = format!("{}_compressed.png", stem.to_string_lossy()); + (PathBuf::from(new_name), ImageFormat::Png, None, Some(9)) + } else { + // Keep same format (JPEG, WebP, etc.) + let ext = args.input.extension().unwrap_or_default(); + let new_name = format!("{}_compressed.{}", stem.to_string_lossy(), ext.to_string_lossy()); + let fmt = ImageFormat::from_path(&args.input).unwrap_or(ImageFormat::Jpeg); + (PathBuf::from(new_name), fmt, Some(args.quality), None) + } + }; + + let spec = JobSpec::ImageConvert { + input: args.input, + output, + format, + quality, + compression, + strip_metadata: true, // Always strip for compression + }; + + execute_image_job(&spec, plan_only, json_output) +} + fn execute_image_job(spec: &JobSpec, plan_only: bool, json_output: bool) -> Result<()> { if plan_only { let plan = forgekit_core::job::executor::execute_job(spec, true)?; From f1a65a141a149be0be022bc6c10cc5366071c1cb Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:45:14 +0800 Subject: [PATCH 22/31] feat(cli): add image info command to display image properties and EXIF metadata --- crates/cli/src/commands/image.rs | 143 +++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs index 7c1e07a..0b29b51 100644 --- a/crates/cli/src/commands/image.rs +++ b/crates/cli/src/commands/image.rs @@ -45,6 +45,15 @@ pub enum ImageCommand { /// For JPEG/WebP/AVIF: reduces quality (default 80) + strips metadata. /// For PNG: uses max compression (level 9) + strips metadata. Compress(CompressArgs), + + /// Show image information (dimensions, format, file size) + /// + /// Examples: + /// forgekit image info photo.jpg + /// forgekit image info photo.jpg --exif + /// + /// Shows basic image properties. Use --exif for camera/EXIF metadata. + Info(InfoArgs), } #[derive(Args, Clone)] @@ -119,12 +128,24 @@ pub struct CompressArgs { pub quality: u8, } +#[derive(Args, Clone)] +pub struct InfoArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: PathBuf, + + /// Show EXIF/camera metadata + #[arg(long, help = "Show EXIF/camera metadata")] + pub exif: bool, +} + pub fn handle_image_command(cmd: ImageCommand, plan_only: bool, json_output: bool) -> Result<()> { match cmd { ImageCommand::Convert(args) => handle_convert(args, plan_only, json_output), ImageCommand::Resize(args) => handle_resize(args, plan_only, json_output), ImageCommand::Strip(args) => handle_strip(args, plan_only, json_output), ImageCommand::Compress(args) => handle_compress(args, plan_only, json_output), + ImageCommand::Info(args) => handle_info(args, json_output), } } @@ -346,6 +367,128 @@ fn handle_compress(args: CompressArgs, plan_only: bool, json_output: bool) -> Re execute_image_job(&spec, plan_only, json_output) } +fn handle_info(args: InfoArgs, json_output: bool) -> Result<()> { + use std::process::Command; + + // Check file exists + if !args.input.exists() { + return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: args.input, + reason: "File does not exist".to_string(), + }); + } + + // Get file size + let file_size = std::fs::metadata(&args.input) + .map(|m| m.len()) + .unwrap_or(0); + let file_size_str = if file_size >= 1_000_000 { + format!("{:.1} MB", file_size as f64 / 1_000_000.0) + } else if file_size >= 1_000 { + format!("{:.1} KB", file_size as f64 / 1_000.0) + } else { + format!("{} bytes", file_size) + }; + + // Get image dimensions using vipsheader + let vips_output = Command::new("vipsheader") + .arg("-a") + .arg(&args.input) + .output(); + + let (width, height, format_str, bands, depth) = match vips_output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let mut width = String::new(); + let mut height = String::new(); + let mut bands = String::new(); + let mut format = String::new(); + let mut depth = String::new(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.splitn(2, ':').collect(); + if parts.len() == 2 { + let key = parts[0].trim(); + let value = parts[1].trim(); + match key { + "width" => width = value.to_string(), + "height" => height = value.to_string(), + "bands" => bands = value.to_string(), + "format" => depth = value.to_string(), + "vips-loader" => format = value.to_string(), + _ => {} + } + } + } + (width, height, format, bands, depth) + } + _ => { + // Fallback - vipsheader not available + ("?".to_string(), "?".to_string(), "?".to_string(), "?".to_string(), "?".to_string()) + } + }; + + if json_output { + let mut info = serde_json::json!({ + "file": args.input.display().to_string(), + "width": width.parse::().unwrap_or(0), + "height": height.parse::().unwrap_or(0), + "format": format_str, + "size_bytes": file_size, + "size": file_size_str, + "bands": bands, + "depth": depth, + }); + + if args.exif { + // Get EXIF using exiftool + if let Ok(output) = Command::new("exiftool").arg("-json").arg(&args.input).output() { + if output.status.success() { + if let Ok(exif) = serde_json::from_slice::(&output.stdout) { + if let Some(arr) = exif.as_array() { + if let Some(first) = arr.first() { + info["exif"] = first.clone(); + } + } + } + } + } + } + + println!("{}", serde_json::to_string_pretty(&info).unwrap()); + } else { + println!("File: {}", args.input.display()); + println!("Dimensions: {}x{}", width, height); + println!("Format: {}", format_str); + println!("Size: {}", file_size_str); + if bands != "?" && !bands.is_empty() { + println!("Bands: {}", bands); + } + if depth != "?" && !depth.is_empty() { + println!("Depth: {}", depth); + } + + if args.exif { + println!("\nEXIF Metadata:"); + // Get EXIF using exiftool + if let Ok(output) = Command::new("exiftool").arg(&args.input).output() { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + println!(" {}", line); + } + } else { + println!(" (exiftool not available)"); + } + } else { + println!(" (exiftool not available)"); + } + } + } + + Ok(()) +} + fn execute_image_job(spec: &JobSpec, plan_only: bool, json_output: bool) -> Result<()> { if plan_only { let plan = forgekit_core::job::executor::execute_job(spec, true)?; From a312237e8c1908e581500e6e9434d728edcb1b51 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:47:06 +0800 Subject: [PATCH 23/31] fix(docs): correct GitHub Releases link in README for ForgeKit binary installation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 82d8bcf..4e5d761 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ If package manager installation isn't available, you can install ForgeKit manual **1. Install ForgeKit binary:** -Download the latest release from [GitHub Releases](https://github.com/nedanwar/forgekit/releases) and add to your PATH. +Download the latest release from [GitHub Releases](https://github.com/nedanwr/forgekit/releases) and add to your PATH. **2. Install dependencies:** From 6b84d3af7dec1feaf7bcc002b36862a2150c9dd6 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:53:23 +0800 Subject: [PATCH 24/31] fix(cli): improve compression range validation in image conversion command --- crates/cli/src/commands/image.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs index 0b29b51..01696d1 100644 --- a/crates/cli/src/commands/image.rs +++ b/crates/cli/src/commands/image.rs @@ -213,7 +213,7 @@ fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Resu // Validate compression range (1-9) and default to 0 (no compression) if not specified let compression = match args.compression { - Some(c) if c >= 1 && c <= 9 => Some(c), + Some(c) if (1..=9).contains(&c) => Some(c), Some(_) => { return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { path: PathBuf::new(), From bf3e1d1303802490bcdf183f96e7d441f18b6701 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:53:36 +0800 Subject: [PATCH 25/31] docs(README): streamline installation instructions and enhance usage examples for PDF and image operations --- README.md | 222 ++++++++---------------------------------------------- 1 file changed, 31 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index 4e5d761..12c3012 100644 --- a/README.md +++ b/README.md @@ -2,219 +2,59 @@ Local-first media and PDF toolkit. Fast, lightweight, and privacy-focused. -**Repository**: https://github.com/nedanwar/forgekit - -## Quick Start - -### Installation - -1. Install dependencies (see [Installation](#installation) section below) -2. Download the latest release from GitHub Releases -3. Extract and add to your PATH - -### Examples - -**PDF Operations:** - -```bash -# Merge multiple PDFs -forgekit pdf merge doc1.pdf doc2.pdf --output merged.pdf - -# Merge with linearization (fast web view) -forgekit pdf merge *.pdf --output all.pdf --linearize - -# Split PDF by pages -forgekit pdf split book.pdf --output-dir pages/ --pages 1-5,10-20 - -# Extract odd pages -forgekit pdf split book.pdf --output-dir pages/ --pages odd - -# See what commands would run (without executing) -forgekit pdf merge a.pdf b.pdf --output c.pdf --plan - -# JSON output for scripting -forgekit pdf merge *.pdf --output out.pdf --json | jq -r '.result.output' -``` - -**Page Specification:** - -- Numbers: `1`, `42` -- Ranges: `1-5`, `10-20`, `7-` (7 to end), `-10` (1 to 10) -- Keywords: `odd`, `even`, `first`, `last` -- Exclusions: `!2`, `!5-10` -- Combined: `1-3,5,7-`, `odd`, `even`, `1-10,!2,!5` - ## Installation -### Package Manager Installation (Recommended) - -Install ForgeKit using your system's package manager. Dependencies are automatically installed alongside ForgeKit - no additional commands needed. - -**macOS (Homebrew):** - -```bash -brew install forgekit -``` - -**Windows (winget):** - -```powershell -winget install forgekit -``` - -**Debian/Ubuntu:** - -```bash -sudo apt install forgekit -``` - -**Fedora/RHEL:** - -```bash -sudo dnf install forgekit -``` - -**Arch Linux:** - -```bash -# Available via AUR (when published) -yay -S forgekit -# or -pacman -S forgekit -``` - -### Manual Installation - -If package manager installation isn't available, you can install ForgeKit manually and then install dependencies separately. - -**1. Install ForgeKit binary:** +**macOS:** `brew install qpdf ghostscript tesseract ffmpeg libvips exiftool && pip3 install ocrmypdf` -Download the latest release from [GitHub Releases](https://github.com/nedanwr/forgekit/releases) and add to your PATH. +**Linux (Debian/Ubuntu):** `sudo apt install qpdf ghostscript tesseract-ocr ffmpeg libvips-tools libimage-exiftool-perl && pip3 install ocrmypdf` -**2. Install dependencies:** +**Windows:** `winget install qpdf.qpdf ArtifexSoftware.GhostScript tesseract-ocr && scoop install libvips exiftool && pip install ocrmypdf` -Check which dependencies are missing: +Then download the latest release from [GitHub Releases](https://github.com/nedanwr/forgekit/releases). -```bash -forgekit check-deps -``` - -Then install them based on your platform: - -**macOS (Homebrew):** - -```bash -brew install qpdf ghostscript tesseract ffmpeg libvips exiftool -pip3 install ocrmypdf -``` - -**Windows (winget/scoop):** - -```powershell -winget install qpdf.qpdf ArtifexSoftware.GhostScript tesseract-ocr Gyan.FFmpeg -scoop install libvips exiftool -pip install ocrmypdf -``` - -**Linux:** +Run `forgekit check-deps` to verify all dependencies are installed. -**Debian/Ubuntu:** +## Usage -```bash -sudo apt install qpdf ghostscript tesseract-ocr ffmpeg libvips-tools libimage-exiftool-perl python3-pip -pip3 install ocrmypdf -``` - -**Fedora/RHEL:** +### PDF Operations ```bash -sudo dnf install qpdf ghostscript tesseract ffmpeg libvips perl-Image-ExifTool python3-pip -pip3 install ocrmypdf +forgekit pdf merge doc1.pdf doc2.pdf --output merged.pdf +forgekit pdf split book.pdf --output-dir pages/ --pages 1-5 +forgekit pdf compress large.pdf --output small.pdf --level high +forgekit pdf ocr scan.pdf --output searchable.pdf --language eng +forgekit pdf metadata doc.pdf --set title="My Doc" --output updated.pdf ``` -**Arch Linux:** +### Image Operations ```bash -sudo pacman -S qpdf ghostscript tesseract ffmpeg libvips perl-image-exiftool python-pip -pip3 install ocrmypdf +forgekit image convert photo.jpg -t webp --quality 80 # photo.webp +forgekit image resize photo.jpg --width 800 # photo_800w.jpg +forgekit image strip photo.jpg # photo_stripped.jpg +forgekit image compress photo.jpg --quality 60 # photo_compressed.jpg +forgekit image info photo.jpg --exif # show dimensions + EXIF ``` -**Note:** If you have a package manager available, we strongly recommend using package manager installation instead (see above). It automatically handles all dependencies including Python and ocrmypdf. - -## Features - -### PDF Operations - -- **Merge**: Combine multiple PDFs into one -- **Split**: Extract pages by ranges or keywords -- **Linearize**: Optimize for fast web view -- **Compress**: Reduce file size with Ghostscript presets -- **OCR**: Add searchable text layer (coming soon) -- **Metadata**: View/edit PDF metadata (coming soon) - -### Image Operations (coming soon) +### Global Options -- Convert formats (WebP, AVIF, JPEG) -- Resize with aspect ratio preservation -- Strip EXIF metadata +- `--plan` - Show commands without executing +- `--json` - Output progress as NDJSON -### Media Operations (coming soon) +## Page Specification -- Video transcoding (H.264) -- Audio conversion and normalization - -## Global Flags - -- `--json`: Output progress as NDJSON (one event per line) -- `--plan`: Show underlying commands without executing -- `--dry-run`: Validate inputs and show plan, don't execute -- `--log-level `: Set log level (debug|info|warn|error) -- `--force`: Overwrite existing files -- `--tools.=path`: Override tool path (e.g., `--tools.qpdf=/usr/local/bin/qpdf`) +- Numbers: `1`, `42` +- Ranges: `1-5`, `7-` (7 to end), `-10` (1 to 10) +- Keywords: `odd`, `even` +- Exclusions: `!2`, `!5-10` +- Combined: `1-3,5,7-`, `1-10,!2` ## Exit Codes -- `0`: Success -- `1`: General error (processing failed) -- `2`: Missing tool (with install hint) -- `3`: Invalid input (file not found, invalid pages spec, etc.) -- `4`: Permission denied -- `5`: Disk full -- `130`: Cancelled (SIGINT) - -## JSON Output - -When using `--json`, ForgeKit outputs NDJSON (newline-delimited JSON) events: - -```json -{"type":"progress","job_id":"abc123","progress":{"current":1,"total":3,"percent":33},"message":"Merging page 1/3"} -{"type":"progress","job_id":"abc123","progress":{"current":2,"total":3,"percent":67},"message":"Merging page 2/3"} -{"type":"complete","job_id":"abc123","result":{"output":"merged.pdf","size_bytes":123456,"duration_ms":1234}} -``` - -## Troubleshooting - -### Tool not found - -If you see "Tool 'qpdf' not found", install the required dependencies (see [Installation](#installation)). - -You can also override tool paths: - -```bash -forgekit pdf merge a.pdf b.pdf --output c.pdf --tools.qpdf=/custom/path/to/qpdf -``` - -### Permission denied - -Ensure you have read access to input files and write access to output directories. - -### Invalid page spec - -Page numbers must be >= 1. Ranges must have start <= end. Use `--help` for examples. - -## Contributing - -Contributions welcome! Please open an issue or pull request on [GitHub](https://github.com/nedanwar/forgekit). +- `0` Success +- `1` General error +- `2` Missing tool (run `check-deps`) +- `3` Invalid input ## License From 9cecd6f3c170044a59bf2d2a2c5a6a9e7663315b Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:53:55 +0800 Subject: [PATCH 26/31] docs(ROADMAP): update roadmap to reflect completion of image operations and increment version to `v0.0.6` --- ROADMAP.md | 70 ++++++++++++++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9c00095..c0cd386 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # ForgeKit Beta-MVP Roadmap (CLI-First) -**Last Updated**: January 2026 -**Status**: Core foundation complete, PDF merge/split/compress/extract/ocr/metadata implemented, dependency management complete -**Current Version**: v0.0.5 +**Last Updated**: January 2026 +**Status**: Core foundation complete, PDF operations complete, Image operations complete +**Current Version**: v0.0.6 ## Versioning Strategy @@ -93,20 +93,21 @@ - ✅ OCR options: --skip-text, --deskew, --force-ocr - ✅ Integration with check-deps command -#### v0.0.6 - Image Operations 📋 +#### v0.0.6 - Image Operations ✅ -**Status**: Pending +**Status**: Completed **Deliverables**: -- Image convert command (`image convert`) with format selection -- Image resize command (`image resize`) with aspect ratio preservation -- Image strip command (`image strip`) for EXIF removal -- libvips tool adapter (primary, fast) -- ImageMagick adapter (fallback for Windows compatibility) -- Image presets (WebP, AVIF, JPEG quality presets) -- Tests for image operations (dimensions, quality, format) -- Golden tests for image conversion +- ✅ Image convert command (`image convert`) with format selection and optional output +- ✅ Image resize command (`image resize`) with aspect ratio preservation +- ✅ Image strip command (`image strip`) for EXIF removal +- ✅ Image compress command (`image compress`) for quality reduction +- ✅ Image info command (`image info`) for dimensions/format/metadata +- ✅ libvips tool adapter with compression parameter support +- ✅ Optional output paths with smart auto-naming (e.g., `photo_800w.jpg`, `photo_stripped.jpg`) +- ✅ PNG compression control (1-9) for conversion +- ✅ RAW format input support (DNG, CR2, NEF, etc.) #### v0.0.7 - Audio Operations 📋 @@ -165,7 +166,7 @@ - ✅ All CLI commands implemented and tested: - PDF: merge, split, compress, linearize, reorder, extract, ocr, metadata - - Image: convert, resize, strip + - Image: convert, resize, strip, compress, info - Audio: convert, normalize - Media: transcode - ✅ All package formats available (deb, rpm, Homebrew, winget) @@ -215,24 +216,24 @@ ### ✅ Completed Features - **Core Foundation**: Tool trait system, error handling, job specs -- **PDF Operations**: Merge and split with qpdf +- **PDF Operations**: Merge, split, compress, linearize, reorder, extract, OCR, metadata +- **Image Operations**: Convert, resize, strip, compress, info (via libvips) - **Pages Grammar**: Full parser with comprehensive tests (11 tests) - **Progress Reporting**: NDJSON output with versioned schema - **CLI Flags**: `--json`, `--plan`, `--dry-run` working - **Documentation**: Comprehensive inline docs, README, CONTRIBUTING guide -- **Testing**: 20 tests passing (13 unit, 2 integration, 5 doc tests) - **Dependency Checking**: `check-deps` command implemented -**Current Version**: v0.0.5 (completed) +**Current Version**: v0.0.6 (completed) ### 🔄 In Progress -- **v0.0.6**: Image Operations +- **v0.0.7**: Audio Operations ### 📋 Pending Features -- Image: convert, resize, strip -- Media: transcode, audio convert/normalize +- Audio: convert, normalize +- Media: transcode - Preset system (YAML) ✅ - Package creation (deb, rpm, Homebrew, winget) - CI/CD setup with package building @@ -345,7 +346,6 @@ - tesseract: 5.0+ - ffmpeg: 5.0+ - libvips: 8.12+ -- ImageMagick: 7.1+ (fallback) - exiftool: 12.0+ ### ADR-0003: Progress/Event JSON Format @@ -428,7 +428,6 @@ tools/ # External tool adapters ocrmypdf.rs # ocrmypdf adapter ffmpeg.rs # ffmpeg adapter libvips.rs # libvips adapter - imagemagick.rs # ImageMagick fallback adapter exiftool.rs # exiftool adapter pipeline/ # Multi-step pipelines mod.rs @@ -756,8 +755,7 @@ SEE ALSO: - **tesseract** 5.0+ (OCR engine) - **ocrmypdf** 14.0+ (Python wrapper for OCR, installed via pip) - **ffmpeg** 5.0+ (audio/video processing) -- **libvips** 8.12+ (image processing, primary) -- **ImageMagick** 7.1+ (image processing fallback) +- **libvips** 8.12+ (image processing) - **exiftool** 12.0+ (metadata handling) **Tool detection order**: @@ -1090,23 +1088,23 @@ presets: **Status**: 🔄 Pending -### Milestone 4: Image Operations (Week 4-5) +### Milestone 4: Image Operations (Week 4-5) ✅ COMPLETED **Acceptance criteria**: -- libvips convert/resize/strip -- ImageMagick fallback detection -- CLI `image convert/resize/strip` subcommands -- Image presets (WebP, AVIF, JPEG) -- Golden tests (dimensions, quality) +- ✅ libvips convert/resize/strip/compress +- ✅ CLI `image convert/resize/strip/compress/info` subcommands +- ✅ Optional output with smart auto-naming +- ✅ PNG compression control (1-9) +- ✅ RAW format input support **Risks**: libvips not available on Windows -**Mitigation**: ImageMagick fallback, clear install hints +**Mitigation**: libvips available via scoop, clear install hints **Estimate**: Best 1 week, likely 1 week, worst 1.5 weeks -**Status**: 🔄 Pending +**Status**: ✅ Completed ### Milestone 5: Audio Operations (Week 5-6) @@ -1282,14 +1280,14 @@ presets: ### Risk 3: libvips Not Available on Windows -**Impact**: Low (ImageMagick fallback exists) +**Impact**: Low (scoop has libvips) -**Probability**: Medium +**Probability**: Low **Mitigation**: -- ImageMagick fallback already planned -- Document both options in install hints +- libvips available via scoop on Windows +- Clear install hints in error messages ### Risk 4: Licensing Caveats (GPL tools) From e54fa437689c7b809071d94303e25db25891698e Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:54:23 +0800 Subject: [PATCH 27/31] chore(deps): bump version to `0.0.6` for `forgekit` and `forgekit-core` in Cargo files --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4b3e00..8a4127c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "forgekit" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "clap", @@ -134,7 +134,7 @@ dependencies = [ [[package]] name = "forgekit-core" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index ce30ff4..337a5bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/core", "crates/cli"] resolver = "2" [workspace.package] -version = "0.0.5" +version = "0.0.6" edition = "2021" authors = ["ForgeKit Contributors"] license = "MIT" From 6baacc7c432f97bb8b805a7106f140515223c8b0 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:59:28 +0800 Subject: [PATCH 28/31] style(cli): format code for better readability in image command arguments and functions --- crates/cli/src/commands/image.rs | 73 ++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs index 01696d1..7e604f8 100644 --- a/crates/cli/src/commands/image.rs +++ b/crates/cli/src/commands/image.rs @@ -67,7 +67,11 @@ pub struct ConvertArgs { pub output: Option, /// Target format (required if --output not specified) - #[arg(short = 't', long, help = "Target format: jpeg, png, webp, avif, tiff, gif")] + #[arg( + short = 't', + long, + help = "Target format: jpeg, png, webp, avif, tiff, gif" + )] pub to: Option, /// Quality (0-100). Applies to JPEG, WebP, and AVIF formats @@ -124,7 +128,12 @@ pub struct CompressArgs { pub output: Option, /// Quality (0-100). Default: 80. For JPEG, WebP, AVIF, and RAW conversion - #[arg(short, long, default_value = "80", help = "Quality (0-100). Default: 80")] + #[arg( + short, + long, + default_value = "80", + help = "Quality (0-100). Default: 80" + )] pub quality: u8, } @@ -175,12 +184,13 @@ fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Resu (fmt, output) } else { // No output - require --to flag and derive output path - let to = args.to.ok_or_else(|| { - forgekit_core::utils::error::ForgeKitError::InvalidInput { - path: PathBuf::new(), - reason: "Either --output or --to is required".to_string(), - } - })?; + let to = + args.to.ok_or_else( + || forgekit_core::utils::error::ForgeKitError::InvalidInput { + path: PathBuf::new(), + reason: "Either --output or --to is required".to_string(), + }, + )?; let fmt = to.parse::().map_err(|_| { forgekit_core::utils::error::ForgeKitError::InvalidInput { path: PathBuf::new(), @@ -261,7 +271,12 @@ fn handle_resize(args: ResizeArgs, plan_only: bool, json_output: bool) -> Result (None, Some(h)) => format!("_{}h", h), (None, None) => unreachable!(), }; - let new_name = format!("{}{}.{}", stem.to_string_lossy(), suffix, ext.to_string_lossy()); + let new_name = format!( + "{}{}.{}", + stem.to_string_lossy(), + suffix, + ext.to_string_lossy() + ); PathBuf::from(new_name) }; @@ -287,7 +302,11 @@ fn handle_strip(args: StripArgs, plan_only: bool, json_output: bool) -> Result<( } })?; let ext = args.input.extension().unwrap_or_default(); - let new_name = format!("{}_stripped.{}", stem.to_string_lossy(), ext.to_string_lossy()); + let new_name = format!( + "{}_stripped.{}", + stem.to_string_lossy(), + ext.to_string_lossy() + ); PathBuf::from(new_name) }; @@ -323,7 +342,9 @@ fn handle_compress(args: CompressArgs, plan_only: bool, json_output: bool) -> Re if is_raw { return Err(forgekit_core::utils::error::ForgeKitError::InvalidInput { path: args.input, - reason: "RAW files cannot be compressed. Use 'image convert' to convert to JPEG/WebP first.".to_string(), + reason: + "RAW files cannot be compressed. Use 'image convert' to convert to JPEG/WebP first." + .to_string(), }); } @@ -332,7 +353,11 @@ fn handle_compress(args: CompressArgs, plan_only: bool, json_output: bool) -> Re // Determine output format and path let (output, format, quality, compression) = if let Some(output) = args.output { let fmt = ImageFormat::from_path(&output).unwrap_or(ImageFormat::Jpeg); - let comp = if fmt == ImageFormat::Png { Some(9) } else { None }; + let comp = if fmt == ImageFormat::Png { + Some(9) + } else { + None + }; (output, fmt, Some(args.quality), comp) } else { let stem = args.input.file_stem().ok_or_else(|| { @@ -349,7 +374,11 @@ fn handle_compress(args: CompressArgs, plan_only: bool, json_output: bool) -> Re } else { // Keep same format (JPEG, WebP, etc.) let ext = args.input.extension().unwrap_or_default(); - let new_name = format!("{}_compressed.{}", stem.to_string_lossy(), ext.to_string_lossy()); + let new_name = format!( + "{}_compressed.{}", + stem.to_string_lossy(), + ext.to_string_lossy() + ); let fmt = ImageFormat::from_path(&args.input).unwrap_or(ImageFormat::Jpeg); (PathBuf::from(new_name), fmt, Some(args.quality), None) } @@ -379,9 +408,7 @@ fn handle_info(args: InfoArgs, json_output: bool) -> Result<()> { } // Get file size - let file_size = std::fs::metadata(&args.input) - .map(|m| m.len()) - .unwrap_or(0); + let file_size = std::fs::metadata(&args.input).map(|m| m.len()).unwrap_or(0); let file_size_str = if file_size >= 1_000_000 { format!("{:.1} MB", file_size as f64 / 1_000_000.0) } else if file_size >= 1_000 { @@ -424,7 +451,13 @@ fn handle_info(args: InfoArgs, json_output: bool) -> Result<()> { } _ => { // Fallback - vipsheader not available - ("?".to_string(), "?".to_string(), "?".to_string(), "?".to_string(), "?".to_string()) + ( + "?".to_string(), + "?".to_string(), + "?".to_string(), + "?".to_string(), + "?".to_string(), + ) } }; @@ -442,7 +475,11 @@ fn handle_info(args: InfoArgs, json_output: bool) -> Result<()> { if args.exif { // Get EXIF using exiftool - if let Ok(output) = Command::new("exiftool").arg("-json").arg(&args.input).output() { + if let Ok(output) = Command::new("exiftool") + .arg("-json") + .arg(&args.input) + .output() + { if output.status.success() { if let Ok(exif) = serde_json::from_slice::(&output.stdout) { if let Some(arr) = exif.as_array() { From 765cbd083a0b37298b04795471f0b503cdb0862c Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:59:42 +0800 Subject: [PATCH 29/31] style(core): improve code readability by formatting function arguments and adding new tests for image compression --- crates/core/src/job/executor.rs | 125 +++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 9 deletions(-) diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 160a9e9..cecf4b5 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -129,7 +129,15 @@ pub fn execute_job_with_progress( quality, compression, strip_metadata, - } => execute_image_convert(input, output, format, *quality, *compression, *strip_metadata, plan_only), + } => execute_image_convert( + input, + output, + format, + *quality, + *compression, + *strip_metadata, + plan_only, + ), JobSpec::ImageResize { input, output, @@ -1079,7 +1087,13 @@ fn execute_image_convert( plan_only: bool, ) -> Result { if plan_only { - return Ok(LibvipsTool::plan_convert(input, output, quality, compression, strip)); + return Ok(LibvipsTool::plan_convert( + input, + output, + quality, + compression, + strip, + )); } if !input.exists() { @@ -1091,7 +1105,15 @@ fn execute_image_convert( let tool_info = probe_libvips()?; let tool = LibvipsTool; - tool.convert(&tool_info.path, input, output, format, quality, compression, strip)?; + tool.convert( + &tool_info.path, + input, + output, + format, + quality, + compression, + strip, + )?; Ok(format!( "Successfully converted image to {} ({})", @@ -1460,9 +1482,16 @@ mod image_operation_tests { let input = PathBuf::from("photo.jpg"); let output = PathBuf::from("photo.webp"); - let result = - execute_image_convert(&input, &output, &ImageFormat::WebP, Some(80), None, true, true) - .unwrap(); + let result = execute_image_convert( + &input, + &output, + &ImageFormat::WebP, + Some(80), + None, + true, + true, + ) + .unwrap(); // Plan uses libvips format assert!(result.contains("vips copy")); @@ -1476,9 +1505,16 @@ mod image_operation_tests { let input = PathBuf::from("photo.jpg"); let output = PathBuf::from("photo.png"); - let result = - execute_image_convert(&input, &output, &ImageFormat::Png, None, Some(0), false, true) - .unwrap(); + let result = execute_image_convert( + &input, + &output, + &ImageFormat::Png, + None, + Some(0), + false, + true, + ) + .unwrap(); assert!(result.contains("vips copy")); assert!(result.contains("photo.png")); @@ -1547,4 +1583,75 @@ mod image_operation_tests { assert!(result.contains("photo.jpg")); assert!(result.contains("[strip]")); } + + // Compress tests (compress uses ImageConvert with specific settings) + + #[test] + fn test_execute_image_compress_jpeg_plan() { + // JPEG compress: quality reduction + strip metadata + let input = PathBuf::from("photo.jpg"); + let output = PathBuf::from("photo_compressed.jpg"); + + let result = execute_image_convert( + &input, + &output, + &ImageFormat::Jpeg, + Some(80), // quality 80 + None, // no PNG compression + true, // strip metadata + true, // plan only + ) + .unwrap(); + + assert!(result.contains("vips copy")); + assert!(result.contains("photo.jpg")); + assert!(result.contains("Q=80")); + assert!(result.contains("strip")); + } + + #[test] + fn test_execute_image_compress_png_plan() { + // PNG compress: max compression level + strip metadata + let input = PathBuf::from("image.png"); + let output = PathBuf::from("image_compressed.png"); + + let result = execute_image_convert( + &input, + &output, + &ImageFormat::Png, + None, // no quality for PNG + Some(9), // max compression + true, // strip metadata + true, // plan only + ) + .unwrap(); + + assert!(result.contains("vips copy")); + assert!(result.contains("image.png")); + assert!(result.contains("compression=9")); + assert!(result.contains("strip")); + } + + #[test] + fn test_execute_image_compress_webp_plan() { + // WebP compress: quality reduction + strip metadata + let input = PathBuf::from("photo.webp"); + let output = PathBuf::from("photo_compressed.webp"); + + let result = execute_image_convert( + &input, + &output, + &ImageFormat::WebP, + Some(60), // lower quality for more compression + None, + true, // strip metadata + true, // plan only + ) + .unwrap(); + + assert!(result.contains("vips copy")); + assert!(result.contains("photo.webp")); + assert!(result.contains("Q=60")); + assert!(result.contains("strip")); + } } From fae23eeb731ebdd151c84d954289503712493c9b Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:59:48 +0800 Subject: [PATCH 30/31] test(core): add unit tests for image resize and compression descriptions --- crates/core/src/job/spec.rs | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index 6f0ee43..4473bbf 100644 --- a/crates/core/src/job/spec.rs +++ b/crates/core/src/job/spec.rs @@ -415,4 +415,45 @@ mod tests { }; assert_eq!(spec.description(), "Strip image metadata"); } + + #[test] + fn test_image_resize_description_height_only() { + let spec = JobSpec::ImageResize { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("thumb.jpg"), + width: None, + height: Some(600), + }; + assert_eq!(spec.description(), "Resize image to height 600"); + } + + // Compress tests (compress uses ImageConvert with compression settings) + + #[test] + fn test_image_compress_jpeg_description() { + // JPEG compress shows quality + let spec = JobSpec::ImageConvert { + input: PathBuf::from("photo.jpg"), + output: PathBuf::from("photo_compressed.jpg"), + format: ImageFormat::Jpeg, + quality: Some(80), + compression: None, + strip_metadata: true, + }; + assert_eq!(spec.description(), "Convert image to jpg (quality 80)"); + } + + #[test] + fn test_image_compress_png_description() { + // PNG compress doesn't show quality (uses compression level instead) + let spec = JobSpec::ImageConvert { + input: PathBuf::from("image.png"), + output: PathBuf::from("image_compressed.png"), + format: ImageFormat::Png, + quality: None, + compression: Some(9), + strip_metadata: true, + }; + assert_eq!(spec.description(), "Convert image to png"); + } } From 20f41c5f8eccb331582b0b9def9e059b43246ff7 Mon Sep 17 00:00:00 2001 From: Naveed <46922100+nedanwr@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:06:02 +0800 Subject: [PATCH 31/31] chore(core): allow clippy warning for too many arguments in convert function --- crates/core/src/tools/libvips.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/core/src/tools/libvips.rs b/crates/core/src/tools/libvips.rs index 9b8e246..d2d33f6 100644 --- a/crates/core/src/tools/libvips.rs +++ b/crates/core/src/tools/libvips.rs @@ -117,6 +117,7 @@ impl LibvipsTool { /// Convert image format with optional quality, compression, and strip options. /// /// Uses `vips copy input output[Q=quality,compression=level,strip]` syntax. + #[allow(clippy::too_many_arguments)] pub fn convert( &self, tool_path: &Path,