use anyhow::{Context, Result, bail};
use bytes::Bytes;
use futures::future::try_join_all;
use futures::try_join;
use typst::syntax::{FileId, Source, VirtualPath};
use typst_as_lib::TypstEngine;
use crate::assets::{AssetProvider, BoxFuture};
use crate::pages::Page;
use crate::{Backend, RenderedArtifact, ResolvedDoc, Target};
mod context;
mod fonts;
mod markdown;
mod template;
mod virtual_files;
use context::{CONTEXT_VIRTUAL_PATH, build_context_source};
use fonts::collect_fonts;
pub use markdown::md_to_typst;
use markdown::typst_string;
#[cfg(feature = "typst-html")]
pub use template::render_template_html;
pub use template::{TemplateDoc, render_template};
use virtual_files::{collect_images_for_typst, collect_layout_assets};
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_to_bytes<'a>(
&'a self,
doc: &'a ResolvedDoc,
assets: &'a dyn AssetProvider,
) -> BoxFuture<'a, Result<RenderedArtifact>> {
Box::pin(async move {
let tdir = target_dir(self.target);
let mut classes: Vec<&str> = 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(assets, tdir, c, "content")),
)
.await?;
let ((image_map, mut virtual_files), (asset_map, asset_files), fonts) = try_join!(
collect_images_for_typst(&doc.pages, assets, self.target),
collect_layout_assets(&doc.assets, assets),
collect_fonts(&doc.fonts, assets),
)?;
virtual_files.extend(asset_files);
let typst_bodies: Vec<String> = doc
.pages
.iter()
.map(|p| md_to_typst(&p.body, &image_map))
.collect();
let toc_depth = match self.target {
Target::Pdf => doc.toc,
_ => None,
};
let driver = build_driver(&doc.pages, &typst_bodies, toc_depth);
let context_source = build_context_source(&doc.meta, &doc.brand.0, &asset_map);
let pdf_bytes = spawn_compile(move || {
compile_pdf(driver, context_source, layouts, virtual_files, fonts)
})
.await?;
Ok(RenderedArtifact {
primary: Bytes::from(pdf_bytes),
filename: format!("output.{}", self.target.extension()),
extras: vec![],
})
})
}
}
async fn spawn_compile<T, F>(compile: F) -> Result<T>
where
F: FnOnce() -> Result<T> + Send + 'static,
T: Send + 'static,
{
let dispatch = tracing::dispatcher::get_default(|d| d.clone());
tokio::task::spawn_blocking(move || tracing::dispatcher::with_default(&dispatch, compile))
.await
.context("typst compile thread panicked")?
}
fn forward_warnings(warnings: &[typst::diag::SourceDiagnostic]) {
for w in warnings {
tracing::warn!("typst: {}", w.message);
for hint in &w.hints {
tracing::warn!("typst: hint: {hint}");
}
}
}
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"),
}
}
pub fn build_driver(pages: &[Page], typst_bodies: &[String], toc_depth: Option<u8>) -> 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_class(class),
alias_for(class),
));
}
s.push('\n');
if let Some(depth) = toc_depth {
s.push_str(&format!("#outline(depth: {depth})\n#pagebreak()\n\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 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_class(class: &str) -> String {
class.replace(['/', '\\'], "_")
}
fn compile_pdf(
driver: String,
context_source: String,
layouts: Vec<(String, Vec<u8>)>,
virtual_files: Vec<(String, Vec<u8>)>,
fonts: Vec<Vec<u8>>,
) -> Result<Vec<u8>> {
let mut sources: Vec<Source> = Vec::with_capacity(layouts.len() + 1);
let context_id = FileId::new(None, VirtualPath::new(CONTEXT_VIRTUAL_PATH));
sources.push(Source::new(context_id, context_source));
for (class, bytes) in layouts {
let path = format!("layouts/{}.typ", sanitize_class(&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>)> = virtual_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)
.fonts(fonts)
.search_fonts_with(typst_as_lib::typst_kit_options::TypstKitFontOptions::default())
.build();
let result = engine.compile();
forward_warnings(&result.warnings);
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)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::PageOrigin;
fn p(class: &str, body: &str) -> Page {
Page {
class: class.into(),
body: body.into(),
origin: PageOrigin::Explicit,
}
}
#[test]
fn no_toc_request_omits_outline() {
let pages = vec![p("content", "# Hi")];
let bodies = vec!["= Hi".to_string()];
let driver = build_driver(&pages, &bodies, None);
assert!(!driver.contains("#outline"));
}
#[test]
fn toc_request_emits_outline_with_requested_depth_before_pages() {
let pages = vec![p("content", "# Hi")];
let bodies = vec!["= Hi".to_string()];
let driver = build_driver(&pages, &bodies, Some(2));
assert!(driver.contains("#outline(depth: 2)"), "{driver}");
assert!(driver.contains("#pagebreak()"), "{driver}");
let outline_pos = driver.find("#outline").unwrap();
let page_call_pos = driver.find("#layout_content").unwrap();
assert!(
outline_pos < page_call_pos,
"outline must precede the first page's layout call:\n{driver}"
);
}
}