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, Clone, Default)]
pub struct AiArgs {
#[arg(long, value_name = "URL")]
pub ai_base_url: Option<String>,
#[arg(long, value_name = "KEY", env = "UNPDF_AI_API_KEY")]
pub ai_api_key: Option<String>,
#[arg(long, value_name = "MODEL")]
pub ai_model: Option<String>,
#[arg(long, value_enum, default_value = "all")]
pub ai_image_scope: AiImageScope,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, ValueEnum)]
pub enum AiImageScope {
#[default]
All,
LowConfidenceOnly,
}
impl From<AiImageScope> for unpdf::ImageScope {
fn from(scope: AiImageScope) -> Self {
match scope {
AiImageScope::All => unpdf::ImageScope::All,
AiImageScope::LowConfidenceOnly => unpdf::ImageScope::LowConfidencePagesOnly,
}
}
}
impl AiArgs {
fn to_config(&self) -> Result<Option<unpdf::AiConfig>, String> {
match (
self.ai_base_url.as_deref(),
self.ai_api_key.as_deref(),
self.ai_model.as_deref(),
) {
(None, None, None) => Ok(None),
(Some(base_url), Some(api_key), Some(model)) => {
let mut config = unpdf::AiConfig::new(base_url, api_key, model);
config.image_scope = self.ai_image_scope.into();
Ok(Some(config))
}
(base_url, api_key, model) => {
let missing: Vec<&str> = [
("--ai-base-url", base_url.is_none()),
("--ai-api-key (or UNPDF_AI_API_KEY)", api_key.is_none()),
("--ai-model", model.is_none()),
]
.into_iter()
.filter_map(|(flag, absent)| absent.then_some(flag))
.collect();
Err(format!(
"incomplete AI configuration — also required: {}",
missing.join(", ")
))
}
}
}
}
#[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)]
pub refine: bool,
#[command(flatten)]
pub ai: AiArgs,
#[arg(long)]
pub ai_refine: bool,
#[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(long)]
refine: bool,
#[command(flatten)]
ai: AiArgs,
#[arg(long)]
ai_refine: bool,
#[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)]
refine: bool,
#[command(flatten)]
ai: AiArgs,
#[arg(long)]
ai_refine: bool,
#[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)]
refine: bool,
#[command(flatten)]
ai: AiArgs,
#[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,
#[command(flatten)]
ai: AiArgs,
},
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,
refine,
ai,
ai_refine,
max_heading,
pages,
page_markers,
}) => cmd_markdown(
&input,
output.as_deref(),
frontmatter,
table_mode,
cleanup,
refine,
&ai,
ai_refine,
max_heading,
pages.as_deref(),
page_markers,
quiet,
),
Some(Commands::Text {
input,
output,
cleanup,
refine,
ai,
pages,
}) => {
let _ = refine;
cmd_text(
&input,
output.as_deref(),
cleanup,
&ai,
pages.as_deref(),
quiet,
)
}
Some(Commands::Json {
input,
output,
compact,
ai,
}) => cmd_json(&input, output.as_deref(), compact, &ai, 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,
refine: cli.refine,
ai: cli.ai.clone(),
ai_refine: cli.ai_refine,
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 let Some(dir) = &image_dir {
render_opts = render_opts.with_image_prefix(image_link_prefix(&out_dir, dir));
}
if let Some(level) = args.cleanup {
render_opts = render_opts.with_cleanup_preset(level.into());
}
if args.refine {
render_opts = render_opts.with_refine();
}
if args.page_markers {
render_opts = render_opts.with_page_markers(unpdf::PageMarkerStyle::Comment);
}
let ai_config = args.ai.to_config()?;
if args.ai_refine && ai_config.is_none() {
return Err("--ai-refine needs --ai-base-url, --ai-api-key and --ai-model".into());
}
if args.ai_refine {
if let Some(config) = ai_config.clone() {
render_opts = render_opts.with_ai_refine(config);
}
}
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);
}
if let Some(config) = ai_config {
parse_options = parse_options
.with_ai(config)
.with_min_image_dimension(args.min_image_size);
return convert_buffered(
args,
parse_options,
render_opts,
&out_dir,
&formats,
image_dir,
);
}
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");
Ok(report_convert_result(
args,
&summary,
image_dir.as_deref(),
quality.as_ref(),
))
}
fn convert_buffered(
args: &ConvertArgs,
parse_options: ParseOptions,
render_opts: RenderOptions,
out_dir: &Path,
formats: &[writer::OutputFormat],
image_dir: Option<PathBuf>,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut doc = parse_file_with_options(&args.input, parse_options)?;
let mut mfw = writer::MultiFormatWriter::new(out_dir, formats, render_opts, image_dir.clone())?;
let page_count = doc.pages.len() as u32;
mfw.write_document_start(&doc.metadata, page_count)?;
let pb = if args.quiet {
ProgressBar::hidden()
} else {
let b = ProgressBar::new(page_count as u64);
b.set_style(
ProgressStyle::default_bar()
.template("{bar:40.cyan/blue} {pos}/{len} pages ({eta})")
.unwrap(),
);
b
};
for page in &mut doc.pages {
mfw.write_page(page)?;
pb.inc(1);
}
let summary = mfw.finish()?;
pb.finish_with_message("Done");
Ok(report_convert_result(
args,
&summary,
image_dir.as_deref(),
Some(&doc.extraction_quality),
))
}
fn report_convert_result(
args: &ConvertArgs,
summary: &writer::WriteSummary,
image_dir: Option<&Path>,
quality: Option<&unpdf::ExtractionQuality>,
) -> bool {
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.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.and_then(|q| q.warning_message());
if let Some(warning) = &warning {
if !args.quiet {
eprintln!("{}: {}", "Warning".yellow().bold(), warning);
}
}
warning.is_some()
}
#[allow(clippy::too_many_arguments)]
fn cmd_markdown(
input: &Path,
output: Option<&Path>,
frontmatter: bool,
table_mode: TableMode,
cleanup: Option<CleanupLevel>,
refine: bool,
ai: &AiArgs,
ai_refine: bool,
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 ai_config = ai.to_config()?;
if ai_refine && ai_config.is_none() {
return Err("--ai-refine needs --ai-base-url, --ai-api-key and --ai-model".into());
}
let mut options = ParseOptions::new()
.lenient()
.with_pages(page_selection.clone());
if let Some(config) = ai_config.clone() {
options = options.with_ai(config);
}
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());
}
if refine {
render_options = render_options.with_refine();
}
if ai_refine {
if let Some(config) = ai_config {
render_options = render_options.with_ai_refine(config);
}
}
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>,
ai: &AiArgs,
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 mut options = ParseOptions::new().lenient().with_pages(page_selection);
if let Some(config) = ai.to_config()? {
options = options.with_ai(config);
}
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,
ai: &AiArgs,
quiet: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut options = ParseOptions::new().lenient();
if let Some(config) = ai.to_config()? {
options = options.with_ai(config);
}
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()
);
}
if q.undecodable_content_streams > 0 {
println!(
"{}: {}",
"Content streams".bold(),
format!("{} undecodable, left out", q.undecodable_content_streams).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");
}
fn image_link_prefix(out_dir: &Path, image_dir: &Path) -> String {
let rel = image_dir.strip_prefix(out_dir).unwrap_or(image_dir);
let text = rel.to_string_lossy().replace('\\', "/");
if text.is_empty() {
String::new()
} else if text.ends_with('/') {
text
} else {
format!("{}/", text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn image_link_prefix_is_relative_for_the_default_layout() {
let out = Path::new("/tmp/doc_output");
assert_eq!(image_link_prefix(out, &out.join("images")), "images/");
}
#[test]
fn image_link_prefix_keeps_nested_relative_paths() {
let out = Path::new("/tmp/doc_output");
assert_eq!(
image_link_prefix(out, &out.join("assets").join("img")),
"assets/img/"
);
}
#[test]
fn image_link_prefix_falls_back_to_the_path_as_given_when_outside() {
let out = Path::new("/tmp/doc_output");
assert_eq!(
image_link_prefix(out, Path::new("/var/shared/pics")),
"/var/shared/pics/"
);
}
#[test]
fn image_link_prefix_is_empty_when_images_sit_in_the_output_root() {
let out = Path::new("/tmp/doc_output");
assert_eq!(image_link_prefix(out, out), "");
}
fn args(base_url: Option<&str>, api_key: Option<&str>, model: Option<&str>) -> AiArgs {
AiArgs {
ai_base_url: base_url.map(String::from),
ai_api_key: api_key.map(String::from),
ai_model: model.map(String::from),
ai_image_scope: AiImageScope::All,
}
}
#[test]
fn no_ai_flags_yields_no_config() {
assert!(args(None, None, None).to_config().unwrap().is_none());
}
#[test]
fn all_three_flags_yield_a_config() {
let config = args(Some("http://localhost"), Some("k"), Some("m"))
.to_config()
.unwrap()
.expect("all three supplied");
assert_eq!(config.base_url, "http://localhost");
assert_eq!(config.api_key, "k");
assert_eq!(config.model, "m");
assert_eq!(config.image_scope, unpdf::ImageScope::All);
}
#[test]
fn image_scope_maps_onto_the_library_enum() {
let mut a = args(Some("u"), Some("k"), Some("m"));
a.ai_image_scope = AiImageScope::LowConfidenceOnly;
let config = a.to_config().unwrap().unwrap();
assert_eq!(
config.image_scope,
unpdf::ImageScope::LowConfidencePagesOnly
);
}
#[test]
fn partial_config_is_an_error_naming_what_is_missing() {
let err = args(None, None, Some("m")).to_config().unwrap_err();
assert!(err.contains("--ai-base-url"), "{err}");
assert!(err.contains("--ai-api-key"), "{err}");
assert!(!err.contains("--ai-model"), "{err}");
let err = args(Some("u"), Some("k"), None).to_config().unwrap_err();
assert!(err.contains("--ai-model"), "{err}");
assert!(!err.contains("--ai-base-url"), "{err}");
}
fn subcommand_args(name: &str) -> Vec<String> {
Cli::command()
.get_subcommands()
.find(|c| c.get_name() == name)
.unwrap_or_else(|| panic!("{name} subcommand"))
.get_arguments()
.map(|a| a.get_id().to_string())
.collect()
}
#[test]
fn ai_refine_is_scoped_to_markdown_rendering_commands() {
let has_ai_refine = |name: &str| subcommand_args(name).iter().any(|a| a == "ai_refine");
assert!(has_ai_refine("markdown"));
assert!(has_ai_refine("convert"), "convert renders extract.md");
assert!(!has_ai_refine("text"));
assert!(!has_ai_refine("json"));
}
#[test]
fn parse_time_ai_flags_reach_every_command_that_assembles_a_document() {
for name in ["convert", "markdown", "text", "json"] {
let args = subcommand_args(name);
for flag in ["ai_base_url", "ai_api_key", "ai_model", "ai_image_scope"] {
assert!(
args.iter().any(|a| a == flag),
"{name} is missing --{}",
flag.replace('_', "-")
);
}
}
}
#[test]
fn ai_flags_stay_off_commands_that_cannot_use_them() {
for name in ["info", "extract"] {
assert!(
!subcommand_args(name).iter().any(|a| a.starts_with("ai_")),
"{name} has no use for the AI passes"
);
}
}
}