use crate::{DocumentPlan, Error, InlineModule, Result, typst_context::TypstContext};
use klirr_foundation::{FontRequiring, Pdf, TYPST_LAYOUT_FOUNDATION, ToTypstFn};
use log::debug;
use typst::layout::PagedDocument;
use typst_pdf::PdfOptions;
pub const TYPST_VIRTUAL_NAME_MAIN: &str = "main.typ";
pub const TYPST_VIRTUAL_NAME_LAYOUT: &str = "layout.typ";
pub const TYPST_VIRTUAL_NAME_DATA: &str = "data.typ";
pub const TYPST_VIRTUAL_NAME_L10N: &str = "l10n.typ";
pub const TYPST_FOUNDATION_NAME: &str = "foundation.typ";
pub const TYPST_FOUNDATION_CONTENT: &str = TYPST_LAYOUT_FOUNDATION;
fn render_document(plan: &DocumentPlan) -> Result<Pdf> {
debug!("☑️ Creating typst context");
let context = TypstContext::from_plan(plan)?;
debug!("☑️ Compiling typst...");
let compile_result = typst::compile::<PagedDocument>(&context);
let doc = compile_result.output.map_err(Error::build_pdf)?;
debug!("✅ Compiled typst source: #{} pages", doc.pages.len());
let pdf_bytes =
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(Error::export_document_to_pdf)?;
Ok(Pdf::from(pdf_bytes))
}
pub fn render<I: ToTypstFn, D: ToTypstFn, L: ToTypstFn + FontRequiring, E>(
i18n: I,
data: D,
layout: L,
map_render_error: impl Fn(Error) -> E,
) -> Result<Pdf, E> {
let l10n_typst_str = i18n.to_typst_fn();
let data_typst_str = data.to_typst_fn();
let layout_typst_str = layout.to_typst_fn();
let main = format!(
r#"
#import "{}": provide as provide_data
#import "{}": provide as provide_localization
#import "{}": render
#render(provide_data(), provide_localization())
"#,
TYPST_VIRTUAL_NAME_DATA, TYPST_VIRTUAL_NAME_L10N, TYPST_VIRTUAL_NAME_LAYOUT
);
let plan = DocumentPlan::new(
layout.required_fonts(),
InlineModule::new(TYPST_VIRTUAL_NAME_MAIN, main),
)
.with_modules(vec![
InlineModule::new(TYPST_FOUNDATION_NAME, TYPST_FOUNDATION_CONTENT),
InlineModule::new(TYPST_VIRTUAL_NAME_LAYOUT, layout_typst_str),
InlineModule::new(TYPST_VIRTUAL_NAME_L10N, l10n_typst_str),
InlineModule::new(TYPST_VIRTUAL_NAME_DATA, data_typst_str),
]);
render_document(&plan).map_err(map_render_error)
}
#[cfg(test)]
mod tests {
use crate::{DocumentPlan, render::render_document, render_test_helpers::*};
use klirr_core_invoice::{
Currency, Data, Date, ExchangeRatesMap, HasSample, InvoicedItems, Language, UnitPrice,
ValidInput,
};
use klirr_foundation::{FontIdentifier, FontWeight};
use test_log::test;
#[test]
fn renders_simple_document() {
let plan = DocumentPlan::new(
[FontIdentifier::ComputerModern(FontWeight::Regular)],
crate::module::InlineModule::new("main.typ", "#box(\"hello\")"),
);
assert!(render_document(&plan).is_ok());
}
#[test]
fn sample_expenses() {
if running_in_ci() {
return;
}
compare_image_against_expected(
Data::sample(),
ValidInput::builder()
.items(InvoicedItems::Expenses)
.date("2025-05-31".parse::<Date>().unwrap())
.language(Language::EN)
.build(),
fixture("expected_expenses.png"),
MockedExchangeRatesFetcher::from(ExchangeRatesMap::from_iter([
(Currency::EUR, UnitPrice::from(10)),
(Currency::SEK, UnitPrice::from(10)),
])),
);
}
#[test]
fn sample_services() {
if running_in_ci() {
return;
}
compare_image_against_expected(
Data::sample(),
ValidInput::builder()
.items(InvoicedItems::Service { time_off: None })
.date(Date::sample())
.language(Language::EN)
.build(),
fixture("expected_services.png"),
MockedExchangeRatesFetcher::default(),
);
}
}