mod update;
use clap::{Parser, Subcommand, ValueEnum};
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use undoc::render::{CleanupPreset, HeadingConfig, JsonFormat, RenderOptions, TableFallback};
#[derive(Parser)]
#[command(
name = "undoc",
author = "iyulab",
version,
about = "Extract content from Office documents",
long_about = "undoc - High-performance Microsoft Office document extraction tool.\n\n\
Converts DOCX, XLSX, and PPTX files to Markdown, plain text, or JSON.\n\n\
Usage:\n \
undoc <file> Extract all formats to output directory\n \
undoc <file> <output> Extract to specified directory\n \
undoc md <file> Convert to Markdown only"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(global = false)]
input: Option<PathBuf>,
#[arg(global = false)]
output: Option<PathBuf>,
#[arg(long, global = true)]
cleanup: Option<CleanupMode>,
}
#[derive(Subcommand)]
enum Commands {
Convert {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
cleanup: Option<CleanupMode>,
},
#[command(visible_alias = "md")]
Markdown {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
frontmatter: bool,
#[arg(long, default_value = "markdown")]
table_mode: TableMode,
#[arg(long)]
cleanup: Option<CleanupMode>,
#[arg(long, default_value = "4")]
max_heading: u8,
},
Text {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
cleanup: Option<CleanupMode>,
},
Json {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
compact: bool,
},
Info {
input: PathBuf,
},
Extract {
input: PathBuf,
#[arg(short, long, default_value = ".")]
output: PathBuf,
},
Update {
#[arg(long)]
check: bool,
#[arg(long)]
force: bool,
},
Version,
}
#[derive(Clone, ValueEnum)]
enum TableMode {
Markdown,
Html,
Ascii,
}
impl From<TableMode> for TableFallback {
fn from(mode: TableMode) -> Self {
match mode {
TableMode::Markdown => TableFallback::Markdown,
TableMode::Html => TableFallback::Html,
TableMode::Ascii => TableFallback::Ascii,
}
}
}
#[derive(Clone, ValueEnum)]
enum CleanupMode {
Minimal,
Standard,
Aggressive,
}
impl From<CleanupMode> for CleanupPreset {
fn from(mode: CleanupMode) -> Self {
match mode {
CleanupMode::Minimal => CleanupPreset::Minimal,
CleanupMode::Standard => CleanupPreset::Default,
CleanupMode::Aggressive => CleanupPreset::Aggressive,
}
}
}
fn should_check_update(cli: &Cli) -> bool {
!matches!(
&cli.command,
Some(Commands::Update { .. }) | Some(Commands::Version)
)
}
fn main() {
let cli = Cli::parse();
let update_rx = if should_check_update(&cli) {
Some(update::check_update_async())
} else {
None
};
let result = run(cli);
if let Some(rx) = update_rx {
if let Some(update_result) = update::try_get_update_result(&rx) {
update::print_update_notification(&update_result);
}
}
if let Err(e) = result {
eprintln!("{}: {}", "Error".red().bold(), e);
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
if cli.command.is_none() {
if let Some(input) = cli.input {
return run_convert(&input, cli.output.as_ref(), cli.cleanup);
} else {
use clap::CommandFactory;
Cli::command().print_help()?;
return Ok(());
}
}
match cli.command.unwrap() {
Commands::Convert {
input,
output,
cleanup,
} => {
run_convert(&input, output.as_ref(), cleanup)?;
}
Commands::Markdown {
input,
output,
frontmatter,
table_mode,
cleanup,
max_heading,
} => {
let pb = create_spinner("Parsing document...");
let doc = undoc::parse_file(&input)?;
pb.set_message("Rendering to Markdown...");
let mut options = RenderOptions::new()
.with_frontmatter(frontmatter)
.with_table_fallback(table_mode.into())
.with_max_heading(max_heading);
if let Some(mode) = cleanup {
options = options.with_cleanup_preset(mode.into());
}
let markdown = undoc::render::to_markdown(&doc, &options)?;
pb.finish_and_clear();
write_output(output.as_ref(), &markdown)?;
if output.is_some() {
println!(
"{} Converted to Markdown: {}",
"✓".green().bold(),
output.unwrap().display()
);
}
}
Commands::Text {
input,
output,
cleanup,
} => {
let pb = create_spinner("Parsing document...");
let doc = undoc::parse_file(&input)?;
pb.set_message("Rendering to text...");
let mut options = RenderOptions::new();
if let Some(mode) = cleanup {
options = options.with_cleanup_preset(mode.into());
}
let text = undoc::render::to_text(&doc, &options)?;
pb.finish_and_clear();
write_output(output.as_ref(), &text)?;
if output.is_some() {
println!(
"{} Converted to text: {}",
"✓".green().bold(),
output.unwrap().display()
);
}
}
Commands::Json {
input,
output,
compact,
} => {
let pb = create_spinner("Parsing document...");
let doc = undoc::parse_file(&input)?;
pb.set_message("Rendering to JSON...");
let format = if compact {
JsonFormat::Compact
} else {
JsonFormat::Pretty
};
let json = undoc::render::to_json(&doc, format)?;
pb.finish_and_clear();
write_output(output.as_ref(), &json)?;
if output.is_some() {
println!(
"{} Converted to JSON: {}",
"✓".green().bold(),
output.unwrap().display()
);
}
}
Commands::Info { input } => {
let pb = create_spinner("Analyzing document...");
let format = undoc::detect_format_from_path(&input)?;
let doc = undoc::parse_file(&input)?;
pb.finish_and_clear();
println!("{}", "Document Information".cyan().bold());
println!("{}", "─".repeat(40));
println!(
"{}: {}",
"File".bold(),
input.file_name().unwrap_or_default().to_string_lossy()
);
println!("{}: {:?}", "Format".bold(), format);
println!("{}: {}", "Sections".bold(), doc.sections.len());
println!("{}: {}", "Resources".bold(), doc.resources.len());
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(pages) = doc.metadata.page_count {
println!("{}: {}", "Pages/Slides/Sheets".bold(), pages);
}
if let Some(ref created) = doc.metadata.created {
println!("{}: {}", "Created".bold(), created);
}
if let Some(ref modified) = doc.metadata.modified {
println!("{}: {}", "Modified".bold(), modified);
}
let text = doc.plain_text();
let word_count = text.split_whitespace().count();
let char_count = text.len();
println!("\n{}", "Content Statistics".cyan().bold());
println!("{}", "─".repeat(40));
println!("{}: {}", "Words".bold(), word_count);
println!("{}: {}", "Characters".bold(), char_count);
}
Commands::Extract { input, output } => {
let pb = create_spinner("Extracting resources...");
let doc = undoc::parse_file(&input)?;
fs::create_dir_all(&output)?;
let mut count = 0;
for (id, resource) in &doc.resources {
let filename = resource.suggested_filename(id);
let path = output.join(&filename);
fs::write(&path, &resource.data)?;
count += 1;
}
pb.finish_and_clear();
if count > 0 {
println!(
"{} Extracted {} resources to {}",
"✓".green().bold(),
count,
output.display()
);
} else {
println!("{} No resources found in document", "!".yellow().bold());
}
}
Commands::Update { check, force } => {
if let Err(e) = update::run_update(check, force) {
eprintln!("{}: {}", "Error".red().bold(), e);
std::process::exit(1);
}
}
Commands::Version => {
print_version();
}
}
Ok(())
}
fn run_convert(
input: &PathBuf,
output: Option<&PathBuf>,
cleanup: Option<CleanupMode>,
) -> Result<(), Box<dyn std::error::Error>> {
let update_rx = update::check_update_async();
let pb = create_spinner("Parsing document...");
let output_dir = match output {
Some(p) => p.clone(),
None => {
let stem = input
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let parent = input.parent().unwrap_or(std::path::Path::new("."));
parent.join(format!("{}_output", stem))
}
};
fs::create_dir_all(&output_dir)?;
let doc = undoc::parse_file(input)?;
let heading_config = HeadingConfig::default().with_default_style_mapping();
let mut options = RenderOptions::new()
.with_frontmatter(true)
.with_heading_config(heading_config);
if let Some(mode) = cleanup {
options = options.with_cleanup_preset(mode.into());
}
pb.set_message("Generating Markdown...");
let markdown = undoc::render::to_markdown(&doc, &options)?;
let md_path = output_dir.join("extract.md");
fs::write(&md_path, &markdown)?;
pb.set_message("Generating text...");
let text = undoc::render::to_text(&doc, &options)?;
let txt_path = output_dir.join("extract.txt");
fs::write(&txt_path, &text)?;
pb.set_message("Generating JSON...");
let json = undoc::render::to_json(&doc, JsonFormat::Pretty)?;
let json_path = output_dir.join("content.json");
fs::write(&json_path, &json)?;
let mut image_count = 0;
let mut media_count = 0;
if !doc.resources.is_empty() {
pb.set_message("Extracting resources...");
let images_dir = output_dir.join("images");
let media_dir = output_dir.join("media");
for (id, resource) in &doc.resources {
let filename = resource.suggested_filename(id);
if resource.is_image() {
fs::create_dir_all(&images_dir)?;
fs::write(images_dir.join(&filename), &resource.data)?;
image_count += 1;
} else {
fs::create_dir_all(&media_dir)?;
fs::write(media_dir.join(&filename), &resource.data)?;
media_count += 1;
}
}
}
pb.finish_and_clear();
println!("{}", "Conversion Complete".green().bold());
println!("{}", "─".repeat(40));
println!("{}: {}", "Output".bold(), output_dir.display());
println!(" {} extract.md", "✓".green());
println!(" {} extract.txt", "✓".green());
println!(" {} content.json", "✓".green());
if image_count > 0 {
println!(" {} images/ ({} files)", "✓".green(), image_count);
}
if media_count > 0 {
println!(" {} media/ ({} files)", "✓".green(), media_count);
}
let word_count = text.split_whitespace().count();
println!("\n{}", "Statistics".cyan().bold());
println!("{}", "─".repeat(40));
println!("{}: {}", "Sections".bold(), doc.sections.len());
println!("{}: {}", "Words".bold(), word_count);
println!(
"{}: {} (images: {}, media: {})",
"Resources".bold(),
image_count + media_count,
image_count,
media_count
);
if let Some(result) = update::try_get_update_result(&update_rx) {
update::print_update_notification(&result);
}
Ok(())
}
fn print_version() {
println!("{} {}", "undoc".green().bold(), env!("CARGO_PKG_VERSION"));
println!("High-performance Microsoft Office document extraction to Markdown");
println!();
println!("Supported formats: DOCX, XLSX, PPTX");
println!("Repository: https://github.com/iyulab/undoc");
}
fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
.template("{spinner:.blue} {msg}")
.unwrap(),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(100));
pb
}
fn write_output(path: Option<&PathBuf>, content: &str) -> Result<(), Box<dyn std::error::Error>> {
match path {
Some(p) => {
fs::write(p, content)?;
}
None => {
let stdout = io::stdout();
let mut handle = stdout.lock();
writeln!(handle, "{}", content)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_parse() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
}