use std::sync::Arc;
use serde::{Deserialize, Serialize};
use typst::World;
use typst::WorldExt;
use typst_pdf::{PdfOptions, PdfStandards};
use crate::RenderFileSystem;
use crate::error::{CompilationError, ConfigError, PapermakeError, Result};
use crate::typst::PapermakeWorld;
#[derive(Debug, Serialize, Clone)]
pub struct RenderError {
pub message: String,
pub start: usize,
pub end: usize,
pub file: Option<String>,
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.file {
Some(file) => write!(f, "{}:{}-{}: {}", file, self.start, self.end, self.message),
None => write!(f, "{}:{}: {}", self.start, self.end, self.message),
}
}
}
#[derive(Debug, Serialize)]
pub struct RenderResult {
pub pdf: Option<Vec<u8>>,
pub errors: Vec<RenderError>,
pub success: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PdfStandard {
#[serde(rename = "1.7")]
V1_7,
#[serde(rename = "a-2b")]
A2b,
#[serde(rename = "a-3b")]
A3b,
}
impl From<PdfStandard> for typst_pdf::PdfStandard {
fn from(standard: PdfStandard) -> Self {
match standard {
PdfStandard::V1_7 => typst_pdf::PdfStandard::V_1_7,
PdfStandard::A2b => typst_pdf::PdfStandard::A_2b,
PdfStandard::A3b => typst_pdf::PdfStandard::A_3b,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub pdf_standards: Vec<PdfStandard>,
}
impl RenderOptions {
pub fn pdf_a3b() -> Self {
Self {
pdf_standards: vec![PdfStandard::A3b],
}
}
}
fn pdf_options(options: &RenderOptions) -> Result<PdfOptions<'static>> {
let standards: Vec<typst_pdf::PdfStandard> = options
.pdf_standards
.iter()
.copied()
.map(Into::into)
.collect();
let standards = PdfStandards::new(&standards).map_err(|e| {
PapermakeError::Config(ConfigError::InvalidConfig {
setting: "pdf_standards".to_string(),
reason: e.to_string(),
})
})?;
Ok(PdfOptions {
standards,
..Default::default()
})
}
pub fn render_template(
main_typ: String,
file_system: Arc<dyn RenderFileSystem>,
data: &serde_json::Value,
) -> Result<RenderResult> {
render_template_with_options(main_typ, file_system, data, &RenderOptions::default())
}
pub fn render_template_with_options(
main_typ: String,
file_system: Arc<dyn RenderFileSystem>,
data: &serde_json::Value,
options: &RenderOptions,
) -> Result<RenderResult> {
let pdf_opts = pdf_options(options)?;
let data_str = serde_json::to_string(&data)?;
let world = PapermakeWorld::with_file_system(main_typ, data_str, file_system);
let compile_result = typst::compile(&world);
let mut errors = Vec::new();
let mut pdf = None;
let mut success = false;
match compile_result.output {
Ok(document) => {
match typst_pdf::pdf(&document, &pdf_opts) {
Ok(pdf_bytes) => {
pdf = Some(pdf_bytes);
success = true;
}
Err(pdf_error) => {
errors.push(RenderError {
message: format!("PDF generation failed: {:?}", pdf_error),
start: 0,
end: 0,
file: None,
});
}
}
}
Err(diagnostics) => {
for diagnostic in diagnostics {
let span = diagnostic.span;
let mut render_error = RenderError {
message: diagnostic.message.to_string(),
start: 0,
end: 0,
file: None,
};
if let Some(id) = span.id() {
if let Ok(_source) = world.source(id) {
render_error.file = Some(format!("{:?}", id));
if let Some(range) = world.range(span) {
render_error.start = range.start;
render_error.end = range.end;
}
}
}
errors.push(render_error);
}
}
}
Ok(RenderResult {
pdf,
errors,
success,
})
}
pub fn render_template_with_cache(
main_typ: String,
file_system: Arc<dyn RenderFileSystem>,
data: serde_json::Value,
world_cache: Option<&mut PapermakeWorld>,
) -> Result<RenderResult> {
let data_str = serde_json::to_string(&data)?;
let world = match world_cache {
Some(cached_world) => {
cached_world.update_data(data_str).map_err(|e| {
PapermakeError::Compilation(CompilationError::DataInjection {
reason: format!("Failed to update cached world data: {}", e),
})
})?;
cached_world
}
None => {
return render_template(main_typ, file_system, &data);
}
};
let compile_result = typst::compile(world as &dyn World);
let mut errors = Vec::new();
let mut pdf = None;
let mut success = false;
match compile_result.output {
Ok(document) => match typst_pdf::pdf(&document, &PdfOptions::default()) {
Ok(pdf_bytes) => {
pdf = Some(pdf_bytes);
success = true;
}
Err(pdf_error) => {
errors.push(RenderError {
message: format!("PDF generation failed: {:?}", pdf_error),
start: 0,
end: 0,
file: None,
});
}
},
Err(diagnostics) => {
for diagnostic in diagnostics {
let span = diagnostic.span;
let mut render_error = RenderError {
message: diagnostic.message.to_string(),
start: 0,
end: 0,
file: None,
};
if let Some(id) = span.id() {
if let Ok(_source) = world.source(id) {
render_error.file = Some(format!("{:?}", id));
if let Some(range) = world.range(span) {
render_error.start = range.start;
render_error.end = range.end;
}
}
}
errors.push(render_error);
}
}
}
Ok(RenderResult {
pdf,
errors,
success,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::typst::InMemoryFileSystem;
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
fn render_with(options: &RenderOptions) -> RenderResult {
render_template_with_options(
"Hello #data.name!".to_string(),
Arc::new(InMemoryFileSystem::new()),
&serde_json::json!({ "name": "World" }),
options,
)
.unwrap()
}
#[test]
fn default_render_is_plain_pdf() {
let result = render_with(&RenderOptions::default());
assert!(result.success, "render failed: {:?}", result.errors);
let pdf = result.pdf.unwrap();
assert!(pdf.starts_with(b"%PDF"));
assert!(!contains(&pdf, b"pdfaid"));
}
#[test]
fn pdf_a3b_render_declares_conformance() {
let result = render_with(&RenderOptions::pdf_a3b());
assert!(
result.success,
"PDF/A-3b render failed: {:?}",
result.errors
);
let pdf = result.pdf.unwrap();
assert!(pdf.starts_with(b"%PDF"));
assert!(contains(&pdf, b"pdfaid"));
}
#[test]
fn conflicting_standards_are_rejected() {
let options = RenderOptions {
pdf_standards: vec![PdfStandard::A2b, PdfStandard::A3b],
};
let result = render_template_with_options(
"Hello".to_string(),
Arc::new(InMemoryFileSystem::new()),
&serde_json::json!({}),
&options,
);
assert!(matches!(result, Err(PapermakeError::Config(_))));
}
#[test]
fn pdf_standard_serde_uses_typst_cli_names() {
assert_eq!(
serde_json::to_string(&PdfStandard::A3b).unwrap(),
"\"a-3b\""
);
assert_eq!(
serde_json::from_str::<PdfStandard>("\"a-2b\"").unwrap(),
PdfStandard::A2b
);
assert_eq!(
serde_json::from_str::<PdfStandard>("\"1.7\"").unwrap(),
PdfStandard::V1_7
);
}
}