mod builder;
mod config;
pub mod world;
pub use builder::ReportBuilder;
pub use config::EngineConfig;
pub use world::ReportWorld;
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;
pub struct Engine {
config: EngineConfig,
packs: HashMap<PackId, Arc<Pack>>,
components: ComponentRegistry,
default_theme: Theme,
pack_loader: PackLoader,
}
impl Engine {
pub fn new() -> Result<Self> {
Self::with_config(EngineConfig::default())
}
pub fn with_config(config: EngineConfig) -> Result<Self> {
let components = ComponentRegistry::with_standard_components();
let pack_loader = PackLoader::new(&config.pack_paths);
Ok(Self {
config,
packs: HashMap::new(),
components,
default_theme: Theme::default_theme(),
pack_loader,
})
}
pub fn set_default_theme(&mut self, theme: Theme) {
self.default_theme = theme;
}
pub fn default_theme(&self) -> &Theme {
&self.default_theme
}
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(())
}
pub fn register_pack(&mut self, pack: Pack) {
self.packs.insert(pack.id.clone(), Arc::new(pack));
}
pub fn get_pack(&self, pack_id: &PackId) -> Option<&Arc<Pack>> {
self.packs.get(pack_id)
}
pub fn loaded_packs(&self) -> Vec<&PackId> {
self.packs.keys().collect()
}
pub fn components(&self) -> &ComponentRegistry {
&self.components
}
pub fn components_mut(&mut self) -> &mut ComponentRegistry {
&mut self.components
}
pub fn report(&self, template_id: impl Into<String>) -> ReportBuilder {
ReportBuilder::new(template_id)
}
pub fn render_pdf(&self, request: &RenderRequest) -> Result<Vec<u8>> {
self.render(request).map(|output| output.into_bytes())
}
pub fn render(&self, request: &RenderRequest) -> Result<RenderOutput> {
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);
}
let typst_source = self.generate_typst_source(request, &theme)?;
let pdf_bytes = self.compile_typst(&typst_source, request)?;
Ok(RenderOutput::Pdf(pdf_bytes))
}
fn generate_typst_source(&self, request: &RenderRequest, theme: &Theme) -> Result<String> {
let mut source = String::new();
source.push_str("// Theme Tokens\n");
source.push_str(&theme.tokens.to_typst_definitions());
source.push_str("\n\n");
source.push_str("// Theme Helpers\n");
source.push_str(include_str!("../../templates/theme_helpers.typ"));
source.push_str("\n\n");
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("");
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\n",
title_str.replace('"', "\\\""),
date_str.replace('"', "\\\""),
author_str.replace('"', "\\\""),
footer_link_url.replace('"', "\\\""),
footer_prefix.replace('"', "\\\""),
));
source.push_str(&self.generate_page_setup(theme));
source.push_str("\n\n");
source.push_str("// Component Functions\n");
for component_id in self.components.list_components() {
if let Some(template) = self.components.get_template(component_id) {
source.push_str(template);
source.push_str("\n\n");
}
}
source.push_str("// Report Content\n");
source.push_str(&self.generate_content(request)?);
Ok(source)
}
fn generate_page_setup(&self, _theme: &Theme) -> String {
r#"#set page(
paper: "a4",
fill: color-background,
margin: (
top: page-margin-top,
bottom: page-margin-bottom,
left: page-margin,
right: page-margin,
),
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))
]
},
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),
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)]]
)
]
},
)
#set text(
font: (font-body, "Arial", "Liberation Sans", "Noto Sans"),
size: font-size-base,
fill: color-text,
)
#set text(hyphenate: true)
#set par(justify: false, leading: 0.75em)
#set heading(numbering: none)
#show heading: set par(justify: false)
#show heading.where(level: 1): set text(size: font-size-2xl, weight: "bold", fill: color-text)
#show heading.where(level: 2): set text(size: font-size-xl, weight: "bold", fill: color-text)
#show heading.where(level: 3): set text(size: font-size-lg, weight: "bold", fill: color-text)
"#
.to_string()
}
fn generate_content(&self, request: &RenderRequest) -> Result<String> {
let mut content = String::new();
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 = title.replace('"', "\\\""),
subtitle = subtitle.replace('"', "\\\""),
author = author.replace('"', "\\\""),
date = date.replace('"', "\\\""),
));
}
for component in &request.components {
let component_type = component
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let data = component
.get("data")
.cloned()
.unwrap_or_else(|| component.clone());
let fn_name = component_function_name(component_type);
content.push_str(&format!(
"#{}(json.decode(\"{}\"))\n\n#v(spacing-4)\n\n",
fn_name,
serde_json::to_string(&data)
.unwrap_or_default()
.replace('\\', "\\\\")
.replace('"', "\\\"")
));
}
Ok(content)
}
fn compile_typst(&self, source: &str, request: &RenderRequest) -> Result<Vec<u8>> {
use crate::render::typst_compile;
typst_compile::compile_to_pdf(source, &self.config, request)
}
}
fn component_function_name(component_type: &str) -> &str {
match component_type {
"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()
}
}