//! Core rendering engine
//!
//! The Engine is the main entry point for rendering reports.
//! It manages template packs, themes, and the Typst rendering pipeline.
mod builder;
mod config;
pub mod world;
pub use builder::ReportBuilder;
pub use config::EngineConfig;
pub use world::ReportWorld;
use crate::components::catalog::LayoutHint;
use crate::components::ComponentRegistry;
use crate::pack::{Pack, PackId, PackLoader};
use crate::render::{RenderOutput, RenderRequest};
use crate::theme::Theme;
use crate::Result;
use std::collections::HashMap;
use std::sync::Arc;
use world::FontCache;
/// The main rendering engine
pub struct Engine {
/// Engine configuration
config: EngineConfig,
/// Loaded template packs
packs: HashMap<PackId, Arc<Pack>>,
/// Component registry
components: ComponentRegistry,
/// Default theme
default_theme: Theme,
/// Pack loader
pack_loader: PackLoader,
/// Cached font data, built once at construction time
font_cache: Arc<FontCache>,
}
impl Engine {
/// Create a new engine with default configuration
pub fn new() -> Result<Self> {
Self::with_config(EngineConfig::default())
}
/// Create a new engine with custom configuration
pub fn with_config(config: EngineConfig) -> Result<Self> {
let components = ComponentRegistry::with_standard_components();
let pack_loader = PackLoader::new(&config.pack_paths);
let font_cache = Arc::new(FontCache::new(&config));
Ok(Self {
config,
packs: HashMap::new(),
components,
default_theme: Theme::default_theme(),
pack_loader,
font_cache,
})
}
/// Rebuild the font cache from the current configuration.
///
/// Call this if font paths or system font availability change at runtime.
pub fn reload_fonts(&mut self) {
self.font_cache = Arc::new(FontCache::new(&self.config));
}
/// Set the default theme
pub fn set_default_theme(&mut self, theme: Theme) {
self.default_theme = theme;
}
/// Get the default theme
pub fn default_theme(&self) -> &Theme {
&self.default_theme
}
/// Load a template pack from the configured paths
pub fn load_pack(&mut self, pack_id: &str) -> Result<()> {
let pack = self.pack_loader.load(pack_id)?;
self.packs.insert(PackId::new(pack_id), Arc::new(pack));
Ok(())
}
/// Register an embedded pack
pub fn register_pack(&mut self, pack: Pack) {
self.packs.insert(pack.id.clone(), Arc::new(pack));
}
/// Get a loaded pack
pub fn get_pack(&self, pack_id: &PackId) -> Option<&Arc<Pack>> {
self.packs.get(pack_id)
}
/// List loaded packs
pub fn loaded_packs(&self) -> Vec<&PackId> {
self.packs.keys().collect()
}
/// Get the component registry
pub fn components(&self) -> &ComponentRegistry {
&self.components
}
/// Get mutable component registry
pub fn components_mut(&mut self) -> &mut ComponentRegistry {
&mut self.components
}
/// Create a new report builder
pub fn report(&self, template_id: impl Into<String>) -> ReportBuilder {
ReportBuilder::new(template_id)
}
/// Render a report to PDF
pub fn render_pdf(&self, request: &RenderRequest) -> Result<Vec<u8>> {
self.render(request).map(|output| output.into_bytes())
}
/// Render a report
pub fn render(&self, request: &RenderRequest) -> Result<RenderOutput> {
let theme = self.resolve_theme(request);
let typst_source = self.generate_typst_source(request, &theme)?;
let pdf_bytes = self.compile_typst(&typst_source, request)?;
Ok(RenderOutput::Pdf(pdf_bytes))
}
/// Render a report to its intermediate Typst source.
///
/// Returns the full `.typ` source that would be compiled by [`Self::render`] /
/// [`Self::render_pdf`]. Useful for snapshot testing, lint/format pipelines
/// (e.g. `typstyle`), and downstream tooling that wants to inspect or
/// post-process the source before compilation.
pub fn render_typ(&self, request: &RenderRequest) -> Result<String> {
let theme = self.resolve_theme(request);
self.generate_typst_source(request, &theme)
}
/// Merge themes: default -> pack -> request
fn resolve_theme(&self, request: &RenderRequest) -> Theme {
let mut theme = self.default_theme.clone();
if let Some(pack_id) = &request.pack_id {
if let Some(pack) = self.packs.get(pack_id) {
if let Some(pack_theme) = &pack.default_theme {
theme.merge(pack_theme);
}
}
}
if let Some(request_theme) = &request.theme {
theme.merge(request_theme);
}
theme
}
/// Generate Typst source from request
fn generate_typst_source(&self, request: &RenderRequest, theme: &Theme) -> Result<String> {
let mut source = String::new();
// Add theme token definitions
source.push_str("// Theme Tokens\n");
source.push_str(&theme.tokens.to_typst_definitions());
source.push_str("\n\n");
// Add theme helper functions
source.push_str("// Theme Helpers\n");
source.push_str(include_str!("../../templates/theme_helpers.typ"));
source.push_str("\n\n");
// Inject metadata as Typst variables (needed by header/footer)
source.push_str("// Report Metadata\n");
let title_str = request.title.as_deref().unwrap_or("");
let date_str = request
.metadata
.get("date")
.map(|s| s.as_str())
.unwrap_or("");
let author_str = request
.metadata
.get("author")
.map(|s| s.as_str())
.unwrap_or("");
let footer_link_url = request
.metadata
.get("footer_link_url")
.map(|s| s.as_str())
.unwrap_or("");
let footer_prefix = request
.metadata
.get("footer_prefix")
.map(|s| s.as_str())
.unwrap_or("");
let footer_tagline = request
.metadata
.get("footer_tagline")
.map(|s| s.as_str())
.unwrap_or("");
source.push_str(&format!(
"#let report-title = \"{}\"\n#let report-date = \"{}\"\n#let report-author = \"{}\"\n#let report-footer-link-url = \"{}\"\n#let report-footer-prefix = \"{}\"\n#let report-footer-tagline = \"{}\"\n\n",
escape_for_typst_string(title_str),
escape_for_typst_string(date_str),
escape_for_typst_string(author_str),
escape_for_typst_string(footer_link_url),
escape_for_typst_string(footer_prefix),
escape_for_typst_string(footer_tagline),
));
// Add page setup
source.push_str(&self.generate_page_setup(theme, &request.page_setup));
source.push_str("\n\n");
// Add component functions — only those actually used in this request.
// When flow-group or grid-component is present their dispatchers
// reference every component function, so we fall back to including all.
source.push_str("// Component Functions\n");
let used_types = collect_used_component_types(&request.components);
let needs_all = used_types.contains("flow-group") || used_types.contains("grid-component");
for component_id in self.components.list_components() {
if needs_all || used_types.contains(component_id.0.as_str()) {
if let Some(template) = self.components.get_template(component_id) {
source.push_str(template);
source.push_str("\n\n");
}
}
}
// Add report content
source.push_str("// Report Content\n");
source.push_str(&self.generate_content(request)?);
Ok(source)
}
/// Generate page setup Typst code
fn generate_page_setup(&self, _theme: &Theme, page_setup: &crate::render::PageSetup) -> String {
let paper = page_setup.paper.as_deref().unwrap_or("a4");
let flipped = page_setup.orientation.as_deref() == Some("landscape");
let margin_block = if let Some(m) = &page_setup.margin {
format!(" margin: {},\n", m)
} else {
" margin: (\n top: page-margin-top,\n bottom: page-margin-bottom,\n left: page-margin,\n right: page-margin,\n ),\n".to_string()
};
let header_block = if page_setup.show_header {
r#" header: context {
if counter(page).get().first() > 1 [
#set text(size: font-size-xs, fill: color-text-muted)
#grid(
columns: (1fr, auto),
gutter: spacing-3,
[#report-title],
[#report-date]
)
#v(4pt)
#line(length: 100%, stroke: (paint: color-border, thickness: 0.7pt))
]
},
"#
} else {
""
};
let footer_block = if page_setup.show_footer {
r#" footer: context {
if counter(page).get().first() > 1 [
#v(1pt)
#line(length: 100%, stroke: (paint: color-border, thickness: 0.5pt))
#v(3pt)
#grid(
columns: (1fr, auto, 1fr),
gutter: spacing-3,
[#text(size: font-size-xs, fill: color-text-muted)[
#if report-footer-prefix != "" { report-footer-prefix + " " }
#if report-footer-link-url != "" {
link(report-footer-link-url)[#text(weight: "semibold", fill: color-text-muted)[#report-author]]
} else {
text(weight: "semibold")[#report-author]
}
]],
[#text(size: font-size-xs, fill: color-text-muted)[#counter(page).display("1 / 1", both: true)]],
align(right)[#text(size: font-size-xs, fill: color-text-muted)[#report-footer-tagline]]
)
]
},
"#
} else {
""
};
format!(
"#set page(\n paper: \"{}\",{}\n fill: color-background,\n{}{}{})\n\n#set text(\n font: (font-body, \"Arial\", \"Liberation Sans\", \"Noto Sans\"),\n size: font-size-base,\n fill: color-text,\n)\n\n#set text(hyphenate: true)\n#set par(justify: false, leading: 0.75em)\n\n#set heading(numbering: none)\n#show heading: set par(justify: false)\n#show heading.where(level: 1): set text(size: font-size-2xl, weight: \"bold\", fill: color-text)\n#show heading.where(level: 2): set text(size: font-size-xl, weight: \"bold\", fill: color-text)\n#show heading.where(level: 3): set text(size: font-size-lg, weight: \"bold\", fill: color-text)\n#show heading.where(level: 4): set text(size: font-size-base, weight: \"bold\", fill: color-text-muted)\n",
paper,
if flipped { "\n flipped: true," } else { "" },
margin_block,
header_block,
footer_block,
)
}
/// Generate report content
fn generate_content(&self, request: &RenderRequest) -> Result<String> {
let mut content = String::new();
// Title page
let has_title = request.title.as_ref().is_some_and(|t| !t.is_empty());
if has_title {
let title = request.title.as_deref().unwrap_or("");
let subtitle = request.subtitle.as_deref().unwrap_or("");
let author = request
.metadata
.get("author")
.map(|s| s.as_str())
.unwrap_or("");
let date = request
.metadata
.get("date")
.map(|s| s.as_str())
.unwrap_or("");
content.push_str(&format!(
r#"#block(width: 100%, height: 100%, breakable: false)[
#v(1fr)
#block(width: 60pt, height: 4pt, fill: color-primary, radius: 2pt)
#v(spacing-5)
#block(width: 100%)[#set par(leading: 0.4em); #text(size: 36pt, weight: "bold", fill: color-text, tracking: -0.02em)[{title}]]
#v(spacing-3)
#text(size: 18pt, fill: color-text-muted)[{subtitle}]
#v(1fr)
#line(length: 100%, stroke: 0.5pt + color-border)
#v(spacing-3)
#text(size: font-size-xs, fill: color-text-muted)[{author}#h(1fr){date}]
]
#pagebreak()
"#,
title = escape_for_typst_markup(title),
subtitle = escape_for_typst_markup(subtitle),
author = escape_for_typst_markup(author),
date = escape_for_typst_markup(date),
));
}
// Components
let components = &request.components;
let mut i = 0;
while i < components.len() {
let component = &components[i];
let component_type = component
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
use crate::components::catalog::ComponentCatalog;
let layout_hint = ComponentCatalog::get(component_type)
.map(|d| d.layout_hint)
.unwrap_or(LayoutHint::KeepTogether);
// AlwaysNewPage: emit a page break before the component.
if layout_hint == LayoutHint::AlwaysNewPage {
content.push_str("#pagebreak()\n\n");
}
// KeepWithNext: wrap this component and the immediately following
// non-structural sibling in a keep-together block.
if layout_hint == LayoutHint::KeepWithNext {
let next = components.get(i + 1);
let next_type = next
.and_then(|n| n.get("type"))
.and_then(|v| v.as_str())
.unwrap_or("");
let next_hint = ComponentCatalog::get(next_type)
.map(|d| d.layout_hint)
.unwrap_or(LayoutHint::KeepTogether);
// A `Breakable` next component (e.g. a long audit table) must be
// allowed to span pages. Pairing it into a `breakable: false`
// keep-together block traps it and makes it overflow the page
// instead of breaking, overwriting content below (#366). So a
// heading is only kept together with a non-breakable sibling.
let next_is_content = !next_type.is_empty()
&& !matches!(
next_hint,
LayoutHint::AlwaysNewPage
| LayoutHint::KeepWithNext
| LayoutHint::Breakable
);
if next_is_content {
let next = next.expect("next_type non-empty implies next is Some");
let cur_data = component
.get("data")
.cloned()
.unwrap_or_else(|| component.clone());
let next_data = next.get("data").cloned().unwrap_or_else(|| next.clone());
let cur_fn = component_function_name(component_type);
let next_fn = component_function_name(next_type);
let cur_lit = to_typst_dict(&cur_data);
let next_lit = to_typst_dict(&next_data);
content.push_str(&format!(
"#block(breakable: false)[\n #{cur_fn}({cur_lit})\n #v(spacing-3)\n #{next_fn}({next_lit})\n]\n\n#v(spacing-4)\n\n"
));
i += 2;
continue;
}
}
let data = component
.get("data")
.cloned()
.unwrap_or_else(|| component.clone());
// Generate component call
let fn_name = component_function_name(component_type);
let data_lit = to_typst_dict(&data);
// Layout / structural components manage their own spacing.
let is_structural = matches!(
layout_hint,
LayoutHint::AlwaysNewPage | LayoutHint::KeepWithNext
) || component_type == "page-break";
if is_structural {
content.push_str(&format!("#{}({})\n\n", fn_name, data_lit));
} else {
content.push_str(&format!(
"#{}({})\n\n#v(spacing-4)\n\n",
fn_name, data_lit
));
}
i += 1;
}
Ok(content)
}
/// Compile Typst source to PDF
fn compile_typst(&self, source: &str, request: &RenderRequest) -> Result<Vec<u8>> {
use crate::render::typst_compile;
typst_compile::compile_to_pdf(source, &self.font_cache, request)
}
}
/// Convert a JSON value to a Typst literal expression.
///
/// - `null` → `none`
/// - booleans / numbers → their Typst equivalents
/// - strings → `"..."` with proper escape sequences
/// - arrays → `(item, ...)` with trailing comma for single-element arrays
/// - objects → `(key: val, ...)` with string-quoted keys for non-identifiers
pub fn to_typst_dict(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "none".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
out
}
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(to_typst_dict).collect();
match items.len() {
0 => "()".to_string(),
// Single-element: needs trailing comma to be an array, not parenthesized value
1 => format!("({},)", items[0]),
_ => format!("({})", items.join(", ")),
}
}
serde_json::Value::Object(obj) => {
if obj.is_empty() {
return "(:)".to_string();
}
let entries: Vec<String> = obj
.iter()
.map(|(k, v)| {
if is_typst_ident(k) {
format!("{}: {}", k, to_typst_dict(v))
} else {
let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\": {}", escaped_key, to_typst_dict(v))
}
})
.collect();
format!("({})", entries.join(", "))
}
}
}
/// Returns true if `s` is a valid Typst identifier.
///
/// Typst identifiers start with a letter or underscore and continue with
/// letters, digits, underscores, or hyphens.
fn is_typst_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
_ => false,
}
}
/// Escape a string for embedding inside a Typst string literal `"..."`.
fn escape_for_typst_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}
/// Escape a string for embedding inside a Typst content block `[...]`.
fn escape_for_typst_markup(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'#' | '[' | ']' | '\\' => {
out.push('\\');
out.push(ch);
}
c => out.push(c),
}
}
out
}
/// Collect all component type IDs referenced anywhere in a component list,
/// including deeply nested items inside flow-group and grid-component.
fn collect_used_component_types(
components: &[serde_json::Value],
) -> std::collections::HashSet<String> {
let mut used = std::collections::HashSet::new();
for c in components {
collect_types_in_value(c, &mut used);
}
used
}
fn collect_types_in_value(v: &serde_json::Value, used: &mut std::collections::HashSet<String>) {
match v {
serde_json::Value::Object(obj) => {
if let Some(t) = obj.get("type").and_then(|t| t.as_str()) {
used.insert(t.to_string());
}
for val in obj.values() {
collect_types_in_value(val, used);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
collect_types_in_value(item, used);
}
}
_ => {}
}
}
fn component_function_name(component_type: &str) -> &str {
match component_type {
// Avoid collision with Typst's built-in `image(...)`.
"image" => "report-image",
_ => component_type,
}
}
impl Default for Engine {
fn default() -> Self {
Self::new().expect("Failed to create default engine")
}
}
impl std::fmt::Debug for Engine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Engine")
.field("config", &self.config)
.field("packs", &self.packs.keys().collect::<Vec<_>>())
.field("default_theme", &self.default_theme.id)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_to_typst_dict_primitives() {
assert_eq!(to_typst_dict(&json!(null)), "none");
assert_eq!(to_typst_dict(&json!(true)), "true");
assert_eq!(to_typst_dict(&json!(false)), "false");
assert_eq!(to_typst_dict(&json!(42)), "42");
assert_eq!(to_typst_dict(&json!(3.14)), "3.14");
assert_eq!(to_typst_dict(&json!("hello")), "\"hello\"");
}
#[test]
fn test_to_typst_dict_string_escaping() {
assert_eq!(to_typst_dict(&json!("a\"b")), r#""a\"b""#);
assert_eq!(to_typst_dict(&json!("a\nb")), "\"a\\nb\"");
assert_eq!(to_typst_dict(&json!("a\tb")), "\"a\\tb\"");
assert_eq!(to_typst_dict(&json!("a\\b")), "\"a\\\\b\"");
}
#[test]
fn test_to_typst_dict_arrays() {
assert_eq!(to_typst_dict(&json!([])), "()");
assert_eq!(to_typst_dict(&json!([1])), "(1,)");
assert_eq!(to_typst_dict(&json!([1, 2])), "(1, 2)");
}
#[test]
fn test_to_typst_dict_objects() {
assert_eq!(to_typst_dict(&json!({})), "(:)");
// Note: serde_json preserves insertion order for objects created via json!()
assert_eq!(to_typst_dict(&json!({"score": 85})), "(score: 85)");
}
#[test]
fn test_to_typst_dict_non_ident_key() {
assert_eq!(to_typst_dict(&json!({"2xl": "val"})), "(\"2xl\": \"val\")");
}
#[test]
fn test_is_typst_ident() {
assert!(is_typst_ident("score"));
assert!(is_typst_ident("max_score"));
assert!(is_typst_ident("chart-type"));
assert!(is_typst_ident("_private"));
assert!(!is_typst_ident("2xl"));
assert!(!is_typst_ident(""));
assert!(!is_typst_ident("has space"));
}
}