use std::path::{Path, PathBuf};
use clap::{Parser, ValueEnum};
use liepress::{
ConvertOptions, PageConfig, html_file_to_pdf, html_file_to_png, html_file_to_svg,
markdown_file_to_pdf, markdown_file_to_png, markdown_file_to_svg,
};
#[derive(ValueEnum, Clone, Debug)]
enum Format {
Pdf,
Svg,
Png,
Html,
}
#[derive(Clone, Debug)]
enum InputFormat {
Markdown,
Html,
}
fn infer_format_from_ext(path: &Path) -> Option<Format> {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref()
{
Some("pdf") => Some(Format::Pdf),
Some("svg") => Some(Format::Svg),
Some("png") => Some(Format::Png),
Some("html") | Some("htm") => Some(Format::Html),
_ => None,
}
}
fn infer_input_format_from_ext(path: &Path) -> Option<InputFormat> {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref()
{
Some("md") | Some("markdown") => Some(InputFormat::Markdown),
Some("html") | Some("htm") => Some(InputFormat::Html),
_ => None,
}
}
#[derive(Parser, Debug)]
#[command(name = "liepress")]
#[command(about = "Convert Markdown or HTML to PDF, SVG, PNG or HTML")]
struct Args {
#[arg(short, long, value_name = "FILE")]
input: PathBuf,
#[arg(short, long, value_name = "FILE")]
output: PathBuf,
#[arg(short, long, value_enum)]
format: Option<Format>,
#[arg(short = 's', long = "style", value_name = "CSS_FILE")]
style: Option<PathBuf>,
#[arg(short = 't', long = "title", value_name = "TITLE")]
title: Option<String>,
#[arg(short = 'S', long = "strict", default_value_t = false)]
strict: bool,
#[arg(short = 'p', long = "page-size", value_name = "SIZE")]
page_size: Option<String>,
#[arg(long = "page-width", value_name = "WIDTH")]
page_width: Option<String>,
#[arg(long = "page-height", value_name = "HEIGHT")]
page_height: Option<String>,
#[arg(short = 'L', long = "landscape", default_value_t = false)]
landscape: bool,
#[arg(short = 'P', long = "portrait", default_value_t = false)]
portrait: bool,
#[arg(long = "margin", value_name = "MARGIN")]
margin: Option<String>,
#[arg(long = "margin-top", value_name = "MARGIN")]
margin_top: Option<String>,
#[arg(long = "margin-bottom", value_name = "MARGIN")]
margin_bottom: Option<String>,
#[arg(long = "margin-left", value_name = "MARGIN")]
margin_left: Option<String>,
#[arg(long = "margin-right", value_name = "MARGIN")]
margin_right: Option<String>,
#[arg(long = "no-auto-font", default_value_t = false)]
no_auto_font: bool,
#[arg(long = "header", value_name = "TEXT")]
header: Option<String>,
#[arg(long = "footer", value_name = "TEXT")]
footer: Option<String>,
#[arg(long = "no-page-number", default_value_t = false)]
no_page_number: bool,
#[arg(long = "height-unlimited", default_value_t = false)]
height_unlimited: bool,
}
fn parse_length(value: &str) -> Option<f32> {
let value = value.trim();
if value == "0" {
return Some(0.0);
}
if let Some(v) = value.strip_suffix("pt") {
v.trim().parse::<f32>().ok()
} else if let Some(v) = value.strip_suffix("mm") {
let mm = v.trim().parse::<f32>().ok()?;
Some(mm * 72.0 / 25.4)
} else if let Some(v) = value.strip_suffix("cm") {
let cm = v.trim().parse::<f32>().ok()?;
Some(cm * 72.0 / 2.54)
} else if let Some(v) = value.strip_suffix("in") {
let inches = v.trim().parse::<f32>().ok()?;
Some(inches * 72.0)
} else if let Some(v) = value.strip_suffix("px") {
v.trim().parse::<f32>().ok()
} else {
value.parse::<f32>().ok()
}
}
fn resolve_page_size(name: &str) -> (Option<f32>, Option<f32>) {
match name.trim().to_ascii_lowercase().as_str() {
"a3" => (Some(841.890), Some(1190.551)),
"a4" => (Some(595.276), Some(841.890)),
"a5" => (Some(419.528), Some(595.276)),
"a6" => (Some(297.638), Some(419.528)),
"letter" => (Some(612.0), Some(792.0)),
"legal" => (Some(612.0), Some(1008.0)),
"tabloid" | "ledger" => (Some(792.0), Some(1224.0)),
_ => (None, None),
}
}
fn build_page_config(args: &Args) -> Option<PageConfig> {
let has_page_args = args.page_size.is_some()
|| args.page_width.is_some()
|| args.page_height.is_some()
|| args.landscape
|| args.portrait
|| args.margin.is_some()
|| args.margin_top.is_some()
|| args.margin_bottom.is_some()
|| args.margin_left.is_some()
|| args.margin_right.is_some()
|| args.header.is_some()
|| args.footer.is_some()
|| args.no_page_number
|| args.height_unlimited;
if !has_page_args {
return None;
}
let mut config = PageConfig::default();
if let Some(size) = &args.page_size {
let (w, h) = resolve_page_size(size);
config.width = w;
config.height = h;
}
if let Some(w) = &args.page_width {
config.width = parse_length(w);
}
if let Some(h) = &args.page_height {
config.height = parse_length(h);
}
if args.landscape {
if let (Some(w), Some(h)) = (config.width, config.height) {
config.width = Some(w.max(h));
config.height = Some(w.min(h));
} else {
config.width = Some(841.890);
config.height = Some(595.276);
}
} else if args.portrait
&& let (Some(w), Some(h)) = (config.width, config.height)
{
config.width = Some(w.min(h));
config.height = Some(w.max(h));
}
if let Some(m) = &args.margin
&& let Some(v) = parse_length(m)
{
config.margin_top = Some(v);
config.margin_bottom = Some(v);
config.margin_left = Some(v);
config.margin_right = Some(v);
}
if let Some(v) = &args.margin_top {
config.margin_top = parse_length(v);
}
if let Some(v) = &args.margin_bottom {
config.margin_bottom = parse_length(v);
}
if let Some(v) = &args.margin_left {
config.margin_left = parse_length(v);
}
if let Some(v) = &args.margin_right {
config.margin_right = parse_length(v);
}
if args.height_unlimited {
config.height_unlimited = Some(true);
}
if let Some(header) = &args.header {
if header.is_empty() {
config.header = None;
} else {
config.header = Some(header.clone());
}
}
if let Some(footer) = &args.footer {
if footer.is_empty() {
config.footer = None;
} else {
config.footer = Some(footer.clone());
}
}
if args.no_page_number {
config.footer = None;
}
Some(config)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let input_format = infer_input_format_from_ext(&args.input)
.ok_or_else(|| format!(
"Cannot determine input format from extension '{}'. Supported: .md, .markdown, .html, .htm",
args.input.extension().and_then(|e| e.to_str()).unwrap_or("(none)")
))?;
let format = args.format.clone().or_else(|| infer_format_from_ext(&args.output))
.ok_or_else(|| format!(
"Cannot determine output format from extension '{}'. Use -f/--format or a supported extension (.pdf, .svg, .png, .html).",
args.output.extension().and_then(|e| e.to_str()).unwrap_or("(none)")
))?;
let mut opts = ConvertOptions::default();
if let Some(css_path) = &args.style {
opts.css_file = Some(css_path.clone());
}
opts.strict = args.strict;
if args.no_auto_font {
opts.auto_font = false;
}
if let Some(page_config) = build_page_config(&args) {
opts.page_config = Some(page_config);
}
match (input_format, format) {
(InputFormat::Markdown, Format::Pdf) => {
let pdf_bytes = markdown_file_to_pdf(&args.input, &opts)?;
std::fs::write(&args.output, pdf_bytes)?;
println!("PDF saved to: {}", args.output.display());
}
(InputFormat::Markdown, Format::Svg) => {
let svgs = markdown_file_to_svg(&args.input, &opts)?;
if svgs.is_empty() {
eprintln!("Warning: no SVG pages generated");
} else if svgs.len() == 1 {
std::fs::write(&args.output, &svgs[0])?;
println!("SVG saved to: {}", args.output.display());
} else {
let stem = args
.output
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let ext = args
.output
.extension()
.unwrap_or_default()
.to_string_lossy();
let parent = args.output.parent().unwrap_or(std::path::Path::new("."));
for (i, svg) in svgs.iter().enumerate() {
let filename = format!("{}_{}.{}", stem, i + 1, ext);
let path = parent.join(&filename);
std::fs::write(&path, svg)?;
println!("SVG saved to: {}", path.display());
}
}
}
(InputFormat::Markdown, Format::Png) => {
let pngs = markdown_file_to_png(&args.input, &opts)?;
if pngs.len() == 1 {
std::fs::write(&args.output, &pngs[0])?;
println!("PNG saved to: {}", args.output.display());
} else {
let stem = args
.output
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let ext = args
.output
.extension()
.unwrap_or_default()
.to_string_lossy();
let parent = args.output.parent().unwrap_or(std::path::Path::new("."));
for (i, png) in pngs.iter().enumerate() {
let filename = format!("{}_{}.{}", stem, i + 1, ext);
let path = parent.join(&filename);
std::fs::write(&path, png)?;
println!("PNG saved to: {}", path.display());
}
}
}
(InputFormat::Markdown, Format::Html) => {
let md_content = std::fs::read_to_string(&args.input)?;
let user_css = if let Some(css_path) = &args.style {
Some(std::fs::read_to_string(css_path)?)
} else {
None
};
let fallback_title = args
.input
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
let html = liepress::markdown_to_html_document(
&md_content,
user_css.as_deref(),
args.title.as_deref(),
fallback_title.as_deref(),
);
std::fs::write(&args.output, html)?;
println!("HTML saved to: {}", args.output.display());
}
(InputFormat::Html, Format::Pdf) => {
let pdf_bytes = html_file_to_pdf(&args.input, &opts)?;
std::fs::write(&args.output, pdf_bytes)?;
println!("PDF saved to: {}", args.output.display());
}
(InputFormat::Html, Format::Svg) => {
let svgs = html_file_to_svg(&args.input, &opts)?;
if svgs.is_empty() {
eprintln!("Warning: no SVG pages generated");
} else if svgs.len() == 1 {
std::fs::write(&args.output, &svgs[0])?;
println!("SVG saved to: {}", args.output.display());
} else {
let stem = args
.output
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let ext = args
.output
.extension()
.unwrap_or_default()
.to_string_lossy();
let parent = args.output.parent().unwrap_or(std::path::Path::new("."));
for (i, svg) in svgs.iter().enumerate() {
let filename = format!("{}_{}.{}", stem, i + 1, ext);
let path = parent.join(&filename);
std::fs::write(&path, svg)?;
println!("SVG saved to: {}", path.display());
}
}
}
(InputFormat::Html, Format::Png) => {
let pngs = html_file_to_png(&args.input, &opts)?;
if pngs.len() == 1 {
std::fs::write(&args.output, &pngs[0])?;
println!("PNG saved to: {}", args.output.display());
} else {
let stem = args
.output
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let ext = args
.output
.extension()
.unwrap_or_default()
.to_string_lossy();
let parent = args.output.parent().unwrap_or(std::path::Path::new("."));
for (i, png) in pngs.iter().enumerate() {
let filename = format!("{}_{}.{}", stem, i + 1, ext);
let path = parent.join(&filename);
std::fs::write(&path, png)?;
println!("PNG saved to: {}", path.display());
}
}
}
(InputFormat::Html, Format::Html) => {
let html_content = std::fs::read_to_string(&args.input)?;
std::fs::write(&args.output, html_content)?;
println!("HTML saved to: {}", args.output.display());
}
}
Ok(())
}