1use crate::error::{Error, Result};
4use crate::resolver::{normalize_file_path, EmbeddedResolver};
5use crate::stats::EmbedStats;
6use crate::util::decompress;
7use include_dir::{Dir, File};
8use std::collections::{BTreeSet, HashMap};
9use std::sync::{Mutex, MutexGuard};
10use typst::foundations::Dict;
11use typst::layout::PagedDocument;
12use typst_as_lib::{TypstAsLibError, TypstEngine};
13
14pub struct Document {
19 templates: &'static Dir<'static>,
20 packages: &'static Dir<'static>,
21 fonts: &'static Dir<'static>,
22 entry: &'static str,
23 inputs: Mutex<Option<Dict>>,
24 runtime_files: Mutex<HashMap<String, Vec<u8>>>,
25 stats: EmbedStats,
26 compiled_cache: Mutex<Option<PagedDocument>>,
27}
28
29impl Document {
30 #[doc(hidden)]
33 pub fn __new(
34 templates: &'static Dir<'static>,
35 packages: &'static Dir<'static>,
36 fonts: &'static Dir<'static>,
37 entry: &'static str,
38 stats: EmbedStats,
39 ) -> Self {
40 Self {
41 templates,
42 packages,
43 fonts,
44 entry,
45 inputs: Mutex::new(None),
46 runtime_files: Mutex::new(HashMap::new()),
47 stats,
48 compiled_cache: Mutex::new(None),
49 }
50 }
51
52 fn lock_inputs(&self) -> MutexGuard<'_, Option<Dict>> {
53 self.inputs.lock().expect("lock poisoned")
54 }
55
56 fn lock_runtime_files(&self) -> MutexGuard<'_, HashMap<String, Vec<u8>>> {
57 self.runtime_files.lock().expect("lock poisoned")
58 }
59
60 fn lock_cache(&self) -> MutexGuard<'_, Option<PagedDocument>> {
61 self.compiled_cache.lock().expect("lock poisoned")
62 }
63
64 pub fn with_inputs<T: Into<Dict>>(self, inputs: T) -> Self {
105 *self.lock_inputs() = Some(inputs.into());
106 *self.lock_cache() = None;
107 self
108 }
109
110 pub fn add_file(self, path: impl Into<String>, data: impl Into<Vec<u8>>) -> Result<Self> {
127 let raw = path.into();
128 let normalized = normalize_file_path(&raw);
129
130 if normalized.is_empty() {
131 return Err(Error::InvalidFilePath("path is empty".into()));
132 }
133 if normalized.starts_with('/') {
134 return Err(Error::InvalidFilePath(format!(
135 "absolute path not allowed: {normalized}"
136 )));
137 }
138 if normalized.split('/').any(|s| s == "..") {
139 return Err(Error::InvalidFilePath(format!(
140 "path with '..' not allowed: {normalized}"
141 )));
142 }
143
144 self.lock_runtime_files().insert(normalized, data.into());
145 *self.lock_cache() = None;
146 Ok(self)
147 }
148
149 pub fn has_file(&self, path: impl AsRef<str>) -> bool {
153 let normalized = normalize_file_path(path.as_ref());
154
155 if self.lock_runtime_files().contains_key(&normalized) {
157 return true;
158 }
159
160 if find_entry(self.templates, &normalized).is_some() {
162 return true;
163 }
164
165 false
166 }
167
168 pub fn select_pages(&self, pages: impl IntoIterator<Item = usize>) -> Pages<'_> {
195 Pages {
196 doc: self,
197 indices: pages.into_iter().collect(),
198 }
199 }
200
201 pub fn page_count(&self) -> Result<usize> {
213 self.with_compiled(|compiled| Ok(compiled.pages.len()))
214 }
215
216 pub fn stats(&self) -> &EmbedStats {
218 &self.stats
219 }
220
221 fn compile_cached(&self) -> Result<()> {
223 if self.lock_cache().is_some() {
224 return Ok(());
225 }
226
227 let main_file =
229 find_entry(self.templates, self.entry).ok_or(Error::EntryNotFound(self.entry))?;
230
231 let main_bytes = decompress(main_file.contents())?;
232 let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
233
234 let mut resolver = EmbeddedResolver::new(self.templates, self.packages);
235 for (path, data) in self.lock_runtime_files().iter() {
236 resolver.insert_runtime_file(path.clone(), data.clone());
237 }
238
239 let font_data: Vec<Vec<u8>> = self
241 .fonts
242 .files()
243 .map(|f| decompress(f.contents()).map_err(Error::from))
244 .collect::<Result<Vec<_>>>()?;
245
246 let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
247
248 let engine = TypstEngine::builder()
249 .main_file((self.entry, main_content))
250 .add_file_resolver(resolver)
251 .fonts(font_refs)
252 .build();
253
254 let inputs = self.lock_inputs().clone();
256
257 let warned_result = if let Some(inputs) = inputs {
258 engine.compile_with_input::<_, PagedDocument>(inputs)
259 } else {
260 engine.compile::<PagedDocument>()
261 };
262
263 let compiled = warned_result.output.map_err(|e| {
265 let msg = match e {
266 TypstAsLibError::TypstSource(diagnostics) => diagnostics
267 .iter()
268 .map(|d| d.message.as_str())
269 .collect::<Vec<_>>()
270 .join("\n"),
271 other => other.to_string(),
272 };
273 Error::Compilation(msg)
274 })?;
275
276 *self.lock_cache() = Some(compiled);
277
278 Ok(())
279 }
280
281 fn with_compiled<F, T>(&self, f: F) -> Result<T>
283 where
284 F: FnOnce(&PagedDocument) -> Result<T>,
285 {
286 self.compile_cached()?;
287 let cache = self.lock_cache();
288 let compiled = cache
289 .as_ref()
290 .expect("compiled_cache must be Some after successful compile_cached()");
291 f(compiled)
292 }
293
294 #[cfg(feature = "pdf")]
302 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
303 pub fn to_pdf(&self) -> Result<Vec<u8>> {
304 self.render_pdf(None)
305 }
306
307 #[cfg(feature = "svg")]
315 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
316 pub fn to_svg(&self) -> Result<Vec<String>> {
317 self.render_svg(None)
318 }
319
320 #[cfg(feature = "png")]
331 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
332 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
333 self.render_png(None, dpi)
334 }
335
336 #[cfg(feature = "pdf")]
337 fn render_pdf(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<u8>> {
338 self.with_compiled(|compiled| {
339 let indices = validate_page_selection(selected, compiled.pages.len())?;
340 let options = match indices {
341 Some(indices) => {
342 use std::num::NonZeroUsize;
343 use typst::layout::PageRanges;
344
345 let ranges = indices
346 .iter()
347 .map(|&i| {
348 let n = Some(NonZeroUsize::new(i + 1).unwrap());
349 n..=n
350 })
351 .collect();
352 typst_pdf::PdfOptions {
353 page_ranges: Some(PageRanges::new(ranges)),
354 tagged: false,
357 ..Default::default()
358 }
359 }
360 None => typst_pdf::PdfOptions::default(),
361 };
362 typst_pdf::pdf(compiled, &options).map_err(|e| Error::PdfGeneration(format!("{e:?}")))
363 })
364 }
365
366 #[cfg(feature = "svg")]
367 fn render_svg(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<String>> {
368 self.with_compiled(|compiled| {
369 let indices = validate_page_selection(selected, compiled.pages.len())?;
370 match indices {
371 Some(indices) => Ok(indices
372 .iter()
373 .map(|&i| typst_svg::svg(&compiled.pages[i]))
374 .collect()),
375 None => Ok(compiled.pages.iter().map(typst_svg::svg).collect()),
376 }
377 })
378 }
379
380 #[cfg(feature = "png")]
381 fn render_png(&self, selected: Option<&BTreeSet<usize>>, dpi: f32) -> Result<Vec<Vec<u8>>> {
382 self.with_compiled(|compiled| {
383 let pixel_per_pt = dpi / 72.0;
384 let indices = validate_page_selection(selected, compiled.pages.len())?;
385 let pages: Box<dyn Iterator<Item = &_>> = match &indices {
386 Some(indices) => Box::new(indices.iter().map(|&i| &compiled.pages[i])),
387 None => Box::new(compiled.pages.iter()),
388 };
389 pages
390 .map(|page| {
391 typst_render::render(page, pixel_per_pt)
392 .encode_png()
393 .map_err(|e| Error::PngEncoding(e.to_string()))
394 })
395 .collect()
396 })
397 }
398}
399
400pub struct Pages<'a> {
405 doc: &'a Document,
406 indices: BTreeSet<usize>,
407}
408
409impl Pages<'_> {
410 #[cfg(feature = "pdf")]
415 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
416 pub fn to_pdf(&self) -> Result<Vec<u8>> {
417 self.doc.render_pdf(Some(&self.indices))
418 }
419
420 #[cfg(feature = "svg")]
425 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
426 pub fn to_svg(&self) -> Result<Vec<String>> {
427 self.doc.render_svg(Some(&self.indices))
428 }
429
430 #[cfg(feature = "png")]
438 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
439 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
440 self.doc.render_png(Some(&self.indices), dpi)
441 }
442}
443
444fn validate_page_selection(
447 selected: Option<&BTreeSet<usize>>,
448 total_pages: usize,
449) -> Result<Option<Vec<usize>>> {
450 if total_pages == 0 {
451 return Err(Error::InvalidPageSelection("document has no pages".into()));
452 }
453 match selected {
454 None => Ok(None),
455 Some(pages) => {
456 if pages.is_empty() {
457 return Err(Error::InvalidPageSelection(
458 "page selection is empty".into(),
459 ));
460 }
461 if let Some(&max) = pages.last() {
462 if max >= total_pages {
463 return Err(Error::InvalidPageSelection(format!(
464 "page index {max} out of range (valid: 0..={})",
465 total_pages - 1
466 )));
467 }
468 }
469 Ok(Some(pages.iter().copied().collect()))
470 }
471 }
472}
473
474fn find_entry<'a>(dir: &'a Dir<'a>, path: &str) -> Option<&'a File<'a>> {
476 let normalized = path.trim_start_matches("./").replace('\\', "/");
477 let (dir_path, file_name) = match normalized.rsplit_once('/') {
478 Some((d, f)) => (Some(d), f),
479 None => (None, normalized.as_str()),
480 };
481
482 let target_dir = match dir_path {
483 Some(dir_path) => {
484 let mut current = dir;
485 for segment in dir_path.split('/') {
486 current = current
487 .dirs()
488 .find(|d| d.path().file_name().and_then(|n| n.to_str()) == Some(segment))?;
489 }
490 current
491 }
492 None => dir,
493 };
494
495 target_dir
496 .files()
497 .find(|f| f.path().file_name().and_then(|n| n.to_str()) == Some(file_name))
498}