Skip to main content

typst_bake/
document.rs

1//! Self-contained document for Typst template rendering.
2
3use 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
19/// A compiled document together with the warnings typst produced for it.
20///
21/// Kept as one unit so that invalidating the cache always drops both.
22struct Compiled {
23    doc: PagedDocument,
24    warnings: Vec<Diagnostic>,
25}
26
27/// A fully self-contained document ready for rendering.
28///
29/// Created by the [`document!`](crate::document!) macro with embedded templates, fonts,
30/// and packages. All resources are compressed with zstd and decompressed lazily at runtime.
31pub 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    /// PDF export options. Set by [`Document::with_pdf_config`]. A plain field (no
41    /// `Mutex`): the builder takes `self` by value to write it, and rendering reads it
42    /// through `&self`. Affects PDF export only, so it never invalidates `compiled_cache`.
43    #[cfg(feature = "pdf")]
44    pdf_config: PdfConfig,
45}
46
47impl Document {
48    /// Internal constructor used by the macro.
49    /// Do not use directly.
50    #[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    /// Add input data to the document.
85    ///
86    /// Define your data structs using the derive macros:
87    /// - **Top-level struct**: Use both [`IntoValue`](crate::IntoValue) and [`IntoDict`](crate::IntoDict)
88    /// - **Nested structs**: Use [`IntoValue`](crate::IntoValue) only
89    ///
90    /// In `.typ` files, access the data via `sys.inputs`:
91    /// ```typ
92    /// #import sys: inputs
93    /// = #inputs.title
94    /// ```
95    ///
96    /// # Example
97    ///
98    /// ```rust,ignore
99    /// use typst_bake::{IntoValue, IntoDict};
100    ///
101    /// #[derive(IntoValue, IntoDict)]  // Top-level: both macros
102    /// struct Inputs {
103    ///     title: String,
104    ///     products: Vec<Product>,
105    /// }
106    ///
107    /// #[derive(IntoValue)]  // Nested: IntoValue only
108    /// struct Product {
109    ///     name: String,
110    ///     price: f64,
111    /// }
112    ///
113    /// let inputs = Inputs {
114    ///     title: "Catalog".to_string(),
115    ///     products: vec![
116    ///         Product { name: "Apple".to_string(), price: 1.50 },
117    ///     ],
118    /// };
119    ///
120    /// let pdf = typst_bake::document!("main.typ")
121    ///     .with_inputs(inputs)
122    ///     .to_pdf()?;
123    /// ```
124    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    /// Add or replace a runtime file at the given path.
131    ///
132    /// The file becomes available to Typst templates via `#image("path")`,
133    /// `#read("path")`, etc. Runtime files take priority over embedded files
134    /// with the same path.
135    ///
136    /// # Errors
137    /// Returns [`Error::InvalidFilePath`] if the path is empty, absolute, or
138    /// contains `..` segments.
139    ///
140    /// # Example
141    /// ```rust,ignore
142    /// let pdf = typst_bake::document!("main.typ")
143    ///     .add_file("images/chart.png", chart_bytes)?
144    ///     .to_pdf()?;
145    /// ```
146    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    /// Set PDF export options.
170    ///
171    /// Configures PDF-only settings such as tagging, conformance standard, document
172    /// identifier, and creation timestamp. See [`PdfConfig`]. These options affect
173    /// [`to_pdf`](Self::to_pdf) only; SVG/PNG output ignores them.
174    ///
175    /// This does not invalidate the compiled cache (options apply at the PDF export
176    /// stage, not during compilation). Invalid configurations are reported when
177    /// [`to_pdf`](Self::to_pdf) is called, not here; the default config never errors.
178    ///
179    /// # Example
180    /// ```rust,ignore
181    /// use typst_bake::{PdfConfig, PdfStandard};
182    ///
183    /// // Disable tagging to shrink the PDF (bookmarks are preserved).
184    /// let pdf = typst_bake::document!("main.typ")
185    ///     .with_pdf_config(PdfConfig {
186    ///         tagged: false,
187    ///         standard: PdfStandard::A2b,
188    ///         ..Default::default()
189    ///     })
190    ///     .to_pdf()?;
191    /// ```
192    ///
193    /// Note: a page selection (via [`select_pages`](Self::select_pages)) always forces
194    /// tagging off and drops bookmarks for excluded pages; prefer `tagged: false` on the
195    /// full document if you only want to disable tagging.
196    #[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    /// Check if a file exists at the given path.
204    ///
205    /// Checks both embedded (compile-time) and runtime files.
206    pub fn has_file(&self, path: impl AsRef<str>) -> bool {
207        let normalized = normalize_file_path(path.as_ref());
208
209        // Check runtime files first.
210        if self.lock_runtime_files().contains_key(&normalized) {
211            return true;
212        }
213
214        // Check embedded templates.
215        if find_entry(self.templates, &normalized).is_some() {
216            return true;
217        }
218
219        false
220    }
221
222    /// Select specific pages for output, returning a [`Pages`] view.
223    ///
224    /// Pages are 0-indexed. Duplicates are removed and pages are always
225    /// output in document order regardless of input order.
226    ///
227    /// # Errors
228    /// Returns [`Error::InvalidPageSelection`] at render time if any index
229    /// is out of range or the selection is empty.
230    ///
231    /// # Example
232    /// ```rust,ignore
233    /// // Select specific pages
234    /// let pdf = typst_bake::document!("main.typ")
235    ///     .select_pages([0, 2, 4])
236    ///     .to_pdf()?;
237    ///
238    /// // Works with ranges too
239    /// let svgs = typst_bake::document!("main.typ")
240    ///     .select_pages(0..3)
241    ///     .to_svg()?;
242    ///
243    /// // Reuse with different selections
244    /// let doc = typst_bake::document!("main.typ");
245    /// let cover = doc.select_pages([0]).to_pdf()?;
246    /// let body = doc.select_pages(1..5).to_pdf()?;
247    /// ```
248    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    /// Get the total number of pages in the compiled document.
256    ///
257    /// Compiles the document if not already compiled.
258    /// Returns the total page count regardless of `select_pages`.
259    ///
260    /// # Example
261    /// ```rust,ignore
262    /// let doc = typst_bake::document!("main.typ");
263    /// let count = doc.page_count()?;
264    /// let last_page = doc.select_pages([count - 1]).to_pdf()?;
265    /// ```
266    pub fn page_count(&self) -> Result<usize> {
267        self.with_compiled(|compiled| Ok(compiled.pages().len()))
268    }
269
270    /// Get the warnings Typst produced while compiling the document.
271    ///
272    /// Compiles the document if not already compiled. Returns an empty vector when
273    /// the document compiled cleanly, and `Err(Error::Compilation(..))` if it failed
274    /// to compile at all.
275    ///
276    /// Warnings are never printed by this crate; read them here and report them
277    /// however you like.
278    ///
279    /// # Example
280    /// ```rust,ignore
281    /// // Bind the document: `document!(..).to_pdf()?` drops it before you can ask.
282    /// let doc = typst_bake::document!("main.typ");
283    /// let pdf = doc.to_pdf()?;
284    /// for warning in doc.warnings()? {
285    ///     eprintln!("{warning}");
286    /// }
287    /// ```
288    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    /// Get compression statistics for embedded content.
298    pub fn stats(&self) -> &EmbedStats {
299        &self.stats
300    }
301
302    /// Compile the document, reusing the cached result if available.
303    fn compile_cached(&self) -> Result<()> {
304        if self.lock_cache().is_some() {
305            return Ok(());
306        }
307
308        // Read main template content (compressed)
309        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        // Collect and decompress fonts from the embedded fonts directory
321        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        // Clone inputs (preserve for retry on failure)
336        let inputs = self.lock_inputs().clone();
337
338        // Drive the world directly (mirrors typst-as-lib's internal `do_compile`) so the
339        // `World` stays in scope to resolve diagnostic spans into source locations.
340        let mut world_builder = engine.world_builder();
341        if let Some(inputs) = inputs {
342            world_builder = world_builder.with_inputs(inputs);
343        }
344        // A build failure is an input-injection error, not a source diagnostic; preserve its
345        // message in a location-less diagnostic.
346        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        // Replicate the engine's default eviction policy (`Some(0)`); `world_builder` does not
358        // evict automatically. The comemo cache is global, so don't enlarge this blindly.
359        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        // Resolve now: `world` is local, and spans cannot be resolved without it.
372        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    /// Compile if needed, then call `f` with a reference to the compiled document.
384    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    /// Compile the document and generate PDF.
397    ///
398    /// # Returns
399    /// PDF data as bytes.
400    ///
401    /// # Errors
402    /// Returns an error if compilation or PDF generation fails.
403    #[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    /// Compile the document and generate SVG for each page.
410    ///
411    /// # Returns
412    /// A vector of SVG strings, one per page.
413    ///
414    /// # Errors
415    /// Returns an error if compilation fails.
416    #[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    /// Compile the document and generate PNG for each page.
423    ///
424    /// # Arguments
425    /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
426    ///
427    /// # Returns
428    /// A vector of PNG bytes, one per page.
429    ///
430    /// # Errors
431    /// Returns an error if compilation or PNG encoding fails.
432    #[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            // Base options come from the stored config (incl. `tagged`, standard, ident,
442            // timestamp, creator).
443            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                // --- Single safety net: page selection forces tagging off ---
460                //
461                // Page selection sets `page_ranges`, which is incompatible with tagged
462                // PDF. typst-pdf does NOT error on `tagged: true` + `page_ranges`; it
463                // silently emits a structure tree referencing ALL pages while only a
464                // subset is exported, yielding a malformed/misaligned tag tree. See:
465                //   - typst/typst#7743 (tagged PDF incompatible with page ranges)
466                // So we defensively force tagging off here, overriding `PdfConfig.tagged`
467                // — but ONLY on the page-selection path. Full-document `tagged: false` is
468                // handled by `to_typst` and is unaffected.
469                //
470                // Bookmarks: the document outline (/Outlines) is independent of tagging
471                // (typst-pdf sets the outline unconditionally; the tag tree only when
472                // enabled), so disabling tagging keeps bookmarks. Bookmarks pointing at
473                // EXCLUDED pages are still dropped here — that loss is caused by
474                // `page_ranges`, not by tagging.
475                //
476                // Accessible standards (PDF/A-*a, PDF/UA-1) mandate tagging, so they
477                // cannot coexist with page selection; reject them explicitly rather than
478                // emit a non-conformant PDF.
479                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            // Invariant backstop: tagged PDF + page ranges must never escape together.
489            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            // Defaults match 0.14 behaviour: no bleed, no pretty-printing.
499            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            // `RenderOptions::default()` uses 2.0 pixels per point, so always set it
519            // explicitly from the requested DPI.
520            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
540/// A lightweight view into a [`Document`] with a page selection filter.
541///
542/// Created by [`Document::select_pages`]. Holds a reference to the
543/// document and an owned set of page indices.
544pub struct Pages<'a> {
545    doc: &'a Document,
546    indices: BTreeSet<usize>,
547}
548
549impl Pages<'_> {
550    /// Compile the document and generate PDF for the selected pages.
551    ///
552    /// # Errors
553    /// Returns an error if compilation, PDF generation, or page selection fails.
554    #[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    /// Compile the document and generate SVG for the selected pages.
561    ///
562    /// # Errors
563    /// Returns an error if compilation or page selection fails.
564    #[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    /// Compile the document and generate PNG for the selected pages.
571    ///
572    /// # Arguments
573    /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
574    ///
575    /// # Errors
576    /// Returns an error if compilation, PNG encoding, or page selection fails.
577    #[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
584/// Validate page selection and return indices to render.
585/// Returns `None` if no selection (= all pages).
586fn 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
614/// Resolve a span into a [`SourceLocation`] using the compilation world.
615///
616/// The entry file's `FileId` is mapped back to the user-facing entry path so it
617/// matches exactly what was requested (including nested entries).
618fn 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
641/// Convert a Typst [`SourceDiagnostic`] into a typst-bake [`Diagnostic`] with
642/// resolved source locations.
643fn 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
672/// Find a file in a `Dir` tree by a potentially nested path (e.g. "dir/main.typ").
673fn 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    /// Compile a self-contained broken source and resolve its diagnostics. No
703    /// embedded resolver or fonts are needed for an eval-time error.
704    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        // `bad_call` is an unknown variable; the error span points at it on line 2.
720        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        // The entry file path matches exactly what was requested.
727        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    /// Compile a source that yields warnings but still succeeds, and resolve them.
827    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        // `show page` warns (with a hint) but still compiles.
844        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        // Hints survive the conversion and render through `Display`.
862        assert!(!warning.hints.is_empty(), "warning carries a hint");
863        assert!(warning.to_string().contains("\n  hint: "));
864    }
865}