use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use futures::future::try_join_all;
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
use regex::Regex;
use typst::syntax::{FileId, Source, VirtualPath};
use typst_as_lib::TypstEngine;
use crate::assets::{AssetProvider, BoxFuture};
use crate::pages::Page;
use crate::{Artifact, Backend, RenderRequest, Target};
pub struct TypstBackend {
target: Target,
}
impl TypstBackend {
pub fn new(target: Target) -> Self {
assert!(matches!(target, Target::Pdf | Target::PdfPresentation));
Self { target }
}
async fn fetch_layout(
assets: &dyn AssetProvider,
target_dir: &str,
class: &str,
fallback: &str,
) -> Result<(String, Vec<u8>)> {
let key = format!("typst/layouts/{target_dir}/{class}.typ");
if let Some(b) = assets.get(&key).await? {
return Ok((class.to_string(), b.to_vec()));
}
tracing::warn!(class, target_dir, "typst layout not found; falling back to {fallback}");
let fallback_key = format!("typst/layouts/{target_dir}/{fallback}.typ");
let Some(b) = assets.get(&fallback_key).await? else {
bail!(
"fallback typst layout '{fallback}' for target '{target_dir}' missing from asset provider"
);
};
Ok((class.to_string(), b.to_vec()))
}
}
impl Backend for TypstBackend {
fn target(&self) -> Target {
self.target
}
fn render<'a>(&'a self, req: &'a RenderRequest<'a>) -> BoxFuture<'a, Result<Artifact>> {
Box::pin(async move {
let tdir = target_dir(self.target);
let mut classes: Vec<&str> = req.doc.pages.iter().map(|p| p.class.as_str()).collect();
classes.sort();
classes.dedup();
let layouts: Vec<(String, Vec<u8>)> = try_join_all(
classes.iter().map(|c| Self::fetch_layout(req.assets, tdir, c, "content")),
)
.await?;
let (image_map, image_files) =
collect_images_for_typst(&req.doc.pages, req.assets).await?;
let typst_bodies: Vec<String> = req
.doc
.pages
.iter()
.map(|p| md_to_typst(&p.body, &image_map))
.collect();
let driver = build_driver(&req.doc.pages, &typst_bodies);
let out_path: PathBuf = req.out.to_path_buf();
if let Some(parent) = out_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let pdf_bytes =
tokio::task::spawn_blocking(move || compile_pdf(driver, layouts, image_files))
.await
.context("typst compile thread panicked")??;
tokio::fs::write(&out_path, &pdf_bytes).await?;
Ok(Artifact { primary: out_path, extras: vec![] })
})
}
}
async fn collect_images_for_typst(
pages: &[Page],
provider: &dyn AssetProvider,
) -> Result<(BTreeMap<String, String>, Vec<(String, Vec<u8>)>)> {
let re = Regex::new(r"!\[[^\]]*\]\((?P<url>[^)\s]+)\)").unwrap();
let mut urls: BTreeMap<String, ()> = BTreeMap::new();
for page in pages {
for cap in re.captures_iter(&page.body) {
let url = cap.name("url").unwrap().as_str();
if is_remote(url) {
continue;
}
urls.insert(url.to_string(), ());
}
}
let url_vec: Vec<String> = urls.into_keys().collect();
let fetched: Vec<(String, Option<Vec<u8>>)> = try_join_all(url_vec.iter().map(|u| async move {
let bytes = provider.get(u).await?.map(|b| b.to_vec());
Ok::<_, anyhow::Error>((u.clone(), bytes))
}))
.await?;
let mut map = BTreeMap::new();
let mut files = Vec::new();
for (url, bytes) in fetched {
if let Some(bytes) = bytes {
let vpath = format!("images/{}", sanitize_path(&url));
map.insert(url, format!("/{vpath}"));
files.push((vpath, bytes));
}
}
Ok((map, files))
}
fn is_remote(url: &str) -> bool {
url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("data:")
|| url.starts_with("file://")
}
fn target_dir(target: Target) -> &'static str {
match target {
Target::Pdf => "pdf",
Target::PdfPresentation => "pdf-presentation",
_ => unreachable!("typst backend only handles pdf and pdf-presentation"),
}
}
fn build_driver(pages: &[Page], typst_bodies: &[String]) -> String {
let mut s = String::new();
let mut classes: Vec<&str> = pages.iter().map(|p| p.class.as_str()).collect();
classes.sort();
classes.dedup();
for class in &classes {
s.push_str(&format!(
"#import \"layouts/{}.typ\": layout as {}\n",
sanitize_path(class),
alias_for(class),
));
}
s.push('\n');
for (page, body) in pages.iter().zip(typst_bodies) {
let alias = alias_for(&page.class);
let escaped = typst_string(body);
s.push_str(&format!("#{alias}({escaped})\n"));
}
s
}
fn md_to_typst(md: &str, images: &BTreeMap<String, String>) -> String {
let mut out = String::new();
let mut in_image = 0i32;
let parser = Parser::new(md);
for ev in parser {
match ev {
Event::Start(Tag::Heading { level, .. }) => {
out.push_str(&"=".repeat(heading_depth(level)));
out.push(' ');
}
Event::End(TagEnd::Heading(_)) => out.push_str("\n\n"),
Event::Start(Tag::Paragraph) => {}
Event::End(TagEnd::Paragraph) => out.push_str("\n\n"),
Event::Start(Tag::Emphasis) => out.push('_'),
Event::End(TagEnd::Emphasis) => out.push('_'),
Event::Start(Tag::Strong) => out.push('*'),
Event::End(TagEnd::Strong) => out.push('*'),
Event::Start(Tag::List(_)) => {}
Event::End(TagEnd::List(_)) => out.push('\n'),
Event::Start(Tag::Item) => out.push_str("- "),
Event::End(TagEnd::Item) => out.push('\n'),
Event::Start(Tag::BlockQuote(_)) => out.push_str("#quote(block: true)[\n"),
Event::End(TagEnd::BlockQuote(_)) => out.push_str("\n]\n\n"),
Event::Start(Tag::Image { dest_url, .. }) => {
in_image += 1;
match images.get(dest_url.as_ref()) {
Some(vpath) => {
out.push_str(&format!("#image({})", typst_string(vpath)));
}
None => {
out.push_str(&format!("[image unresolved: {dest_url}]"));
}
}
}
Event::End(TagEnd::Image) => {
in_image -= 1;
}
Event::Start(Tag::CodeBlock(_)) => out.push_str("```\n"),
Event::End(TagEnd::CodeBlock) => out.push_str("```\n\n"),
Event::Text(t) => {
if in_image == 0 {
out.push_str(&escape_typst_inline(&t));
}
}
Event::Code(c) => {
out.push('`');
out.push_str(&c);
out.push('`');
}
Event::SoftBreak => out.push(' '),
Event::HardBreak => out.push_str("\\\n"),
_ => {}
}
}
out
}
fn heading_depth(level: HeadingLevel) -> usize {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn escape_typst_inline(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'#' | '@' | '<' | '>' | '$' | '\\' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
fn alias_for(class: &str) -> String {
let mut out = String::from("layout_");
for c in class.chars() {
if c.is_ascii_alphanumeric() { out.push(c) } else { out.push('_') }
}
out
}
fn sanitize_path(class: &str) -> String {
class.replace(['/', '\\'], "_")
}
fn typst_string(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => {}
_ => out.push(c),
}
}
out.push('"');
out
}
fn compile_pdf(
driver: String,
layouts: Vec<(String, Vec<u8>)>,
image_files: Vec<(String, Vec<u8>)>,
) -> Result<Vec<u8>> {
let mut sources: Vec<Source> = Vec::with_capacity(layouts.len());
for (class, bytes) in layouts {
let path = format!("layouts/{}.typ", sanitize_path(&class));
let src = String::from_utf8(bytes)
.with_context(|| format!("layout '{class}' is not valid UTF-8"))?;
let id = FileId::new(None, VirtualPath::new(path));
sources.push(Source::new(id, src));
}
let image_refs: Vec<(&str, Vec<u8>)> = image_files
.iter()
.map(|(p, b)| (p.as_str(), b.clone()))
.collect();
let engine = TypstEngine::builder()
.main_file(driver)
.with_static_source_file_resolver(sources)
.with_static_file_resolver(image_refs)
.search_fonts_with(typst_as_lib::typst_kit_options::TypstKitFontOptions::default())
.build();
let result = engine.compile();
let doc = result.output.map_err(format_lib_error)?;
let options = typst_pdf::PdfOptions::default();
let pdf = typst_pdf::pdf(&doc, &options).map_err(format_diagnostics)?;
Ok(pdf)
}
fn format_lib_error(err: typst_as_lib::TypstAsLibError) -> anyhow::Error {
use typst_as_lib::TypstAsLibError as E;
match err {
E::TypstSource(diags) => format_diagnostics(diags),
other => anyhow::anyhow!(other.to_string()),
}
}
fn format_diagnostics(diags: impl IntoIterator<Item = typst::diag::SourceDiagnostic>) -> anyhow::Error {
let mut out = String::from("typst compilation errors:\n");
for e in diags {
let sev = match e.severity {
typst::diag::Severity::Error => "error",
typst::diag::Severity::Warning => "warning",
};
out.push_str(&format!(" [{sev}] {}\n", e.message));
for hint in &e.hints {
out.push_str(&format!(" hint: {hint}\n"));
}
}
anyhow::anyhow!(out)
}