pub mod ast {
pub use ferrodoc_ast::*;
}
pub use ferrodoc_ast::Pandoc;
use ferrodoc_ast::Block;
#[cfg(feature = "html")]
pub use ferrodoc_html::{Highlighting, Page, Wrap as HtmlWrap};
use std::fmt;
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Markdown,
Gfm,
PandocMarkdown,
Html,
Docx,
Odt,
Epub,
Ipynb,
Latex,
Rst,
Asciidoc,
Json,
Plain,
}
impl Format {
pub const NAMES: &'static [&'static str] = &[
"markdown", "commonmark", "gfm", "pandoc_markdown", "html", "docx", "odt", "epub",
"ipynb", "latex", "rst", "asciidoc", "json", "plain",
];
pub fn parse(name: &str) -> Option<Self> {
match name.to_ascii_lowercase().as_str() {
"markdown" | "commonmark" | "md" => Some(Format::Markdown),
"gfm" | "markdown_github" => Some(Format::Gfm),
"pandoc_markdown" | "pandoc-markdown" => Some(Format::PandocMarkdown),
"html" | "htm" | "html5" => Some(Format::Html),
"docx" => Some(Format::Docx),
"odt" => Some(Format::Odt),
"epub" | "epub2" | "epub3" => Some(Format::Epub),
"ipynb" | "jupyter" | "notebook" => Some(Format::Ipynb),
"latex" | "tex" => Some(Format::Latex),
"rst" | "rest" | "restructuredtext" => Some(Format::Rst),
"asciidoc" | "adoc" | "asciidoctor" => Some(Format::Asciidoc),
"json" => Some(Format::Json),
"plain" | "text" | "txt" => Some(Format::Plain),
_ => None,
}
}
pub fn from_path(path: &std::path::Path) -> Option<Self> {
Format::parse(path.extension()?.to_str()?)
}
pub fn readable(self) -> bool {
!matches!(self, Format::Plain | Format::Latex | Format::Rst | Format::Asciidoc)
}
pub fn compiled(self) -> bool {
match self {
Format::Markdown | Format::Gfm | Format::PandocMarkdown => cfg!(feature = "markdown"),
Format::Html => cfg!(feature = "html"),
Format::Docx => cfg!(feature = "docx"),
Format::Odt => cfg!(feature = "odt"),
Format::Epub => cfg!(feature = "epub"),
Format::Ipynb => cfg!(feature = "ipynb"),
Format::Latex => cfg!(feature = "latex"),
Format::Rst => cfg!(feature = "rst"),
Format::Asciidoc => cfg!(feature = "asciidoc"),
Format::Plain => cfg!(feature = "text"),
Format::Json => true,
}
}
pub fn embeds_media(self) -> bool {
matches!(self, Format::Docx | Format::Odt | Format::Epub | Format::Ipynb)
}
pub fn extensions(self) -> &'static [&'static str] {
match self {
Format::Markdown => &["raw_html"],
Format::Gfm => &[
"raw_html",
"pipe_tables",
"strikeout",
"task_lists",
"tex_math_dollars",
"footnotes",
"autolink_bare_uris",
"gfm_auto_identifiers",
],
Format::PandocMarkdown => &[
"raw_html",
"pipe_tables",
"strikeout",
"task_lists",
"tex_math_dollars",
"footnotes",
"auto_identifiers",
"header_attributes",
"definition_lists",
"superscript",
"subscript",
"yaml_metadata_block",
],
_ => &[],
}
}
pub fn wrapping(self) -> Wrapping {
match self {
Format::Markdown
| Format::Gfm
| Format::Html
| Format::Plain
| Format::Latex
| Format::Rst
| Format::Asciidoc
| Format::Ipynb => Wrapping::Fills,
Format::Docx
| Format::Odt
| Format::Epub
| Format::Json
| Format::PandocMarkdown => Wrapping::NotText,
}
}
pub fn writable(self) -> bool {
self != Format::PandocMarkdown
}
pub fn name(self) -> &'static str {
match self {
Format::Markdown => "markdown",
Format::Gfm => "gfm",
Format::PandocMarkdown => "pandoc_markdown",
Format::Html => "html",
Format::Docx => "docx",
Format::Odt => "odt",
Format::Epub => "epub",
Format::Ipynb => "ipynb",
Format::Latex => "latex",
Format::Rst => "rst",
Format::Asciidoc => "asciidoc",
Format::Json => "json",
Format::Plain => "plain",
}
}
}
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wrap {
Auto(usize),
None,
Preserve,
}
impl fmt::Display for Wrap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Wrap::Auto(columns) => write!(f, "--wrap=auto --columns={columns}"),
Wrap::None => f.write_str("--wrap=none"),
Wrap::Preserve => f.write_str("--wrap=preserve"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wrapping {
Fills,
Joined,
Preserved,
NotText,
}
pub const EXTENSIONS: &[&str] = &[
"abbreviations", "all_symbols_escapable", "angle_brackets_escapable",
"ascii_identifiers", "auto_identifiers", "autolink_bare_uris",
"backtick_code_blocks", "blank_before_blockquote",
"blank_before_header", "bracketed_spans", "citations",
"definition_lists", "east_asian_line_breaks", "emoji",
"escaped_line_breaks", "example_lists", "fancy_lists",
"fenced_code_attributes", "fenced_code_blocks", "fenced_divs",
"footnotes", "four_space_rule", "gfm_auto_identifiers", "grid_tables",
"gutenberg", "hard_line_breaks", "header_attributes",
"ignore_line_breaks", "implicit_figures", "implicit_header_references",
"inline_code_attributes", "inline_notes", "intraword_underscores",
"latex_macros", "line_blocks", "link_attributes",
"lists_without_preceding_blankline", "literate_haskell", "mark",
"markdown_attribute", "markdown_in_html_blocks",
"mmd_header_identifiers", "mmd_link_attributes", "mmd_title_block",
"multiline_tables", "native_divs", "native_spans", "old_dashes",
"pandoc_title_block", "pipe_tables", "raw_attribute", "raw_html",
"raw_tex", "rebase_relative_paths", "shortcut_reference_links",
"short_subsuperscripts", "simple_tables", "smart",
"spaced_reference_links", "space_in_atx_header", "startnum",
"strikeout", "subscript", "superscript", "table_attributes",
"table_captions", "task_lists", "tex_math_dollars",
"tex_math_double_backslash", "tex_math_single_backslash",
"wikilinks_title_after_pipe", "wikilinks_title_before_pipe",
"yaml_metadata_block",
];
#[derive(Debug)]
pub enum Error {
NotReadable(Format),
NotWritable(Format),
NotCompiled(Format),
NotWrappable(Format, Wrap),
Invalid {
format: Format,
detail: String,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NotReadable(format) => {
write!(f, "cannot read {format}: it is an output-only format")
}
Error::NotWritable(format) => {
write!(f, "cannot write {format}: it is an input-only format")
}
Error::NotCompiled(format) => {
write!(f, "cannot handle {format}: this build was compiled without it")
}
Error::NotWrappable(format, wrap) => write!(
f,
"cannot write {format} with {wrap}: {}",
match format.wrapping() {
Wrapping::Fills => "this build cannot",
Wrapping::Joined =>
"that writer joins every soft break into a space, which is --wrap=none",
Wrapping::Preserved =>
"that writer leaves lines where the document put them, \
which is --wrap=preserve",
Wrapping::NotText => "it is not a line-based format",
}
),
Error::Invalid { format, detail } => write!(f, "invalid {format} input: {detail}"),
}
}
}
impl std::error::Error for Error {}
pub type Media = std::collections::HashMap<String, Vec<u8>>;
pub fn parse_with_media(input: &[u8], from: Format) -> Result<(Pandoc, Media), Error> {
match from {
#[cfg(feature = "docx")]
Format::Docx => ferrodoc_docx::read_docx_with_media(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(feature = "odt")]
Format::Odt => ferrodoc_odt::read_odt_with_media(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(feature = "epub")]
Format::Epub => ferrodoc_epub::read_epub_with_media(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(feature = "ipynb")]
Format::Ipynb => {
let text = String::from_utf8(input.to_vec()).map_err(|e| Error::Invalid {
format: from,
detail: format!("not UTF-8: {e}"),
})?;
ferrodoc_ipynb::read_ipynb_with_media(&text).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
})
}
_ => Ok((parse(input, from)?, Media::new())),
}
}
pub fn parse(input: &[u8], from: Format) -> Result<Pandoc, Error> {
let text = |input: &[u8]| -> Result<String, Error> {
String::from_utf8(input.to_vec()).map_err(|e| Error::Invalid {
format: from,
detail: format!("not UTF-8: {e}"),
})
};
match from {
#[cfg(feature = "markdown")]
Format::Markdown => ferrodoc_markdown::read_commonmark(&text(input)?).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "markdown"))]
Format::Markdown => Err(Error::NotCompiled(from)),
#[cfg(feature = "markdown")]
Format::Gfm => ferrodoc_markdown::read_gfm(&text(input)?).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "markdown"))]
Format::Gfm => Err(Error::NotCompiled(from)),
#[cfg(feature = "markdown")]
Format::PandocMarkdown => {
ferrodoc_markdown::read_pandoc_markdown(&text(input)?).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
})
}
#[cfg(not(feature = "markdown"))]
Format::PandocMarkdown => Err(Error::NotCompiled(from)),
#[cfg(feature = "docx")]
Format::Docx => ferrodoc_docx::read_docx(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "docx"))]
Format::Docx => Err(Error::NotCompiled(from)),
#[cfg(feature = "odt")]
Format::Odt => ferrodoc_odt::read_odt(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "odt"))]
Format::Odt => Err(Error::NotCompiled(from)),
#[cfg(feature = "epub")]
Format::Epub => ferrodoc_epub::read_epub(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "epub"))]
Format::Epub => Err(Error::NotCompiled(from)),
#[cfg(feature = "ipynb")]
Format::Ipynb => ferrodoc_ipynb::read_ipynb(&text(input)?).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "ipynb"))]
Format::Ipynb => Err(Error::NotCompiled(from)),
Format::Json => serde_json::from_slice(input).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(feature = "html")]
Format::Html => ferrodoc_html::read_html(&text(input)?).map_err(|e| Error::Invalid {
format: from,
detail: e.to_string(),
}),
#[cfg(not(feature = "html"))]
Format::Html => Err(Error::NotCompiled(from)),
Format::Plain => Err(Error::NotReadable(Format::Plain)),
Format::Latex => Err(Error::NotReadable(Format::Latex)),
Format::Rst => Err(Error::NotReadable(Format::Rst)),
Format::Asciidoc => Err(Error::NotReadable(Format::Asciidoc)),
}
}
#[cfg(any(feature = "docx", feature = "odt", feature = "epub", feature = "ipynb"))]
fn data_url(url: &str) -> Option<Vec<u8>> {
let rest = url.strip_prefix("data:")?;
let (media_type, data) = rest.split_once(',')?;
if media_type
.rsplit(';')
.next()
.is_some_and(|token| token.eq_ignore_ascii_case("base64"))
{
return base64(data);
}
percent_decode(data)
}
#[cfg(any(feature = "docx", feature = "odt", feature = "epub", feature = "ipynb"))]
fn base64(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len() / 4 * 3);
let (mut bits, mut held) = (0u32, 0u32);
for byte in text.bytes() {
let value = match byte {
b'A'..=b'Z' => u32::from(byte - b'A'),
b'a'..=b'z' => u32::from(byte - b'a') + 26,
b'0'..=b'9' => u32::from(byte - b'0') + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
byte if byte.is_ascii_whitespace() => continue,
_ => return None,
};
bits = bits << 6 | value;
held += 6;
if held >= 8 {
held -= 8;
out.push(u8::try_from(bits >> held & 0xff).ok()?);
}
}
Some(out)
}
#[cfg(any(feature = "docx", feature = "odt", feature = "epub", feature = "ipynb"))]
fn percent_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len());
let mut bytes = text.bytes();
while let Some(byte) = bytes.next() {
if byte == b'%' {
let mut digits = [0u8; 2];
for digit in &mut digits {
*digit = bytes.next()?;
}
if !digits.iter().all(u8::is_ascii_hexdigit) {
return None;
}
let text = std::str::from_utf8(&digits).ok()?;
out.push(u8::from_str_radix(text, 16).ok()?);
} else {
out.push(byte);
}
}
Some(out)
}
pub fn render(doc: &Pandoc, to: Format) -> Result<Vec<u8>, Error> {
render_with_media(doc, to, &|_| None)
}
pub fn render_wrapped(doc: &Pandoc, to: Format, wrap: Wrap) -> Result<Vec<u8>, Error> {
render_wrapped_with_media(doc, to, wrap, &|_| None)
}
pub fn render_wrapped_with_media(
doc: &Pandoc,
to: Format,
wrap: Wrap,
media: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let wrapping = to.wrapping();
let honoured = match wrapping {
Wrapping::Fills | Wrapping::NotText => true,
Wrapping::Joined => wrap == Wrap::None,
Wrapping::Preserved => wrap == Wrap::Preserve,
};
if !honoured {
return Err(Error::NotWrappable(to, wrap));
}
let columns = match (wrapping, wrap) {
(Wrapping::Fills, Wrap::None) => Some(usize::MAX),
(Wrapping::Fills, Wrap::Auto(columns)) => Some(columns),
_ => None,
};
#[cfg(feature = "html")]
if to == Format::Html {
let wrap = match wrap {
Wrap::None => ferrodoc_html::Wrap::None,
Wrap::Preserve => ferrodoc_html::Wrap::Preserve,
Wrap::Auto(columns) => ferrodoc_html::Wrap::Fill(columns),
};
return Ok(ferrodoc_html::write_html_wrapped(doc, "", wrap).into_bytes());
}
match (columns, to) {
#[cfg(feature = "ipynb")]
(columns, Format::Ipynb) => {
let resolve = |url: &str| data_url(url).or_else(|| media(url));
ferrodoc_ipynb::write_ipynb_wrapped(doc, &resolve, columns).map_err(|e| Error::Invalid {
format: to,
detail: e.to_string(),
})
}
#[cfg(feature = "markdown")]
(Some(columns), Format::Markdown) => {
Ok(ferrodoc_markdown::write_markdown_wrapped(doc, columns).into_bytes())
}
#[cfg(feature = "markdown")]
(Some(columns), Format::Gfm) => {
Ok(ferrodoc_markdown::write_gfm_wrapped(doc, columns).into_bytes())
}
#[cfg(feature = "text")]
(Some(columns), Format::Plain) if columns != usize::MAX => {
Ok(ferrodoc_text::write_text_wrapped(doc, columns).into_bytes())
}
#[cfg(feature = "text")]
(None, Format::Plain) if wrap == Wrap::Preserve => {
Ok(ferrodoc_text::write_text_preserved(doc).into_bytes())
}
#[cfg(feature = "asciidoc")]
(_, Format::Asciidoc) => {
let wrap = match wrap {
Wrap::None => ferrodoc_asciidoc::Wrap::None,
Wrap::Preserve => ferrodoc_asciidoc::Wrap::Preserve,
Wrap::Auto(columns) => ferrodoc_asciidoc::Wrap::Fill(columns),
};
Ok(ferrodoc_asciidoc::write_asciidoc_wrapped(doc, wrap).into_bytes())
}
#[cfg(feature = "rst")]
(_, Format::Rst) => {
let wrap = match wrap {
Wrap::None => ferrodoc_rst::Wrap::None,
Wrap::Preserve => ferrodoc_rst::Wrap::Preserve,
Wrap::Auto(columns) => ferrodoc_rst::Wrap::Fill(columns),
};
Ok(ferrodoc_rst::write_rst_wrapped(doc, wrap).into_bytes())
}
#[cfg(feature = "latex")]
(_, Format::Latex) => {
let wrap = match wrap {
Wrap::None => ferrodoc_latex::Wrap::None,
Wrap::Preserve => ferrodoc_latex::Wrap::Preserve,
Wrap::Auto(columns) => ferrodoc_latex::Wrap::Fill(columns),
};
Ok(ferrodoc_latex::write_latex_wrapped(doc, wrap).into_bytes())
}
_ => render_with_media(doc, to, media),
}
}
#[cfg_attr(not(any(feature = "docx", feature = "odt", feature = "epub", feature = "ipynb")), allow(unused_variables))]
pub fn render_with_media(
doc: &Pandoc,
to: Format,
media: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> Result<Vec<u8>, Error> {
match to {
#[cfg(feature = "markdown")]
Format::Markdown => Ok(ferrodoc_markdown::write_markdown(doc).into_bytes()),
#[cfg(not(feature = "markdown"))]
Format::Markdown => Err(Error::NotCompiled(to)),
#[cfg(feature = "markdown")]
Format::Gfm => Ok(ferrodoc_markdown::write_gfm(doc).into_bytes()),
#[cfg(not(feature = "markdown"))]
Format::Gfm => Err(Error::NotCompiled(to)),
Format::PandocMarkdown => Err(Error::NotWritable(to)),
#[cfg(feature = "html")]
Format::Html => Ok(ferrodoc_html::write_html(doc).into_bytes()),
#[cfg(not(feature = "html"))]
Format::Html => Err(Error::NotCompiled(to)),
#[cfg(feature = "text")]
Format::Plain => Ok(ferrodoc_text::write_text(doc).into_bytes()),
#[cfg(not(feature = "text"))]
Format::Plain => Err(Error::NotCompiled(to)),
#[cfg(feature = "latex")]
Format::Latex => Ok(ferrodoc_latex::write_latex(doc).into_bytes()),
#[cfg(not(feature = "latex"))]
Format::Latex => Err(Error::NotCompiled(to)),
#[cfg(feature = "rst")]
Format::Rst => Ok(ferrodoc_rst::write_rst(doc).into_bytes()),
#[cfg(not(feature = "rst"))]
Format::Rst => Err(Error::NotCompiled(to)),
#[cfg(feature = "asciidoc")]
Format::Asciidoc => Ok(ferrodoc_asciidoc::write_asciidoc(doc).into_bytes()),
#[cfg(not(feature = "asciidoc"))]
Format::Asciidoc => Err(Error::NotCompiled(to)),
#[cfg(feature = "docx")]
Format::Docx => {
let resolve = |url: &str| data_url(url).or_else(|| media(url));
ferrodoc_docx::write_docx_with_media(doc, &resolve).map_err(|e| Error::Invalid {
format: to,
detail: e.to_string(),
})
}
#[cfg(not(feature = "docx"))]
Format::Docx => Err(Error::NotCompiled(to)),
#[cfg(feature = "odt")]
Format::Odt => {
let resolve = |url: &str| data_url(url).or_else(|| media(url));
ferrodoc_odt::write_odt_with_media(doc, &resolve).map_err(|e| Error::Invalid {
format: to,
detail: e.to_string(),
})
}
#[cfg(not(feature = "odt"))]
Format::Odt => Err(Error::NotCompiled(to)),
#[cfg(feature = "epub")]
Format::Epub => {
let resolve = |url: &str| data_url(url).or_else(|| media(url));
ferrodoc_epub::write_epub_with_media(doc, &resolve).map_err(|e| Error::Invalid {
format: to,
detail: e.to_string(),
})
}
#[cfg(not(feature = "epub"))]
Format::Epub => Err(Error::NotCompiled(to)),
#[cfg(feature = "ipynb")]
Format::Ipynb => {
let resolve = |url: &str| data_url(url).or_else(|| media(url));
ferrodoc_ipynb::write_ipynb_with_media(doc, &resolve).map_err(|e| Error::Invalid {
format: to,
detail: e.to_string(),
})
}
#[cfg(not(feature = "ipynb"))]
Format::Ipynb => Err(Error::NotCompiled(to)),
Format::Json => {
let mut json = serde_json::to_vec(doc).map_err(|e| Error::Invalid {
format: to,
detail: e.to_string(),
})?;
json.push(b'\n');
Ok(json)
}
}
}
#[cfg(feature = "latex")]
pub fn render_latex_standalone(doc: &Pandoc) -> String {
ferrodoc_latex::write_latex_standalone(doc)
}
#[cfg(feature = "html")]
#[cfg(feature = "html")]
pub fn render_page(doc: &Pandoc, page: &Page<'_>) -> Result<Vec<u8>, String> {
ferrodoc_html::write_page(doc, page).map(String::into_bytes)
}
#[cfg(feature = "html")]
#[must_use]
pub fn render_html_with_id_prefix(doc: &Pandoc, id_prefix: &str, wrap: Wrap) -> Vec<u8> {
let wrap = match wrap {
Wrap::None => ferrodoc_html::Wrap::None,
Wrap::Preserve => ferrodoc_html::Wrap::Preserve,
Wrap::Auto(columns) => ferrodoc_html::Wrap::Fill(columns),
};
ferrodoc_html::write_html_wrapped(doc, id_prefix, wrap).into_bytes()
}
#[cfg(feature = "html")]
#[must_use]
pub fn render_html_unhighlighted(doc: &Pandoc, id_prefix: &str, wrap: Wrap) -> Vec<u8> {
let wrap = match wrap {
Wrap::None => ferrodoc_html::Wrap::None,
Wrap::Preserve => ferrodoc_html::Wrap::Preserve,
Wrap::Auto(columns) => ferrodoc_html::Wrap::Fill(columns),
};
ferrodoc_html::write_html_unhighlighted(doc, id_prefix, wrap).into_bytes()
}
pub fn shift_heading_level(doc: &mut Pandoc, by: i64) {
if by == 0 {
return;
}
if by < 0
&& let Some(Block::Header(level, _, inlines)) = doc.blocks.first()
&& level + by == 0
{
let title = ferrodoc_ast::MetaValue::MetaInlines(inlines.clone());
doc.meta.insert("title".to_owned(), title);
doc.blocks.remove(0);
}
shift_blocks(&mut doc.blocks, by);
}
fn shift_blocks(blocks: &mut [Block], by: i64) {
for block in blocks.iter_mut() {
match block {
Block::Header(level, _, inlines) => {
if *level + by < 1 {
*block = Block::Para(std::mem::take(inlines));
} else {
*level += by;
}
}
Block::Div(_, inner) | Block::BlockQuote(inner) => shift_blocks(inner, by),
Block::BulletList(items) | Block::OrderedList(_, items) => {
for item in items {
shift_blocks(item, by);
}
}
_ => {}
}
}
}
pub fn render_with_reference(
doc: &Pandoc,
to: Format,
reference: &[u8],
media: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> Result<Vec<u8>, Error> {
if !matches!(to, Format::Docx | Format::Odt) {
return Err(Error::NotWritable(to));
}
#[cfg(feature = "odt")]
if to == Format::Odt {
let resolve = |url: &str| data_url(url).or_else(|| media(url));
return ferrodoc_odt::write_odt_with_reference(doc, &resolve, Some(reference))
.map_err(|e| Error::Invalid { format: to, detail: e.to_string() });
}
#[cfg(not(feature = "odt"))]
if to == Format::Odt {
return Err(Error::NotCompiled(to));
}
#[cfg(not(feature = "docx"))]
{
let _ = (doc, reference, media);
Err(Error::NotCompiled(to))
}
#[cfg(feature = "docx")]
{
let resolve = |url: &str| data_url(url).or_else(|| media(url));
ferrodoc_docx::write_docx_with_reference(doc, &resolve, Some(reference))
.map_err(|e| Error::Invalid { format: to, detail: e.to_string() })
}
}
pub fn prefix_identifiers(doc: &mut Pandoc, prefix: &str) {
if prefix.is_empty() {
return;
}
prefix_blocks(&mut doc.blocks, prefix);
}
fn prefix_blocks(blocks: &mut [Block], prefix: &str) {
for block in blocks.iter_mut() {
match block {
Block::Header(_, attr, inlines) => {
prefix_attr(attr, prefix);
prefix_inlines(inlines, prefix);
}
Block::Div(attr, inner) => {
prefix_attr(attr, prefix);
prefix_blocks(inner, prefix);
}
Block::Plain(inlines) | Block::Para(inlines) => prefix_inlines(inlines, prefix),
Block::BlockQuote(inner) => prefix_blocks(inner, prefix),
Block::BulletList(items) | Block::OrderedList(_, items) => {
for item in items {
prefix_blocks(item, prefix);
}
}
Block::CodeBlock(attr, _) => prefix_attr(attr, prefix),
_ => {}
}
}
}
fn prefix_attr(attr: &mut ferrodoc_ast::Attr, prefix: &str) {
if !attr.identifier.is_empty() {
attr.identifier.insert_str(0, prefix);
}
}
fn prefix_inlines(inlines: &mut [ferrodoc_ast::Inline], prefix: &str) {
use ferrodoc_ast::Inline;
for inline in inlines {
match inline {
Inline::Link(attr, inner, target) => {
prefix_attr(attr, prefix);
if let Some(fragment) = target.url.strip_prefix('#') {
target.url = format!("#{prefix}{fragment}");
}
prefix_inlines(inner, prefix);
}
Inline::Image(attr, inner, _) | Inline::Span(attr, inner) => {
prefix_attr(attr, prefix);
prefix_inlines(inner, prefix);
}
Inline::Emph(inner)
| Inline::Strong(inner)
| Inline::Strikeout(inner)
| Inline::Superscript(inner)
| Inline::Subscript(inner)
| Inline::SmallCaps(inner)
| Inline::Underline(inner)
| Inline::Quoted(_, inner)
| Inline::Cite(_, inner) => prefix_inlines(inner, prefix),
Inline::Note(blocks) => prefix_blocks(blocks, prefix),
Inline::Code(attr, _) => prefix_attr(attr, prefix),
_ => {}
}
}
}
pub fn ascii_only(html: &str) -> String {
if html.is_ascii() {
return html.to_owned();
}
let mut out = String::with_capacity(html.len());
for ch in html.chars() {
if ch.is_ascii() {
out.push(ch);
} else {
let _ = write!(out, "&#x{:X};", ch as u32);
}
}
out
}
pub fn strip_comments(doc: &mut Pandoc) {
strip_comment_blocks(&mut doc.blocks);
}
fn strip_comment_blocks(blocks: &mut [Block]) {
for block in blocks.iter_mut() {
match block {
Block::Plain(inlines) | Block::Para(inlines) | Block::Header(_, _, inlines) => {
strip_comment_inlines(inlines);
}
_ => {}
}
}
for block in blocks.iter_mut() {
if let Block::RawBlock(format, text) = block
&& format.0 == "html"
{
*text = without_comments(text);
}
}
for block in blocks.iter_mut() {
match block {
Block::Div(_, inner) | Block::BlockQuote(inner) => strip_comment_blocks(inner),
Block::BulletList(items) | Block::OrderedList(_, items) => {
for item in items {
strip_comment_blocks(item);
}
}
_ => {}
}
}
}
fn strip_comment_inlines(inlines: &mut [ferrodoc_ast::Inline]) {
use ferrodoc_ast::Inline;
for inline in inlines {
match inline {
Inline::RawInline(format, text) if format.0 == "html" => {
*text = without_comments(text);
}
Inline::Emph(inner)
| Inline::Strong(inner)
| Inline::Strikeout(inner)
| Inline::Superscript(inner)
| Inline::Subscript(inner)
| Inline::SmallCaps(inner)
| Inline::Underline(inner)
| Inline::Span(_, inner)
| Inline::Quoted(_, inner)
| Inline::Cite(_, inner) => strip_comment_inlines(inner),
Inline::Link(_, inner, _) | Inline::Image(_, inner, _) => {
strip_comment_inlines(inner);
}
Inline::Note(blocks) => strip_comment_blocks(blocks),
_ => {}
}
}
}
fn without_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("<!--") {
match rest[start..].find("-->") {
Some(end) => {
out.push_str(&rest[..start]);
rest = &rest[start + end + 3..];
}
None => break,
}
}
out.push_str(rest);
out
}
#[cfg(feature = "html")]
pub fn number_sections(doc: &mut Pandoc) {
ferrodoc_html::number_sections(doc);
}
pub fn convert(input: &[u8], from: Format, to: Format) -> Result<Vec<u8>, Error> {
if !to.embeds_media() {
return render(&parse(input, from)?, to);
}
let (doc, media) = parse_with_media(input, from)?;
render_with_media(&doc, to, &|url| media.get(url).cloned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(all(feature = "markdown", feature = "html"))]
fn markdown_to_html() {
let out = convert(b"*hi*\n", Format::Markdown, Format::Html).unwrap();
assert_eq!(out, b"<p><em>hi</em></p>\n");
}
#[test]
#[cfg(all(feature = "markdown", feature = "docx", feature = "html"))]
fn docx_round_trips_through_the_facade() {
let docx = convert(b"# Title\n\nBody.\n", Format::Markdown, Format::Docx).unwrap();
let html = convert(&docx, Format::Docx, Format::Html).unwrap();
let html = String::from_utf8(html).unwrap();
assert!(html.contains("<h1"), "{html}");
assert!(html.contains("Body."), "{html}");
}
#[test]
fn only_docx_output_embeds_media() {
assert!(Format::Docx.embeds_media());
for format in [Format::Markdown, Format::Gfm, Format::Html, Format::Json, Format::Plain] {
assert!(!format.embeds_media(), "{format} does not embed image bytes");
}
}
#[test]
fn formats_are_named_and_guessed() {
assert_eq!(Format::parse("CommonMark"), Some(Format::Markdown));
assert_eq!(Format::from_path(std::path::Path::new("a/b.docx")), Some(Format::Docx));
assert_eq!(Format::parse("pdf"), None);
assert_eq!(Format::parse("html5"), Some(Format::Html));
assert_eq!(Format::parse("markdown_github"), Some(Format::Gfm));
assert_eq!(Format::parse("html4"), None);
assert_eq!(Format::parse("markdown_strict"), None);
}
#[test]
fn every_wrap_mode_is_honoured_or_refused_by_name() {
use ferrodoc_ast::{Block, Inline};
let doc = Pandoc::new(vec![Block::Para(vec![
Inline::Str("one".into()),
Inline::SoftBreak,
Inline::Str("two".into()),
])]);
let out = |to, wrap| {
render_wrapped(&doc, to, wrap).map(|bytes| String::from_utf8(bytes).expect("utf-8"))
};
assert_eq!(out(Format::Gfm, Wrap::None).unwrap(), "one two\n");
assert_eq!(out(Format::Gfm, Wrap::Preserve).unwrap(), "one\ntwo\n");
assert_eq!(out(Format::Gfm, Wrap::Auto(5)).unwrap(), "one\ntwo\n");
assert_eq!(out(Format::Gfm, Wrap::Auto(72)).unwrap(), "one two\n");
assert_eq!(out(Format::Html, Wrap::None).unwrap(), "<p>one two</p>\n");
assert_eq!(out(Format::Html, Wrap::Preserve).unwrap(), "<p>one\ntwo</p>\n");
assert_eq!(out(Format::Html, Wrap::Auto(5)).unwrap(), "<p>one\ntwo</p>\n");
assert_eq!(out(Format::Html, Wrap::Auto(72)).unwrap(), "<p>one two</p>\n");
#[cfg(feature = "text")]
assert_eq!(out(Format::Plain, Wrap::None).unwrap(), "one two\n");
#[cfg(feature = "text")]
assert_eq!(out(Format::Plain, Wrap::Auto(5)).unwrap(), "one\ntwo\n");
#[cfg(feature = "text")]
assert_eq!(out(Format::Plain, Wrap::Auto(72)).unwrap(), "one two\n");
#[cfg(feature = "latex")]
assert_eq!(out(Format::Latex, Wrap::None).unwrap(), "one two\n");
#[cfg(feature = "latex")]
assert_eq!(out(Format::Latex, Wrap::Preserve).unwrap(), "one\ntwo\n");
#[cfg(feature = "latex")]
assert_eq!(out(Format::Latex, Wrap::Auto(5)).unwrap(), "one\ntwo\n");
#[cfg(feature = "latex")]
assert_eq!(out(Format::Latex, Wrap::Auto(72)).unwrap(), "one two\n");
#[cfg(feature = "rst")]
assert_eq!(out(Format::Rst, Wrap::None).unwrap(), "one two\n");
#[cfg(feature = "rst")]
assert_eq!(out(Format::Rst, Wrap::Preserve).unwrap(), "one\ntwo\n");
#[cfg(feature = "rst")]
assert_eq!(out(Format::Rst, Wrap::Auto(5)).unwrap(), "one\ntwo\n");
#[cfg(feature = "rst")]
assert_eq!(out(Format::Rst, Wrap::Auto(72)).unwrap(), "one two\n");
#[cfg(feature = "asciidoc")]
assert_eq!(out(Format::Asciidoc, Wrap::None).unwrap(), "one two\n");
#[cfg(feature = "asciidoc")]
assert_eq!(out(Format::Asciidoc, Wrap::Preserve).unwrap(), "one\ntwo\n");
#[cfg(feature = "asciidoc")]
assert_eq!(out(Format::Asciidoc, Wrap::Auto(5)).unwrap(), "one\ntwo\n");
#[cfg(feature = "asciidoc")]
assert_eq!(out(Format::Asciidoc, Wrap::Auto(72)).unwrap(), "one two\n");
assert!(out(Format::Json, Wrap::Auto(72)).is_ok());
}
#[test]
fn unsupported_directions_are_errors_not_panics() {
assert!(matches!(
convert(b"x", Format::Plain, Format::Json),
Err(Error::NotReadable(Format::Plain))
));
}
#[test]
#[cfg(all(feature = "markdown", feature = "html"))]
fn html_converts_to_markdown() {
let out = convert(b"<h1>T</h1><ul><li>a</li></ul>", Format::Html, Format::Markdown)
.expect("html is readable");
assert_eq!(String::from_utf8(out).unwrap(), "# T\n\n- a\n");
}
#[test]
#[cfg(all(feature = "markdown", feature = "docx"))]
fn docx_converts_to_markdown() {
let docx = convert(b"# Title\n\nBody *text*.\n", Format::Markdown, Format::Docx)
.expect("writable");
let md = convert(&docx, Format::Docx, Format::Markdown).expect("convertible");
let md = String::from_utf8(md).expect("utf8");
assert!(md.contains("# Title"), "{md}");
assert!(md.contains("*text*"), "{md}");
}
#[test]
#[cfg(all(feature = "markdown", feature = "docx"))]
fn a_table_survives_docx_to_gfm() {
let source = b"| Region | Q1 |\n|--------|---:|\n| North | 120 |\n";
let docx = convert(source, Format::Gfm, Format::Docx).expect("writable");
let gfm = String::from_utf8(convert(&docx, Format::Docx, Format::Gfm).unwrap()).unwrap();
assert!(gfm.contains("| Region | Q1 |"), "{gfm}");
assert!(gfm.contains("| North | 120 |"), "{gfm}");
let md = String::from_utf8(convert(&docx, Format::Docx, Format::Markdown).unwrap()).unwrap();
assert!(!md.contains('|'), "{md}");
}
#[test]
#[cfg(all(feature = "markdown", feature = "docx"))]
fn images_survive_docx_to_docx() {
let png: &[u8] = &[
0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, b'I', b'H', b'D', b'R',
0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4, 0x89, 0, 0, 0, 0, b'I',
b'E', b'N', b'D', 0xae, 0x42, 0x60, 0x82,
];
let doc = parse(b"\n", Format::Markdown).unwrap();
let first = render_with_media(&doc, Format::Docx, &|url| {
(url == "pic.png").then(|| png.to_vec())
})
.unwrap();
let (_, media) = parse_with_media(&first, Format::Docx).unwrap();
assert_eq!(media.len(), 1, "the reader hands back what the package holds");
let second = convert(&first, Format::Docx, Format::Docx).unwrap();
let (_, again) = parse_with_media(&second, Format::Docx).unwrap();
assert_eq!(again.values().next().map(Vec::as_slice), Some(png));
assert!(parse_with_media(b"# t\n", Format::Markdown).unwrap().1.is_empty());
}
#[test]
#[cfg(all(feature = "html", feature = "docx"))]
fn a_data_url_carries_its_own_picture_into_a_package() {
let html = concat!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9">"#,
r#"<circle cx="4" cy="4" r="4"></circle></svg>"#,
);
let docx = convert(html.as_bytes(), Format::Html, Format::Docx).unwrap();
let (_, media) = parse_with_media(&docx, Format::Docx).unwrap();
let bytes = media.values().next().expect("the package holds the picture");
assert_eq!(String::from_utf8_lossy(bytes), html);
}
#[test]
#[cfg(any(feature = "docx", feature = "odt", feature = "epub", feature = "ipynb"))]
fn a_data_url_is_read_in_both_spellings() {
assert_eq!(data_url("data:image/svg+xml;base64,Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));
assert_eq!(data_url("data:text/plain,a%20b%2Fc").as_deref(), Some(&b"a b/c"[..]));
assert_eq!(data_url("data:image/png;BASE64,Zm9vYmE=").as_deref(), Some(&b"fooba"[..]));
assert_eq!(data_url("data:image/png;base64,Zm9vYmE").as_deref(), Some(&b"fooba"[..]));
assert_eq!(
data_url("data:image/png;base64,Zm9v\n YmFy").as_deref(),
Some(&b"foobar"[..])
);
assert_eq!(data_url("pic.png"), None);
assert_eq!(data_url("data:image/png;base64,not_base64!"), None);
assert_eq!(data_url("data:image/png;base64"), None);
assert_eq!(data_url("data:text/plain,a%+1b"), None);
assert_eq!(data_url("data:text/plain,a%2"), None);
}
#[test]
#[cfg(all(feature = "docx", feature = "html"))]
fn malformed_input_is_reported_not_panicked() {
assert!(convert(b"not a zip", Format::Docx, Format::Html).is_err());
assert!(convert(b"{oops", Format::Json, Format::Html).is_err());
}
}