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/ 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" diff --git a/README.md b/README.md index 82d8bcf..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/nedanwar/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 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) diff --git a/crates/cli/src/commands/check.rs b/crates/cli/src/commands/check.rs index 565adec..21803be 100644 --- a/crates/cli/src/commands/check.rs +++ b/crates/cli/src/commands/check.rs @@ -1,5 +1,6 @@ use forgekit_core::tools::exiftool::ExiftoolTool; use forgekit_core::tools::gs::GsTool; +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 +24,7 @@ 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)), ]; let mut all_ok = true; diff --git a/crates/cli/src/commands/image.rs b/crates/cli/src/commands/image.rs new file mode 100644 index 0000000..7e604f8 --- /dev/null +++ b/crates/cli/src/commands/image.rs @@ -0,0 +1,567 @@ +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), + + /// 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), + + /// 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)] +pub struct ConvertArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: 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 (required if --output not specified) + #[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 + #[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, +} + +#[derive(Args, Clone)] +pub struct ResizeArgs { + /// Input image file + #[arg(required = true, help = "Input image file")] + pub input: 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")] + 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 (defaults to input_stripped.ext in current dir) + #[arg(short, long, help = "Output image file")] + 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, +} + +#[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), + } +} + +fn handle_convert(args: ConvertArgs, plan_only: bool, json_output: bool) -> Result<()> { + // 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!( + "Unknown format '{}'. Supported: jpeg, png, webp, avif, tiff, gif", + to + ), + } + })?; + // 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.input.clone(), + reason: "Cannot determine filename from input".to_string(), + } + })?; + let output = PathBuf::from(stem).with_extension(fmt.extension()); + (fmt, output) + }; + + // 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(), + }); + } + } + + // Validate compression range (1-9) and default to 0 (no compression) if not specified + let compression = match args.compression { + Some(c) if (1..=9).contains(&c) => 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, + format, + quality: args.quality, + compression, + 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(), + }); + } + + // 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, + 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<()> { + // 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, + }; + + 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 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)?; + 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(()) + } +} 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; 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"); diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 625cd9c..cecf4b5 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -28,10 +28,12 @@ use crate::job::JobSpec; use crate::presets::get_compression_strategy; use crate::tools::exiftool::ExiftoolTool; use crate::tools::gs::GsTool; +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 +120,31 @@ 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, + compression, + strip_metadata, + } => execute_image_convert( + input, + output, + format, + *quality, + *compression, + *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 +1068,125 @@ fn execute_pdf_metadata_set( )) } +// ========== Image Operations ========== + +/// Probe for libvips tool. +fn probe_libvips() -> Result { + let tool = LibvipsTool; + let config = ToolConfig::default(); + tool.probe(&config) +} + +fn execute_image_convert( + input: &Path, + 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, + compression, + strip, + )); + } + + if !input.exists() { + return Err(ForgeKitError::InvalidInput { + path: input.to_path_buf(), + reason: "Input file does not exist".to_string(), + }); + } + + let tool_info = probe_libvips()?; + let tool = LibvipsTool; + tool.convert( + &tool_info.path, + input, + output, + format, + quality, + compression, + strip, + )?; + + 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 { + 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_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), + (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 { + 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_info = probe_libvips()?; + let tool = LibvipsTool; + tool.strip_metadata(&tool_info.path, input, output)?; + + Ok(format!( + "Successfully stripped metadata from image: {}", + output.display() + )) +} + #[cfg(test)] mod tests { use super::*; @@ -1325,3 +1471,187 @@ 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), + None, + 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, + 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")); + } + + #[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]")); + } + + // 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")); + } +} diff --git a/crates/core/src/job/spec.rs b/crates/core/src/job/spec.rs index 0e32e80..4473bbf 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,52 @@ pub enum JobSpec { /// The metadata action to perform. action: MetadataAction, }, + + // ========== Image Operations ========== + /// Convert image to a different format. + /// + /// Uses libvips 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, + /// Compression level (0-9). For PNG: 0=fastest, 9=smallest. + compression: 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 +230,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 +358,102 @@ 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), + compression: None, + 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, + compression: Some(0), + 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"); + } + + #[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"); + } } diff --git a/crates/core/src/tools/libvips.rs b/crates/core/src/tools/libvips.rs new file mode 100644 index 0000000..d2d33f6 --- /dev/null +++ b/crates/core/src/tools/libvips.rs @@ -0,0 +1,363 @@ +//! # 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, 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, + input: &Path, + output: &Path, + _format: &ImageFormat, + quality: Option, + compression: 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 let Some(c) = compression { + options.push(format!("compression={}", c)); + } + 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, + 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()); + } + + 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), + None, + true, + ); + assert!(plan.contains("vips copy")); + assert!(plan.contains("input.jpg")); + assert!(plan.contains("Q=80")); + 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( + Path::new("input.jpg"), + Path::new("output.jpg"), + Some(800), + None, + ); + assert!(plan.contains("vipsthumbnail")); + assert!(plan.contains("-s 800x")); + } +} diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index 356c972..a96e601 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -1,7 +1,9 @@ pub mod exiftool; pub mod gs; +pub mod libvips; pub mod ocrmypdf; pub mod qpdf; pub mod trait_def; +pub use libvips::LibvipsTool; pub use trait_def::{Tool, ToolConfig, ToolInfo}; 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()); + } +} 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; 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.