use crate::error::{Error, Result};
use crate::resolver::{normalize_file_path, EmbeddedResolver};
use crate::stats::EmbedStats;
use crate::util::decompress;
use include_dir::{Dir, File};
use std::collections::{BTreeSet, HashMap};
use std::sync::{Mutex, MutexGuard};
use typst::foundations::Dict;
use typst::layout::PagedDocument;
use typst_as_lib::{TypstAsLibError, TypstEngine};
pub struct Document {
templates: &'static Dir<'static>,
packages: &'static Dir<'static>,
fonts: &'static Dir<'static>,
entry: &'static str,
inputs: Mutex<Option<Dict>>,
runtime_files: Mutex<HashMap<String, Vec<u8>>>,
stats: EmbedStats,
compiled_cache: Mutex<Option<PagedDocument>>,
}
impl Document {
#[doc(hidden)]
pub fn __new(
templates: &'static Dir<'static>,
packages: &'static Dir<'static>,
fonts: &'static Dir<'static>,
entry: &'static str,
stats: EmbedStats,
) -> Self {
Self {
templates,
packages,
fonts,
entry,
inputs: Mutex::new(None),
runtime_files: Mutex::new(HashMap::new()),
stats,
compiled_cache: Mutex::new(None),
}
}
fn lock_inputs(&self) -> MutexGuard<'_, Option<Dict>> {
self.inputs.lock().expect("lock poisoned")
}
fn lock_runtime_files(&self) -> MutexGuard<'_, HashMap<String, Vec<u8>>> {
self.runtime_files.lock().expect("lock poisoned")
}
fn lock_cache(&self) -> MutexGuard<'_, Option<PagedDocument>> {
self.compiled_cache.lock().expect("lock poisoned")
}
pub fn with_inputs<T: Into<Dict>>(self, inputs: T) -> Self {
*self.lock_inputs() = Some(inputs.into());
*self.lock_cache() = None;
self
}
pub fn add_file(self, path: impl Into<String>, data: impl Into<Vec<u8>>) -> Result<Self> {
let raw = path.into();
let normalized = normalize_file_path(&raw);
if normalized.is_empty() {
return Err(Error::InvalidFilePath("path is empty".into()));
}
if normalized.starts_with('/') {
return Err(Error::InvalidFilePath(format!(
"absolute path not allowed: {normalized}"
)));
}
if normalized.split('/').any(|s| s == "..") {
return Err(Error::InvalidFilePath(format!(
"path with '..' not allowed: {normalized}"
)));
}
self.lock_runtime_files().insert(normalized, data.into());
*self.lock_cache() = None;
Ok(self)
}
pub fn has_file(&self, path: impl AsRef<str>) -> bool {
let normalized = normalize_file_path(path.as_ref());
if self.lock_runtime_files().contains_key(&normalized) {
return true;
}
if find_entry(self.templates, &normalized).is_some() {
return true;
}
false
}
pub fn select_pages(&self, pages: impl IntoIterator<Item = usize>) -> Pages<'_> {
Pages {
doc: self,
indices: pages.into_iter().collect(),
}
}
pub fn page_count(&self) -> Result<usize> {
self.with_compiled(|compiled| Ok(compiled.pages.len()))
}
pub fn stats(&self) -> &EmbedStats {
&self.stats
}
fn compile_cached(&self) -> Result<()> {
if self.lock_cache().is_some() {
return Ok(());
}
let main_file =
find_entry(self.templates, self.entry).ok_or(Error::EntryNotFound(self.entry))?;
let main_bytes = decompress(main_file.contents())?;
let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
let mut resolver = EmbeddedResolver::new(self.templates, self.packages);
for (path, data) in self.lock_runtime_files().iter() {
resolver.insert_runtime_file(path.clone(), data.clone());
}
let font_data: Vec<Vec<u8>> = self
.fonts
.files()
.map(|f| decompress(f.contents()).map_err(Error::from))
.collect::<Result<Vec<_>>>()?;
let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
let engine = TypstEngine::builder()
.main_file((self.entry, main_content))
.add_file_resolver(resolver)
.fonts(font_refs)
.build();
let inputs = self.lock_inputs().clone();
let warned_result = if let Some(inputs) = inputs {
engine.compile_with_input::<_, PagedDocument>(inputs)
} else {
engine.compile::<PagedDocument>()
};
let compiled = warned_result.output.map_err(|e| {
let msg = match e {
TypstAsLibError::TypstSource(diagnostics) => diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("\n"),
other => other.to_string(),
};
Error::Compilation(msg)
})?;
*self.lock_cache() = Some(compiled);
Ok(())
}
fn with_compiled<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&PagedDocument) -> Result<T>,
{
self.compile_cached()?;
let cache = self.lock_cache();
let compiled = cache
.as_ref()
.expect("compiled_cache must be Some after successful compile_cached()");
f(compiled)
}
#[cfg(feature = "pdf")]
#[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
pub fn to_pdf(&self) -> Result<Vec<u8>> {
self.render_pdf(None)
}
#[cfg(feature = "svg")]
#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
pub fn to_svg(&self) -> Result<Vec<String>> {
self.render_svg(None)
}
#[cfg(feature = "png")]
#[cfg_attr(docsrs, doc(cfg(feature = "png")))]
pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
self.render_png(None, dpi)
}
#[cfg(feature = "pdf")]
fn render_pdf(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<u8>> {
self.with_compiled(|compiled| {
let indices = validate_page_selection(selected, compiled.pages.len())?;
let options = match indices {
Some(indices) => {
use std::num::NonZeroUsize;
use typst::layout::PageRanges;
let ranges = indices
.iter()
.map(|&i| {
let n = Some(NonZeroUsize::new(i + 1).unwrap());
n..=n
})
.collect();
typst_pdf::PdfOptions {
page_ranges: Some(PageRanges::new(ranges)),
tagged: false,
..Default::default()
}
}
None => typst_pdf::PdfOptions::default(),
};
typst_pdf::pdf(compiled, &options).map_err(|e| Error::PdfGeneration(format!("{e:?}")))
})
}
#[cfg(feature = "svg")]
fn render_svg(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<String>> {
self.with_compiled(|compiled| {
let indices = validate_page_selection(selected, compiled.pages.len())?;
match indices {
Some(indices) => Ok(indices
.iter()
.map(|&i| typst_svg::svg(&compiled.pages[i]))
.collect()),
None => Ok(compiled.pages.iter().map(typst_svg::svg).collect()),
}
})
}
#[cfg(feature = "png")]
fn render_png(&self, selected: Option<&BTreeSet<usize>>, dpi: f32) -> Result<Vec<Vec<u8>>> {
self.with_compiled(|compiled| {
let pixel_per_pt = dpi / 72.0;
let indices = validate_page_selection(selected, compiled.pages.len())?;
let pages: Box<dyn Iterator<Item = &_>> = match &indices {
Some(indices) => Box::new(indices.iter().map(|&i| &compiled.pages[i])),
None => Box::new(compiled.pages.iter()),
};
pages
.map(|page| {
typst_render::render(page, pixel_per_pt)
.encode_png()
.map_err(|e| Error::PngEncoding(e.to_string()))
})
.collect()
})
}
}
pub struct Pages<'a> {
doc: &'a Document,
indices: BTreeSet<usize>,
}
impl Pages<'_> {
#[cfg(feature = "pdf")]
#[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
pub fn to_pdf(&self) -> Result<Vec<u8>> {
self.doc.render_pdf(Some(&self.indices))
}
#[cfg(feature = "svg")]
#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
pub fn to_svg(&self) -> Result<Vec<String>> {
self.doc.render_svg(Some(&self.indices))
}
#[cfg(feature = "png")]
#[cfg_attr(docsrs, doc(cfg(feature = "png")))]
pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
self.doc.render_png(Some(&self.indices), dpi)
}
}
fn validate_page_selection(
selected: Option<&BTreeSet<usize>>,
total_pages: usize,
) -> Result<Option<Vec<usize>>> {
if total_pages == 0 {
return Err(Error::InvalidPageSelection("document has no pages".into()));
}
match selected {
None => Ok(None),
Some(pages) => {
if pages.is_empty() {
return Err(Error::InvalidPageSelection(
"page selection is empty".into(),
));
}
if let Some(&max) = pages.last() {
if max >= total_pages {
return Err(Error::InvalidPageSelection(format!(
"page index {max} out of range (valid: 0..={})",
total_pages - 1
)));
}
}
Ok(Some(pages.iter().copied().collect()))
}
}
}
fn find_entry<'a>(dir: &'a Dir<'a>, path: &str) -> Option<&'a File<'a>> {
let normalized = path.trim_start_matches("./").replace('\\', "/");
let (dir_path, file_name) = match normalized.rsplit_once('/') {
Some((d, f)) => (Some(d), f),
None => (None, normalized.as_str()),
};
let target_dir = match dir_path {
Some(dir_path) => {
let mut current = dir;
for segment in dir_path.split('/') {
current = current
.dirs()
.find(|d| d.path().file_name().and_then(|n| n.to_str()) == Some(segment))?;
}
current
}
None => dir,
};
target_dir
.files()
.find(|f| f.path().file_name().and_then(|n| n.to_str()) == Some(file_name))
}