use ferrodoc::{Format, Wrap};
use ferrodoc::ast::{Block, Inline};
use std::fmt::Write as _;
use std::io::{Read as _, Write as _};
use std::path::PathBuf;
use std::process::ExitCode;
const USAGE_HEAD: &str = "\
ferrodoc — convert documents between markdown, HTML, DOCX and the pandoc AST
USAGE:
ferrodoc [OPTIONS] [INPUT]
With no INPUT, or with `-`, the document is read from standard input.
With no --output, the result is written to standard output.
OPTIONS:
-f, --from <FORMAT> Input format [inferred from INPUT's extension]
-t, --to <FORMAT> Output format [inferred from --output's extension]
-o, --output <FILE> Write to FILE instead of standard output
-s, --standalone Wrap HTML in a page, or LaTeX in a whole document
-c, --css <FILE> Inline a stylesheet into that page
--wrap <MODE> auto | none | preserve, as pandoc means them:
`auto` fills to --columns, `none` puts each
block on one line, `preserve` keeps the
document's own breaks. Every text writer does
all three, and the default is `auto` — which
is pandoc's [auto]
--columns <N> Fill width for --wrap=auto [72]
-d, --defaults <FILE> Read flags from a defaults file. Applied where
the flag appears, so a later option overrides
it and an earlier one does not, which is what
pandoc does. A key this build has no flag for
is an error naming the key.
--shift-heading-level-by N Shift every heading; a heading pushed
above level 1 becomes a paragraph, and `-1` on
a leading `#` makes it the title
--strip-comments Drop HTML comments
--ascii Escape every non-ASCII character (html only)
--id-prefix PREFIX Prefix every identifier and internal link
--metadata-file FILE Metadata as a flat `key: value` file
--resource-path DIR[:DIR] Where to look for a picture the
document names, after its own directory
--reference-doc FILE Take the styles from this .docx
--data-dir DIR Where `templates/default.html5` and a
`--template` named rather than pathed live
--eol crlf|lf|native What ends a line in text output
--quiet Say nothing on stderr but errors
--fail-if-warnings Exit 3 if anything warned
--verbose Accepted; this build has no extra diagnostics
--toc Put a table of contents at the top of the page.
HTML output with --standalone; accepted and
ignored otherwise, which is what pandoc does.
Three levels deep, pandoc's --toc-depth default.
-N, --number-sections Number the headings, and the contents entries
with them. HTML output; a heading whose classes
include `unnumbered` keeps its place and takes
no number.
-M, --metadata <K[=V]> Set a metadata field: `-M title=Report` names an
HTML page or a DOCX. A bare `-M draft` sets it
to true. Nothing is invented in the page head
beyond title, author and lang.
--extract-media <DIR>
Write the input's embedded images under DIR and
point the output at them. Without it a
`docx -> markdown` conversion names pictures it
never writes, so they cannot be recovered.
-h, --help Print this help
-v, --version Print the version
-V, --variable KEY=VAL A template variable, which wins over the
document's own metadata
--template FILE Use this template instead of pandoc's default
--toc-depth N How deep the contents go [3]
--no-highlight Do not colour code (see COMPATIBILITY.md for
the languages that are coloured)
-H, --include-in-header FILE Verbatim into <head> } each implies
-B, --include-before-body FILE Verbatim after <body> } --standalone,
-A, --include-after-body FILE Verbatim before </body>} as in pandoc
FORMATS:
";
const HELP_READABLE: &[(Format, &str)] = &[
(Format::PandocMarkdown, "markdown (md, pandoc_markdown)"),
(Format::Gfm, "gfm"),
(Format::Markdown, "commonmark"),
(Format::Html, "html"),
(Format::Docx, "docx"),
(Format::Odt, "odt"),
(Format::Epub, "epub"),
(Format::Ipynb, "ipynb"),
(Format::Json, "json"),
];
const HELP_WRITE_ONLY: &[(Format, &str)] = &[
(Format::Latex, "latex (tex)"),
(Format::Rst, "rst"),
(Format::Asciidoc, "asciidoc (adoc)"),
(Format::Plain, "plain (text)"),
];
const USAGE_TAIL: &str = " `markdown` is **pandoc's own dialect**, both read and written, which
is what `pandoc -f markdown` and `pandoc -t markdown` mean: YAML
metadata blocks, header attributes (`# H {#id .class}`), definition
lists, superscript and subscript, and pipe tables.
`commonmark` is the strict CommonMark reader and writer, and it is
how you ask for the older meaning of `markdown` here.
`gfm` is GitHub Flavored Markdown: tables, task lists, strikethrough
and bare-URL links. Prefer it over `commonmark` for anything with a
table — CommonMark has no table syntax, so a table is written there
as the raw `<table>`, which keeps it but is not pretty.
EXAMPLES:
ferrodoc README.md -o readme.html
ferrodoc README.md -s -o readme.html # a page a browser can open
ferrodoc notes.md -s --css site.css -o notes.html
ferrodoc manual.md -s --toc -N -o manual.html # contents and numbering
ferrodoc notes.md -M title='Q3 review' -o notes.docx
ferrodoc report.docx -t gfm # DOCX in, GitHub markdown out
ferrodoc report.docx -t markdown # DOCX in, pandoc's markdown out
ferrodoc report.docx -t commonmark # DOCX in, strict CommonMark out
ferrodoc page.html -t markdown # HTML in, markdown out
ferrodoc report.docx -t plain
ferrodoc report.docx -t gfm --extract-media out # and keep the pictures
ferrodoc minutes.odt -t gfm # LibreOffice in, markdown out
ferrodoc book.epub -t gfm # an e-book in, markdown out
ferrodoc manual.md -o manual.epub # markdown in, an e-book out
ferrodoc report.docx -t latex | pdflatex # DOCX in, PDF out, via TeX
ferrodoc analysis.ipynb -t docx # a notebook in, Word out
ferrodoc README.md -o readme.odt # markdown in, LibreOffice out
cat notes.md | ferrodoc -f markdown -t docx -o notes.docx
";
fn formats_block() -> String {
let listed = |table: &[(Format, &'static str)]| -> Vec<&'static str> {
table
.iter()
.filter(|(format, _)| format.compiled())
.map(|(_, spelling)| *spelling)
.collect()
};
let write_only = listed(HELP_WRITE_ONLY);
let joined = match write_only.split_last() {
None => String::new(),
Some((last, [])) => (*last).to_owned(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
};
let read_only: Vec<&str> = HELP_READABLE
.iter()
.filter(|(format, _)| format.compiled() && !format.writable())
.map(|(_, spelling)| *spelling)
.collect();
let those = if read_only.is_empty() {
"those".to_owned()
} else {
format!("those except {}", read_only.join(", "))
};
let mut block = format!(" input: {}\n", listed(HELP_READABLE).join(", "));
if joined.is_empty() {
let _ = writeln!(block, " output: {those}");
} else {
let _ = writeln!(block, " output: {those}, plus {joined}");
}
block
}
fn usage() -> String {
format!("{USAGE_HEAD}{}\n{USAGE_TAIL}", formats_block())
}
fn main() -> ExitCode {
match run() {
Ok(()) if FAIL_ON_WARNING.load(std::sync::atomic::Ordering::Relaxed)
&& WARNED.load(std::sync::atomic::Ordering::Relaxed) =>
{
eprintln!("Failing because there were warnings.");
ExitCode::from(3)
}
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("ferrodoc: {message}");
ExitCode::FAILURE
}
}
}
struct Options {
from: Option<Format>,
to: Option<Format>,
output: Option<PathBuf>,
input: Option<PathBuf>,
standalone: bool,
page: PageFlags,
shaping: Shaping,
resource_path: Vec<PathBuf>,
reference: Option<Vec<u8>>,
extract_media: Option<PathBuf>,
wrap: Option<Wrap>,
toc: bool,
number_sections: bool,
metadata: Vec<(String, Option<String>)>,
}
fn wrap_mode(mode: &str) -> Result<Wrap, String> {
match mode {
"auto" => Ok(Wrap::Auto(0)),
"none" => Ok(Wrap::None),
"preserve" => Ok(Wrap::Preserve),
other => Err(format!("unknown --wrap {other:?}; expected auto, none or preserve")),
}
}
fn metadata_pair(raw: String) -> (String, Option<String>) {
match raw.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (raw, None),
}
}
fn expand_defaults(args: &[String], depth: usize) -> Result<Vec<String>, String> {
if depth > 8 {
return Err("--defaults files are nested more than 8 deep".to_owned());
}
let mut out = Vec::with_capacity(args.len());
let mut i = 0;
while i < args.len() {
let (name, attached) = match args[i].split_once('=') {
Some((name, value)) if name.starts_with("--") => (name, Some(value.to_owned())),
_ => (args[i].as_str(), None),
};
if name != "-d" && name != "--defaults" {
out.push(args[i].clone());
i += 1;
continue;
}
let path = if let Some(value) = attached {
value
} else {
i += 1;
args.get(i)
.cloned()
.ok_or_else(|| "--defaults needs a value".to_owned())?
};
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read --defaults {path}: {e}"))?;
out.extend(expand_defaults(&defaults_to_args(&text, &path)?, depth + 1)?);
i += 1;
}
Ok(out)
}
fn metadata_file(path: &str) -> Result<Vec<(String, String)>, String> {
let text = slurp(path)?;
let mut pairs = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---" {
continue;
}
if line.starts_with([' ', '\t']) || trimmed.starts_with('-') {
return Err(format!(
"{path}: cannot read {trimmed:?}: this reads a flat `key: value` file"
));
}
let (key, raw) = trimmed
.split_once(':')
.ok_or_else(|| format!("{path}: cannot read {trimmed:?}: expected `key: value`"))?;
pairs.push((key.to_owned(), raw.trim().trim_matches(['"', '\'']).to_owned()));
}
Ok(pairs)
}
fn defaults_to_args(text: &str, path: &str) -> Result<Vec<String>, String> {
let mut out = Vec::new();
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---" {
continue;
}
if line.starts_with([' ', '\t']) || trimmed.starts_with('-') {
return Err(format!("{path}: cannot read {trimmed:?}: this reads a flat `key: value` file"));
}
let (key, raw) = trimmed
.split_once(':')
.ok_or_else(|| format!("{path}: cannot read {trimmed:?}: expected `key: value`"))?;
let value = raw.trim().trim_matches(['"', '\'']).to_owned();
let mut items = Vec::new();
while lines.peek().is_some_and(|l| l.starts_with([' ', '\t'])) {
let item = lines.next().unwrap_or_default();
items.push(item.trim().trim_start_matches("- ").to_owned());
}
let flags = defaults_key(key, &value, &items)
.ok_or_else(|| format!("{path}: `{key}` is not a defaults key ferrodoc reads"))?;
out.extend(flags);
}
Ok(out)
}
fn defaults_key(key: &str, value: &str, items: &[String]) -> Option<Vec<String>> {
let switch = |flag: &str| match value {
"true" | "yes" => Some(vec![flag.to_owned()]),
"false" | "no" => Some(Vec::new()),
_ => None,
};
match key {
"from" | "reader" => Some(vec!["--from".to_owned(), value.to_owned()]),
"to" | "writer" => Some(vec!["--to".to_owned(), value.to_owned()]),
"output-file" => Some(vec!["--output".to_owned(), value.to_owned()]),
"input-file" => Some(vec![value.to_owned()]),
"standalone" => switch("--standalone"),
"table-of-contents" | "toc" => switch("--toc"),
"number-sections" => switch("--number-sections"),
"wrap" => Some(vec!["--wrap".to_owned(), value.to_owned()]),
"columns" => Some(vec!["--columns".to_owned(), value.to_owned()]),
"css" => {
match (value.is_empty(), items) {
(true, [only]) => Some(vec!["--css".to_owned(), only.clone()]),
(false, []) => Some(vec!["--css".to_owned(), value.to_owned()]),
_ => None,
}
}
"extract-media" => Some(vec!["--extract-media".to_owned(), value.to_owned()]),
"metadata" => Some(
items
.iter()
.flat_map(|item| ["--metadata".to_owned(), item.replace(": ", "=")])
.collect(),
),
_ => None,
}
}
#[derive(Default)]
struct Parsed {
from: Option<Format>,
to: Option<Format>,
deprecated: Vec<String>,
output: Option<PathBuf>,
input: Option<PathBuf>,
stdin_requested: bool,
framing: Framing,
page: PageFlags,
shaping: Shaping,
resource_path: Vec<PathBuf>,
data_dir: Option<PathBuf>,
reference: Option<Vec<u8>>,
extract_media: Option<PathBuf>,
wrap: Option<Wrap>,
columns: usize,
metadata: Vec<(String, Option<String>)>,
}
#[derive(Default)]
struct Framing {
standalone: bool,
toc: bool,
number_sections: bool,
}
fn take_flag(
arg: &str,
value: &mut dyn FnMut(&str) -> Result<String, String>,
out: &mut Parsed,
) -> Result<bool, String> {
match arg {
"-f" | "--from" | "-t" | "--to" => {
let name = value(arg)?;
let slot = usize::from(matches!(arg, "-t" | "--to"));
let parsed = format(&name, slot == 0)?;
if out.deprecated.len() <= slot {
out.deprecated.resize(slot + 1, String::new());
}
let base = name.split(['+', '-']).next().unwrap_or(&name);
out.deprecated[slot] =
if base.eq_ignore_ascii_case("markdown_github") { name } else { String::new() };
if slot == 0 {
out.from = Some(parsed);
} else {
out.to = Some(parsed);
}
}
"-o" | "--output" => out.output = Some(PathBuf::from(value(arg)?)),
"--quiet" | "--fail-if-warnings" | "--verbose" => {}
"--no-highlight" => out.page.highlighting = ferrodoc::Highlighting::None,
"--syntax-highlighting" | "--highlight-style" => {
let given = value(arg)?;
if given != "none" {
return Err(format!(
"{arg}={given}: only `none` is available here; \
COMPATIBILITY.md names the languages that are highlighted"
));
}
out.page.highlighting = ferrodoc::Highlighting::None;
}
"-s" | "--standalone" => out.framing.standalone = true,
"--toc" | "--table-of-contents" => out.framing.toc = true,
"-N" | "--number-sections" => out.framing.number_sections = true,
"--strip-comments" => out.shaping.strip_comments = true,
"--ascii" => out.shaping.ascii = true,
"--shift-heading-level-by" | "--eol" => {
let given = value(arg)?;
shaping_option(arg, &given, &mut out.shaping)?;
}
"--resource-path" => {
out.resource_path.extend(value(arg)?.split(':').map(PathBuf::from));
}
"--data-dir" => out.data_dir = Some(PathBuf::from(value(arg)?)),
"--reference-doc" | "--reference-docx" => {
let path = value(arg)?;
out.reference =
Some(std::fs::read(&path).map_err(|e| format!("cannot read {path}: {e}"))?);
}
"-M" | "--metadata" => out.metadata.push(metadata_pair(value(arg)?)),
"--metadata-file" => {
for (key, given) in metadata_file(&value(arg)?)? {
out.metadata.push((key, Some(given)));
}
}
"--extract-media" => out.extract_media = Some(PathBuf::from(value(arg)?)),
"--wrap" => out.wrap = Some(wrap_mode(&value(arg)?)?),
"--columns" => {
let raw = value(arg)?;
out.columns = raw
.parse::<usize>()
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| format!("--columns needs a positive number, not {raw:?}"))?;
}
name if page_flag(name) => {
let given = value(name)?;
if matches!(
name,
"-H" | "--include-in-header"
| "-B"
| "--include-before-body"
| "-A"
| "--include-after-body"
) {
out.framing.standalone = true;
}
page_option(name, given, &mut out.page)?;
}
_ => return Ok(false),
}
Ok(true)
}
fn parse_args(args: &[String]) -> Result<Option<Options>, String> {
let args = &expand_defaults(args, 0)?;
diagnostics(args);
let mut out = Parsed { columns: 72, ..Parsed::default() };
let mut i = 0;
while i < args.len() {
let (arg, attached) = split_attached(&args[i]);
if matches!(arg, "-h" | "--help" | "-v" | "--version") {
print!("{}", if arg.contains('h') { usage() } else { version() });
return Ok(None);
}
let mut value = |name: &str| -> Result<String, String> {
if let Some(attached) = attached.clone() {
return Ok(attached);
}
i += 1;
args.get(i).cloned().ok_or_else(|| format!("{name} needs a value"))
};
if !take_flag(arg, &mut value, &mut out)? {
match arg {
"-" => {
if out.input.is_some() {
return Err("more than one input given".to_owned());
}
out.stdin_requested = true;
}
other if other.starts_with('-') && other.len() > 1 => {
return Err(format!("unknown option {other} (try --help)"));
}
other => {
if out.input.is_some() || out.stdin_requested {
return Err("more than one input given".to_owned());
}
out.input = Some(PathBuf::from(other));
}
}
}
i += 1;
}
out.page.template = read_template(out.page.template.take(), out.data_dir.as_deref())?;
for _ in out.deprecated.iter().filter(|name| !name.is_empty()) {
warn("[WARNING] Deprecated: markdown_github. Use gfm instead.");
}
Ok(Some(Options {
from: out.from,
to: out.to,
output: out.output,
input: out.input,
standalone: out.framing.standalone,
page: out.page,
shaping: out.shaping,
resource_path: out.resource_path,
reference: out.reference,
extract_media: out.extract_media,
wrap: Some(widened(out.wrap.unwrap_or(Wrap::Auto(0)), out.columns)),
toc: out.framing.toc,
number_sections: out.framing.number_sections,
metadata: out.metadata,
}))
}
fn run() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(Options {
from,
to,
output,
input,
standalone,
page,
shaping,
resource_path,
reference,
extract_media,
wrap,
toc,
number_sections,
metadata,
}) = parse_args(&args)?
else {
return Ok(());
};
let from = inferred(from, input.as_deref(), "input", "--from", "a file", true)?;
let to = inferred(to, output.as_deref(), "output", "--to", "an output file", false)?;
let bytes = read_input(input.as_deref())?;
if matches!(from, Format::Markdown | Format::Gfm) && opens_with_metadata_block(&bytes) {
warn(
"ferrodoc: this document opens with what pandoc would read as a YAML \
metadata block; ferrodoc reads CommonMark, where it is a thematic break and \
a heading in the body",
);
}
let base = input
.as_deref()
.and_then(std::path::Path::parent)
.unwrap_or_else(|| std::path::Path::new("."))
.to_owned();
let (mut doc, embedded) = read_document(&bytes, from, to, extract_media.is_some())?;
if let Some(dir) = extract_media.as_deref() {
extract(&mut doc, &embedded, dir)?;
}
for (key, value) in metadata {
let value = match value {
Some(value) => ferrodoc::ast::MetaValue::MetaString(value),
None => ferrodoc::ast::MetaValue::MetaBool(true),
};
doc.meta.insert(key, value);
}
if number_sections {
#[cfg(feature = "html")]
if to == Format::Html {
ferrodoc::number_sections(&mut doc);
}
if to != Format::Html {
warn(&format!(
"ferrodoc: --number-sections is HTML-only here; the {to} output is unnumbered"
));
}
}
if toc && to != Format::Html {
warn(&format!("ferrodoc: --toc is HTML-only here; the {to} output has no contents"));
}
if shaping.strip_comments {
ferrodoc::strip_comments(&mut doc);
}
if shaping.shift_headings != 0 {
ferrodoc::shift_heading_level(&mut doc, shaping.shift_headings);
}
ferrodoc::prefix_identifiers(&mut doc, &page.id_prefix);
let stem = input
.as_deref()
.and_then(|path| path.file_stem())
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_default();
let page = page.as_page(toc, &stem, wrap);
if reference.is_some() && !matches!(to, Format::Docx | Format::Odt) {
return Err(format!("--reference-doc applies to docx and odt output, not {to}"));
}
let converted = if let Some(reference) = &reference {
ferrodoc::render_with_reference(
&doc,
to,
reference,
&resolve(&embedded, &base, &resource_path, to),
)
.map_err(|e| format!("--reference-doc: {e}"))?
} else if wants_page(standalone, to, &doc)? {
render_page(&doc, to, &page)?
} else {
render_fragment(&doc, to, wrap, &page, &resolve(&embedded, &base, &resource_path, to))?
};
write_output(output.as_deref(), &reshaped(converted, &shaping, to)?)
}
fn render_fragment(
doc: &ferrodoc::Pandoc,
to: Format,
wrap: Option<ferrodoc::Wrap>,
page: &ferrodoc::Page<'_>,
media: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> Result<Vec<u8>, String> {
if !page.css.is_empty() {
return Err("--css needs --standalone: a fragment has no <head>".to_owned());
}
if to == Format::Html && page.highlighting == ferrodoc::Highlighting::None {
return Ok(ferrodoc::render_html_unhighlighted(
doc,
&page.id_prefix,
wrap.unwrap_or(ferrodoc::Wrap::Auto(72)),
));
}
if to == Format::Html && !page.id_prefix.is_empty() {
return Ok(ferrodoc::render_html_with_id_prefix(
doc,
&page.id_prefix,
wrap.unwrap_or(ferrodoc::Wrap::Auto(72)),
));
}
match wrap {
Some(wrap) => ferrodoc::render_wrapped_with_media(doc, to, wrap, media),
None => ferrodoc::render_with_media(doc, to, media),
}
.map_err(|e| e.to_string())
}
fn read_input(input: Option<&std::path::Path>) -> Result<Vec<u8>, String> {
let Some(path) = input else {
let mut bytes = Vec::new();
std::io::stdin()
.read_to_end(&mut bytes)
.map_err(|e| format!("cannot read standard input: {e}"))?;
return Ok(bytes);
};
std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))
}
fn write_output(output: Option<&std::path::Path>, bytes: &[u8]) -> Result<(), String> {
let Some(path) = output else {
return std::io::stdout()
.write_all(bytes)
.map_err(|e| format!("cannot write to standard output: {e}"));
};
std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
}
fn wants_page(standalone: bool, to: Format, doc: &ferrodoc::Pandoc) -> Result<bool, String> {
match (standalone, to) {
(false, _) => Ok(false),
(true, Format::Html | Format::Latex) => Ok(true),
(true, _) if doc.meta.is_empty() => Ok(false),
(true, _) => Err(format!(
"--standalone for {to} would write a title block from this document's \
metadata, which this build does not write; drop -s, or convert to \
html or latex, which have one"
)),
}
}
#[cfg_attr(not(any(feature = "html", feature = "latex")), allow(unused_variables))]
fn render_page(
doc: &ferrodoc::Pandoc,
to: Format,
page: &ferrodoc::Page<'_>,
) -> Result<Vec<u8>, String> {
if to == Format::Latex {
if !page.css.is_empty() {
return Err("--css applies to html output, not latex".to_owned());
}
if page.toc {
warn("ferrodoc: --toc is HTML-only here; the latex output has no contents");
}
#[cfg(feature = "latex")]
return Ok(ferrodoc::render_latex_standalone(doc).into_bytes());
#[cfg(not(feature = "latex"))]
return Err(format!("{to} support was not compiled into this build"));
}
if to != Format::Html {
return Err(format!("--standalone applies to html or latex output, not {to}"));
}
#[cfg(feature = "html")]
return ferrodoc::render_page(doc, page);
#[cfg(not(feature = "html"))]
return Err(format!("{to} support was not compiled into this build"));
}
fn extensions(name: &str) -> Result<String, String> {
let Some(cut) = name.find(['+', '-']) else {
return Ok(name.to_owned());
};
let (base, mut rest) = name.split_at(cut);
if Format::parse(name).is_some() {
return Ok(name.to_owned());
}
let format = Format::parse(base)
.ok_or_else(|| format!("unknown format {base:?} in {name:?}"))?;
let has = |extension: &str| format.extensions().contains(&extension);
while !rest.is_empty() {
let on = rest.starts_with('+');
let end = rest[1..].find(['+', '-']).map_or(rest.len(), |at| at + 1);
let extension = &rest[1..end];
rest = &rest[end..];
if extension.is_empty() {
return Err(format!("{name:?} has a `+` or `-` with no extension after it"));
}
if !ferrodoc::EXTENSIONS.contains(&extension) {
return Err(format!(
"no extension named {extension:?}; `pandoc --list-extensions` has the names"
));
}
if has(extension) == on {
continue;
}
let elsewhere: Vec<&str> = [Format::Markdown, Format::Gfm, Format::PandocMarkdown]
.into_iter()
.filter(|other| *other != format && other.extensions().contains(&extension))
.map(Format::name)
.collect();
let instead = if elsewhere.is_empty() {
"no dialect here reads it".to_owned()
} else {
format!("{} reads it", elsewhere.join(" and "))
};
return Err(if on {
format!("`{base}` does not read `{extension}` here, and this build cannot turn one on: {instead}")
} else {
format!("`{base}` reads `{extension}` here and this build cannot turn it off: {instead}")
});
}
Ok(base.to_owned())
}
fn version() -> String {
format!("ferrodoc {}\n", env!("CARGO_PKG_VERSION"))
}
fn split_attached(arg: &str) -> (&str, Option<String>) {
match arg.split_once('=') {
Some((name, value)) if name.starts_with("--") => (name, Some(value.to_owned())),
_ => (arg, None),
}
}
fn diagnostics(args: &[String]) {
use std::sync::atomic::Ordering::Relaxed;
for arg in args {
match arg.as_str() {
"--quiet" => QUIET.store(true, Relaxed),
"--fail-if-warnings" => FAIL_ON_WARNING.store(true, Relaxed),
_ => {}
}
}
}
static QUIET: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static FAIL_ON_WARNING: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn warn(message: &str) {
use std::sync::atomic::Ordering::Relaxed;
WARNED.store(true, Relaxed);
if !QUIET.load(Relaxed) {
eprintln!("{message}");
}
}
fn inferred(
given: Option<Format>,
path: Option<&std::path::Path>,
role: &str,
flag: &str,
what: &str,
input: bool,
) -> Result<Format, String> {
let from_path = if input { Format::from_input_path } else { Format::from_path };
given.or_else(|| path.and_then(from_path)).ok_or_else(|| {
format!("cannot tell the {role} format: pass {flag}, or name {what} with a known extension")
})
}
fn widened(wrap: Wrap, columns: usize) -> Wrap {
match wrap {
Wrap::Auto(_) => Wrap::Auto(columns),
other => other,
}
}
fn read_document(
bytes: &[u8],
from: Format,
to: Format,
extracting: bool,
) -> Result<(ferrodoc::Pandoc, ferrodoc::Media), String> {
if to.embeds_media() || extracting {
return ferrodoc::parse_with_media(bytes, from).map_err(|e| e.to_string());
}
let doc = ferrodoc::parse(bytes, from).map_err(|e| e.to_string())?;
Ok((doc, ferrodoc::Media::new()))
}
#[derive(Default)]
struct Shaping {
shift_headings: i64,
strip_comments: bool,
eol: Option<&'static str>,
ascii: bool,
}
fn shaping_option(name: &str, given: &str, shaping: &mut Shaping) -> Result<(), String> {
match name {
"--shift-heading-level-by" => {
shaping.shift_headings = given
.parse()
.map_err(|_| format!("--shift-heading-level-by wants a number, not {given:?}"))?;
}
"--eol" => {
shaping.eol = match given {
"crlf" => Some("\r\n"),
"lf" => Some("\n"),
"native" => Some(if cfg!(windows) { "\r\n" } else { "\n" }),
other => {
return Err(format!("unknown --eol {other:?}; expected crlf, lf or native"));
}
};
}
other => return Err(format!("{other} is not a shaping flag")),
}
Ok(())
}
struct PageFlags {
css: Vec<String>,
toc_depth: i64,
header_includes: Vec<String>,
include_before: Vec<String>,
include_after: Vec<String>,
variables: Vec<(String, String)>,
template: Option<String>,
id_prefix: String,
highlighting: ferrodoc::Highlighting,
}
impl Default for PageFlags {
fn default() -> Self {
PageFlags {
css: Vec::new(),
toc_depth: 3,
header_includes: Vec::new(),
include_before: Vec::new(),
include_after: Vec::new(),
variables: Vec::new(),
template: None,
id_prefix: String::new(),
highlighting: ferrodoc::Highlighting::default(),
}
}
}
impl PageFlags {
fn as_page<'a>(&'a self, toc: bool, stem: &'a str, wrap: Option<Wrap>) -> ferrodoc::Page<'a> {
ferrodoc::Page {
css: self.css.clone(),
toc,
toc_depth: self.toc_depth,
header_includes: self.header_includes.clone(),
include_before: self.include_before.clone(),
include_after: self.include_after.clone(),
variables: self.variables.clone(),
template: self.template.as_deref(),
id_prefix: self.id_prefix.clone(),
pagetitle: Some(stem),
highlighting: self.highlighting,
wrap: match wrap {
Some(Wrap::Preserve) => ferrodoc::HtmlWrap::Preserve,
Some(Wrap::Auto(columns)) => ferrodoc::HtmlWrap::Fill(columns),
Some(Wrap::None) | None => ferrodoc::HtmlWrap::None,
},
}
}
}
fn page_flag(name: &str) -> bool {
matches!(
name,
"-c" | "--css"
| "--toc-depth"
| "-H"
| "--include-in-header"
| "-B"
| "--include-before-body"
| "-A"
| "--include-after-body"
| "-V"
| "--variable"
| "--template"
| "--id-prefix"
)
}
fn page_option(name: &str, given: String, page: &mut PageFlags) -> Result<(), String> {
match name {
"-c" | "--css" => page.css.push(given),
"--toc-depth" => {
page.toc_depth = given
.parse()
.map_err(|_| format!("--toc-depth wants a number, not {given:?}"))?;
}
"-H" | "--include-in-header" => page.header_includes.push(slurp_value(&given)?),
"-B" | "--include-before-body" => page.include_before.push(slurp_value(&given)?),
"-A" | "--include-after-body" => page.include_after.push(slurp_value(&given)?),
"-V" | "--variable" => {
let (key, value) = metadata_pair(given);
page.variables.push((key, value.unwrap_or_else(|| "true".to_owned())));
}
"--template" => page.template = Some(given),
"--id-prefix" => page.id_prefix = given,
other => return Err(format!("{other} is not a page flag")),
}
Ok(())
}
fn read_template(
template: Option<String>,
data_dir: Option<&std::path::Path>,
) -> Result<Option<String>, String> {
if let Some(named) = template {
if let Some(dir) = data_dir
&& !std::path::Path::new(&named).is_file()
{
return slurp(&dir.join("templates").join(&named).to_string_lossy()).map(Some);
}
return slurp(&named).map(Some);
}
let Some(dir) = data_dir else { return Ok(None) };
let default = dir.join("templates").join("default.html5");
if default.is_file() {
return slurp(&default.to_string_lossy()).map(Some);
}
Ok(None)
}
fn reshaped(converted: Vec<u8>, shaping: &Shaping, to: Format) -> Result<Vec<u8>, String> {
let converted = if shaping.ascii {
if to != Format::Html {
return Err(format!(
"--ascii is html-only here; the {to} writer has its own spelling for a \
non-ASCII character and this build does not write it"
));
}
ferrodoc::ascii_only(&String::from_utf8_lossy(&converted)).into_bytes()
} else {
converted
};
Ok(match shaping.eol {
Some(ending) if ending != "\n" && !to.embeds_media() => {
String::from_utf8_lossy(&converted).replace('\n', ending).into_bytes()
}
_ => converted,
})
}
fn slurp(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))
}
fn slurp_value(path: &str) -> Result<String, String> {
slurp(path).map(|text| text.trim_end_matches('\n').to_owned())
}
fn opens_with_metadata_block(bytes: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(bytes) else { return false };
let mut lines = text.lines();
if lines.next().map(str::trim_end) != Some("---") {
return false;
}
let Some(second) = lines.next() else { return false };
if second.trim().is_empty() {
return false;
}
std::iter::once(second)
.chain(lines)
.any(|line| matches!(line.trim_end(), "---" | "..."))
}
fn extract(
doc: &mut ferrodoc::Pandoc,
embedded: &ferrodoc::Media,
dir: &std::path::Path,
) -> Result<(), String> {
let mut written = std::collections::HashMap::new();
for (url, bytes) in embedded {
let relative = std::path::Path::new(url);
if relative.is_absolute()
|| relative
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return Err(format!("refusing to extract {url:?}: it escapes {}", dir.display()));
}
let path = dir.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
std::fs::write(&path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
written.insert(url.clone(), path.to_string_lossy().into_owned());
}
repoint(&mut doc.blocks, &written);
Ok(())
}
fn repoint(blocks: &mut [Block], written: &std::collections::HashMap<String, String>) {
for block in blocks {
match block {
Block::Plain(inlines) | Block::Para(inlines) | Block::Header(_, _, inlines) => {
repoint_inlines(inlines, written);
}
Block::LineBlock(lines) => {
for line in lines {
repoint_inlines(line, written);
}
}
Block::BlockQuote(inner) | Block::Div(_, inner) => repoint(inner, written),
Block::BulletList(items) | Block::OrderedList(_, items) => {
for item in items {
repoint(item, written);
}
}
Block::DefinitionList(entries) => {
for (term, definitions) in entries {
repoint_inlines(term, written);
for definition in definitions {
repoint(definition, written);
}
}
}
Block::Figure(_, caption, inner) => {
repoint_caption(caption, written);
repoint(inner, written);
}
Block::Table(table) => {
repoint_caption(&mut table.caption, written);
for row in table
.head
.rows
.iter_mut()
.chain(table.bodies.iter_mut().flat_map(|b| {
b.head.iter_mut().chain(b.body.iter_mut())
}))
.chain(table.foot.rows.iter_mut())
{
for cell in &mut row.cells {
repoint(&mut cell.blocks, written);
}
}
}
Block::CodeBlock(..) | Block::RawBlock(..) | Block::HorizontalRule => {}
}
}
}
fn repoint_caption(
caption: &mut ferrodoc::ast::Caption,
written: &std::collections::HashMap<String, String>,
) {
if let Some(short) = caption.short.as_mut() {
repoint_inlines(short, written);
}
repoint(&mut caption.blocks, written);
}
fn repoint_inlines(inlines: &mut [Inline], written: &std::collections::HashMap<String, String>) {
for inline in inlines {
match inline {
Inline::Image(_, alt, target) => {
if let Some(path) = written.get(&target.url) {
target.url.clone_from(path);
}
repoint_inlines(alt, written);
}
Inline::Emph(inner)
| Inline::Underline(inner)
| Inline::Strong(inner)
| Inline::Strikeout(inner)
| Inline::Superscript(inner)
| Inline::Subscript(inner)
| Inline::SmallCaps(inner)
| Inline::Quoted(_, inner)
| Inline::Link(_, inner, _)
| Inline::Span(_, inner)
| Inline::Cite(_, inner) => repoint_inlines(inner, written),
Inline::Note(blocks) => repoint(blocks, written),
Inline::Str(_)
| Inline::Code(..)
| Inline::Math(..)
| Inline::RawInline(..)
| Inline::Space
| Inline::SoftBreak
| Inline::LineBreak => {}
}
}
}
fn resolve<'a>(
embedded: &'a ferrodoc::Media,
base: &'a std::path::Path,
search: &'a [PathBuf],
to: Format,
) -> impl Fn(&str) -> Option<Vec<u8>> + 'a {
move |url| {
if let Some(bytes) = embedded.get(url) {
return Some(bytes.clone());
}
let found = std::iter::once(base)
.chain(search.iter().map(PathBuf::as_path))
.find_map(|dir| std::fs::read(dir.join(url)).ok());
if found.is_none() {
let tail = if to == Format::Docx {
"replacing image with description".to_owned()
} else {
format!("PandocResourceNotFound {url:?}")
};
warn(&format!("[WARNING] Could not fetch resource {url}: {tail}"));
}
found
}
}
fn format(name: &str, input: bool) -> Result<Format, String> {
let name = &extensions(name)?;
let known = || -> String {
Format::NAMES
.iter()
.copied()
.filter(|name| Format::parse(name).is_some_and(Format::compiled))
.collect::<Vec<_>>()
.join(", ")
};
let resolve = if input { Format::parse_input } else { Format::parse };
let format = resolve(name)
.ok_or_else(|| format!("unknown format {name:?}; known formats: {}", known()))?;
if !format.compiled() {
return Err(format!(
"{format} support was not compiled into this build; known formats: {}",
known()
));
}
Ok(format)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn help_lists_exactly_the_formats_this_build_has() {
let help = usage();
let block = help
.split("FORMATS:\n")
.nth(1)
.expect("a FORMATS block")
.split("\n\n")
.next()
.expect("the list ends at a blank line");
for (format, spelling) in HELP_READABLE.iter().chain(HELP_WRITE_ONLY) {
assert_eq!(
block.contains(spelling),
format.compiled(),
"{format}: compiled = {}, listed in --help = {}\n{block}",
format.compiled(),
block.contains(spelling),
);
}
}
#[test]
fn a_metadata_block_is_recognised_and_its_near_misses_are_not() {
let opens = |s: &str| opens_with_metadata_block(s.as_bytes());
assert!(opens("---\ntitle: A\n---\n\nBody.\n"));
assert!(opens("---\ntitle: A\n...\n\nBody.\n"), "`...` closes one too");
assert!(opens("---\r\ntitle: A\r\n---\r\n"), "CRLF");
assert!(!opens("---\n\nSome text\n\n---\n"));
assert!(!opens("---\ntitle: A\n\nBody.\n"), "no closing fence");
assert!(!opens("***\n\nBody.\n\n---\n"), "a different break character");
assert!(!opens("Body.\n\n---\ntitle: A\n---\n"), "not at the start");
assert!(!opens(""));
assert!(!opens("---\n"));
}
#[test]
fn extraction_refuses_a_key_that_escapes_the_directory() {
let dir = std::env::temp_dir().join("ferrodoc-extract-escape");
std::fs::create_dir_all(&dir).expect("a writable temp dir");
for hostile in ["../escaped.png", "a/../../escaped.png", "/etc/escaped.png"] {
let mut media = ferrodoc::Media::new();
media.insert(hostile.to_owned(), b"bytes".to_vec());
let mut doc = ferrodoc::Pandoc::new(Vec::new());
let err = extract(&mut doc, &media, &dir).expect_err(hostile);
assert!(err.contains("escapes"), "{err}");
}
assert!(!dir.join("../escaped.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_include_flags_imply_standalone() {
let include = std::env::temp_dir().join("ferrodoc-include.html");
std::fs::write(&include, "<!-- x -->\n").expect("a writable temp dir");
let path = include.to_string_lossy().into_owned();
let parse = |flag: &str, value: &str| {
let argv = [flag, value, "x.md"].map(str::to_owned);
parse_args(&argv).expect("parsed").expect("options").standalone
};
for flag in ["-H", "--include-in-header", "-B", "--include-before-body", "-A"] {
assert!(parse(flag, &path), "{flag} should imply --standalone");
}
for (flag, value) in [("-c", "x.css"), ("--toc-depth", "2"), ("-V", "k=v")] {
assert!(!parse(flag, value), "{flag} should not imply --standalone");
}
let _ = std::fs::remove_file(&include);
}
#[test]
fn standalone_is_pandocs_no_op_until_the_document_has_metadata() {
let plain = ferrodoc::Pandoc::new(Vec::new());
assert!(!wants_page(true, Format::Plain, &plain).expect("no-op"));
assert!(!wants_page(true, Format::Rst, &plain).expect("no-op"));
assert!(wants_page(true, Format::Html, &plain).expect("a page"));
assert!(wants_page(true, Format::Latex, &plain).expect("a page"));
assert!(!wants_page(false, Format::Html, &plain).expect("a fragment"));
let mut titled = ferrodoc::Pandoc::new(Vec::new());
titled.meta.insert(
"title".to_owned(),
ferrodoc_ast::MetaValue::MetaString("X".to_owned()),
);
let error = wants_page(true, Format::Plain, &titled).expect_err("a title block");
assert!(error.contains("title block"), "{error}");
}
#[test]
fn extension_syntax_is_accepted_when_it_asks_for_nothing() {
for accepted in [
"gfm+footnotes",
"gfm+pipe_tables+strikeout",
"markdown+footnotes",
"pandoc_markdown+yaml_metadata_block",
] {
let base = accepted.split(['+', '-']).next().expect("a base");
assert_eq!(extensions(accepted).as_deref(), Ok(base), "{accepted}");
}
let off = extensions("gfm-pipe_tables").expect_err("cannot disable");
assert!(off.contains("pipe_tables") && off.contains("cannot turn it off"), "{off}");
let on = extensions("commonmark+footnotes").expect_err("cannot enable");
assert!(on.contains("gfm"), "{on}");
let dialect = extensions("markdown-footnotes").expect_err("cannot disable");
assert!(dialect.contains("footnotes"), "{dialect}");
let typo = extensions("gfm+fotnotes").expect_err("typo");
assert!(typo.contains("no extension named"), "{typo}");
let also = extensions("gfm-nothing").expect_err("typo the other way");
assert!(also.contains("no extension named"), "{also}");
assert_eq!(extensions("gfm").as_deref(), Ok("gfm"));
}
#[test]
fn a_defaults_file_is_the_flags_it_stands_for() {
let flags = |yaml: &str| defaults_to_args(yaml, "d.yaml");
assert_eq!(
flags("from: gfm\nto: html\nstandalone: true\n").expect("read"),
["--from", "gfm", "--to", "html", "--standalone"]
);
assert_eq!(flags("standalone: false\n").expect("read"), Vec::<String>::new());
assert_eq!(flags("---\n# a comment\n\nto: rst\n").expect("read"), ["--to", "rst"]);
let refused = flags("to: html\nfilters:\n - x.lua\n").expect_err("filters");
assert!(refused.contains("filters"), "{refused}");
let dir = std::env::temp_dir().join("ferrodoc-defaults-order");
std::fs::create_dir_all(&dir).expect("a writable temp dir");
let file = dir.join("d.yaml");
std::fs::write(&file, "to: html\n").expect("written");
let path = file.display().to_string();
let expand = |args: &[&str]| {
expand_defaults(
&args.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
0,
)
.expect("expanded")
};
assert_eq!(expand(&["-t", "plain", "--defaults", &path]), ["-t", "plain", "--to", "html"]);
assert_eq!(expand(&["--defaults", &path, "-t", "plain"]), ["--to", "html", "-t", "plain"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn extraction_repoints_a_picture_wherever_it_sits() {
let dir = std::env::temp_dir().join("ferrodoc-extract-walk");
std::fs::create_dir_all(&dir).expect("a writable temp dir");
let mut media = ferrodoc::Media::new();
media.insert("media/p.png".to_owned(), b"bytes".to_vec());
let image = || {
Inline::Image(
Box::default(),
Vec::new(),
Box::new(ferrodoc::ast::Target { url: "media/p.png".to_owned(), title: String::new() }),
)
};
let para = || Block::Para(vec![image()]);
let mut doc = ferrodoc::Pandoc::new(vec![
para(),
Block::BlockQuote(vec![para()]),
Block::BulletList(vec![vec![para()]]),
Block::DefinitionList(vec![(vec![image()], vec![vec![para()]])]),
Block::Para(vec![Inline::Note(vec![para()])]),
Block::Para(vec![Inline::Emph(vec![image()])]),
]);
extract(&mut doc, &media, &dir).expect("extractable");
let json = serde_json::to_string(&doc).expect("serializable");
assert!(!json.contains("\"media/p.png\""), "a picture was left unrepointed: {json}");
let written = dir.join("media/p.png").to_string_lossy().into_owned();
let expected = serde_json::to_string(&written).expect("serializable");
assert_eq!(json.matches(&expected).count(), 7, "{json}");
assert_eq!(std::fs::read(dir.join("media/p.png")).expect("written"), b"bytes");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_package_outranks_a_file_of_the_same_name() {
let dir = std::env::temp_dir().join("ferrodoc-resolve-test");
std::fs::create_dir_all(&dir).expect("a writable temp dir");
std::fs::write(dir.join("pic.png"), b"from disk").expect("writable");
let mut embedded = ferrodoc::Media::new();
embedded.insert("pic.png".to_owned(), b"from the package".to_vec());
let none: &[PathBuf] = &[];
assert_eq!(
resolve(&embedded, &dir, none, Format::Docx)("pic.png").as_deref(),
Some(&b"from the package"[..])
);
let empty = ferrodoc::Media::new();
assert_eq!(resolve(&empty, &dir, none, Format::Docx)("pic.png").as_deref(), Some(&b"from disk"[..]));
assert!(resolve(&empty, &dir, none, Format::Docx)("absent.png").is_none());
let elsewhere = dir.join("pics");
std::fs::create_dir_all(&elsewhere).expect("a writable temp dir");
std::fs::write(elsewhere.join("pic.png"), b"from the path").expect("writable");
std::fs::write(elsewhere.join("only.png"), b"only here").expect("writable");
let search = vec![elsewhere.clone()];
assert_eq!(
resolve(&empty, &dir, &search, Format::Docx)("pic.png").as_deref(),
Some(&b"from disk"[..])
);
assert_eq!(
resolve(&empty, &dir, &search, Format::Docx)("only.png").as_deref(),
Some(&b"only here"[..])
);
let _ = std::fs::remove_dir_all(&dir);
}
}