use std::collections::BTreeMap;
use std::sync::Arc;
use bytes::Bytes;
use mdcast::backends::typst::{TemplateDoc, render_template};
use mdcast::{AssetProvider, BoxFuture, BrandHandle, BrandSpec, DocMeta};
const INVOICE_TEMPLATE: &str = r#"
#import "/context.typ": doc-meta, brand-color
#import "partials/header.typ": letterhead
#let invoice = json("/data.json")
#letterhead
#image("logo.svg", width: 2cm)
= Invoice #invoice.number
#doc-meta.title
#table(
columns: (1fr, auto, auto),
[*Description*], [*Qty*], [*Total*],
..invoice.items.map(it => (
[#it.description], [#str(it.qty)], [#str(it.total)],
)).flatten(),
)
#text(fill: brand-color("accent", default: black))[Total due: #invoice.total]
"#;
const HEADER_PARTIAL: &str = r#"#let letterhead = [#text(weight: "bold")[Acme Corp]]"#;
const LOGO_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#;
struct MapAssets(BTreeMap<&'static str, &'static [u8]>);
impl AssetProvider for MapAssets {
fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, anyhow::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) -> BoxFuture<'a, anyhow::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) })
}
}
fn invoice_assets() -> MapAssets {
MapAssets(BTreeMap::from([
("templates/invoice.typ", INVOICE_TEMPLATE.as_bytes()),
("templates/partials/header.typ", HEADER_PARTIAL.as_bytes()),
("templates/logo.svg", LOGO_SVG),
]))
}
fn invoice_data() -> serde_json::Value {
serde_json::json!({
"number": "INV-2026-0042",
"total": "129.99",
"items": [
{"description": "Consulting", "qty": 3, "total": "99.99"},
{"description": "Support", "qty": 1, "total": "30.00"},
],
})
}
#[tokio::test]
async fn renders_invoice_shaped_pdf_from_template_and_data() {
let brand = BrandSpec {
name: "Acme".into(),
palette: BTreeMap::from([("accent".to_string(), "#2E5AAC".to_string())]),
..Default::default()
};
let doc = TemplateDoc {
template: "templates/invoice.typ".to_string(),
data: invoice_data(),
meta: DocMeta {
title: Some("Q3 Services Invoice".to_string()),
..Default::default()
},
brand: BrandHandle(Arc::new(brand)),
};
let artifact = render_template(&doc, &invoice_assets())
.await
.unwrap_or_else(|e| panic!("render_template failed: {e:#}"));
assert!(
artifact.primary.starts_with(b"%PDF-"),
"not a PDF: {:?}",
&artifact.primary[..artifact.primary.len().min(16)]
);
assert!(
artifact.primary.len() > 500,
"suspiciously small PDF: {} bytes",
artifact.primary.len()
);
}
#[tokio::test]
async fn missing_template_key_names_it_in_the_error() {
let doc = TemplateDoc {
template: "templates/does-not-exist.typ".to_string(),
data: serde_json::json!({}),
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
};
let err = render_template(&doc, &invoice_assets()).await.unwrap_err();
assert!(
err.to_string().contains("templates/does-not-exist.typ"),
"expected the missing key in the error, got: {err:#}"
);
}