use std::collections::BTreeMap;
use anyhow::{Context, Result, bail};
use bytes::Bytes;
use futures::future::try_join_all;
use typst::syntax::{FileId, Source, VirtualPath};
use typst_as_lib::TypstEngine;
use crate::assets::AssetProvider;
use crate::{BrandHandle, DocMeta, RenderedArtifact};
use super::context::{CONTEXT_VIRTUAL_PATH, build_context_source};
pub const DATA_VIRTUAL_PATH: &str = "data.json";
pub struct TemplateDoc {
pub template: String,
pub data: serde_json::Value,
pub meta: DocMeta,
pub brand: BrandHandle,
}
pub async fn render_template(
doc: &TemplateDoc,
assets: &dyn AssetProvider,
) -> Result<RenderedArtifact> {
let (main_id, sources, binaries) = assemble(doc, assets).await?;
let pdf_bytes =
super::spawn_compile(move || compile_template_pdf(&main_id, sources, binaries)).await?;
Ok(RenderedArtifact {
primary: Bytes::from(pdf_bytes),
filename: "output.pdf".to_string(),
extras: vec![],
})
}
#[cfg(feature = "typst-html")]
pub async fn render_template_html(
doc: &TemplateDoc,
assets: &dyn AssetProvider,
) -> Result<RenderedArtifact> {
let (main_id, sources, binaries) = assemble(doc, assets).await?;
let html_bytes =
super::spawn_compile(move || compile_template_html(&main_id, sources, binaries)).await?;
Ok(RenderedArtifact {
primary: Bytes::from(html_bytes),
filename: "output.html".to_string(),
extras: vec![],
})
}
async fn assemble(
doc: &TemplateDoc,
assets: &dyn AssetProvider,
) -> Result<(String, Vec<Source>, Vec<(String, Vec<u8>)>)> {
let Some(template_bytes) = assets.get(&doc.template).await? else {
bail!(
"typst template '{}' not found in asset provider",
doc.template
);
};
let template_src = String::from_utf8(template_bytes.to_vec())
.with_context(|| format!("template '{}' is not valid UTF-8", doc.template))?;
let prefix = match doc.template.rsplit_once('/') {
Some((dir, _)) => format!("{dir}/"),
None => String::new(),
};
let sibling_keys: Vec<String> = assets
.list(&prefix)
.await?
.into_iter()
.filter(|k| k != &doc.template)
.collect();
let siblings: Vec<(String, Bytes)> =
try_join_all(sibling_keys.into_iter().map(|key| async move {
let Some(bytes) = assets.get(&key).await? else {
bail!("template asset '{key}' listed by provider but not found on fetch");
};
Ok::<(String, Bytes), anyhow::Error>((key, bytes))
}))
.await?;
let mut sources = Vec::with_capacity(siblings.len() + 2);
let context_source = build_context_source(&doc.meta, &doc.brand.0, &BTreeMap::new());
sources.push(Source::new(
FileId::new(None, VirtualPath::new(CONTEXT_VIRTUAL_PATH)),
context_source,
));
sources.push(Source::new(
FileId::new(None, VirtualPath::new(doc.template.as_str())),
template_src,
));
let mut binaries: Vec<(String, Vec<u8>)> = vec![(
DATA_VIRTUAL_PATH.to_string(),
serde_json::to_vec(&doc.data).context("serialize TemplateDoc.data to JSON")?,
)];
for (key, bytes) in siblings {
if key.ends_with(".typ") {
let src = String::from_utf8(bytes.to_vec())
.with_context(|| format!("template partial '{key}' is not valid UTF-8"))?;
sources.push(Source::new(
FileId::new(None, VirtualPath::new(key.as_str())),
src,
));
} else {
binaries.push((key, bytes.to_vec()));
}
}
Ok((doc.template.clone(), sources, binaries))
}
fn build_engine(
sources: Vec<Source>,
binaries: Vec<(String, Vec<u8>)>,
) -> TypstEngine<typst_as_lib::TypstTemplateCollection> {
let binary_refs: Vec<(&str, Vec<u8>)> = binaries
.iter()
.map(|(p, b)| (p.as_str(), b.clone()))
.collect();
TypstEngine::builder()
.with_static_source_file_resolver(sources)
.with_static_file_resolver(binary_refs)
.search_fonts_with(typst_as_lib::typst_kit_options::TypstKitFontOptions::default())
.build()
}
fn compile_template_pdf(
main_id: &str,
sources: Vec<Source>,
binaries: Vec<(String, Vec<u8>)>,
) -> Result<Vec<u8>> {
let engine = build_engine(sources, binaries);
let result = engine.compile(main_id);
super::forward_warnings(&result.warnings);
let compiled = result.output.map_err(super::format_lib_error)?;
let options = typst_pdf::PdfOptions::default();
let pdf = typst_pdf::pdf(&compiled, &options).map_err(super::format_diagnostics)?;
Ok(pdf)
}
#[cfg(feature = "typst-html")]
fn compile_template_html(
main_id: &str,
sources: Vec<Source>,
binaries: Vec<(String, Vec<u8>)>,
) -> Result<Vec<u8>> {
let engine = build_engine(sources, binaries);
let result: typst::diag::Warned<Result<typst_html::HtmlDocument, _>> = engine.compile(main_id);
super::forward_warnings(&result.warnings);
let compiled = result.output.map_err(super::format_lib_error)?;
let html = typst_html::html(&compiled).map_err(super::format_diagnostics)?;
Ok(html.into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BrandSpec;
use crate::assets::sync_provider;
use std::sync::Arc;
fn doc(template: &str, data: serde_json::Value) -> TemplateDoc {
TemplateDoc {
template: template.to_string(),
data,
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
}
}
#[tokio::test]
async fn missing_template_key_is_a_clear_error() {
let assets = sync_provider(|_| Ok(None));
let d = doc("templates/does-not-exist.typ", serde_json::json!({}));
let err = render_template(&d, &assets).await.unwrap_err();
assert!(
err.to_string().contains("templates/does-not-exist.typ"),
"error should name the missing key: {err}"
);
}
#[tokio::test]
async fn non_utf8_template_is_a_clear_error() {
let assets = sync_provider(|key| match key {
"templates/bad.typ" => Ok(Some(Bytes::from_static(&[0xff, 0xfe, 0xfd]))),
_ => Ok(None),
});
let d = doc("templates/bad.typ", serde_json::json!({}));
let err = render_template(&d, &assets).await.unwrap_err();
assert!(err.to_string().contains("templates/bad.typ"));
}
#[tokio::test]
async fn real_compile_reads_data_json_and_context() {
const TEMPLATE: &str = r#"
#import "/context.typ": doc-meta
#let invoice = json("/data.json")
= Invoice #invoice.number
#doc-meta.title
"#;
let assets = sync_provider(|key| match key {
"templates/invoice.typ" => Ok(Some(Bytes::from_static(TEMPLATE.as_bytes()))),
_ => Ok(None),
});
let mut d = doc(
"templates/invoice.typ",
serde_json::json!({"number": "INV-042"}),
);
d.meta.title = Some("Q3 Invoice".to_string());
let artifact = render_template(&d, &assets).await.unwrap();
assert!(artifact.primary.starts_with(b"%PDF-"));
}
struct MapAssets(BTreeMap<&'static str, &'static [u8]>);
impl AssetProvider for MapAssets {
fn get<'a>(&'a self, key: &'a str) -> crate::assets::BoxFuture<'a, Result<Option<Bytes>>> {
let v = self.0.get(key).map(|b| Bytes::from_static(b));
Box::pin(async move { Ok(v) })
}
fn list<'a>(
&'a self,
prefix: &'a str,
) -> crate::assets::BoxFuture<'a, Result<Vec<String>>> {
let out: Vec<String> = self
.0
.keys()
.filter(|k| k.starts_with(prefix))
.map(|k| k.to_string())
.collect();
Box::pin(async move { Ok(out) })
}
}
#[tokio::test]
async fn sibling_typ_partial_and_image_resolve_by_relative_path() {
const MAIN: &str = r#"
#import "partials/header.typ": greeting
#figure(image("logo.svg", width: 1cm))
#greeting
"#;
const PARTIAL: &str = "#let greeting = [Hello]";
const LOGO: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#;
let assets = MapAssets(BTreeMap::from([
("templates/invoice.typ", MAIN.as_bytes()),
("templates/partials/header.typ", PARTIAL.as_bytes()),
("templates/logo.svg", LOGO),
]));
let d = doc("templates/invoice.typ", serde_json::json!({}));
let artifact = render_template(&d, &assets).await.unwrap();
assert!(artifact.primary.starts_with(b"%PDF-"));
}
}