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 (pandoc's default),
`none` puts each block on one line, `preserve`
keeps the document's own breaks. A writer that
cannot lay lines out that way says so rather
than ignoring the flag: markdown and gfm do
all three, html and plain are `none`, latex,
rst and asciidoc are `preserve`.
--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.
--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
FORMATS:
";
const HELP_READABLE: &[(Format, &str)] = &[
(Format::Markdown, "markdown (commonmark, md)"),
(Format::Gfm, "gfm"),
(Format::PandocMarkdown, "pandoc_markdown"),
(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` here is **CommonMark**, which is not what `pandoc -f markdown`
means. Pandoc's own dialect adds YAML metadata blocks, header attributes
(`# H {#id .class}`), definition lists and superscript/subscript, and
none of those are read: they come through as the literal text they are
written with. Footnotes are read by `gfm` and not by `markdown`, which
is also how pandoc has it.
`gfm` is GitHub Flavored Markdown: tables, task lists, strikethrough
and bare-URL links. Prefer it over `markdown` for anything with a
table — CommonMark has no table syntax, so a table degrades there to
one paragraph per cell.
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, 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(()) => 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,
css: Option<PathBuf>,
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 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,
}
}
fn parse_args(args: &[String]) -> Result<Option<Options>, String> {
let args = &expand_defaults(args, 0)?;
let mut from: Option<Format> = None;
let mut to: Option<Format> = None;
let mut output: Option<PathBuf> = None;
let mut input: Option<PathBuf> = None;
let mut stdin_requested = false;
let mut standalone = false;
let mut css: Option<PathBuf> = None;
let mut extract_media: Option<PathBuf> = None;
let mut wrap: Option<Wrap> = None;
let mut columns = 72usize;
let mut toc = false;
let mut number_sections = false;
let mut metadata: Vec<(String, Option<String>)> = Vec::new();
let mut i = 0;
while i < args.len() {
let (arg, attached) = match args[i].split_once('=') {
Some((name, value)) if name.starts_with("--") => (name, Some(value.to_owned())),
_ => (args[i].as_str(), 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"))
};
match arg {
"-h" | "--help" => {
print!("{}", usage());
return Ok(None);
}
"-V" | "--version" => {
println!("ferrodoc {}", env!("CARGO_PKG_VERSION"));
return Ok(None);
}
"-f" | "--from" => {
let name = value("--from")?;
from = Some(format(&name)?);
}
"-t" | "--to" => {
let name = value("--to")?;
to = Some(format(&name)?);
}
"-o" | "--output" => output = Some(PathBuf::from(value("--output")?)),
"-s" | "--standalone" => standalone = true,
"-c" | "--css" => css = Some(PathBuf::from(value("--css")?)),
"--toc" | "--table-of-contents" => toc = true,
"-N" | "--number-sections" => number_sections = true,
"-M" | "--metadata" => metadata.push(metadata_pair(value("--metadata")?)),
"--extract-media" => {
extract_media = Some(PathBuf::from(value("--extract-media")?));
}
"--wrap" => wrap = Some(wrap_mode(&value("--wrap")?)?),
"--columns" => {
let raw = value("--columns")?;
columns = raw
.parse::<usize>()
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| format!("--columns needs a positive number, not {raw:?}"))?;
}
"-" => {
if input.is_some() {
return Err("more than one input given".to_owned());
}
stdin_requested = true;
}
other if other.starts_with('-') && other.len() > 1 => {
return Err(format!("unknown option {other} (try --help)"));
}
other => {
if input.is_some() || stdin_requested {
return Err("more than one input given".to_owned());
}
input = Some(PathBuf::from(other));
}
}
i += 1;
}
Ok(Some(Options {
from,
to,
output,
input,
standalone,
css,
extract_media,
wrap: wrap.map(|wrap| match wrap {
Wrap::Auto(_) => Wrap::Auto(columns),
other => other,
}),
toc,
number_sections,
metadata,
}))
}
fn run() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(Options {
from,
to,
output,
input,
standalone,
css,
extract_media,
wrap,
toc,
number_sections,
metadata,
}) = parse_args(&args)?
else {
return Ok(());
};
let Some(from) = from.or_else(|| input.as_deref().and_then(Format::from_path)) else {
return Err(
"cannot tell the input format: pass --from, or name a file with a known extension"
.to_owned(),
);
};
let Some(to) = to.or_else(|| output.as_deref().and_then(Format::from_path)) else {
return Err(
"cannot tell the output format: pass --to, or name an output file with a known extension"
.to_owned(),
);
};
let bytes = read_input(input.as_deref())?;
if matches!(from, Format::Markdown | Format::Gfm) && opens_with_metadata_block(&bytes) {
eprintln!(
"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) = if to.embeds_media() || extract_media.is_some() {
ferrodoc::parse_with_media(&bytes, from).map_err(|e| e.to_string())?
} else {
(ferrodoc::parse(&bytes, from).map_err(|e| e.to_string())?, ferrodoc::Media::new())
};
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 {
eprintln!("ferrodoc: --number-sections is HTML-only here; the {to} output is unnumbered");
}
}
if toc && to != Format::Html {
eprintln!("ferrodoc: --toc is HTML-only here; the {to} output has no contents");
}
let converted = if wants_page(standalone, to, &doc)? {
render_page(&doc, to, css.as_deref(), toc)?
} else {
if css.is_some() {
return Err("--css needs --standalone: a fragment has no <head>".to_owned());
}
match wrap {
Some(wrap) => {
ferrodoc::render_wrapped_with_media(&doc, to, wrap, &resolve(&embedded, &base))
.map_err(|e| e.to_string())?
}
None => ferrodoc::render_with_media(&doc, to, &resolve(&embedded, &base))
.map_err(|e| e.to_string())?,
}
};
write_output(output.as_deref(), &converted)
}
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,
css: Option<&std::path::Path>,
toc: bool,
) -> Result<Vec<u8>, String> {
if to == Format::Latex {
if css.is_some() {
return Err("--css applies to html output, not latex".to_owned());
}
if toc {
eprintln!("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}"));
}
let css = css
.map(|path| {
std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))
})
.transpose()?;
#[cfg(feature = "html")]
return Ok(ferrodoc::render_html_standalone(doc, css.as_deref(), toc));
#[cfg(not(feature = "html"))]
return Err(format!("{to} support was not compiled into this build"));
}
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,
) -> impl Fn(&str) -> Option<Vec<u8>> + 'a {
move |url| {
embedded
.get(url)
.cloned()
.or_else(|| std::fs::read(base.join(url)).ok())
}
}
fn format(name: &str) -> Result<Format, String> {
if let Some((base, _)) = name.split_once(['+', '-'])
&& Format::parse(base).is_some()
&& Format::parse(name).is_none()
{
return Err(format!(
"extension syntax is not supported: {name:?}. `markdown` is CommonMark, \
`gfm` adds tables, task lists and footnotes, and `pandoc_markdown` adds \
YAML metadata, header attributes, definition lists and super/subscript"
));
}
let known = || -> String {
Format::NAMES
.iter()
.copied()
.filter(|name| Format::parse(name).is_some_and(Format::compiled))
.collect::<Vec<_>>()
.join(", ")
};
let format = Format::parse(name)
.ok_or_else(|| format!("unknown format {name:?}; known formats: {}", known()))?;
if name.eq_ignore_ascii_case("markdown_github") {
eprintln!("[WARNING] Deprecated: markdown_github. Use gfm instead.");
}
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 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 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());
assert_eq!(resolve(&embedded, &dir)("pic.png").as_deref(), Some(&b"from the package"[..]));
assert_eq!(
resolve(&ferrodoc::Media::new(), &dir)("pic.png").as_deref(),
Some(&b"from disk"[..])
);
assert!(resolve(&ferrodoc::Media::new(), &dir)("absent.png").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}