use std::collections::HashMap;
use std::path::Path;
use std::sync::OnceLock;
use typst::foundations::{Bytes, Datetime, Dict};
use typst::layout::PagedDocument;
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use std::time::Duration;
use crate::error::AppShotsError;
pub(crate) const COMPILE_TIMEOUT: Duration = Duration::from_secs(30);
fn bundled_fonts() -> &'static [Font] {
static FONTS: OnceLock<Vec<Font>> = OnceLock::new();
FONTS.get_or_init(|| {
let mut fonts = Vec::new();
for data in typst_assets::fonts() {
let bytes = Bytes::new(data);
for font in Font::iter(bytes) {
fonts.push(font);
}
}
fonts
})
}
pub(crate) struct AppWorld {
library: LazyHash<Library>,
book: LazyHash<FontBook>,
fonts: Vec<Font>,
main_id: FileId,
sources: HashMap<FileId, Source>,
files: HashMap<FileId, Bytes>,
}
impl AppWorld {
pub(crate) fn new(
template_source: &str,
inputs: Dict,
extra_fonts: Vec<Vec<u8>>,
files: HashMap<String, Vec<u8>>,
) -> Self {
let mut fonts: Vec<Font> = bundled_fonts().to_vec();
for font_data in extra_fonts {
let bytes = Bytes::new(font_data);
for font in Font::iter(bytes) {
fonts.push(font);
}
}
let book = LazyHash::new(FontBook::from_fonts(&fonts));
let library = LazyHash::new(Library::builder().with_inputs(inputs).build());
let main_id = FileId::new(None, VirtualPath::new(Path::new("/main.typ")));
let source = Source::new(main_id, template_source.to_owned());
let mut sources = HashMap::with_capacity(1);
sources.insert(main_id, source);
let files = files
.into_iter()
.map(|(path, data)| {
let id = FileId::new(None, VirtualPath::new(Path::new(&path)));
(id, Bytes::new(data))
})
.collect();
Self {
library,
book,
fonts,
main_id,
sources,
files,
}
}
}
impl World for AppWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
&self.book
}
fn main(&self) -> FileId {
self.main_id
}
fn source(&self, id: FileId) -> typst::diag::FileResult<Source> {
self.sources
.get(&id)
.cloned()
.ok_or(typst::diag::FileError::NotFound(
id.vpath().as_rootless_path().into(),
))
}
fn file(&self, id: FileId) -> typst::diag::FileResult<Bytes> {
self.files
.get(&id)
.cloned()
.ok_or(typst::diag::FileError::NotFound(
id.vpath().as_rootless_path().into(),
))
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.get(index).cloned()
}
fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
Datetime::from_ymd(2026, 1, 1)
}
}
pub(crate) fn compile_template(
world: &AppWorld,
) -> Result<(PagedDocument, Vec<String>), AppShotsError> {
let warned = typst::compile::<PagedDocument>(world);
let warnings: Vec<String> = warned
.warnings
.iter()
.map(|w| w.message.to_string())
.collect();
match warned.output {
Ok(doc) => Ok((doc, warnings)),
Err(errors) => {
let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
Err(AppShotsError::TemplateCompileError(messages.join("; ")))
}
}
}
pub(crate) async fn compile_template_with_timeout(
world: AppWorld,
timeout: Duration,
) -> Result<(PagedDocument, Vec<String>), AppShotsError> {
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let result = compile_template(&world);
let _ = tx.send(result);
});
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(AppShotsError::TemplateCompileError(
"compilation thread panicked".into(),
)),
Err(_) => Err(AppShotsError::TemplateCompileError(format!(
"compilation timed out after {}s",
timeout.as_secs()
))),
}
}
#[cfg(test)]
mod tests {
use typst::foundations::{IntoValue, Str};
use super::*;
fn empty_inputs() -> Dict {
Dict::new()
}
#[test]
fn world_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AppWorld>();
}
#[test]
fn source_returns_template() {
let world = AppWorld::new("Hello, world!", empty_inputs(), vec![], HashMap::new());
let source = world.source(world.main());
assert!(source.is_ok());
assert_eq!(
source.as_ref().map(|s| s.text()).ok(),
Some("Hello, world!")
);
}
#[test]
fn source_not_found_for_unknown_id() {
let world = AppWorld::new("test", empty_inputs(), vec![], HashMap::new());
let unknown_id = FileId::new(None, VirtualPath::new(Path::new("/unknown.typ")));
assert!(world.source(unknown_id).is_err());
}
#[test]
fn font_returns_bundled_fonts() {
let world = AppWorld::new("test", empty_inputs(), vec![], HashMap::new());
assert!(world.font(0).is_some());
}
#[test]
fn file_returns_mapped_content() {
let mut files = HashMap::new();
files.insert("/screenshot.png".to_owned(), vec![0x89, 0x50, 0x4E, 0x47]);
let world = AppWorld::new("test", empty_inputs(), vec![], files);
let file_id = FileId::new(None, VirtualPath::new(Path::new("/screenshot.png")));
let result = world.file(file_id);
assert!(result.is_ok());
assert_eq!(result.as_ref().map(|b| b.len()).ok(), Some(4));
}
#[test]
fn file_not_found_for_unknown_path() {
let world = AppWorld::new("test", empty_inputs(), vec![], HashMap::new());
let unknown_id = FileId::new(None, VirtualPath::new(Path::new("/missing.png")));
assert!(world.file(unknown_id).is_err());
}
#[test]
fn compile_simple_template() {
let world = AppWorld::new("Hello, world!", empty_inputs(), vec![], HashMap::new());
let result = compile_template(&world);
assert!(result.is_ok());
let (doc, _warnings) = result.as_ref().ok().map(|(d, w)| (d, w)).unwrap();
assert!(!doc.pages.is_empty());
}
#[test]
fn compile_with_inputs() {
let mut inputs = Dict::new();
inputs.insert("title".into(), Str::from("Test Title").into_value());
let source = r#"#sys.inputs.title"#;
let world = AppWorld::new(source, inputs, vec![], HashMap::new());
let result = compile_template(&world);
assert!(result.is_ok());
}
#[test]
fn compile_error_returns_template_compile_error() {
let world = AppWorld::new("#invalid-syntax(", empty_inputs(), vec![], HashMap::new());
let result = compile_template(&world);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, AppShotsError::TemplateCompileError(_)));
}
#[tokio::test]
async fn compile_with_timeout_succeeds() {
let world = AppWorld::new("Hello, world!", empty_inputs(), vec![], HashMap::new());
let result = compile_template_with_timeout(world, Duration::from_secs(10)).await;
assert!(result.is_ok());
let (doc, _warnings) = result.unwrap();
assert!(!doc.pages.is_empty());
}
#[tokio::test]
async fn compile_with_timeout_returns_error_on_invalid_template() {
let world = AppWorld::new("#invalid-syntax(", empty_inputs(), vec![], HashMap::new());
let result = compile_template_with_timeout(world, Duration::from_secs(10)).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AppShotsError::TemplateCompileError(_)
));
}
}