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 unhwp::{parse_file, render, RenderOptions, TableFallback};
#[derive(Parser)]
#[command(
name = "unhwp",
author = "iyulab",
version,
about = "Extract content from HWP/HWPX documents",
long_about = "unhwp - High-performance HWP/HWPX document extraction tool.\n\n\
Converts HWP and HWPX files to Markdown, plain text, or JSON.\n\n\
Usage:\n \
unhwp <file> Extract all formats to output directory\n \
unhwp <file> <output> Extract to specified directory\n \
unhwp 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,
Skip,
}
impl From<TableMode> for TableFallback {
fn from(mode: TableMode) -> Self {
match mode {
TableMode::Markdown => TableFallback::SimplifiedMarkdown,
TableMode::Html => TableFallback::Html,
TableMode::Skip => TableFallback::Skip,
}
}
}
#[derive(Clone, ValueEnum)]
enum CleanupMode {
None,
Minimal,
Standard,
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 = parse_file(&input)?;
pb.set_message("Rendering to Markdown...");
let mut options = RenderOptions::default()
.with_table_fallback(table_mode.into())
.with_max_heading_level(max_heading);
if frontmatter {
options = options.with_frontmatter();
}
apply_cleanup(&mut options, cleanup);
let markdown = render::render_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 = parse_file(&input)?;
pb.set_message("Extracting text...");
let text = doc.plain_text();
pb.finish_and_clear();
write_output(output.as_ref(), &text)?;
if output.is_some() {
println!(
"{} Converted to text: {}",
"✓".green().bold(),
output.unwrap().display()
);
}
let _ = cleanup;
}
Commands::Json {
input,
output,
compact,
} => {
let pb = create_spinner("Parsing document...");
let doc = parse_file(&input)?;
pb.set_message("Rendering to JSON...");
let json = if compact {
serde_json::to_string(&doc)?
} else {
serde_json::to_string_pretty(&doc)?
};
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 = unhwp::detect_format_from_path(&input)?;
let doc = 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(ref created) = doc.metadata.created {
println!("{}: {}", "Created".bold(), created);
}
if let Some(ref modified) = doc.metadata.modified {
println!("{}: {}", "Modified".bold(), modified);
}
if doc.metadata.is_distribution {
println!("{}: {}", "Distribution".bold(), "Yes (DRM protected)");
}
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);
println!("{}: {}", "Paragraphs".bold(), doc.paragraph_count());
}
Commands::Extract { input, output } => {
let pb = create_spinner("Extracting resources...");
let doc = parse_file(&input)?;
fs::create_dir_all(&output)?;
let mut count = 0;
for (name, resource) in &doc.resources {
let path = output.join(name);
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 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 = parse_file(input)?;
let mut options = RenderOptions::default()
.with_frontmatter()
.with_image_prefix("images/");
apply_cleanup(&mut options, cleanup);
pb.set_message("Generating Markdown...");
let markdown = render::render_markdown(&doc, &options)?;
let md_path = output_dir.join("extract.md");
fs::write(&md_path, &markdown)?;
pb.set_message("Generating text...");
let text = doc.plain_text();
let txt_path = output_dir.join("extract.txt");
fs::write(&txt_path, &text)?;
pb.set_message("Generating JSON...");
let json = serde_json::to_string_pretty(&doc)?;
let json_path = output_dir.join("content.json");
fs::write(&json_path, &json)?;
let mut image_count = 0;
if !doc.resources.is_empty() {
pb.set_message("Extracting resources...");
let images_dir = output_dir.join("images");
fs::create_dir_all(&images_dir)?;
for (name, resource) in &doc.resources {
fs::write(images_dir.join(name), &resource.data)?;
image_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);
}
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!("{}: {}", "Resources".bold(), image_count);
Ok(())
}
fn apply_cleanup(options: &mut RenderOptions, cleanup: Option<CleanupMode>) {
if let Some(mode) = cleanup {
match mode {
CleanupMode::None => {}
CleanupMode::Minimal => *options = options.clone().with_minimal_cleanup(),
CleanupMode::Standard => *options = options.clone().with_cleanup(),
CleanupMode::Aggressive => *options = options.clone().with_aggressive_cleanup(),
}
}
}
fn print_version() {
println!("{} {}", "unhwp".green().bold(), env!("CARGO_PKG_VERSION"));
println!("High-performance HWP/HWPX document extraction to Markdown");
println!();
println!("Supported formats: HWP 5.0, HWPX, HWP 3.x");
println!("Repository: https://github.com/iyulab/unhwp");
}
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();
}
}