minipdf-cli 0.7.0

Experimental Rust CLI for MiniPdf Office-to-PDF conversion.
use std::fs;
use std::path::{Path, PathBuf};

use clap::{Args, Parser, Subcommand, ValueEnum};

#[derive(Debug, Parser)]
#[command(name = "minipdf")]
#[command(version)]
#[command(about = "Convert Office files to PDF with the experimental Rust MiniPdf engine.")]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,

    #[arg(value_name = "INPUT")]
    input: Option<PathBuf>,

    #[arg(short, long, value_name = "OUTPUT")]
    output: Option<PathBuf>,

    #[arg(long, value_name = "DIR")]
    fonts: Option<PathBuf>,

    #[command(flatten)]
    page: PageArgs,
}

#[derive(Debug, Subcommand)]
enum Commands {
    Convert(ConvertArgs),
}

#[derive(Debug, Parser)]
struct ConvertArgs {
    #[arg(value_name = "INPUT")]
    input: PathBuf,

    #[arg(short, long, value_name = "OUTPUT")]
    output: Option<PathBuf>,

    #[arg(long, value_name = "DIR")]
    fonts: Option<PathBuf>,

    #[command(flatten)]
    page: PageArgs,
}

#[derive(Debug, Args, Default)]
struct PageArgs {
    #[arg(long, value_enum, value_name = "SIZE")]
    paper_size: Option<PaperSizeArg>,

    #[arg(long, value_name = "POINTS", requires = "page_height")]
    page_width: Option<f32>,

    #[arg(long, value_name = "POINTS", requires = "page_width")]
    page_height: Option<f32>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum PaperSizeArg {
    A4,
    Letter,
}

fn main() {
    let cli = Cli::parse();
    let result = match cli.command {
        Some(Commands::Convert(args)) => {
            run_convert(args.input, args.output, args.fonts, args.page)
        }
        None => {
            let Some(input) = cli.input else {
                eprintln!("Error: input file is required. Use --help for usage.");
                std::process::exit(1);
            };
            run_convert(input, cli.output, cli.fonts, cli.page)
        }
    };

    if let Err(err) = result {
        eprintln!("Error: {err}");
        std::process::exit(1);
    }
}

fn run_convert(
    input: PathBuf,
    output: Option<PathBuf>,
    fonts: Option<PathBuf>,
    page: PageArgs,
) -> minipdf::Result<()> {
    if !input.exists() {
        return Err(minipdf::MiniPdfError::InvalidInput(format!(
            "file not found: {}",
            input.display()
        )));
    }

    let ext = input
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .unwrap_or_default();
    if ext != "xlsx" && ext != "docx" && ext != "pptx" {
        return Err(minipdf::MiniPdfError::InvalidInput(format!(
            "unsupported file type '.{ext}'. Supported: .xlsx, .docx, .pptx"
        )));
    }

    if let Some(font_dir) = fonts {
        register_fonts_from_dir(&font_dir)?;
    } else {
        register_system_fallback_fonts()?;
    }

    let options = conversion_options(page)?;
    let output = output.unwrap_or_else(|| input.with_extension("pdf"));
    minipdf::convert_to_pdf_with_options(&input, &output, &options)?;
    println!("{}", output.display());
    Ok(())
}

fn conversion_options(page: PageArgs) -> minipdf::Result<minipdf::ConversionOptions> {
    if page.paper_size.is_some() && page.page_width.is_some() {
        return Err(minipdf::MiniPdfError::InvalidInput(
            "use either --paper-size or --page-width/--page-height, not both".to_owned(),
        ));
    }
    let page_size = match (page.paper_size, page.page_width, page.page_height) {
        (Some(PaperSizeArg::A4), None, None) => Some(minipdf::PageSize::A4),
        (Some(PaperSizeArg::Letter), None, None) => Some(minipdf::PageSize::LETTER),
        (None, Some(width), Some(height)) => Some(minipdf::PageSize::new(width, height)?),
        (None, None, None) => None,
        _ => {
            return Err(minipdf::MiniPdfError::InvalidInput(
                "--page-width and --page-height must be specified together".to_owned(),
            ));
        }
    };
    Ok(minipdf::ConversionOptions { page_size })
}

fn register_fonts_from_dir(font_dir: &Path) -> minipdf::Result<()> {
    if !font_dir.is_dir() {
        return Err(minipdf::MiniPdfError::InvalidInput(format!(
            "font directory not found: {}",
            font_dir.display()
        )));
    }

    for entry in fs::read_dir(font_dir)? {
        let entry = entry?;
        let path = entry.path();
        let ext = path
            .extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| ext.to_ascii_lowercase());
        if matches!(ext.as_deref(), Some("ttf" | "ttc" | "otf")) {
            let data = fs::read(&path)?;
            let name = font_alias_from_data(&data).unwrap_or_else(|| {
                path.file_stem()
                    .and_then(|name| name.to_str())
                    .unwrap_or("font")
                    .to_owned()
            });
            minipdf::register_font(name, data);
        }
    }

    Ok(())
}

fn register_system_fallback_fonts() -> minipdf::Result<()> {
    minipdf::register_system_fonts()
}

fn font_alias_from_data(data: &[u8]) -> Option<String> {
    let face = ttf_parser::Face::parse(data, 0).ok()?;
    let name = |id| {
        face.names()
            .into_iter()
            .find(|name| name.name_id == id && name.is_unicode())
            .and_then(|name| name.to_string())
    };
    let family = name(ttf_parser::name_id::TYPOGRAPHIC_FAMILY)
        .or_else(|| name(ttf_parser::name_id::FAMILY))?;
    let subfamily = name(ttf_parser::name_id::TYPOGRAPHIC_SUBFAMILY)
        .or_else(|| name(ttf_parser::name_id::SUBFAMILY))
        .unwrap_or_default();
    Some(build_font_alias(
        &family,
        &subfamily,
        face.is_bold(),
        face.is_italic(),
    ))
}

fn build_font_alias(family: &str, subfamily: &str, bold: bool, italic: bool) -> String {
    let mut alias = normalize_font_alias(family);
    if subfamily.to_ascii_lowercase().contains("display") && !alias.contains("display") {
        alias.push_str("display");
    }
    if bold && italic {
        alias.push('z');
    } else if bold {
        alias.push('b');
    } else if italic {
        alias.push('i');
    }
    alias
}

fn normalize_font_alias(name: &str) -> String {
    name.chars()
        .filter(|character| character.is_ascii_alphanumeric())
        .flat_map(char::to_lowercase)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::build_font_alias;

    #[test]
    fn builds_stable_cloud_font_aliases() {
        assert_eq!(
            build_font_alias("Grandview", "Regular", false, false),
            "grandview"
        );
        assert_eq!(
            build_font_alias("Grandview", "Bold", true, false),
            "grandviewb"
        );
        assert_eq!(
            build_font_alias("Grandview", "Display", false, false),
            "grandviewdisplay"
        );
    }
}