use std::path::PathBuf;
use std::sync::Arc;
use bytes::Bytes;
use mdcast::backends::Registry;
use mdcast::brand::{LogoPosition, LogoSpec};
use mdcast::pages::auto::classify;
use mdcast::{
AssetProvider, AssetRef, AutoLayout, BrandHandle, BrandSpec, DefaultSplitter, DocMeta,
EmbeddedAssets, LayeredAssets, Page, PageOrigin, PageSplitter, RenderRequest, ResolvedDoc,
Target, sync_provider,
};
mod common;
use common::LogBuf;
const README_EXAMPLE: &str = include_str!("golden/readme-example.md");
fn resolved_doc(md: &str) -> ResolvedDoc {
let raw = DefaultSplitter.split(md);
let pages = classify(raw, &AutoLayout::default());
ResolvedDoc {
pages,
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: Vec::new(),
fonts: Vec::new(),
toc: None,
}
}
fn assets_with_chart() -> impl AssetProvider {
let chart = sync_provider(|key: &str| {
if key == "charts/revenue.svg" {
Ok(Some(Bytes::from_static(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#,
)))
} else {
Ok(None)
}
});
LayeredAssets {
over: chart,
base: EmbeddedAssets,
}
}
fn pandoc_available() -> bool {
std::process::Command::new("pandoc")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn ext_for(target: Target) -> &'static str {
match target {
Target::Docx => "docx",
Target::Odt => "odt",
Target::Pdf => "pdf",
Target::PdfPresentation => "pdf",
Target::Pptx => "pptx",
Target::HtmlReveal => "html",
}
}
async fn render(target: Target, doc: &ResolvedDoc) -> (tempfile::TempDir, PathBuf) {
render_with(target, doc, assets_with_chart()).await
}
async fn render_with(
target: Target,
doc: &ResolvedDoc,
assets: impl AssetProvider,
) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join(format!("out.{}", ext_for(target)));
let req = RenderRequest {
doc,
assets: &assets,
out: &out,
};
let registry = Registry::with_defaults();
registry
.render(target, &req)
.await
.unwrap_or_else(|e| panic!("render {target:?} failed: {e:#}"));
(tmp, out)
}
fn zip_entry_to_string(bytes: &[u8], entry: &str) -> String {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
.unwrap_or_else(|e| panic!("{entry} archive should be a valid zip: {e}"));
let mut file = archive
.by_name(entry)
.unwrap_or_else(|e| panic!("{entry} missing from archive: {e}"));
let mut s = String::new();
std::io::Read::read_to_string(&mut file, &mut s).unwrap();
s
}
fn has_external_resource_ref(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
[
"src=\"http://",
"src=\"https://",
"href=\"http://",
"href=\"https://",
"url(http://",
"url(https://",
"url(\"http://",
"url(\"https://",
]
.iter()
.any(|needle| lower.contains(needle))
}
#[tokio::test]
async fn typst_pdf_smoke() {
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::Pdf, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(
bytes.starts_with(b"%PDF-"),
"not a PDF: {:?}",
&bytes[..bytes.len().min(16)]
);
assert!(
bytes.len() > 500,
"suspiciously small PDF: {} bytes",
bytes.len()
);
}
#[tokio::test]
async fn typst_pdf_presentation_smoke() {
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::PdfPresentation, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(
bytes.starts_with(b"%PDF-"),
"not a PDF: {:?}",
&bytes[..bytes.len().min(16)]
);
assert!(
bytes.len() > 500,
"suspiciously small PDF: {} bytes",
bytes.len()
);
}
fn table_doc() -> ResolvedDoc {
let pages = vec![Page {
class: "content".into(),
body: "\
| Left | Center | Right |
|:-----|:------:|------:|
| **bold** | _em_ and `code` | a\\|b #c [d] \\\\ |
| short |
"
.into(),
origin: PageOrigin::Explicit,
}];
ResolvedDoc {
pages,
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: Vec::new(),
fonts: Vec::new(),
toc: None,
}
}
#[tokio::test]
async fn typst_pdf_table_smoke() {
let doc = table_doc();
let (_tmp, out) = render(Target::Pdf, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(
bytes.starts_with(b"%PDF-"),
"not a PDF: {:?}",
&bytes[..bytes.len().min(16)]
);
}
#[tokio::test]
async fn typst_pdf_presentation_table_smoke() {
let doc = table_doc();
let (_tmp, out) = render(Target::PdfPresentation, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(
bytes.starts_with(b"%PDF-"),
"not a PDF: {:?}",
&bytes[..bytes.len().min(16)]
);
}
fn assets_overriding_content_layout(typ_source: &'static str) -> impl AssetProvider {
let over = sync_provider(move |key: &str| {
if key == "typst/layouts/pdf/content.typ" {
Ok(Some(Bytes::from_static(typ_source.as_bytes())))
} else {
Ok(None)
}
});
LayeredAssets {
over,
base: EmbeddedAssets,
}
}
#[tokio::test]
async fn show_table_rule_in_layout_applies_across_eval_boundary() {
let doc = table_doc();
let (_tmp, default_out) = render(Target::Pdf, &doc).await;
let default_bytes = std::fs::read(&default_out).unwrap();
const THEMED_LAYOUT: &str = r##"
#let layout(body) = [
#set page(margin: 2cm)
#set text(font: "New Computer Modern", size: 11pt)
#show table: set table(fill: rgb("#ff0000"))
#eval(body, mode: "markup")
]
"##;
let themed_assets = assets_overriding_content_layout(THEMED_LAYOUT);
let (_tmp2, themed_out) = render_with(Target::Pdf, &doc, themed_assets).await;
let themed_bytes = std::fs::read(&themed_out).unwrap();
assert!(
themed_bytes.starts_with(b"%PDF-"),
"themed render is not a PDF"
);
assert_ne!(
default_bytes, themed_bytes,
"a #show table: rule set before #eval in the layout had no effect on \
the rendered table — the theming hook the layout is supposed to use \
isn't reaching content produced by #eval"
);
}
fn branded_doc() -> ResolvedDoc {
let pages = vec![
Page {
class: "hero".into(),
body: "# Q3 Operations Review".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "content".into(),
body: "Body text.".into(),
origin: PageOrigin::Explicit,
},
];
let meta = DocMeta {
title: Some("Q3 Operations Review".into()),
author: Some("F13".into()),
date: Some("2026-07-03".into()),
extra: std::collections::BTreeMap::from([(
"classification".to_string(),
"internal".to_string(),
)]),
};
let brand = BrandSpec {
name: "F13".into(),
palette: std::collections::BTreeMap::from([("accent".to_string(), "#243752".to_string())]),
fonts: std::collections::BTreeMap::from([("sans".to_string(), "Arial".to_string())]),
..Default::default()
};
ResolvedDoc {
pages,
meta,
brand: BrandHandle(Arc::new(brand)),
assets: Vec::new(),
fonts: Vec::new(),
toc: None,
}
}
#[tokio::test]
async fn typst_pdf_doc_meta_and_brand_smoke() {
let doc = branded_doc();
let (_tmp, out) = render(Target::Pdf, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-"), "not a PDF");
}
#[tokio::test]
async fn typst_pdf_presentation_doc_meta_and_brand_smoke() {
let doc = branded_doc();
let (_tmp, out) = render(Target::PdfPresentation, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-"), "not a PDF");
}
fn toc_doc(toc: Option<u8>) -> ResolvedDoc {
let pages = vec![
Page {
class: "content".into(),
body: "# Chapter One\n\nBody one.\n\n## Section 1.1\n\nMore body.".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "content".into(),
body: "# Chapter Two\n\nBody two.".into(),
origin: PageOrigin::Explicit,
},
];
ResolvedDoc {
pages,
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: Vec::new(),
fonts: Vec::new(),
toc,
}
}
#[tokio::test]
async fn typst_pdf_toc_smoke() {
let with_toc = toc_doc(Some(2));
let (_tmp, out) = render(Target::Pdf, &with_toc).await;
let with_toc_bytes = std::fs::read(&out).unwrap();
assert!(with_toc_bytes.starts_with(b"%PDF-"), "not a PDF");
let without_toc = toc_doc(None);
let (_tmp2, out2) = render(Target::Pdf, &without_toc).await;
let without_toc_bytes = std::fs::read(&out2).unwrap();
assert_ne!(
with_toc_bytes, without_toc_bytes,
"requesting a TOC should change the rendered PDF (an extra outline page)"
);
}
#[tokio::test]
async fn typst_pdf_presentation_ignores_toc_request() {
let with_toc = toc_doc(Some(2));
let (_tmp, out) = render(Target::PdfPresentation, &with_toc).await;
let with_toc_bytes = std::fs::read(&out).unwrap();
let without_toc = toc_doc(None);
let (_tmp2, out2) = render(Target::PdfPresentation, &without_toc).await;
let without_toc_bytes = std::fs::read(&out2).unwrap();
assert_eq!(
with_toc_bytes, without_toc_bytes,
"pdf-presentation should ignore the TOC request entirely"
);
}
#[tokio::test]
async fn pandoc_docx_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_docx_smoke: `pandoc` not on PATH");
return;
}
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::Docx, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"PK"), "docx should be a zip container");
assert!(
bytes.len() > 200,
"suspiciously small docx: {} bytes",
bytes.len()
);
let document_xml = zip_entry_to_string(&bytes, "word/document.xml");
let break_count = document_xml.matches(r#"<w:br w:type="page"/>"#).count();
assert_eq!(
break_count, 4,
"expected 4 page breaks in word/document.xml, found {break_count}:\n{document_xml}"
);
for class in ["hero", "content", "thanks"] {
let needle = format!(r#"<w:pStyle w:val="{class}" />"#);
assert!(
document_xml.contains(&needle),
"expected {needle:?} in word/document.xml (reference.docx style \
not applied for class {class:?}):\n{document_xml}"
);
}
let styles_xml = zip_entry_to_string(&bytes, "word/styles.xml");
assert!(
styles_xml.contains("2E5AAC"),
"expected reference.docx's brand accent color in word/styles.xml"
);
}
fn all_classes_doc() -> ResolvedDoc {
let pages = vec![
Page {
class: "hero".into(),
body: "Hero body text.".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "content".into(),
body: "Content body text.".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "thanks".into(),
body: "Thanks body text.".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "image-full".into(),
body: "Image-full body text.".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "section-divider".into(),
body: "Section-divider body text.".into(),
origin: PageOrigin::Explicit,
},
Page {
class: "callout".into(),
body: "Callout body text.".into(),
origin: PageOrigin::Explicit,
},
];
ResolvedDoc {
pages,
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: Vec::new(),
fonts: Vec::new(),
toc: None,
}
}
#[tokio::test]
async fn docx_custom_styles_apply_to_plain_paragraphs() {
if !pandoc_available() {
eprintln!("skipping docx_custom_styles_apply_to_plain_paragraphs: `pandoc` not on PATH");
return;
}
let doc = all_classes_doc();
let (_tmp, out) = render(Target::Docx, &doc).await;
let bytes = std::fs::read(&out).unwrap();
let document_xml = zip_entry_to_string(&bytes, "word/document.xml");
for class in [
"hero",
"content",
"thanks",
"image-full",
"section-divider",
"callout",
] {
let needle = format!(r#"<w:pStyle w:val="{class}" />"#);
assert!(
document_xml.contains(&needle),
"expected {needle:?} in word/document.xml:\n{document_xml}"
);
}
}
#[tokio::test]
async fn odt_custom_styles_apply_to_plain_paragraphs() {
if !pandoc_available() {
eprintln!("skipping odt_custom_styles_apply_to_plain_paragraphs: `pandoc` not on PATH");
return;
}
let doc = all_classes_doc();
let (_tmp, out) = render(Target::Odt, &doc).await;
let bytes = std::fs::read(&out).unwrap();
let content_xml = zip_entry_to_string(&bytes, "content.xml");
for class in [
"hero",
"content",
"thanks",
"image-full",
"section-divider",
"callout",
] {
let needle = format!(r#"text:style-name="{class}""#);
assert!(
content_xml.contains(&needle),
"expected {needle:?} in content.xml:\n{content_xml}"
);
}
}
#[tokio::test]
async fn pandoc_odt_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_odt_smoke: `pandoc` not on PATH");
return;
}
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::Odt, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"PK"), "odt should be a zip container");
assert!(
bytes.len() > 200,
"suspiciously small odt: {} bytes",
bytes.len()
);
let content_xml = zip_entry_to_string(&bytes, "content.xml");
let break_count = content_xml
.matches(r#"<text:p text:style-name="PageBreak"/>"#)
.count();
assert_eq!(
break_count, 4,
"expected 4 page breaks in content.xml, found {break_count}:\n{content_xml}"
);
let styles_xml = zip_entry_to_string(&bytes, "styles.xml");
assert!(
styles_xml.contains(r#"style:name="PageBreak""#)
&& styles_xml.contains(r#"fo:break-before="page""#),
"reference.odt's PageBreak style (fo:break-before=\"page\") should be \
carried into the output styles.xml"
);
for class in ["hero", "content", "thanks"] {
let needle = format!(r#"text:style-name="{class}""#);
assert!(
content_xml.contains(&needle),
"expected {needle:?} in content.xml (reference.odt style not \
applied for class {class:?}):\n{content_xml}"
);
}
assert!(
styles_xml.contains("#2e5aac"),
"expected reference.odt's brand accent color in styles.xml"
);
}
#[tokio::test]
async fn pandoc_docx_toc_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_docx_toc_smoke: `pandoc` not on PATH");
return;
}
let doc = toc_doc(Some(3));
let (_tmp, out) = render(Target::Docx, &doc).await;
let bytes = std::fs::read(&out).unwrap();
let document_xml = zip_entry_to_string(&bytes, "word/document.xml");
assert!(
document_xml.contains(r#"TOC \o "1-3""#),
"expected a `TOC \\o \"1-3\"` field instruction (--toc-depth=3) in \
word/document.xml:\n{document_xml}"
);
let without_toc = toc_doc(None);
let (_tmp2, out2) = render(Target::Docx, &without_toc).await;
let bytes2 = std::fs::read(&out2).unwrap();
let document_xml2 = zip_entry_to_string(&bytes2, "word/document.xml");
assert!(
!document_xml2.contains("TOC \\o"),
"no TOC was requested — word/document.xml should carry no TOC field"
);
}
#[tokio::test]
async fn pandoc_odt_toc_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_odt_toc_smoke: `pandoc` not on PATH");
return;
}
let doc = toc_doc(Some(2));
let (_tmp, out) = render(Target::Odt, &doc).await;
let bytes = std::fs::read(&out).unwrap();
let content_xml = zip_entry_to_string(&bytes, "content.xml");
assert!(
content_xml.contains("text:table-of-content"),
"expected a text:table-of-content element (--toc) in content.xml:\n{content_xml}"
);
let without_toc = toc_doc(None);
let (_tmp2, out2) = render(Target::Odt, &without_toc).await;
let bytes2 = std::fs::read(&out2).unwrap();
let content_xml2 = zip_entry_to_string(&bytes2, "content.xml");
assert!(
!content_xml2.contains("text:table-of-content"),
"no TOC was requested — content.xml should carry no table-of-content element"
);
}
fn slide_count(bytes: &[u8]) -> usize {
let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
archive
.file_names()
.filter(|n| n.starts_with("ppt/slides/slide") && n.ends_with(".xml"))
.count()
}
#[tokio::test]
async fn pandoc_pptx_ignores_toc_request() {
if !pandoc_available() {
eprintln!("skipping pandoc_pptx_ignores_toc_request: `pandoc` not on PATH");
return;
}
let with_toc = toc_doc(Some(3));
let (_tmp, out) = render(Target::Pptx, &with_toc).await;
let with_toc_bytes = std::fs::read(&out).unwrap();
let without_toc = toc_doc(None);
let (_tmp2, out2) = render(Target::Pptx, &without_toc).await;
let without_toc_bytes = std::fs::read(&out2).unwrap();
assert_eq!(
slide_count(&with_toc_bytes),
slide_count(&without_toc_bytes),
"requesting a TOC should not add a slide to pptx output"
);
}
#[tokio::test]
async fn pandoc_pptx_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_pptx_smoke: `pandoc` not on PATH");
return;
}
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::Pptx, &doc).await;
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"PK"), "pptx should be a zip container");
assert!(
bytes.len() > 200,
"suspiciously small pptx: {} bytes",
bytes.len()
);
let theme_xml = zip_entry_to_string(&bytes, "ppt/theme/theme1.xml");
assert!(
theme_xml.contains("2E5AAC"),
"expected reference.pptx's brand accent color in ppt/theme/theme1.xml:\n{theme_xml}"
);
}
#[tokio::test]
async fn pandoc_pptx_autofit_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_pptx_autofit_smoke: `pandoc` not on PATH");
return;
}
let bullets: String = (1..=30)
.map(|n| format!("- bullet {n}\n"))
.collect::<String>();
let doc = ResolvedDoc {
pages: vec![Page {
class: "content".into(),
body: format!("# Long slide\n\n{bullets}"),
origin: PageOrigin::Explicit,
}],
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: Vec::new(),
fonts: Vec::new(),
toc: None,
};
let (_tmp, out) = render(Target::Pptx, &doc).await;
let bytes = std::fs::read(&out).unwrap();
let slide_xml = zip_entry_to_string(&bytes, "ppt/slides/slide1.xml");
assert!(
slide_xml.contains("<a:normAutofit"),
"expected <a:normAutofit/> in ppt/slides/slide1.xml:\n{slide_xml}"
);
}
#[tokio::test]
async fn pandoc_html_reveal_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_html_reveal_smoke: `pandoc` not on PATH");
return;
}
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::HtmlReveal, &doc).await;
let html = std::fs::read_to_string(&out).unwrap();
assert!(html.contains("<html"), "not an html document");
assert!(
html.len() > 1000,
"suspiciously small html: {} bytes",
html.len()
);
assert!(
!has_external_resource_ref(&html),
"html-reveal output should be self-contained (--embed-resources default) — found an external resource reference"
);
}
#[tokio::test]
async fn pandoc_html_reveal_ignores_toc_request() {
if !pandoc_available() {
eprintln!("skipping pandoc_html_reveal_ignores_toc_request: `pandoc` not on PATH");
return;
}
let doc = toc_doc(Some(3));
let (_tmp, out) = render(Target::HtmlReveal, &doc).await;
let html = std::fs::read_to_string(&out).unwrap();
assert!(
!html.contains(r#"id="TOC""#),
"requesting a TOC should not add pandoc's TOC div to html-reveal output"
);
}
fn html_reveal_branded_doc() -> ResolvedDoc {
let mut doc = branded_doc();
let mut brand = (*doc.brand.0).clone();
brand.logo = Some(LogoSpec {
key: "branding/logo.svg".into(),
position: LogoPosition::TopRight,
width: Some("120px".into()),
});
doc.brand = BrandHandle(Arc::new(brand));
doc
}
fn assets_with_logo() -> impl AssetProvider {
let logo = sync_provider(|key: &str| {
if key == "branding/logo.svg" {
Ok(Some(Bytes::from_static(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#,
)))
} else {
Ok(None)
}
});
LayeredAssets {
over: logo,
base: EmbeddedAssets,
}
}
#[tokio::test]
async fn pandoc_html_reveal_brand_smoke() {
if !pandoc_available() {
eprintln!("skipping pandoc_html_reveal_brand_smoke: `pandoc` not on PATH");
return;
}
let doc = html_reveal_branded_doc();
let (_tmp, out) = render_with(Target::HtmlReveal, &doc, assets_with_logo()).await;
let html = std::fs::read_to_string(&out).unwrap();
assert!(
html.contains("<style data-brand>"),
"expected an injected brand <style> block:\n{html}"
);
assert!(
html.contains("--r-selection-background-color: #243752;"),
"expected the mapped accent custom property:\n{html}"
);
assert!(
html.contains("--brand-accent: #243752;"),
"expected the --brand-<key> passthrough:\n{html}"
);
assert!(
!html.contains("--r-main-font: Arial;"),
"unrecognised font key should not map to --r-main-font:\n{html}"
);
assert!(
html.contains("data:image/svg+xml;base64,"),
"expected the logo embedded as a data URI:\n{html}"
);
assert!(
html.contains("width: 120px;"),
"expected the configured logo width:\n{html}"
);
assert!(
!has_external_resource_ref(&html),
"branding must not break self-containment — found an external resource reference"
);
}
#[tokio::test]
async fn pandoc_html_reveal_unbranded_has_no_brand_style_block() {
if !pandoc_available() {
eprintln!(
"skipping pandoc_html_reveal_unbranded_has_no_brand_style_block: `pandoc` not on PATH"
);
return;
}
let doc = resolved_doc(README_EXAMPLE);
let (_tmp, out) = render(Target::HtmlReveal, &doc).await;
let html = std::fs::read_to_string(&out).unwrap();
assert!(
!html.contains("data-brand"),
"an unbranded doc should not inject a brand <style> block:\n{html}"
);
}
#[tokio::test]
async fn typst_unknown_class_falls_back_to_content_with_warning() {
let doc = ResolvedDoc {
pages: vec![Page {
class: "definitely-not-a-real-class".into(),
body: "# Hi\n\nSome body text.".into(),
origin: PageOrigin::Explicit,
}],
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: Vec::new(),
fonts: Vec::new(),
toc: None,
};
let buf = LogBuf::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let bytes = {
let _guard = tracing::subscriber::set_default(subscriber);
let (_tmp, out) = render(Target::Pdf, &doc).await;
std::fs::read(&out).unwrap()
};
assert!(
bytes.starts_with(b"%PDF-"),
"fallback render should still produce a real PDF"
);
let logs = buf.contents();
assert!(
logs.contains("falling back to content"),
"expected the documented fallback warning in logs, got:\n{logs}"
);
}
fn layout_asset_doc() -> ResolvedDoc {
ResolvedDoc {
pages: vec![Page {
class: "content".into(),
body: "Body text.".into(),
origin: PageOrigin::Explicit,
}],
meta: DocMeta::default(),
brand: BrandHandle(Arc::new(BrandSpec::default())),
assets: vec![AssetRef {
key: "branding/logo.svg".into(),
}],
fonts: Vec::new(),
toc: None,
}
}
fn assets_with_logo_layout(serve_logo: bool) -> impl AssetProvider {
const LOGO_LAYOUT: &str = r##"
#import "/context.typ": asset-path
#let layout(body) = [
#set page(margin: 2cm)
#let logo = asset-path("branding/logo.svg")
#if logo != none [#image(logo, width: 1cm)]
#eval(body, mode: "markup")
]
"##;
let over = sync_provider(move |key: &str| match key {
"typst/layouts/pdf/content.typ" => Ok(Some(Bytes::from_static(LOGO_LAYOUT.as_bytes()))),
"branding/logo.svg" if serve_logo => Ok(Some(Bytes::from_static(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#,
))),
_ => Ok(None),
});
LayeredAssets {
over,
base: EmbeddedAssets,
}
}
#[tokio::test]
async fn typst_layout_embeds_declared_asset() {
let doc = layout_asset_doc();
let assets = assets_with_logo_layout(true);
let (_tmp, out) = render_with(Target::Pdf, &doc, assets).await;
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-"), "not a PDF");
}
#[tokio::test]
async fn typst_missing_layout_asset_warns_and_degrades() {
let doc = layout_asset_doc();
let assets = assets_with_logo_layout(false);
let buf = LogBuf::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let bytes = {
let _guard = tracing::subscriber::set_default(subscriber);
let (_tmp, out) = render_with(Target::Pdf, &doc, assets).await;
std::fs::read(&out).unwrap()
};
assert!(
bytes.starts_with(b"%PDF-"),
"missing asset should still produce a real PDF"
);
let logs = buf.contents();
assert!(
logs.contains("layout asset not found in provider; skipping"),
"expected the documented degrade warning in logs, got:\n{logs}"
);
}