#![warn(clippy::print_stderr, clippy::print_stdout)]
#[cfg(feature = "math")]
mod ascii;
mod inline;
mod math;
use std::{
fmt::Write as _,
path::{
Path,
PathBuf,
},
};
use anyhow::{
Context as _,
Result,
};
use asciidoc_parser::{
Document,
blocks::{
Block,
BreakType,
CompoundDelimitedContext,
FindBlocks,
IsBlock,
ListType,
QuoteType,
SectionType,
},
document::TocMode,
};
use adocers_render_core::numbering::Numbering;
use crate::inline::{
string,
typst as inline_markup,
};
#[derive(Clone, Copy, Debug, Default)]
#[expect(
clippy::struct_excessive_bools,
reason = "these mirror command line flags, and one field per flag is what reads clearly"
)]
pub struct Options {
pub icons: bool,
pub highlight: bool,
pub mermaid: bool,
pub math: bool,
}
#[derive(Clone, Debug)]
pub struct Pdf {
pub bytes: Vec<u8>,
pub fallback: Option<String>,
}
pub fn pdf(document: &Document<'_>, base: &Path, options: &Options) -> Result<Pdf> {
let (source, images) = markup(document, base, options);
let error = match compile(&source, &images) {
Ok(bytes) => {
return Ok(Pdf {
bytes,
fallback: None,
});
}
Err(error) => error,
};
if !options.math {
return Err(error);
}
let plain = Options {
math: false,
..*options
};
let (source, images) = markup(document, base, &plain);
match compile(&source, &images) {
Ok(bytes) => Ok(Pdf {
bytes,
fallback: Some(format!(
"an equation could not be typeset, so every equation is shown as its source \
({error:#})"
)),
}),
Err(_) => Err(error),
}
}
pub fn markup(
document: &Document<'_>,
base: &Path,
options: &Options,
) -> (String, Vec<(String, Vec<u8>)>) {
let mut out = Preamble::new(document, *options).to_string();
let mut emitter = Emitter {
out: String::new(),
base: base.to_path_buf(),
images_dir: attribute(document, "imagesdir"),
images: Vec::new(),
toc: Toc::of(document),
options: *options,
stem: attribute(document, "stem"),
figure: attribute(document, "figure-caption"),
figures: 0,
numbering: Numbering::of(document),
};
if emitter.toc.above() {
emitter.outline(emitter.toc.levels, &emitter.toc.title.clone());
}
emitter.blocks(document.child_blocks());
if emitter.toc.mode == Some(TocMode::Bottom) {
emitter.outline(emitter.toc.levels, &emitter.toc.title.clone());
}
out.push_str(&prune_links(&emitter.out));
(out, emitter.images)
}
fn compile(source: &str, images: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
use typst_as_lib::{
TypstEngine,
typst_kit_options::TypstKitFontOptions,
};
let engine = TypstEngine::builder()
.main_file(source.to_string())
.search_fonts_with(TypstKitFontOptions::default().include_system_fonts(false))
.with_static_file_resolver(
images
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice())),
)
.build();
let document = engine
.compile()
.output
.map_err(|error| anyhow::anyhow!("{error}"))
.context("laying the document out")?;
typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
.map_err(|errors| anyhow::anyhow!(describe(&errors)))
.context("writing the PDF")
}
fn describe(errors: &[typst::diag::SourceDiagnostic]) -> String {
errors
.iter()
.map(|error| error.message.to_string())
.collect::<Vec<_>>()
.join("; ")
}
const FITTED: &str = "#let fitted(body) = layout(size => {\n\x20 let natural = \
measure(body).width\n\x20 let factor = size.width / natural * 100%\n\x20 if \
natural > size.width {\n\x20 scale(body, x: factor, y: factor, reflow: \
true, origin: top + left)\n\x20 } else { body }\n})";
const CALLOUT: &str = "#let adoccallout(n) = if n <= 10 { str.from-unicode(0x2775 + n) } else if \
n <= 20 { str.from-unicode(0x24E0 + n) } else { numbering(\"(1)\", n) \
}\n#show regex(\"[\\u{2776}-\\u{277F}\\u{24EB}-\\u{24F4}]\"): it => \
text(fill: rgb(\"#1565a8\"), size: 1.35em, baseline: 0.12em)[#it]";
fn footnotes(document: &Document<'_>, options: Options) -> String {
let texts: Vec<String> = document
.catalog()
.footnotes()
.iter()
.map(|footnote| format!("[{}]", prose(&footnote.text, options.math)))
.collect();
let array = match texts.len() {
0 => String::new(),
1 => format!("{},", texts[0]),
_ => texts.join(", "),
};
format!(
"#let adocfootnotes = ({array})\n#let adocfootnote(n) = if n <= adocfootnotes.len() {{ \
footnote(adocfootnotes.at(n - 1)) }}"
)
}
fn properties(document: &Document<'_>, title: &str) -> String {
let mut out = format!("title: {}", string(title));
let authors: Vec<String> = document
.authors()
.iter()
.map(|author| string(author.name()))
.collect();
if !authors.is_empty() {
let _ = write!(out, ", author: ({},)", authors.join(", "));
}
if let Some(description) = attribute(document, "description") {
let _ = write!(out, ", description: {}", string(&description));
}
let keywords: Vec<String> = ["keywords", "page-tags"]
.iter()
.filter_map(|name| attribute(document, name))
.flat_map(|value| {
value
.split(',')
.map(str::trim)
.filter(|word| !word.is_empty())
.map(string)
.collect::<Vec<_>>()
})
.collect();
if !keywords.is_empty() {
let _ = write!(out, ", keywords: ({},)", keywords.join(", "));
}
match attribute(document, "revdate").as_deref().and_then(date) {
Some(date) => {
let _ = write!(out, ", date: {date}");
}
None => out.push_str(", date: none"),
}
out
}
fn locale(document: &Document<'_>) -> String {
let Some(tag) = attribute(document, "lang") else {
return String::new();
};
let mut subtags = tag.trim().split('-');
let Some(language) = subtags.next().filter(|subtag| alphabetic(subtag, 2..=3)) else {
return String::new();
};
let mut out = format!(", lang: {}", string(&language.to_lowercase()));
if let Some(region) = subtags.find(|subtag| alphabetic(subtag, 2..=2)) {
let _ = write!(out, ", region: {}", string(®ion.to_uppercase()));
}
out
}
fn alphabetic(subtag: &str, length: std::ops::RangeInclusive<usize>) -> bool {
length.contains(&subtag.chars().count()) && subtag.chars().all(char::is_alphabetic)
}
fn date(value: &str) -> Option<String> {
let mut parts = value.trim().splitn(3, '-');
let year: i32 = parts.next()?.parse().ok()?;
let month: u8 = parts.next()?.parse().ok()?;
let day: u8 = parts.next()?.trim().parse().ok()?;
(1..=12).contains(&month).then_some(())?;
(1..=31).contains(&day).then_some(())?;
Some(format!(
"datetime(year: {year}, month: {month}, day: {day})"
))
}
fn attribute(document: &Document<'_>, name: &str) -> Option<String> {
match document.attribute_value(name) {
asciidoc_parser::document::InterpretedValue::Value(value) if !value.is_empty() => {
Some(value.clone())
}
_ => None,
}
}
fn details(document: &Document<'_>) -> String {
let mut out = String::new();
let authors: Vec<String> = document
.authors()
.iter()
.map(|author| match author.email() {
Some(email) => format!(
"{} #link(\"mailto:{email}\")[\\<{}\\>]",
inline_markup(author.name()),
inline_markup(email)
),
None => inline_markup(author.name()),
})
.collect();
if !authors.is_empty() {
let label = if authors.len() == 1 {
"Author"
} else {
"Authors"
};
let _ = writeln!(out, "*{label}:* {}\\", authors.join(", "));
}
for (name, label) in [("revnumber", "Version"), ("revdate", "Date")] {
if let Some(value) = attribute(document, name) {
let _ = writeln!(out, "*{label}:* {}\\", inline_markup(&value));
}
}
for attribute in document.header().attributes() {
let name = attribute.name().data();
let Some(shown) = adocers_render_core::metadata::displayed_as(name) else {
continue;
};
let asciidoc_parser::document::InterpretedValue::Value(value) = attribute.value() else {
continue;
};
if value.is_empty() {
continue;
}
let _ = writeln!(
out,
"*{}:* {}\\",
adocers_render_core::metadata::label_for(shown),
inline_markup(value)
);
}
let mut out = out.trim_end().trim_end_matches('\\').to_string();
if let Some(remark) = attribute(document, "revremark") {
let _ = write!(
out,
"\n#v(0.4em)\n#text(style: \"italic\")[{}]",
inline_markup(&remark)
);
}
out
}
fn prose(html: &str, math: bool) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some((at, open, close, latex)) = equation(rest) {
out.push_str(&inline_markup(&rest[..at]));
let body = &rest[at + open.len()..];
let Some(end) = body.find(close) else {
out.push_str(&inline_markup(&rest[at..at + open.len()]));
rest = &rest[at + open.len()..];
continue;
};
let source = inline::unescape(&body[..end]);
match typeset(&source, math, latex) {
Some(converted) => out.push_str(&converted),
None => {
let _ = write!(out, "#raw({})", string(source.trim()));
}
}
rest = &body[end + close.len()..];
}
out.push_str(&inline_markup(rest));
out
}
fn equation(html: &str) -> Option<(usize, &'static str, &'static str, bool)> {
let latex = html.find("\\(").map(|at| (at, "\\(", "\\)", true));
let ascii = html.find("\\$").map(|at| (at, "\\$", "\\$", false));
match (latex, ascii) {
(Some(l), Some(a)) if a.0 < l.0 => Some(a),
(Some(l), _) => Some(l),
(None, found) => found,
}
}
fn typeset(source: &str, math: bool, latex: bool) -> Option<String> {
math.then(|| converted(source, latex))
.flatten()
.map(|converted| format!("${converted}$"))
}
fn converted(source: &str, latex: bool) -> Option<String> {
if latex {
return math::typst(source);
}
asciimath(source)
}
#[cfg(feature = "math")]
fn asciimath(source: &str) -> Option<String> {
ascii::typst(source)
}
#[cfg(not(feature = "math"))]
fn asciimath(_source: &str) -> Option<String> {
None
}
struct Preamble(String);
impl Preamble {
fn new(document: &Document<'_>, options: Options) -> Self {
let mut out = String::new();
let title = document
.doctitle_sanitized()
.unwrap_or_else(|| "Untitled".to_string());
let _ = writeln!(
out,
"#set document({})\n#set page(paper: \"a4\", margin: 2.2cm, numbering: \"1\")\n#set \
text(size: 10.5pt{})\n#set par(justify: true, leading: 0.62em)\n#show heading: it => \
block(above: 1.4em, below: 0.7em, it)\n#show link: it => text(fill: \
rgb(\"#1565a8\"), it)\n#show raw.where(block: true): it => block(\n\x20 width: 100%, \
fill: rgb(\"#f5f6f8\"), inset: 8pt, radius: 3pt, it,\n)\n{}\n{CALLOUT}\n{}\n{}\n",
properties(document, &title),
locale(document),
FITTED,
footnotes(document, options),
math::PRELUDE
);
if document.doctitle().is_some() {
let (heading, subtitle) = match (document.header().main_title(), document.subtitle()) {
(Some(main), Some(subtitle)) => {
(inline_markup(main), Some(inline_markup(subtitle)))
}
_ => (inline_markup(&title), None),
};
let _ = writeln!(
out,
"#align(center)[#text(size: 20pt, weight: \"bold\")[{heading}]]"
);
if let Some(subtitle) = subtitle {
let _ = writeln!(
out,
"#v(-0.6em)\n#align(center)[#text(size: 14pt)[{subtitle}]]"
);
}
let details = details(document);
if !details.is_empty() {
let _ = writeln!(
out,
"#v(0.4em)\n#align(center)[#block(width: 80%)[#set text(size: 9pt, fill: \
rgb(\"#656d77\"))\n#set align(center)\n{details}]]"
);
}
out.push('\n');
}
Self(out)
}
}
impl std::fmt::Display for Preamble {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
struct Emitter {
out: String,
base: PathBuf,
images_dir: Option<String>,
images: Vec<(String, Vec<u8>)>,
toc: Toc,
options: Options,
stem: Option<String>,
figure: Option<String>,
figures: usize,
numbering: Numbering,
}
#[derive(Clone, Debug)]
struct Toc {
mode: Option<TocMode>,
title: String,
levels: usize,
placed: bool,
}
impl Toc {
fn of(document: &Document<'_>) -> Self {
let mode = document.toc_mode();
Self {
mode: (mode != TocMode::Disabled).then_some(mode),
title: document.toc_title().to_string(),
levels: document.toc_levels(),
placed: false,
}
}
fn above(&self) -> bool {
matches!(
self.mode,
Some(TocMode::Auto | TocMode::Left | TocMode::Right | TocMode::Top)
)
}
}
impl Emitter {
fn prose(&mut self, html: &str) -> String {
let converted = prose(html, self.options.math);
self.pictures(&converted)
}
fn pictures(&mut self, markup: &str) -> String {
let mut out = String::with_capacity(markup.len());
let mut rest = markup;
while let Some(at) = rest.find(inline::PICTURE) {
out.push_str(&rest[..at]);
rest = &rest[at + inline::PICTURE.len()..];
let Some((marker, tail)) = rest.split_once(inline::PICTURE) else {
out.push_str(inline::PICTURE);
break;
};
rest = tail;
let mut parts = marker.splitn(3, inline::FIELD);
let (Some(target), Some(height), Some(words)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
match self.picture(target, height) {
Some(placed) => out.push_str(&placed),
None => out.push_str(words),
}
}
out.push_str(rest);
out
}
fn picture(&mut self, target: &str, height: &str) -> Option<String> {
let name = format!("/{target}");
if !self.images.iter().any(|(known, _)| *known == name) {
let bytes = std::fs::read(self.base.join(target)).ok()?;
self.images.push((name.clone(), bytes));
}
let height = height.parse::<f64>().map_or_else(
|_| "1em".to_string(),
|pixels| format!("{}pt", pixels * 0.75),
);
Some(format!("#box(image({}, height: {height}))", string(&name)))
}
fn outline(&mut self, levels: usize, title: &str) {
if self.toc.placed {
return;
}
self.toc.placed = true;
let title = if title.is_empty() {
"none".to_string()
} else {
format!("[{}]", inline_markup(title))
};
let _ = writeln!(
self.out,
"#outline(title: {title}, depth: {levels})\n#v(0.5em)\n"
);
}
fn blocks<'src>(&mut self, blocks: impl Iterator<Item = &'src Block<'src>>) {
for block in blocks {
self.block(block);
}
}
fn block(&mut self, block: &Block<'_>) {
if !matches!(block, Block::Section(_) | Block::Media(_)) && !is_diagram(block) {
self.title(block);
}
match block {
Block::Section(section) => {
let level = section.level().clamp(1, 6);
let title = format!(
"{}{}",
self.prose(&self.numbering.prefix(section)),
self.prose(section.section_title())
);
let label = section.id().map(anchor).unwrap_or_default();
if section.section_type() == SectionType::Discrete {
let _ = writeln!(
self.out,
"#heading(level: {level}, outlined: false)[{title}]{label}\n"
);
} else {
let _ = writeln!(self.out, "{} {title}{label}\n", "=".repeat(level));
}
self.blocks(section.child_blocks());
}
Block::Simple(simple) => {
let text = self.prose(simple.content().rendered_html());
if !text.trim().is_empty() {
let _ = writeln!(self.out, "{text}\n");
}
}
Block::List(list) => self.list(list),
Block::Table(table) => self.table(table),
Block::Preamble(preamble) => {
self.blocks(preamble.child_blocks());
if self.toc.mode == Some(TocMode::Preamble) {
self.outline(self.toc.levels, &self.toc.title.clone());
}
}
Block::Toc(toc) => {
if self.toc.mode == Some(TocMode::Macro) {
let attrlist = toc.macro_attrlist();
let named = |name: &str| {
attrlist
.named_attribute(name)
.map(asciidoc_parser::attributes::ElementAttribute::value)
.filter(|value| !value.is_empty())
};
let title =
named("title").map_or_else(|| self.toc.title.clone(), str::to_string);
let levels = named("levels")
.and_then(|levels| levels.parse().ok())
.unwrap_or(self.toc.levels);
self.outline(levels, &title);
}
}
Block::Admonition(admonition) => self.admonition(admonition),
Block::Quote(quote) => self.quote(quote),
Block::Media(media) => self.media(media),
Block::RawDelimited(raw) => self.raw(raw),
Block::CompoundDelimited(compound) => self.compound(compound),
Block::Break(r#break) => match r#break.type_() {
BreakType::Thematic => self.out.push_str("#line(length: 100%)\n\n"),
BreakType::Page => self.out.push_str("#pagebreak()\n\n"),
},
_ => {}
}
}
fn figure_caption(&mut self, titled: bool) -> String {
if !titled {
return String::new();
}
let Some(label) = self.figure.clone().filter(|label| !label.is_empty()) else {
return String::new();
};
self.figures += 1;
format!("{label} {}. ", self.figures)
}
fn title(&mut self, block: &Block<'_>) {
let Some(title) = block.title() else {
return;
};
let caption = block.caption().unwrap_or_default();
let caption = self.prose(caption);
let title = self.prose(title);
let _ = writeln!(
self.out,
"#block(above: 1em, below: 0.5em)[#text(weight: \"bold\", size: \
9.5pt)[{caption}{title}]]\n"
);
}
fn raw(&mut self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) {
let content = inline::text(&self.marks(raw.content().rendered_html()));
if self.diagram(raw, &content) {
return;
}
match raw.raw_context().as_ref() {
"comment" | "pass" => {}
"stem" => self.equation(raw, content.trim()),
_ => {
let language = (raw.raw_context().as_ref() == "listing")
.then(|| source_language(raw))
.flatten();
self.verbatim_as(&content, language.as_deref());
}
}
}
fn equation(&mut self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>, source: &str) {
if let Some(typeset) = self.mathematics(raw, source) {
let _ = writeln!(self.out, "$ {typeset} $\n");
return;
}
let _ = writeln!(self.out, "#align(center)[#raw({})]\n", string(source));
}
fn mathematics(
&self,
raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
source: &str,
) -> Option<String> {
self.options
.math
.then(|| converted(source, self.is_latex(raw)))
.flatten()
}
fn is_latex(&self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> bool {
match raw.declared_style() {
Some("latexmath") => true,
Some("asciimath") => false,
_ => self.stem.as_deref() == Some("latexmath"),
}
}
#[cfg(feature = "mermaid")]
fn diagram(
&mut self,
raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
content: &str,
) -> bool {
if !is_mermaid(raw) {
return false;
}
let Some(svg) = adocers_render_core::mermaid::printable(content) else {
return false;
};
let name = format!("/diagram-{}.svg", self.images.len());
self.images.push((name.clone(), svg.into_bytes()));
self.figure(&name, raw, "");
true
}
#[cfg(not(feature = "mermaid"))]
#[expect(
clippy::unused_self,
reason = "the other half of this pair collects the drawing, and both are called the same \
way"
)]
fn diagram(
&mut self,
_raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
_content: &str,
) -> bool {
false
}
fn verbatim(&mut self, content: &str) {
self.verbatim_as(content, None);
}
fn verbatim_as(&mut self, content: &str, language: Option<&str>) {
let language = match language {
Some(language) => format!(", lang: {}", string(language)),
None => String::new(),
};
let _ = writeln!(
self.out,
"#raw({}, block: true{language})\n",
string(content)
);
}
fn compound(&mut self, compound: &asciidoc_parser::blocks::CompoundDelimitedBlock<'_>) {
let abstracted = compound.declared_style() == Some("abstract");
let framed = !abstracted
&& matches!(
compound.context_kind(),
CompoundDelimitedContext::Example | CompoundDelimitedContext::Sidebar
);
if framed {
self.out.push_str(
"#block(width: 100%, fill: rgb(\"#f2f4f7\"), inset: 10pt, radius: 3pt)[\n",
);
}
if abstracted {
self.out
.push_str("#block(inset: (left: 12pt), stroke: (left: 2pt + rgb(\"#dcdfe4\")))[\n");
}
self.blocks(compound.child_blocks());
if framed || abstracted {
self.out.push_str("]\n\n");
}
}
fn admonition(&mut self, admonition: &asciidoc_parser::blocks::AdmonitionBlock<'_>) {
let name = admonition.name().to_string();
let label = admonition.label().to_string().to_uppercase();
let colour = admonition_colour(&name);
let mark = match self.icon(&name, &label) {
Some(path) => format!("#image({}, width: 20pt)", string(&path)),
None => format!("#text(weight: \"bold\", size: 8pt, fill: rgb(\"{colour}\"))[{label}]"),
};
let _ = write!(
self.out,
"#grid(\n columns: (34pt, 1fr),\n align: (center + horizon, left + horizon),\n \
inset: (x, y) => if x == 1 {{ (left: 11pt, y: 2pt) }} else {{ (right: 10pt) }},\n \
stroke: (x, y) => if x == 1 {{ (left: 1pt + rgb(\"#dcdfe4\")) }} else {{ none }},\n \
[{mark}],\n [\n"
);
match admonition.content() {
Some(content) => {
let converted = self.prose(content.rendered_html());
self.out.push_str(&converted);
self.out.push_str("\n\n");
}
None => self.blocks(admonition.child_blocks()),
}
self.out.push_str("],\n)\n\n");
}
fn marks(&self, html: &str) -> String {
if !self.options.icons {
return html.to_string();
}
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find(CONUM) {
out.push_str(&rest[..at]);
rest = &rest[at + CONUM.len()..];
let number: String = rest.chars().take_while(char::is_ascii_digit).collect();
let Some(mark) = number.parse().ok().and_then(circled) else {
out.push_str(CONUM);
continue;
};
let Some(end) = rest.find("</b>") else {
out.push_str(CONUM);
continue;
};
out.push(mark);
rest = &rest[end + "</b>".len()..];
}
out.push_str(rest);
out
}
fn icon(&mut self, name: &str, label: &str) -> Option<String> {
if !self.options.icons {
return None;
}
let path = format!("/icon-{name}.svg");
if !self.images.iter().any(|(known, _)| *known == path) {
let svg = adocers_render_core::icons::admonition(name, label)?
.replace("currentColor", admonition_colour(name));
self.images.push((path.clone(), svg.into_bytes()));
}
Some(path)
}
fn quote(&mut self, quote: &asciidoc_parser::blocks::QuoteBlock<'_>) {
self.out
.push_str("#block(inset: (left: 12pt), stroke: (left: 2pt + rgb(\"#dcdfe4\")))[\n");
match quote.content() {
Some(content) if quote.type_() == QuoteType::Verse => {
self.verbatim(&inline::text(content.rendered_html()));
}
Some(content) => {
let converted = self.prose(content.rendered_html());
self.out.push_str(&converted);
self.out.push_str("\n\n");
}
None => self.blocks(quote.child_blocks()),
}
if let Some(attribution) = quote.attribution() {
let attribution = self.prose(attribution);
let _ = writeln!(
self.out,
"#text(size: 9pt, fill: rgb(\"#656d77\"))[— {attribution}]"
);
}
self.out.push_str("]\n\n");
}
fn in_images_dir(&self, target: &str) -> String {
let Some(dir) = self.images_dir.as_deref() else {
return target.to_string();
};
if target.starts_with('/') || target.starts_with("data:") || target.contains("://") {
return target.to_string();
}
format!("{}/{target}", dir.trim_end_matches('/'))
}
fn media(&mut self, media: &asciidoc_parser::blocks::MediaBlock<'_>) {
let target = self.in_images_dir(media.resolved_target());
let Ok(bytes) = std::fs::read(self.base.join(&target)) else {
let _ = writeln!(
self.out,
"#text(style: \"italic\", fill: rgb(\"#656d77\"))[[{}]]\n",
inline_markup(&target)
);
return;
};
self.images.push((format!("/{target}"), bytes));
let width = attrlist_size(media, "width", 2);
let height = attrlist_size(media, "height", 3);
self.figure(&format!("/{target}"), media, &size(width, height));
}
fn figure<'src>(&mut self, name: &str, block: &impl IsBlock<'src>, size: &str) {
let caption = match block.title() {
Some(title) => {
let label = self.figure_caption(block.title().is_some());
format!(
", caption: [{}{}], numbering: none",
self.prose(&label),
self.prose(title)
)
}
None => String::new(),
};
let _ = writeln!(
self.out,
"#block(breakable: false, width: 100%)[#figure(fitted(image({}{size})){caption})]\n",
string(name)
);
}
fn list(&mut self, list: &asciidoc_parser::blocks::ListBlock<'_>) {
let items: Vec<_> = list
.child_blocks()
.filter_map(|block| match block {
Block::ListItem(item) => Some(item),
_ => None,
})
.collect();
let checklist = list.type_() == ListType::Unordered && list.is_checklist();
if checklist {
self.out.push_str("#[\n#set list(marker: none)\n");
}
let numbered = list.type_() == ListType::Ordered;
let start = numbered.then(|| list.start()).flatten();
let reversed = numbered && list.has_option("reversed");
let pattern = numbered
.then(|| numbering(list))
.flatten()
.filter(|pattern| *pattern != ARABIC);
if let Some(pattern) = pattern {
let _ = writeln!(self.out, "#set enum(numbering: {})", string(pattern));
}
let marked = list.type_() == ListType::Callout && self.options.icons;
if marked {
self.out
.push_str("#set enum(numbering: n => adoccallout(n))\n");
}
if let Some(start) = start {
let _ = writeln!(self.out, "#set enum(start: {start})");
}
if reversed {
let highest = start.unwrap_or(1) + i64::try_from(items.len()).unwrap_or(1) - 1;
let _ = writeln!(
self.out,
"#set enum(numbering: n => numbering({}, {highest} - n + 1))",
string(pattern.unwrap_or(ARABIC))
);
}
for item in items {
let bullet = match list.type_() {
ListType::Ordered | ListType::Callout => "+",
ListType::Description => "/",
ListType::Unordered => "-",
};
let term = match (list.type_(), item.list_item_marker()) {
(
ListType::Description,
asciidoc_parser::blocks::ListItemMarker::DefinedTerm { term, .. },
) => format!("{}: ", self.prose(term.rendered_html())),
(ListType::Description, _) => ": ".to_string(),
_ => String::new(),
};
let body = self.item(item);
let _ = writeln!(self.out, "{bullet} {term}{}{body}", box_of(item, checklist));
}
if start.is_some() {
self.out.push_str("#set enum(start: 1)\n");
}
if reversed || marked || pattern.is_some() {
let _ = writeln!(self.out, "#set enum(numbering: {})", string(ARABIC));
}
if checklist {
self.out.push_str("]\n");
}
self.out.push('\n');
}
fn item(&mut self, item: &asciidoc_parser::blocks::ListItem<'_>) -> String {
let taken = std::mem::take(&mut self.out);
self.blocks(item.child_blocks());
let body = std::mem::replace(&mut self.out, taken);
let mut lines = body.trim_end().lines();
let mut out = lines.next().unwrap_or_default().to_string();
for line in lines {
out.push('\n');
if !line.is_empty() {
out.push_str(" ");
}
out.push_str(line);
}
out
}
fn table(&mut self, table: &asciidoc_parser::blocks::TableBlock<'_>) {
let columns = table.columns().len().max(1);
let _ = writeln!(
self.out,
"#table(\n columns: {columns},\n stroke: 0.5pt + rgb(\"#dcdfe4\"),"
);
if let Some(header) = table.header_row() {
self.out.push_str(" table.header(");
self.cells(header, true);
self.out.push_str("),\n");
}
for row in table.body_rows() {
self.out.push_str(" ");
self.cells(row, false);
self.out.push('\n');
}
if let Some(footer) = table.footer_row() {
self.out.push_str(" table.footer(");
self.cells(footer, true);
self.out.push_str("),\n");
}
self.out.push_str(")\n\n");
}
fn cells(&mut self, row: &asciidoc_parser::blocks::TableRow<'_>, heading: bool) {
for cell in row.cells() {
let text = match cell.content() {
asciidoc_parser::blocks::TableCellContent::Simple(content) => {
self.prose(content.rendered_html())
}
asciidoc_parser::blocks::TableCellContent::AsciiDoc(_) => String::new(),
};
let body = if heading {
format!("[#text(weight: \"bold\")[{text}]]")
} else {
format!("[{text}]")
};
let (columns, rows) = (cell.colspan(), cell.rowspan());
if columns > 1 || rows > 1 {
let _ = write!(
self.out,
"table.cell(colspan: {columns}, rowspan: {rows}){body}, "
);
continue;
}
let _ = write!(self.out, "{body}, ");
}
}
}
fn defined(markup: &str) -> std::collections::HashSet<String> {
let mut labels = std::collections::HashSet::new();
let mut rest = markup;
while let Some(at) = rest.find("#label(\"") {
let before = &rest[..at];
let after = &rest[at + "#label(\"".len()..];
if let Some(quote) = after.find("\")")
&& !before.ends_with("#link(")
{
labels.insert(after[..quote].to_string());
}
rest = after;
}
for line in markup.lines() {
let Some(name) = line
.strip_suffix('>')
.and_then(|line| line.rsplit_once(" <"))
else {
continue;
};
if !name.0.ends_with('\\') {
labels.insert(name.1.to_string());
}
}
labels
}
fn prune_links(markup: &str) -> String {
let labels = defined(markup);
let mut out = String::with_capacity(markup.len());
let mut rest = markup;
while let Some(at) = rest.find("#link(label(\"") {
out.push_str(&rest[..at]);
let after = &rest[at + "#link(label(\"".len()..];
let Some(quote) = after.find("\")") else {
out.push_str(&rest[at..]);
return out;
};
let name = &after[..quote];
let body = &after[quote + "\")".len()..];
if labels.contains(name) {
out.push_str(&rest[at..at + "#link(label(\"".len() + quote + "\")".len()]);
rest = body;
continue;
}
let body = body.strip_prefix(')').unwrap_or(body);
match (body.strip_prefix('['), body.find(']')) {
(Some(_), Some(end)) => {
out.push_str(&body[1..end]);
rest = &body[end + 1..];
}
_ => {
rest = body;
}
}
}
out.push_str(rest);
out
}
#[cfg(feature = "mermaid")]
fn is_diagram(block: &Block<'_>) -> bool {
matches!(block, Block::RawDelimited(raw) if is_mermaid(raw))
}
#[cfg(not(feature = "mermaid"))]
fn is_diagram(_block: &Block<'_>) -> bool {
false
}
fn attrlist_size(
media: &asciidoc_parser::blocks::MediaBlock<'_>,
name: &str,
index: usize,
) -> Option<f64> {
let attrlist = media.macro_attrlist();
let value = attrlist
.named_attribute(name)
.or_else(|| {
attrlist
.nth_attribute(index)
.filter(|attribute| attribute.name().is_none())
})
.map(asciidoc_parser::attributes::ElementAttribute::value)
.filter(|value| !value.is_empty())?;
value.trim().trim_end_matches('%').parse().ok()
}
fn size(width: Option<f64>, height: Option<f64>) -> String {
let mut out = String::new();
if let Some(width) = width {
let _ = write!(out, ", width: {}pt", width * 0.75);
}
if let Some(height) = height {
let _ = write!(out, ", height: {}pt", height * 0.75);
}
out
}
fn anchor(id: &str) -> String {
let readable = !id.is_empty()
&& id
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':'));
if readable {
return format!(" <{id}>");
}
format!(" #label({})", string(id))
}
fn box_of(item: &asciidoc_parser::blocks::ListItem<'_>, checklist: bool) -> &'static str {
if !checklist {
return "";
}
match item.checkbox() {
Some(true) => "\u{2713} ",
Some(false) => "\u{274f} ",
None => "",
}
}
const CONUM: &str = "<b class=\"conum\">(";
fn circled(number: u32) -> Option<char> {
let point = match number {
1..=10 => 0x2775 + number,
11..=20 => 0x24e0 + number,
_ => return None,
};
char::from_u32(point)
}
fn admonition_colour(name: &str) -> &'static str {
match name {
"tip" => "#b58900",
"important" => "#bf6900",
"caution" => "#bf3400",
"warning" => "#bf0000",
_ => "#19407c",
}
}
const ARABIC: &str = "1.";
fn numbering(list: &asciidoc_parser::blocks::ListBlock<'_>) -> Option<&'static str> {
let style = list
.declared_style()
.filter(|style| {
matches!(
*style,
"arabic"
| "decimal"
| "loweralpha"
| "upperalpha"
| "lowerroman"
| "upperroman"
| "lowergreek"
)
})
.or_else(|| list.marker_style())?;
let pattern = match style {
"loweralpha" => "a.",
"upperalpha" => "A.",
"lowerroman" => "i.",
"upperroman" => "I.",
_ => ARABIC,
};
Some(pattern)
}
fn source_language(raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> Option<String> {
if raw.declared_style()? != "source" {
return None;
}
let declared = raw
.attrlist()?
.named_attribute("language")
.or_else(|| raw.attrlist()?.nth_attribute(2))
.map(asciidoc_parser::attributes::ElementAttribute::value)
.filter(|language| !language.is_empty())?
.to_lowercase();
let resolved = match declared.as_str() {
"sh" | "shell" | "zsh" | "console" | "terminal" => "bash",
"js" | "mjs" | "cjs" | "node" => "javascript",
"ts" => "typescript",
"py" | "python3" => "python",
"rs" => "rust",
"golang" => "go",
"yml" => "yaml",
"htm" | "xhtml" => "html",
"jsonc" => "json",
_ => return Some(declared),
};
Some(resolved.to_string())
}
#[cfg(feature = "mermaid")]
fn is_mermaid(raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> bool {
match raw.declared_style() {
Some(style) if style.to_lowercase() == "mermaid" => true,
Some(style) if style.to_lowercase() == "source" => raw
.attrlist()
.and_then(|attrlist| {
attrlist
.named_attribute("language")
.or_else(|| attrlist.nth_attribute(2))
})
.is_some_and(|language| language.value().to_lowercase() == "mermaid"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use asciidoc_parser::Parser;
fn options() -> Options {
Options {
icons: true,
highlight: true,
mermaid: true,
math: true,
}
}
fn render(source: &str) -> String {
let mut parser = Parser::default();
let document = parser.parse(source);
markup(&document, Path::new("."), &options()).0
}
#[test]
fn emits_a_heading_and_a_paragraph() {
let out = render("= Title\n\n== A section\n\nSome *bold* prose.\n");
assert!(out.contains("= A section"), "{out}");
assert!(out.contains("#strong[bold]"), "{out}");
}
#[test]
fn emits_a_list() {
let out = render("= T\n\n* one\n* two\n");
assert!(out.contains("- one"), "{out}");
assert!(out.contains("- two"), "{out}");
}
#[test]
fn emits_a_table() {
let out = render("= T\n\n|===\n|a |b\n|===\n");
assert!(out.contains("#table("), "{out}");
assert!(out.contains("columns: 2"), "{out}");
}
#[test]
fn hands_a_listing_its_language_to_highlight() {
let out = render("= T\n\n[source,rs]\n----\nfn main() {}\n----\n");
assert!(out.contains("lang: \"rust\""), "{out}");
}
#[test]
fn numbers_a_nested_list_the_way_its_depth_says() {
let out = render("= T\n\n. one\n.. two\n");
assert!(out.contains("#set enum(numbering: \"a.\")"), "{out}");
}
#[test]
fn places_an_outline_when_the_document_asks_for_one() {
let out = render("= T\n:toc:\n:toclevels: 3\n\n== A section\n");
assert!(
out.contains("#outline(title: [Table of Contents], depth: 3)"),
"{out}"
);
}
#[test]
fn hands_typst_the_language_the_document_is_written_in() {
let out = render("= T\n:lang: de\n\nText.\n");
assert!(
out.contains("#set text(size: 10.5pt, lang: \"de\")"),
"{out}"
);
}
#[test]
fn splits_a_region_off_the_language_tag() {
let out = render("= T\n:lang: pt-BR\n\nText.\n");
assert!(
out.contains("#set text(size: 10.5pt, lang: \"pt\", region: \"BR\")"),
"{out}"
);
}
#[test]
fn reads_the_region_past_a_script_subtag() {
let out = render("= T\n:lang: zh-Hans-CN\n\nText.\n");
assert!(
out.contains("#set text(size: 10.5pt, lang: \"zh\", region: \"CN\")"),
"{out}"
);
}
#[test]
fn leaves_typst_its_own_default_when_the_document_names_no_language() {
let out = render("= T\n\nText.\n");
assert!(out.contains("#set text(size: 10.5pt)"), "{out}");
}
#[test]
fn drops_a_language_tag_typst_could_not_read() {
for tag in ["deutsch", "d", "1234", "de_DE"] {
let out = render(&format!("= T\n:lang: {tag}\n\nText.\n"));
assert!(out.contains("#set text(size: 10.5pt)"), "{tag}: {out}");
}
}
#[test]
fn places_no_outline_when_it_was_not_asked_for() {
let out = render("= T\n\n== A section\n");
assert!(!out.contains("#outline"), "{out}");
}
#[test]
fn keeps_a_discrete_heading_out_of_the_outline() {
let out = render("= T\n:toc:\n\n[discrete]\n== Aside\n\n== A section\n");
assert!(out.contains("outlined: false"), "{out}");
}
#[test]
fn keeps_an_items_own_blocks_inside_it() {
let out = render("= T\n\n* one\n+\nMore about one.\n* two\n");
assert!(out.contains("- one\n\n More about one."), "{out}");
}
#[test]
fn numbers_a_list_from_where_it_says() {
let out = render("= T\n\n[start=7]\n. seven\n. eight\n");
assert!(out.contains("#set enum(start: 7)"), "{out}");
assert!(
out.contains("#set enum(start: 1)"),
"should be put back: {out}"
);
}
#[test]
fn keeps_the_text_of_a_paragraph_admonition() {
let out = render("= T\n\nTIP: Worth knowing.\n");
assert!(out.contains("/icon-tip.svg"), "{out}");
assert!(out.contains("Worth knowing."), "{out}");
}
#[test]
fn labels_an_admonition_when_it_draws_no_icon() {
let mut parser = Parser::default();
let document = parser.parse("= T\n\nTIP: Worth knowing.\n");
let plain = Options {
icons: false,
..options()
};
let out = markup(&document, Path::new("."), &plain).0;
assert!(out.contains("TIP"), "{out}");
assert!(!out.contains("icon-tip"), "{out}");
}
#[test]
fn marks_a_callout_and_its_item_the_same_way() {
let out = render("= T\n\n----\ncode <1>\n----\n<1> Why.\n");
assert!(
out.contains('\u{2776}'),
"the listing keeps its mark: {out}"
);
assert!(out.contains("adoccallout"), "and so does its list: {out}");
}
#[test]
fn a_block_picture_is_looked_for_inside_imagesdir() {
let out = render("= T\n:imagesdir: pics\n\nimage::figure.png[]\n");
assert!(out.contains("[pics\\/figure.png]"), "{out}");
}
#[test]
fn a_picture_that_says_where_it_lives_keeps_it() {
let out = render("= T\n:imagesdir: pics\n\nimage::https://example.com/f.png[]\n");
assert!(out.contains("[https:\\/\\/example.com\\/f.png]"), "{out}");
}
#[test]
fn takes_a_page_break_literally() {
let out = render("= T\n\none\n\n<<<\n\ntwo\n");
assert!(out.contains("#pagebreak()"), "{out}");
}
#[test]
fn keeps_a_cross_reference_and_unwraps_a_mark() {
let out = render("= T\n\n[[here]]\n== Here\n\nSee <<here>>, and a note.footnote:[Why.]\n");
assert!(out.contains("#link(label(\"here\"))"), "{out}");
assert!(!out.contains("_footnotedef_"), "{out}");
assert!(out.contains("Why."), "{out}");
}
#[test]
fn a_sections_label_belongs_to_its_heading() {
let out = render("= T\n\nProse.\n\n[[here]]\n== Here\n");
assert!(out.contains("= Here <here>"), "{out}");
assert!(!out.contains("#label(\"here\")\n= Here"), "{out}");
}
#[test]
fn an_anchored_term_can_be_reached() {
let out =
render("= T\n\nSee <<block,a block>>.\n\n[glossary]\n[[block]]block:: A shape.\n");
assert!(out.contains("#box[]#label(\"block\")"), "the anchor: {out}");
assert!(
out.contains("#link(label(\"block\"))[a block]"),
"and the reference to it: {out}"
);
}
#[test]
fn a_reference_to_nothing_keeps_its_words() {
let out = render("= T\n\nSee <<nowhere>>.\n");
assert!(!out.contains("#link"), "{out}");
assert!(out.contains("nowhere"), "{out}");
}
#[test]
fn typesets_both_notations() {
let out = render("= T\n:stem: latexmath\n\n[stem]\n++++\n\\frac{a}{b}\n++++\n");
assert!(out.contains("$ frac(a ,b ) $"), "{out}");
let ascii = render("= T\n\n[stem]\n++++\nsqrt(4)\n++++\n");
if cfg!(feature = "math") {
assert!(ascii.contains("$ sqrt(4) $"), "{ascii}");
} else {
assert!(ascii.contains("#raw(\"sqrt(4)\")"), "{ascii}");
}
}
#[test]
fn typesets_an_equation_in_a_line_of_text() {
let out = render("= T\n:stem: latexmath\n\nA line with stem:[x^2] in it.\n");
assert!(out.contains("$x ^(2 )$"), "{out}");
}
#[test]
fn falls_back_to_the_source_when_an_equation_will_not_typeset() {
let mut parser = Parser::default();
let document = parser.parse("= T\n:stem: latexmath\n\n[stem]\n++++\n\\hbar\\omega\n++++\n");
let pdf = pdf(&document, Path::new("."), &options()).expect("still makes a PDF");
assert!(pdf.bytes.starts_with(b"%PDF"), "should be a PDF");
assert!(
pdf.fallback.is_some_and(|said| said.contains("equation")),
"the fallback should be reported"
);
}
#[test]
fn compiles_to_a_pdf() {
let mut parser = Parser::default();
let document = parser.parse("= Title\n\n== Section\n\nProse with *bold*.\n\n* a\n* b\n");
let pdf = pdf(&document, Path::new("."), &options()).expect("compiles");
assert!(pdf.bytes.starts_with(b"%PDF"), "should be a PDF");
assert!(pdf.bytes.len() > 1000, "should have content");
assert!(pdf.fallback.is_none(), "nothing should have been given up");
}
}