1use crate::error::{Diagnostic, Error, Hint, Result, Severity, SourceLocation};
4#[cfg(feature = "pdf")]
5use crate::pdf_config::PdfConfig;
6use crate::resolver::{EmbeddedResolver, file_id_to_path, normalize_file_path};
7use crate::stats::EmbedStats;
8use crate::util::decompress;
9use include_dir::{Dir, File};
10use std::collections::{BTreeSet, HashMap};
11use std::sync::{Mutex, MutexGuard};
12use typst::diag::SourceDiagnostic;
13use typst::foundations::Dict;
14use typst::syntax::{DiagSpan, FileId};
15use typst::{World, WorldExt};
16use typst_as_lib::{TypstEngine, TypstWorld};
17use typst_layout::PagedDocument;
18
19struct Compiled {
23 doc: PagedDocument,
24 warnings: Vec<Diagnostic>,
25}
26
27pub struct Document {
32 templates: &'static Dir<'static>,
33 packages: &'static Dir<'static>,
34 fonts: &'static Dir<'static>,
35 entry: &'static str,
36 inputs: Mutex<Option<Dict>>,
37 runtime_files: Mutex<HashMap<String, Vec<u8>>>,
38 stats: EmbedStats,
39 compiled_cache: Mutex<Option<Compiled>>,
40 #[cfg(feature = "pdf")]
44 pdf_config: PdfConfig,
45}
46
47impl Document {
48 #[doc(hidden)]
51 pub fn __new(
52 templates: &'static Dir<'static>,
53 packages: &'static Dir<'static>,
54 fonts: &'static Dir<'static>,
55 entry: &'static str,
56 stats: EmbedStats,
57 ) -> Self {
58 Self {
59 templates,
60 packages,
61 fonts,
62 entry,
63 inputs: Mutex::new(None),
64 runtime_files: Mutex::new(HashMap::new()),
65 stats,
66 compiled_cache: Mutex::new(None),
67 #[cfg(feature = "pdf")]
68 pdf_config: PdfConfig::default(),
69 }
70 }
71
72 fn lock_inputs(&self) -> MutexGuard<'_, Option<Dict>> {
73 self.inputs.lock().expect("lock poisoned")
74 }
75
76 fn lock_runtime_files(&self) -> MutexGuard<'_, HashMap<String, Vec<u8>>> {
77 self.runtime_files.lock().expect("lock poisoned")
78 }
79
80 fn lock_cache(&self) -> MutexGuard<'_, Option<Compiled>> {
81 self.compiled_cache.lock().expect("lock poisoned")
82 }
83
84 pub fn with_inputs<T: Into<Dict>>(self, inputs: T) -> Self {
125 *self.lock_inputs() = Some(inputs.into());
126 *self.lock_cache() = None;
127 self
128 }
129
130 pub fn add_file(self, path: impl Into<String>, data: impl Into<Vec<u8>>) -> Result<Self> {
147 let raw = path.into();
148 let normalized = normalize_file_path(&raw);
149
150 if normalized.is_empty() {
151 return Err(Error::InvalidFilePath("path is empty".into()));
152 }
153 if normalized.starts_with('/') {
154 return Err(Error::InvalidFilePath(format!(
155 "absolute path not allowed: {normalized}"
156 )));
157 }
158 if normalized.split('/').any(|s| s == "..") {
159 return Err(Error::InvalidFilePath(format!(
160 "path with '..' not allowed: {normalized}"
161 )));
162 }
163
164 self.lock_runtime_files().insert(normalized, data.into());
165 *self.lock_cache() = None;
166 Ok(self)
167 }
168
169 #[cfg(feature = "pdf")]
197 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
198 pub fn with_pdf_config(mut self, config: PdfConfig) -> Self {
199 self.pdf_config = config;
200 self
201 }
202
203 pub fn has_file(&self, path: impl AsRef<str>) -> bool {
207 let normalized = normalize_file_path(path.as_ref());
208
209 if self.lock_runtime_files().contains_key(&normalized) {
211 return true;
212 }
213
214 if find_entry(self.templates, &normalized).is_some() {
216 return true;
217 }
218
219 false
220 }
221
222 pub fn select_pages(&self, pages: impl IntoIterator<Item = usize>) -> Pages<'_> {
249 Pages {
250 doc: self,
251 indices: pages.into_iter().collect(),
252 }
253 }
254
255 pub fn page_count(&self) -> Result<usize> {
267 self.with_compiled(|compiled| Ok(compiled.pages().len()))
268 }
269
270 pub fn warnings(&self) -> Result<Vec<Diagnostic>> {
289 self.compile_cached()?;
290 let cache = self.lock_cache();
291 let compiled = cache
292 .as_ref()
293 .expect("compiled_cache must be Some after successful compile_cached()");
294 Ok(compiled.warnings.clone())
295 }
296
297 pub fn stats(&self) -> &EmbedStats {
299 &self.stats
300 }
301
302 fn compile_cached(&self) -> Result<()> {
304 if self.lock_cache().is_some() {
305 return Ok(());
306 }
307
308 let main_file =
310 find_entry(self.templates, self.entry).ok_or(Error::EntryNotFound(self.entry))?;
311
312 let main_bytes = decompress(main_file.contents())?;
313 let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
314
315 let mut resolver = EmbeddedResolver::new(self.templates, self.packages);
316 for (path, data) in self.lock_runtime_files().iter() {
317 resolver.insert_runtime_file(path.clone(), data.clone());
318 }
319
320 let font_data: Vec<Vec<u8>> = self
322 .fonts
323 .files()
324 .map(|f| decompress(f.contents()).map_err(Error::from))
325 .collect::<Result<Vec<_>>>()?;
326
327 let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
328
329 let engine = TypstEngine::builder()
330 .main_file((self.entry, main_content))
331 .add_file_resolver(resolver)
332 .fonts(font_refs)
333 .build();
334
335 let inputs = self.lock_inputs().clone();
337
338 let mut world_builder = engine.world_builder();
341 if let Some(inputs) = inputs {
342 world_builder = world_builder.with_inputs(inputs);
343 }
344 let world = world_builder.build().map_err(|e| {
347 Error::Compilation(vec![Diagnostic {
348 severity: Severity::Error,
349 location: None,
350 message: e.to_string(),
351 hints: Vec::new(),
352 trace: Vec::new(),
353 }])
354 })?;
355
356 let warned = typst::compile::<PagedDocument>(&world);
357 typst::comemo::evict(0);
360
361 let main = world.main();
362 let doc = warned.output.map_err(|diagnostics| {
363 Error::Compilation(
364 diagnostics
365 .iter()
366 .map(|d| diagnostic_from(&world, self.entry, main, d))
367 .collect(),
368 )
369 })?;
370
371 let warnings = warned
373 .warnings
374 .iter()
375 .map(|d| diagnostic_from(&world, self.entry, main, d))
376 .collect();
377
378 *self.lock_cache() = Some(Compiled { doc, warnings });
379
380 Ok(())
381 }
382
383 fn with_compiled<F, T>(&self, f: F) -> Result<T>
385 where
386 F: FnOnce(&PagedDocument) -> Result<T>,
387 {
388 self.compile_cached()?;
389 let cache = self.lock_cache();
390 let compiled = cache
391 .as_ref()
392 .expect("compiled_cache must be Some after successful compile_cached()");
393 f(&compiled.doc)
394 }
395
396 #[cfg(feature = "pdf")]
404 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
405 pub fn to_pdf(&self) -> Result<Vec<u8>> {
406 self.render_pdf(None)
407 }
408
409 #[cfg(feature = "svg")]
417 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
418 pub fn to_svg(&self) -> Result<Vec<String>> {
419 self.render_svg(None)
420 }
421
422 #[cfg(feature = "png")]
433 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
434 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
435 self.render_png(None, dpi)
436 }
437
438 #[cfg(feature = "pdf")]
439 fn render_pdf(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<u8>> {
440 self.with_compiled(|compiled| {
441 let mut options = self.pdf_config.to_typst()?;
444
445 let indices = validate_page_selection(selected, compiled.pages().len())?;
446 if let Some(indices) = indices {
447 use std::num::NonZeroUsize;
448 use typst::layout::PageRanges;
449
450 let ranges = indices
451 .iter()
452 .map(|&i| {
453 let n = Some(NonZeroUsize::new(i + 1).unwrap());
454 n..=n
455 })
456 .collect();
457 options.page_ranges = Some(PageRanges::new(ranges));
458
459 if self.pdf_config.standard.requires_tagging() {
480 return Err(Error::InvalidPdfConfig(format!(
481 "page selection is incompatible with {:?} (requires tagging)",
482 self.pdf_config.standard
483 )));
484 }
485 options.tagged = false;
486 }
487
488 debug_assert!(!(options.tagged && options.page_ranges.is_some()));
490
491 typst_pdf::pdf(compiled, &options).map_err(|e| Error::PdfGeneration(format!("{e:?}")))
492 })
493 }
494
495 #[cfg(feature = "svg")]
496 fn render_svg(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<String>> {
497 self.with_compiled(|compiled| {
498 let options = typst_svg::SvgOptions::default();
500 let indices = validate_page_selection(selected, compiled.pages().len())?;
501 match indices {
502 Some(indices) => Ok(indices
503 .iter()
504 .map(|&i| typst_svg::svg(&compiled.pages()[i], &options))
505 .collect()),
506 None => Ok(compiled
507 .pages()
508 .iter()
509 .map(|page| typst_svg::svg(page, &options))
510 .collect()),
511 }
512 })
513 }
514
515 #[cfg(feature = "png")]
516 fn render_png(&self, selected: Option<&BTreeSet<usize>>, dpi: f32) -> Result<Vec<Vec<u8>>> {
517 self.with_compiled(|compiled| {
518 let options = typst_render::RenderOptions {
521 pixel_per_pt: typst::utils::Scalar::new(f64::from(dpi) / 72.0),
522 ..Default::default()
523 };
524 let indices = validate_page_selection(selected, compiled.pages().len())?;
525 let pages: Box<dyn Iterator<Item = &_>> = match &indices {
526 Some(indices) => Box::new(indices.iter().map(|&i| &compiled.pages()[i])),
527 None => Box::new(compiled.pages().iter()),
528 };
529 pages
530 .map(|page| {
531 typst_render::render(page, &options)
532 .encode_png()
533 .map_err(|e| Error::PngEncoding(e.to_string()))
534 })
535 .collect()
536 })
537 }
538}
539
540pub struct Pages<'a> {
545 doc: &'a Document,
546 indices: BTreeSet<usize>,
547}
548
549impl Pages<'_> {
550 #[cfg(feature = "pdf")]
555 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
556 pub fn to_pdf(&self) -> Result<Vec<u8>> {
557 self.doc.render_pdf(Some(&self.indices))
558 }
559
560 #[cfg(feature = "svg")]
565 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
566 pub fn to_svg(&self) -> Result<Vec<String>> {
567 self.doc.render_svg(Some(&self.indices))
568 }
569
570 #[cfg(feature = "png")]
578 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
579 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
580 self.doc.render_png(Some(&self.indices), dpi)
581 }
582}
583
584fn validate_page_selection(
587 selected: Option<&BTreeSet<usize>>,
588 total_pages: usize,
589) -> Result<Option<Vec<usize>>> {
590 if total_pages == 0 {
591 return Err(Error::InvalidPageSelection("document has no pages".into()));
592 }
593 match selected {
594 None => Ok(None),
595 Some(pages) => {
596 if pages.is_empty() {
597 return Err(Error::InvalidPageSelection(
598 "page selection is empty".into(),
599 ));
600 }
601 if let Some(&max) = pages.last()
602 && max >= total_pages
603 {
604 return Err(Error::InvalidPageSelection(format!(
605 "page index {max} out of range (valid: 0..={})",
606 total_pages - 1
607 )));
608 }
609 Ok(Some(pages.iter().copied().collect()))
610 }
611 }
612}
613
614fn span_to_location(
619 world: &TypstWorld,
620 entry: &str,
621 main: FileId,
622 span: impl Into<DiagSpan>,
623) -> Option<SourceLocation> {
624 let span = span.into();
625 let id = span.id()?;
626 let range = world.range(span)?;
627 let source = world.source(id).ok()?;
628 let (line, column) = source.lines().byte_to_line_column(range.start)?;
629 let file = if id == main {
630 entry.to_string()
631 } else {
632 file_id_to_path(id)
633 };
634 Some(SourceLocation {
635 file,
636 line: line + 1,
637 column: column + 1,
638 })
639}
640
641fn diagnostic_from(
644 world: &TypstWorld,
645 entry: &str,
646 main: FileId,
647 diagnostic: &SourceDiagnostic,
648) -> Diagnostic {
649 Diagnostic {
650 severity: match diagnostic.severity {
651 typst::diag::Severity::Error => Severity::Error,
652 typst::diag::Severity::Warning => Severity::Warning,
653 },
654 location: span_to_location(world, entry, main, diagnostic.span),
655 message: diagnostic.message.to_string(),
656 hints: diagnostic
657 .hints
658 .iter()
659 .map(|h| Hint {
660 message: h.v.to_string(),
661 location: span_to_location(world, entry, main, h.span),
662 })
663 .collect(),
664 trace: diagnostic
665 .trace
666 .iter()
667 .filter_map(|t| span_to_location(world, entry, main, t.span))
668 .collect(),
669 }
670}
671
672fn find_entry<'a>(dir: &'a Dir<'a>, path: &str) -> Option<&'a File<'a>> {
674 let normalized = path.trim_start_matches("./").replace('\\', "/");
675 let (dir_path, file_name) = match normalized.rsplit_once('/') {
676 Some((d, f)) => (Some(d), f),
677 None => (None, normalized.as_str()),
678 };
679
680 let target_dir = match dir_path {
681 Some(dir_path) => {
682 let mut current = dir;
683 for segment in dir_path.split('/') {
684 current = current
685 .dirs()
686 .find(|d| d.path().file_name().and_then(|n| n.to_str()) == Some(segment))?;
687 }
688 current
689 }
690 None => dir,
691 };
692
693 target_dir
694 .files()
695 .find(|f| f.path().file_name().and_then(|n| n.to_str()) == Some(file_name))
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 fn compile_error(entry: &'static str, src: &'static str) -> Vec<Diagnostic> {
705 let engine = TypstEngine::builder().main_file((entry, src)).build();
706 let world = engine.world_builder().build().expect("world builds");
707 let warned = typst::compile::<PagedDocument>(&world);
708 typst::comemo::evict(0);
709 let main = world.main();
710 let diagnostics = warned.output.expect_err("source should fail to compile");
711 diagnostics
712 .iter()
713 .map(|d| diagnostic_from(&world, entry, main, d))
714 .collect()
715 }
716
717 #[test]
718 fn compilation_error_exposes_source_location() {
719 let diagnostics = compile_error("test.typ", "Hello\n#bad_call()\n");
721 assert!(!diagnostics.is_empty());
722 let loc = diagnostics[0]
723 .location
724 .as_ref()
725 .expect("diagnostic carries a source location");
726 assert_eq!(loc.file, "test.typ");
728 assert_eq!(loc.line, 2);
729 assert!(loc.column >= 1);
730 assert!(!diagnostics[0].message.is_empty());
731 }
732
733 #[test]
734 fn nested_entry_path_is_preserved() {
735 let diagnostics = compile_error("reports/report.typ", "#oops\n");
736 let loc = diagnostics[0].location.as_ref().expect("has location");
737 assert_eq!(loc.file, "reports/report.typ");
738 assert_eq!(loc.line, 1);
739 }
740
741 #[test]
742 fn diagnostic_display_with_location_hints_and_trace() {
743 let diagnostic = Diagnostic {
744 severity: Severity::Error,
745 location: Some(SourceLocation {
746 file: "report.typ".to_string(),
747 line: 42,
748 column: 12,
749 }),
750 message: "boom".to_string(),
751 hints: vec![Hint {
752 message: "try wrapping it".to_string(),
753 location: None,
754 }],
755 trace: vec![SourceLocation {
756 file: "main.typ".to_string(),
757 line: 5,
758 column: 1,
759 }],
760 };
761 assert_eq!(
762 diagnostic.to_string(),
763 "report.typ:42:12: error: boom\n hint: try wrapping it\n called from: main.typ:5:1"
764 );
765 }
766
767 #[test]
768 fn diagnostic_display_without_location() {
769 let diagnostic = Diagnostic {
770 severity: Severity::Error,
771 location: None,
772 message: "boom".to_string(),
773 hints: Vec::new(),
774 trace: Vec::new(),
775 };
776 assert_eq!(diagnostic.to_string(), "error: boom");
777 }
778
779 #[test]
780 fn diagnostic_display_uses_warning_severity() {
781 let diagnostic = Diagnostic {
782 severity: Severity::Warning,
783 location: Some(SourceLocation {
784 file: "main.typ".to_string(),
785 line: 12,
786 column: 3,
787 }),
788 message: "heading did not stabilize".to_string(),
789 hints: Vec::new(),
790 trace: Vec::new(),
791 };
792 assert_eq!(
793 diagnostic.to_string(),
794 "main.typ:12:3: warning: heading did not stabilize"
795 );
796 }
797
798 #[test]
799 fn located_hint_is_rendered_with_its_position() {
800 let diagnostic = Diagnostic {
801 severity: Severity::Error,
802 location: None,
803 message: "boom".to_string(),
804 hints: vec![
805 Hint {
806 message: "general advice".to_string(),
807 location: None,
808 },
809 Hint {
810 message: "defined here".to_string(),
811 location: Some(SourceLocation {
812 file: "styles.typ".to_string(),
813 line: 8,
814 column: 20,
815 }),
816 },
817 ],
818 trace: Vec::new(),
819 };
820 assert_eq!(
821 diagnostic.to_string(),
822 "error: boom\n hint: general advice\n hint at styles.typ:8:20: defined here"
823 );
824 }
825
826 fn compile_warnings(entry: &'static str, src: &'static str) -> Vec<Diagnostic> {
828 let engine = TypstEngine::builder().main_file((entry, src)).build();
829 let world = engine.world_builder().build().expect("world builds");
830 let warned = typst::compile::<PagedDocument>(&world);
831 typst::comemo::evict(0);
832 let main = world.main();
833 assert!(warned.output.is_ok(), "source should compile");
834 warned
835 .warnings
836 .iter()
837 .map(|d| diagnostic_from(&world, entry, main, d))
838 .collect()
839 }
840
841 #[test]
842 fn warnings_are_resolved_with_severity_location_and_hints() {
843 let diagnostics = compile_warnings("test.typ", "#show page: it => it\nHello\n");
845 assert!(
846 !diagnostics.is_empty(),
847 "source should produce at least one warning"
848 );
849
850 let warning = &diagnostics[0];
851 assert_eq!(warning.severity, Severity::Warning);
852 assert!(warning.message.contains("show page"));
853
854 let loc = warning
855 .location
856 .as_ref()
857 .expect("warning carries a source location");
858 assert_eq!(loc.file, "test.typ");
859 assert_eq!(loc.line, 1);
860
861 assert!(!warning.hints.is_empty(), "warning carries a hint");
863 assert!(warning.to_string().contains("\n hint: "));
864 }
865}