Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/core", "crates/cli"]
resolver = "2"

[workspace.package]
version = "0.0.3"
version = "0.0.5"
edition = "2021"
authors = ["ForgeKit Contributors"]
license = "MIT"
Expand Down
33 changes: 17 additions & 16 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# ForgeKit Beta-MVP Roadmap (CLI-First)

**Last Updated**: December 2025
**Status**: Core foundation complete, PDF merge/split/compress/extract implemented, dependency management complete
**Current Version**: v0.0.4
**Last Updated**: January 2026
**Status**: Core foundation complete, PDF merge/split/compress/extract/ocr/metadata implemented, dependency management complete
**Current Version**: v0.0.5

## Versioning Strategy

Expand Down Expand Up @@ -76,20 +76,22 @@
- ✅ Tests for new PDF operations
- ✅ Integration with existing pages grammar parser

#### v0.0.5 - PDF OCR and Metadata 📋
#### v0.0.5 - PDF OCR and Metadata

**Status**: Pending
**Status**: Completed

**Deliverables**:

- PDF OCR command (`pdf ocr`) with language selection
- PDF metadata command (`pdf metadata`) with get/set operations
- ocrmypdf tool adapter (Python wrapper handling)
- exiftool adapter for metadata operations
- OCR progress reporting (parse ocrmypdf output)
- Metadata read/write operations
- Tests for OCR and metadata operations
- Error handling for OCR failures
- ✅ PDF OCR command (`pdf ocr`) with language selection
- ✅ PDF metadata command (`pdf metadata`) with get/set operations
- ✅ ocrmypdf tool adapter (Python wrapper handling)
- ✅ exiftool adapter for metadata operations
- ✅ OCR progress reporting (basic progress events)
- ✅ Metadata read/write operations (get all, get field, set fields)
- ✅ Tests for OCR and metadata operations (9 new tests)
- ✅ Error handling for OCR failures
- ✅ OCR options: --skip-text, --deskew, --force-ocr
- ✅ Integration with check-deps command

#### v0.0.6 - Image Operations 📋

Expand Down Expand Up @@ -221,15 +223,14 @@
- **Testing**: 20 tests passing (13 unit, 2 integration, 5 doc tests)
- **Dependency Checking**: `check-deps` command implemented

**Current Version**: v0.0.4 (completed)
**Current Version**: v0.0.5 (completed)

### 🔄 In Progress

- **v0.0.5**: PDF OCR and Metadata
- **v0.0.6**: Image Operations

### 📋 Pending Features

- PDF: OCR, metadata
- Image: convert, resize, strip
- Media: transcode, audio convert/normalize
- Preset system (YAML) ✅
Expand Down
6 changes: 5 additions & 1 deletion crates/cli/src/commands/check.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use forgekit_core::tools::exiftool::ExiftoolTool;
use forgekit_core::tools::gs::GsTool;
use forgekit_core::tools::ocrmypdf::OcrmypdfTool;
use forgekit_core::tools::qpdf::QpdfTool;
use forgekit_core::tools::{Tool, ToolConfig};
use forgekit_core::utils::error::Result;
Expand All @@ -19,7 +21,9 @@ pub fn handle_check_deps() -> Result<()> {
let tools: Vec<(&'static str, Box<dyn Tool>)> = vec![
("qpdf", Box::new(QpdfTool)),
("gs", Box::new(GsTool)),
// TODO: Add other tools (ocrmypdf, ffmpeg, libvips, etc.)
("ocrmypdf", Box::new(OcrmypdfTool)),
("exiftool", Box::new(ExiftoolTool)),
// TODO: Add other tools (ffmpeg, libvips, etc.)
];

let mut all_ok = true;
Expand Down
216 changes: 216 additions & 0 deletions crates/cli/src/commands/pdf.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use clap::{Args, Subcommand};
use forgekit_core::job::spec::MetadataAction;
use forgekit_core::job::JobSpec;
use forgekit_core::utils::error::Result;
use forgekit_core::utils::pages::PageSpec;
Expand Down Expand Up @@ -55,6 +56,29 @@ pub enum PdfCommand {
/// Page spec: numbers (1), ranges (1-5, 7-), keywords (odd, even), exclusions (!2)
/// Formats: pdf (default), png, jpeg/jpg (image files per page)
Extract(ExtractArgs),
/// Add OCR text layer to a scanned PDF
///
/// Makes scanned PDFs searchable by adding an invisible text layer.
///
/// Examples:
/// forgekit pdf ocr scan.pdf --output searchable.pdf
/// forgekit pdf ocr scan.pdf --output searchable.pdf --language deu
/// forgekit pdf ocr mixed.pdf --output searchable.pdf --skip-text
/// forgekit pdf ocr tilted.pdf --output fixed.pdf --deskew
///
/// Language codes: eng (English), deu (German), fra (French), spa (Spanish), etc.
/// Use 'tesseract --list-langs' to see available languages.
Ocr(OcrArgs),
/// Read or write PDF metadata (title, author, etc.)
///
/// Examples:
/// forgekit pdf metadata doc.pdf # Show all metadata
/// forgekit pdf metadata doc.pdf --get title # Get specific field
/// forgekit pdf metadata doc.pdf --set title="My Doc" # Set a field
/// forgekit pdf metadata doc.pdf --set title="My Doc" --set author="John" --output updated.pdf
///
/// Supported fields: title, author, subject, keywords, creator, producer
Metadata(MetadataArgs),
}

#[derive(Args, Clone)]
Expand Down Expand Up @@ -169,6 +193,67 @@ pub struct ExtractArgs {
pub format: String,
}

#[derive(Args, Clone)]
pub struct OcrArgs {
/// Input PDF file
#[arg(
required = true,
help = "Input PDF file (typically a scanned document)"
)]
pub input: PathBuf,

/// Output PDF file path
#[arg(short, long, required = true, help = "Output PDF file path")]
pub output: PathBuf,

/// OCR language (e.g., "eng", "deu", "fra")
#[arg(
short,
long,
default_value = "eng",
help = "OCR language code (e.g., eng, deu, fra, spa)"
)]
pub language: String,

/// Skip pages that already have text
#[arg(
long,
help = "Skip pages that already have text (faster for mixed documents)"
)]
pub skip_text: bool,

/// Deskew pages before OCR (fixes tilted scans)
#[arg(long, help = "Deskew pages before OCR (corrects tilted scans)")]
pub deskew: bool,

/// Force OCR even if text already exists
#[arg(long, help = "Force OCR even if text already exists (redo OCR)")]
pub force_ocr: bool,
}

#[derive(Args, Clone)]
pub struct MetadataArgs {
/// Input PDF file
#[arg(required = true, help = "Input PDF file")]
pub input: PathBuf,

/// Output PDF file path (only required for --set)
#[arg(short, long, help = "Output PDF file path (only required for --set)")]
pub output: Option<PathBuf>,

/// Get a specific metadata field
#[arg(long, help = "Get a specific metadata field (e.g., title, author)")]
pub get: Option<String>,

/// Set metadata fields (can be repeated). Format: field=value
#[arg(
long = "set",
value_name = "FIELD=VALUE",
help = "Set metadata field (e.g., --set title=\"My Doc\" --set author=\"John\")"
)]
pub set_fields: Vec<String>,
}

pub fn handle_pdf_command(cmd: PdfCommand, plan_only: bool, json_output: bool) -> Result<()> {
match cmd {
PdfCommand::Merge(args) => handle_merge(args, plan_only, json_output),
Expand All @@ -177,6 +262,8 @@ pub fn handle_pdf_command(cmd: PdfCommand, plan_only: bool, json_output: bool) -
PdfCommand::Linearize(args) => handle_linearize(args, plan_only, json_output),
PdfCommand::Reorder(args) => handle_reorder(args, plan_only, json_output),
PdfCommand::Extract(args) => handle_extract(args, plan_only, json_output),
PdfCommand::Ocr(args) => handle_ocr(args, plan_only, json_output),
PdfCommand::Metadata(args) => handle_metadata(args, plan_only, json_output),
}
}

Expand Down Expand Up @@ -474,3 +561,132 @@ fn handle_extract(args: ExtractArgs, plan_only: bool, json_output: bool) -> Resu
Ok(())
}
}

fn handle_ocr(args: OcrArgs, plan_only: bool, json_output: bool) -> Result<()> {
let spec = JobSpec::PdfOcr {
input: args.input,
output: args.output,
language: args.language,
skip_text: args.skip_text,
deskew: args.deskew,
force_ocr: args.force_ocr,
};

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 {
if json_output {
let reporter = forgekit_core::job::progress::JsonProgressReporter;
forgekit_core::job::executor::execute_job_with_progress(&spec, false, &reporter)?;
} else {
let result = forgekit_core::job::executor::execute_job(&spec, false)?;
println!("{}", result);
}
Ok(())
}
}

fn handle_metadata(args: MetadataArgs, plan_only: bool, json_output: bool) -> Result<()> {
// Determine the action based on arguments
let action = if !args.set_fields.is_empty() {
// Parse set fields (format: field=value)
let fields: Vec<(String, String)> = args
.set_fields
.iter()
.map(|s| {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() == 2 {
Ok((parts[0].to_string(), parts[1].to_string()))
} else {
Err(forgekit_core::utils::error::ForgeKitError::InvalidInput {
path: PathBuf::new(),
reason: format!("Invalid --set format: '{}'. Expected 'field=value'", s),
})
}
})
.collect::<std::result::Result<Vec<_>, _>>()?;
MetadataAction::Set(fields)
} else if let Some(field) = args.get {
MetadataAction::Get(field)
} else {
MetadataAction::GetAll
};

// For set operations, output path is required
if matches!(&action, MetadataAction::Set(_)) && args.output.is_none() {
// Default to modifying in place
let spec = JobSpec::PdfMetadata {
input: args.input.clone(),
output: Some(args.input),
action,
};
return execute_metadata_job(&spec, plan_only, json_output);
}

let spec = JobSpec::PdfMetadata {
input: args.input,
output: args.output,
action,
};

execute_metadata_job(&spec, plan_only, json_output)
}

fn execute_metadata_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 {
// For metadata get operations, the result is already JSON or a value
// Wrap it in a complete event
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(())
}
}
Loading