mod update;
mod writer;
use std::fs;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use unpdf::{
parse_file_with_options, CleanupPreset, JsonFormat, PageSelection, ParseOptions, RenderOptions,
};
use unpdf::{PageStreamOptions, ParseEvent, PdfParser};
#[derive(Parser, Debug)]
pub struct ConvertArgs {
#[arg(value_name = "FILE")]
pub input: PathBuf,
#[arg(short, long, value_name = "DIR")]
pub output: Option<PathBuf>,
#[arg(long, value_enum)]
pub cleanup: Option<CleanupLevel>,
#[arg(long, value_delimiter = ',', default_value = "md")]
pub formats: Vec<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub no_images: bool,
#[arg(long)]
pub keep_ocr_text: bool,
#[arg(long, value_name = "DIR")]
pub image_dir: Option<PathBuf>,
#[arg(long, value_name = "PX", default_value = "64")]
pub min_image_size: u32,
#[arg(long, value_name = "N")]
pub window: Option<usize>,
#[arg(long)]
pub page_markers: bool,
#[arg(short, long)]
pub quiet: bool,
}
#[derive(Parser)]
#[command(name = "unpdf")]
#[command(author = "iyulab")]
#[command(version)]
#[command(about = "Extract PDF content to Markdown, text, and JSON", long_about = None)]
struct Cli {
#[arg(value_name = "FILE")]
input: Option<PathBuf>,
#[arg(value_name = "OUTPUT")]
output: Option<PathBuf>,
#[arg(long, value_enum)]
cleanup: Option<CleanupLevel>,
#[arg(short, long)]
quiet: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Convert(ConvertArgs),
#[command(alias = "md")]
Markdown {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(short, long)]
frontmatter: bool,
#[arg(long, value_enum, default_value = "markdown")]
table_mode: TableMode,
#[arg(long, value_enum)]
cleanup: Option<CleanupLevel>,
#[arg(long, default_value = "6")]
max_heading: u8,
#[arg(long)]
pages: Option<String>,
#[arg(long)]
page_markers: bool,
},
Text {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long, value_enum)]
cleanup: Option<CleanupLevel>,
#[arg(long)]
pages: Option<String>,
},
Json {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
compact: bool,
},
Info {
#[arg(value_name = "FILE")]
input: PathBuf,
},
Extract {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long, value_name = "DIR")]
output: Option<PathBuf>,
#[arg(long)]
pages: Option<String>,
},
Update {
#[arg(long)]
check: bool,
#[arg(long)]
force: bool,
},
Version,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
pub enum CleanupLevel {
Minimal,
Standard,
Aggressive,
}
impl From<CleanupLevel> for CleanupPreset {
fn from(level: CleanupLevel) -> Self {
match level {
CleanupLevel::Minimal => CleanupPreset::Minimal,
CleanupLevel::Standard => CleanupPreset::Standard,
CleanupLevel::Aggressive => CleanupPreset::Aggressive,
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum TableMode {
Markdown,
Html,
Ascii,
}
impl From<TableMode> for unpdf::TableFallback {
fn from(mode: TableMode) -> Self {
match mode {
TableMode::Markdown => unpdf::TableFallback::Markdown,
TableMode::Html => unpdf::TableFallback::Html,
TableMode::Ascii => unpdf::TableFallback::Ascii,
}
}
}
fn check_quality(doc: &unpdf::Document, quiet: bool) -> bool {
if quiet {
return false;
}
if let Some(warning) = doc.extraction_quality.warning_message() {
eprintln!("{}: {}", "Warning".yellow().bold(), warning);
return true;
}
false
}
fn should_check_update(cli: &Cli) -> bool {
!matches!(
&cli.command,
Some(Commands::Update { .. }) | Some(Commands::Version)
)
}
fn main() {
env_logger::init();
let cli = Cli::parse();
let update_rx = if should_check_update(&cli) {
Some(update::check_update_async())
} else {
None
};
let quiet = cli.quiet;
let result = match cli.command {
Some(Commands::Convert(mut args)) => {
if quiet {
args.quiet = true;
}
cmd_convert(&args)
}
Some(Commands::Markdown {
input,
output,
frontmatter,
table_mode,
cleanup,
max_heading,
pages,
page_markers,
}) => cmd_markdown(
&input,
output.as_deref(),
frontmatter,
table_mode,
cleanup,
max_heading,
pages.as_deref(),
page_markers,
quiet,
),
Some(Commands::Text {
input,
output,
cleanup,
pages,
}) => cmd_text(&input, output.as_deref(), cleanup, pages.as_deref(), quiet),
Some(Commands::Json {
input,
output,
compact,
}) => cmd_json(&input, output.as_deref(), compact, quiet),
Some(Commands::Info { input }) => cmd_info(&input, quiet),
Some(Commands::Extract {
input,
output,
pages,
}) => cmd_extract(&input, output.as_deref(), pages.as_deref(), quiet),
Some(Commands::Update { check, force }) => {
if let Err(e) = update::run_update(check, force) {
eprintln!("{}: {}", "Error".red().bold(), e);
std::process::exit(1);
}
Ok(false)
}
Some(Commands::Version) => {
cmd_version();
Ok(false)
}
None => {
if let Some(input) = cli.input {
let args = ConvertArgs {
input,
output: cli.output,
cleanup: cli.cleanup,
formats: vec!["md".to_string()],
all: false,
no_images: false,
keep_ocr_text: false,
image_dir: None,
min_image_size: 64,
window: None,
page_markers: false,
quiet,
};
cmd_convert(&args)
} else {
println!("{}", "Usage: unpdf <FILE> [OUTPUT]".yellow());
println!(" unpdf --help for more information");
Ok(false)
}
}
};
if let Some(rx) = update_rx {
if let Some(update_result) = update::try_get_update_result(&rx) {
update::print_update_notification(&update_result);
}
}
match result {
Ok(had_warnings) => {
if had_warnings {
std::process::exit(2);
}
}
Err(e) => {
eprintln!("{}: {}", "Error".red().bold(), e);
std::process::exit(1);
}
}
}
fn cmd_convert(args: &ConvertArgs) -> Result<bool, Box<dyn std::error::Error>> {
use std::ops::ControlFlow;
let out_dir = args.output.clone().unwrap_or_else(|| {
let stem = args.input.file_stem().unwrap_or_default().to_string_lossy();
PathBuf::from(format!("{}_output", stem))
});
fs::create_dir_all(&out_dir)?;
let formats: Vec<writer::OutputFormat> = if args.all {
vec![
writer::OutputFormat::Markdown,
writer::OutputFormat::Text,
writer::OutputFormat::Json,
]
} else {
let mut v: Vec<_> = args
.formats
.iter()
.filter_map(|s| match s.as_str() {
"md" | "markdown" => Some(writer::OutputFormat::Markdown),
"txt" | "text" => Some(writer::OutputFormat::Text),
"json" => Some(writer::OutputFormat::Json),
other => {
eprintln!("warning: unknown format: {}", other);
None
}
})
.collect();
if v.is_empty() {
v.push(writer::OutputFormat::Markdown);
}
v
};
let image_dir: Option<PathBuf> = if args.no_images {
None
} else {
Some(
args.image_dir
.clone()
.unwrap_or_else(|| out_dir.join("images")),
)
};
let mut render_opts = RenderOptions::new().with_frontmatter(true);
if image_dir.is_some() {
render_opts = render_opts.with_image_prefix("images/");
}
if let Some(level) = args.cleanup {
render_opts = render_opts.with_cleanup_preset(level.into());
}
if args.page_markers {
render_opts = render_opts.with_page_markers(unpdf::PageMarkerStyle::Comment);
}
let mut parse_options = ParseOptions::new()
.lenient()
.with_ocr_suppression(!args.keep_ocr_text);
if image_dir.is_some() {
parse_options = parse_options.with_resources(true);
}
let parser = PdfParser::open_with_options(&args.input, parse_options)?;
let mut mfw =
writer::MultiFormatWriter::new(&out_dir, &formats, render_opts, image_dir.clone())?;
let mut stream_opts = PageStreamOptions {
extract_resources: image_dir.is_some(),
min_image_dimension: args.min_image_size,
suppress_low_confidence_ocr: !args.keep_ocr_text,
..PageStreamOptions::default()
};
if let Some(w) = args.window {
stream_opts.window_size = w.max(1);
}
let total_pages = parser.page_count();
let pb = if args.quiet {
ProgressBar::hidden()
} else {
let b = ProgressBar::new(total_pages as u64);
b.set_style(
ProgressStyle::default_bar()
.template("{bar:40.cyan/blue} {pos}/{len} pages ({eta})")
.unwrap(),
);
b
};
let mut quality = None;
let mut write_err: Option<String> = None;
parser.for_each_page(stream_opts, |ev| {
match ev {
ParseEvent::DocumentStart {
metadata,
page_count,
..
} => {
if let Err(e) = mfw.write_document_start(&metadata, page_count) {
write_err = Some(format!("document start: {}", e));
return ControlFlow::Break(());
}
}
ParseEvent::PageParsed(mut page) => {
if let Err(e) = mfw.write_page(&mut page) {
write_err = Some(format!("page {}: {}", page.number, e));
return ControlFlow::Break(());
}
pb.inc(1);
}
ParseEvent::PageFailed { page, error } => {
eprintln!("page {} failed: {}", page, error);
pb.inc(1);
}
ParseEvent::DocumentEnd { quality: q } => {
quality = Some(q);
}
ParseEvent::Progress { .. } => {}
}
ControlFlow::Continue(())
})?;
if let Some(e) = write_err {
return Err(e.into());
}
let summary = mfw.finish()?;
pb.finish_with_message("Done");
if !args.quiet {
for path in [&summary.md_path, &summary.txt_path, &summary.json_path]
.into_iter()
.flatten()
{
println!("{} {}", "✓".green(), path.display());
}
if summary.image_count > 0 {
let img_dir = image_dir.as_deref().unwrap_or_else(|| Path::new("images"));
println!(
"{} {} image{} → {}",
"✓".green(),
summary.image_count,
if summary.image_count == 1 { "" } else { "s" },
img_dir.display()
);
}
if summary.word_count > 0 {
println!("{} {} words", "✓".green(), summary.word_count);
}
}
let warning = quality.as_ref().and_then(|q| q.warning_message());
if let Some(warning) = &warning {
if !args.quiet {
eprintln!("{}: {}", "Warning".yellow().bold(), warning);
}
}
Ok(warning.is_some())
}
#[allow(clippy::too_many_arguments)]
fn cmd_markdown(
input: &Path,
output: Option<&Path>,
frontmatter: bool,
table_mode: TableMode,
cleanup: Option<CleanupLevel>,
max_heading: u8,
pages: Option<&str>,
page_markers: bool,
quiet: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let page_selection = if let Some(p) = pages {
PageSelection::parse(p).map_err(|e| format!("Invalid page range: {}", e))?
} else {
PageSelection::All
};
let options = ParseOptions::new()
.lenient()
.with_pages(page_selection.clone());
let doc = parse_file_with_options(input, options)?;
let had_warnings = check_quality(&doc, quiet);
let mut render_options = RenderOptions::new()
.with_frontmatter(frontmatter)
.with_table_fallback(table_mode.into())
.with_max_heading(max_heading)
.with_pages(page_selection);
if page_markers {
render_options = render_options.with_page_markers(unpdf::PageMarkerStyle::Comment);
}
if let Some(level) = cleanup {
render_options = render_options.with_cleanup_preset(level.into());
}
let markdown = unpdf::render::to_markdown(&doc, &render_options)?;
if let Some(path) = output {
fs::write(path, &markdown)?;
println!("{} {}", "Saved to".green(), path.display());
} else {
println!("{}", markdown);
}
Ok(had_warnings)
}
fn cmd_text(
input: &Path,
output: Option<&Path>,
cleanup: Option<CleanupLevel>,
pages: Option<&str>,
quiet: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let page_selection = if let Some(p) = pages {
PageSelection::parse(p).map_err(|e| format!("Invalid page range: {}", e))?
} else {
PageSelection::All
};
let options = ParseOptions::new().lenient().with_pages(page_selection);
let doc = parse_file_with_options(input, options)?;
let had_warnings = check_quality(&doc, quiet);
let mut render_options = RenderOptions::new();
if let Some(level) = cleanup {
render_options = render_options.with_cleanup_preset(level.into());
}
let text = unpdf::render::to_text(&doc, &render_options)?;
if let Some(path) = output {
fs::write(path, &text)?;
println!("{} {}", "Saved to".green(), path.display());
} else {
println!("{}", text);
}
Ok(had_warnings)
}
fn cmd_json(
input: &Path,
output: Option<&Path>,
compact: bool,
quiet: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let options = ParseOptions::new().lenient();
let doc = unpdf::parse_file_with_options(input, options)?;
let had_warnings = check_quality(&doc, quiet);
let format = if compact {
JsonFormat::Compact
} else {
JsonFormat::Pretty
};
let json = unpdf::render::to_json(&doc, format)?;
if let Some(path) = output {
fs::write(path, &json)?;
println!("{} {}", "Saved to".green(), path.display());
} else {
println!("{}", json);
}
Ok(had_warnings)
}
fn cmd_info(input: &Path, quiet: bool) -> Result<bool, Box<dyn std::error::Error>> {
let options = ParseOptions::new().lenient();
let doc = parse_file_with_options(input, options)?;
let had_warnings = check_quality(&doc, quiet);
println!("{}", "Document Information".cyan().bold());
println!("{}", "─".repeat(40).dimmed());
println!("{}: {}", "File".bold(), input.display());
println!("{}: PDF {}", "Format".bold(), doc.metadata.pdf_version);
let q = &doc.extraction_quality;
match (q.pages_incomplete, q.declared_page_count) {
(true, Some(declared)) => println!(
"{}: {} {}",
"Pages".bold(),
doc.metadata.page_count,
format!("(incomplete — document declares {})", declared).yellow()
),
(true, None) => println!(
"{}: {} {}",
"Pages".bold(),
doc.metadata.page_count,
"(incomplete — page structure damaged)".yellow()
),
_ => println!("{}: {}", "Pages".bold(), doc.metadata.page_count),
}
if q.suppressed_text_runs > 0 {
println!(
"{}: {}",
"Text runs".bold(),
format!(
"{} unreadable, dropped (fonts' character codes unresolved)",
q.suppressed_text_runs
)
.yellow()
);
}
println!(
"{}: {}",
"Encrypted".bold(),
if doc.metadata.encrypted { "Yes" } else { "No" }
);
if let Some(ref title) = doc.metadata.title {
println!("{}: {}", "Title".bold(), title);
}
if let Some(ref author) = doc.metadata.author {
println!("{}: {}", "Author".bold(), author);
}
if let Some(ref creator) = doc.metadata.creator {
println!("{}: {}", "Creator".bold(), creator);
}
if let Some(ref producer) = doc.metadata.producer {
println!("{}: {}", "Producer".bold(), producer);
}
if let Some(ref created) = doc.metadata.created {
println!("{}: {}", "Created".bold(), created);
}
if let Some(ref modified) = doc.metadata.modified {
println!("{}: {}", "Modified".bold(), modified);
}
println!();
println!("{}", "Content Statistics".cyan().bold());
println!("{}", "─".repeat(40).dimmed());
let text = doc.plain_text();
let words: usize = text.split_whitespace().count();
let chars = text.len();
let images = doc.resources.values().filter(|r| r.is_image()).count();
println!("{}: {}", "Words".bold(), words);
println!("{}: {}", "Characters".bold(), chars);
println!("{}: {}", "Images".bold(), images);
if let Some(ref outline) = doc.outline {
println!("{}: {}", "Bookmarks".bold(), outline.total_items());
}
Ok(had_warnings)
}
fn cmd_extract(
input: &Path,
output: Option<&Path>,
pages: Option<&str>,
quiet: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let page_selection = if let Some(p) = pages {
PageSelection::parse(p).map_err(|e| format!("Invalid page range: {}", e))?
} else {
PageSelection::All
};
let options = ParseOptions::new().lenient().with_pages(page_selection);
let doc = parse_file_with_options(input, options)?;
let had_warnings = check_quality(&doc, quiet);
let output_dir = output
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&output_dir)?;
let mut count = 0;
for (id, resource) in &doc.resources {
if resource.is_image() {
let filename = resource.suggested_filename(id);
let path = output_dir.join(&filename);
fs::write(&path, &resource.data)?;
println!("{} {}", "Extracted".green(), filename);
count += 1;
}
}
println!("\n{} {} images extracted", "Done!".green().bold(), count);
Ok(had_warnings)
}
fn cmd_version() {
println!("{} {}", "unpdf".cyan().bold(), env!("CARGO_PKG_VERSION"));
println!("PDF content extraction tool");
println!();
println!("Repository: {}", "https://github.com/iyulab/unpdf".dimmed());
println!("License: MIT");
}