use std::collections::BTreeMap;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use carta::ast::{Block, MetaValue};
use carta::{
EpubOptions, Error, MathMethod, MediaBag, Output, ReaderOptions, Result, WrapMode,
WriterOptions, media, read_document, render_document, walk,
};
use clap::{ArgAction, Parser};
mod datadir;
mod filters;
const LIST_FLAGS: [&str; 4] = [
"list_input_formats",
"list_output_formats",
"list_extensions",
"print_default_template",
];
#[derive(Parser, Debug)]
#[command(
name = "carta",
version,
about = "Document converter",
disable_version_flag = true
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
#[arg(short = 'f', long = "from", required_unless_present_any = LIST_FLAGS)]
from: Option<String>,
#[arg(short = 't', long = "to", required_unless_present_any = LIST_FLAGS)]
to: Option<String>,
#[arg(short = 'o', long = "output")]
output: Option<PathBuf>,
#[arg(long = "extract-media", value_name = "DIR")]
extract_media: Option<PathBuf>,
#[arg(short = 's', long = "standalone")]
standalone: bool,
#[arg(long = "template", value_name = "FILE")]
template: Option<PathBuf>,
#[arg(short = 'V', long = "variable", value_name = "KEY[:VAL]")]
variable: Vec<String>,
#[arg(long = "wrap", value_name = "auto|none|preserve", default_value = "auto", value_parser = parse_wrap)]
wrap: WrapMode,
#[arg(long = "columns", value_name = "N")]
columns: Option<usize>,
#[arg(short = 'N', long = "number-sections")]
number_sections: bool,
#[arg(long = "toc", visible_alias = "table-of-contents")]
toc: bool,
#[arg(long = "toc-depth", value_name = "N", value_parser = parse_toc_depth)]
toc_depth: Option<usize>,
#[allow(clippy::option_option)]
#[arg(long = "mathjax", value_name = "URL", num_args = 0..=1, require_equals = true)]
mathjax: Option<Option<String>>,
#[allow(clippy::option_option)]
#[arg(long = "katex", value_name = "URL", num_args = 0..=1, require_equals = true)]
katex: Option<Option<String>>,
#[arg(short = 'M', long = "metadata", value_name = "KEY[:VAL]")]
metadata: Vec<String>,
#[arg(long = "metadata-file", value_name = "FILE")]
metadata_file: Vec<PathBuf>,
#[arg(short = 'F', long = "filter", value_name = "PROGRAM")]
filter: Vec<String>,
#[arg(long = "data-dir", value_name = "DIR")]
data_dir: Option<PathBuf>,
#[arg(
short = 'c',
long = "css",
visible_alias = "stylesheet",
value_name = "FILE"
)]
css: Vec<PathBuf>,
#[arg(long = "epub-cover-image", value_name = "FILE")]
epub_cover_image: Option<PathBuf>,
#[arg(long = "epub-embed-font", value_name = "FILE")]
epub_embed_font: Vec<PathBuf>,
#[arg(long = "epub-metadata", value_name = "FILE")]
epub_metadata: Option<PathBuf>,
#[arg(long = "epub-subdirectory", value_name = "DIRNAME")]
epub_subdirectory: Option<String>,
#[arg(long = "split-level", visible_alias = "epub-chapter-level", value_name = "N", value_parser = parse_split_level)]
split_level: Option<usize>,
#[arg(long = "list-input-formats")]
list_input_formats: bool,
#[arg(long = "list-output-formats")]
list_output_formats: bool,
#[allow(clippy::option_option)]
#[arg(long = "list-extensions", value_name = "FORMAT", num_args = 0..=1, require_equals = true)]
list_extensions: Option<Option<String>>,
#[arg(short = 'D', long = "print-default-template", value_name = "FORMAT")]
print_default_template: Option<String>,
#[arg(long = "version", action = ArgAction::Version)]
version: Option<bool>,
input: Option<PathBuf>,
}
fn main() -> ExitCode {
match run(&Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("carta: {error}");
exit_code(&error)
}
}
}
fn exit_code(error: &Error) -> ExitCode {
match error {
Error::UnsupportedExtension { .. } => ExitCode::from(23),
Error::Filter(_) => ExitCode::from(83),
_ => ExitCode::FAILURE,
}
}
fn run(cli: &Cli) -> Result<()> {
if cli.list_input_formats {
return print_lines(&carta::input_format_names());
}
if cli.list_output_formats {
return print_lines(&carta::output_format_names());
}
if let Some(format) = &cli.list_extensions {
return list_extensions(format.as_deref());
}
if let Some(format) = &cli.print_default_template {
return print_default_template(format);
}
match (cli.from.as_deref(), cli.to.as_deref()) {
(Some(from), Some(to)) => convert_document(from, to, cli),
_ => Ok(()),
}
}
fn convert_document(from: &str, to: &str, cli: &Cli) -> Result<()> {
let input = read_input(cli.input.as_deref())?;
let to_base = carta::parse_format_spec(to)?.0;
let data_dir = datadir::resolve(cli.data_dir.as_deref());
let mut writer_options = WriterOptions::default();
writer_options.standalone = cli.standalone;
if let Some((source, dir, ext)) = resolve_template(cli, &to_base, data_dir.as_deref())? {
writer_options.template = Some(source);
writer_options.template_dir = Some(dir);
writer_options.template_ext = Some(ext);
}
writer_options.template_datadir = data_dir.as_ref().map(|dir| dir.join("templates"));
writer_options.wrap = cli.wrap;
writer_options.columns = cli.columns;
writer_options.number_sections = cli.number_sections;
writer_options.toc = cli.toc;
writer_options.toc_depth = cli.toc_depth;
writer_options.math_method = math_method(cli);
writer_options.variables = parse_variables(&cli.variable);
writer_options.metadata = parse_metadata(&cli.metadata);
writer_options.metadata_defaults = read_metadata_files(&cli.metadata_file)?;
writer_options.source_name = Some(source_name(cli.input.as_deref()));
if embeds_resources(to) {
writer_options.epub = epub_options(cli)?;
}
let verbatim = cli.standalone || cli.template.is_some();
let (mut document, resources) = read_document(from, &input, &ReaderOptions::default())?;
carta::merge_metadata(&mut document, &writer_options);
writer_options.metadata.clear();
writer_options.metadata_defaults.clear();
let mut resources = match &cli.extract_media {
Some(dir) => {
extract_media(dir, &resources, &mut document.blocks)?;
MediaBag::new()
}
None => resources,
};
filters::run(&mut document, &cli.filter, &to_base, data_dir.as_deref())?;
if cli.extract_media.is_none() && embeds_resources(to) {
embed_local_media(&mut document.blocks, &mut resources);
}
let output = render_document(to, document, resources, &writer_options)?;
write_output(cli.output.as_deref(), &output, verbatim)
}
fn try_read(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(source) => Ok(Some(source)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn resolve_template(
cli: &Cli,
to_base: &str,
data_dir: Option<&Path>,
) -> Result<Option<(String, PathBuf, String)>> {
if let Some(name) = &cli.template {
return resolve_named_template(name, data_dir).map(Some);
}
if cli.standalone
&& let Some(dir) = data_dir
{
let dir = dir.join("templates");
let extension = default_template_extension(to_base);
if let Some(source) = try_read(&dir.join(format!("default.{extension}")))? {
return Ok(Some((source, dir, extension.to_owned())));
}
}
Ok(None)
}
fn resolve_named_template(
name: &Path,
data_dir: Option<&Path>,
) -> Result<(String, PathBuf, String)> {
let extension = name
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_owned();
if let Some(source) = try_read(name)? {
return Ok((source, template_dir(name), extension));
}
if let Some(dir) = data_dir {
let dir = dir.join("templates");
if let Some(source) = try_read(&dir.join(name))? {
return Ok((source, dir, extension));
}
}
Err(Error::Template(format!(
"could not find template '{}'",
name.display()
)))
}
fn default_template_extension(to_base: &str) -> &str {
match to_base {
"html" | "html5" => "html5",
"html4" => "html4",
"gfm" => "commonmark",
other => other,
}
}
fn embeds_resources(to: &str) -> bool {
to.starts_with("epub")
}
fn epub_options(cli: &Cli) -> Result<EpubOptions> {
let mut epub = EpubOptions::default();
for path in &cli.css {
epub.stylesheets.push(fs::read_to_string(path)?);
}
if let Some(path) = &cli.epub_cover_image {
epub.cover_image = Some((base_name(path), fs::read(path)?));
}
for path in &cli.epub_embed_font {
epub.fonts.push((base_name(path), fs::read(path)?));
}
if let Some(path) = &cli.epub_metadata {
epub.metadata_xml = Some(fs::read_to_string(path)?);
}
epub.subdirectory.clone_from(&cli.epub_subdirectory);
epub.split_level = cli.split_level;
epub.source_date_epoch = source_date_epoch();
epub.locale = std::env::var("LANG").ok();
Ok(epub)
}
fn base_name(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned()
}
fn source_date_epoch() -> Option<i64> {
std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
}
fn embed_local_media(blocks: &mut [Block], media: &mut MediaBag) {
let mut references: Vec<String> = Vec::new();
walk::for_each_image_target(blocks, &mut |target| {
let url = target.url.to_string();
if !references.contains(&url) {
references.push(url);
}
});
for url in references {
if media.contains(&url) || is_remote_reference(&url) {
continue;
}
if let Ok(bytes) = fs::read(&url) {
let mime = mime_from_extension(Path::new(&url));
media.insert(url, mime, bytes);
}
}
}
fn is_remote_reference(url: &str) -> bool {
url.starts_with("data:") || url.starts_with("//") || url.contains("://")
}
fn mime_from_extension(path: &Path) -> Option<String> {
let extension = path
.extension()
.and_then(|ext| ext.to_str())?
.to_ascii_lowercase();
let mime = match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
_ => return None,
};
Some(mime.to_owned())
}
fn extract_media(dir: &Path, media: &MediaBag, blocks: &mut [Block]) -> Result<()> {
for (name, item) in media.iter() {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, &item.bytes)?;
}
media::rewrite_extracted_references(blocks, media, &dir.to_string_lossy());
Ok(())
}
fn source_name(input: Option<&Path>) -> String {
match input {
None => "-".to_owned(),
Some(path) => path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("-")
.to_owned(),
}
}
fn template_dir(path: &Path) -> PathBuf {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
const DEFAULT_MATHJAX_URL: &str = "https://cdn.jsdelivr.net/npm/mathjax@4/tex-chtml.js";
const DEFAULT_KATEX_URL: &str = "https://cdn.jsdelivr.net/npm/katex@latest/dist/";
fn math_method(cli: &Cli) -> MathMethod {
if let Some(url) = &cli.mathjax {
MathMethod::MathJax(
url.clone()
.unwrap_or_else(|| DEFAULT_MATHJAX_URL.to_owned()),
)
} else if let Some(url) = &cli.katex {
MathMethod::Katex(url.clone().unwrap_or_else(|| DEFAULT_KATEX_URL.to_owned()))
} else {
MathMethod::Plain
}
}
fn parse_wrap(value: &str) -> std::result::Result<WrapMode, String> {
match value {
"auto" => Ok(WrapMode::Auto),
"none" => Ok(WrapMode::None),
"preserve" => Ok(WrapMode::Preserve),
other => Err(format!(
"invalid wrap mode '{other}' (expected auto, none, or preserve)"
)),
}
}
fn parse_toc_depth(value: &str) -> std::result::Result<usize, String> {
match value.parse::<usize>() {
Ok(depth @ 1..=6) => Ok(depth),
_ => Err(format!("'{value}' is not a heading level between 1 and 6")),
}
}
fn parse_split_level(value: &str) -> std::result::Result<usize, String> {
match value.parse::<usize>() {
Ok(level @ 1..=6) => Ok(level),
_ => Err(format!("'{value}' is not a heading level between 1 and 6")),
}
}
fn parse_variables(specs: &[String]) -> Vec<(String, String)> {
specs
.iter()
.map(|spec| match spec.split_once([':', '=']) {
Some((key, value)) => (key.to_owned(), value.to_owned()),
None => (spec.clone(), "true".to_owned()),
})
.collect()
}
fn parse_metadata(specs: &[String]) -> BTreeMap<String, MetaValue> {
let mut map: BTreeMap<String, MetaValue> = BTreeMap::new();
for spec in specs {
let (key, value) = match spec.split_once([':', '=']) {
Some((key, "true")) => (key, MetaValue::MetaBool(true)),
Some((key, "false")) => (key, MetaValue::MetaBool(false)),
Some((key, value)) => (key, MetaValue::MetaString(value.into())),
None => (spec.as_str(), MetaValue::MetaBool(true)),
};
let next = match map.remove(key) {
None => value,
Some(MetaValue::MetaList(mut items)) => {
items.push(value);
MetaValue::MetaList(items)
}
Some(first) => MetaValue::MetaList(vec![first, value]),
};
map.insert(key.to_owned(), next);
}
map
}
fn read_metadata_files(paths: &[PathBuf]) -> Result<BTreeMap<String, MetaValue>> {
let mut defaults = BTreeMap::new();
for path in paths {
let content = fs::read_to_string(path)?;
let json = path.extension().and_then(|ext| ext.to_str()) == Some("json");
for (key, value) in carta::parse_metadata_file(&content, json)? {
defaults.insert(key, value);
}
}
Ok(defaults)
}
fn print_default_template(spec: &str) -> Result<()> {
let (base, _) = carta::parse_format_spec(spec)?;
let writer = carta::any_writer_for(&base)?;
match writer.default_template() {
Some(template) => {
io::stdout().lock().write_all(template.as_bytes())?;
Ok(())
}
None => Err(Error::Template(format!(
"format '{base}' has no default template"
))),
}
}
fn print_lines(lines: &[&str]) -> Result<()> {
let mut out = io::stdout().lock();
for line in lines {
writeln!(out, "{line}")?;
}
Ok(())
}
fn list_extensions(format: Option<&str>) -> Result<()> {
let mut out = io::stdout().lock();
for (extension, enabled) in carta::format_extensions(format)? {
let sign = if enabled { '+' } else { '-' };
writeln!(out, "{sign}{}", extension.name())?;
}
Ok(())
}
fn read_input(path: Option<&Path>) -> Result<Vec<u8>> {
if let Some(path) = path {
Ok(fs::read(path)?)
} else {
let mut buffer = Vec::new();
io::stdin().read_to_end(&mut buffer)?;
Ok(buffer)
}
}
fn write_output(path: Option<&Path>, output: &Output, verbatim: bool) -> Result<()> {
if matches!(output, Output::Bytes(_)) && binary_to_terminal(path) {
return Err(Error::Io(io::Error::other(
"refusing to write binary output to a terminal (use -o FILE or redirect stdout)",
)));
}
let mut writer: Box<dyn Write> = match path {
Some(path) => Box::new(fs::File::create(path)?),
None => Box::new(io::stdout().lock()),
};
match output {
Output::Text(text) => {
writer.write_all(text.as_bytes())?;
if !verbatim {
writer.write_all(b"\n")?;
}
}
Output::Bytes(bytes) => writer.write_all(bytes)?,
}
Ok(())
}
fn binary_to_terminal(path: Option<&Path>) -> bool {
path.is_none() && io::stdout().is_terminal()
}
#[cfg(test)]
mod tests {
#![allow(clippy::indexing_slicing)]
use super::{Cli, parse_metadata, parse_toc_depth, parse_variables, parse_wrap, template_dir};
use carta::WrapMode;
use carta::ast::MetaValue;
use clap::CommandFactory;
use std::path::{Path, PathBuf};
fn vars(args: &[&str]) -> Vec<(String, String)> {
parse_variables(&args.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>())
}
#[test]
fn bare_variable_defaults_to_true() {
assert_eq!(
vars(&["flag", "k=v", "eq=a=b"]),
vec![
("flag".to_owned(), "true".to_owned()),
("k".to_owned(), "v".to_owned()),
("eq".to_owned(), "a=b".to_owned()),
]
);
}
#[test]
fn variable_splits_on_the_first_colon_or_equals() {
assert_eq!(
vars(&["k:v", "colon:a=b", "equals=a:b"]),
vec![
("k".to_owned(), "v".to_owned()),
("colon".to_owned(), "a=b".to_owned()),
("equals".to_owned(), "a:b".to_owned()),
]
);
}
#[test]
fn metadata_splits_on_the_first_colon_or_equals() {
let map = parse_metadata(
&["a:val", "b:true", "c:x=y"]
.iter()
.map(|s| (*s).to_owned())
.collect::<Vec<_>>(),
);
assert_eq!(map["a"], MetaValue::MetaString("val".into()));
assert_eq!(map["b"], MetaValue::MetaBool(true));
assert_eq!(map["c"], MetaValue::MetaString("x=y".into()));
}
#[test]
fn metadata_typing_distinguishes_booleans_from_strings() {
let map = parse_metadata(
&["a=true", "b=false", "c=text", "d", "e=True"]
.iter()
.map(|s| (*s).to_owned())
.collect::<Vec<_>>(),
);
assert_eq!(map["a"], MetaValue::MetaBool(true));
assert_eq!(map["b"], MetaValue::MetaBool(false));
assert_eq!(map["c"], MetaValue::MetaString("text".into()));
assert_eq!(map["d"], MetaValue::MetaBool(true));
assert_eq!(map["e"], MetaValue::MetaString("True".into()));
}
#[test]
fn repeated_metadata_key_accumulates_into_a_list() {
let two = parse_metadata(&["k=first".to_owned(), "k=second".to_owned()]);
assert_eq!(
two["k"],
MetaValue::MetaList(vec![
MetaValue::MetaString("first".into()),
MetaValue::MetaString("second".into()),
])
);
let mixed = parse_metadata(&["k".to_owned(), "k=a".to_owned(), "k=b".to_owned()]);
assert_eq!(
mixed["k"],
MetaValue::MetaList(vec![
MetaValue::MetaBool(true),
MetaValue::MetaString("a".into()),
MetaValue::MetaString("b".into()),
])
);
}
#[test]
fn template_dir_is_the_file_parent_or_current_dir() {
assert_eq!(template_dir(Path::new("bare.html")), PathBuf::from("."));
assert_eq!(
template_dir(Path::new("sub/dir/t.html")),
PathBuf::from("sub/dir")
);
assert_eq!(
template_dir(Path::new("/abs/t.html")),
PathBuf::from("/abs")
);
}
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn wrap_mode_parses_the_three_names_and_rejects_others() {
assert_eq!(parse_wrap("auto"), Ok(WrapMode::Auto));
assert_eq!(parse_wrap("none"), Ok(WrapMode::None));
assert_eq!(parse_wrap("preserve"), Ok(WrapMode::Preserve));
assert!(parse_wrap("soft").is_err());
}
#[test]
fn toc_depth_accepts_one_through_six_and_rejects_the_rest() {
assert_eq!(parse_toc_depth("1"), Ok(1));
assert_eq!(parse_toc_depth("6"), Ok(6));
assert!(parse_toc_depth("0").is_err());
assert!(parse_toc_depth("7").is_err());
assert!(parse_toc_depth("two").is_err());
}
}