Skip to main content

typst_pack/
packer.rs

1//! Packing a project directory by discovering what a compile actually uses.
2
3#![cfg(feature = "fs")]
4
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex, OnceLock};
9
10use ecow::EcoVec;
11use typst::diag::{FileError, FileResult, SourceDiagnostic, Warned};
12use typst::foundations::{Bytes, Datetime, Dict, Duration};
13use typst::layout::{Frame, FrameItem};
14use typst::syntax::package::PackageSpec;
15use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
16use typst::text::{Font, FontBook};
17use typst::utils::LazyHash;
18use typst::{Library, LibraryExt, World};
19use typst_kit::datetime::Time;
20use typst_kit::downloader::SystemDownloader;
21use typst_kit::files::{FileLoader, FileStore, FsRoot, SystemFiles};
22use typst_kit::fonts::FontStore;
23use typst_kit::packages::{FsPackages, SystemPackages, UniversePackages};
24use typst_layout::PagedDocument;
25
26use crate::manifest::Metadata;
27use crate::pack::{Pack, PackBuildError, valid_path};
28use crate::world::load_external_resource;
29
30/// The user agent used when downloading packages from Typst Universe.
31const USER_AGENT: &str = concat!("typst-pack/", env!("CARGO_PKG_VERSION"));
32
33/// Controls whether discovery may fall back to External Project Resource loaders.
34#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
35pub enum ProjectResourcePolicy {
36    /// Missing project files cannot fall back to external loaders.
37    #[default]
38    DisallowExternalFallback,
39    /// Missing non-source resources may come from configured external loaders.
40    AllowExternalFallback,
41}
42
43/// Packs a Typst project directory into a [`Pack`].
44///
45/// The packer performs a discovery compile of the project and records every
46/// file Typst actually reads. Source-project files are packed by default.
47/// When [`AllowExternalFallback`](ProjectResourcePolicy::AllowExternalFallback)
48/// is selected, non-source resources resolved by configured external loaders
49/// are declared in the manifest instead of storing their bytes. Files that a
50/// compile with different inputs or a different date would read are not
51/// discovered; add packed files with [`include`](Self::include) or declare an
52/// External Project Resource with [`external_resource`](Self::external_resource).
53pub struct Packer {
54    root: PathBuf,
55    entrypoint: PathBuf,
56    vendor_packages: bool,
57    embed_fonts: bool,
58    include_default_fonts: bool,
59    include: Vec<PathBuf>,
60    font_paths: Vec<PathBuf>,
61    system_fonts: bool,
62    inputs: Dict,
63    package_path: Option<PathBuf>,
64    package_cache_path: Option<PathBuf>,
65    offline: bool,
66    metadata: Option<Metadata>,
67    project_resource_policy: ProjectResourcePolicy,
68    external_resources: BTreeSet<String>,
69    external_resource_loaders: Vec<Box<dyn FileLoader + Send + Sync>>,
70}
71
72impl Packer {
73    /// Creates a packer for the project in `root` with the given entrypoint
74    /// (absolute, or relative to `root`).
75    pub fn new(root: impl Into<PathBuf>, entrypoint: impl Into<PathBuf>) -> Self {
76        Self {
77            root: root.into(),
78            entrypoint: entrypoint.into(),
79            vendor_packages: true,
80            embed_fonts: false,
81            include_default_fonts: false,
82            include: Vec::new(),
83            font_paths: Vec::new(),
84            system_fonts: true,
85            inputs: Dict::new(),
86            package_path: None,
87            package_cache_path: None,
88            offline: false,
89            metadata: None,
90            project_resource_policy: ProjectResourcePolicy::default(),
91            external_resources: BTreeSet::new(),
92            external_resource_loaders: Vec::new(),
93        }
94    }
95
96    /// Whether to store the files of all observed package dependencies inside
97    /// the pack. Defaults to `true`; when disabled, dependencies are recorded
98    /// as external and must be resolvable when the pack is compiled.
99    pub fn vendor_packages(mut self, vendor: bool) -> Self {
100        self.vendor_packages = vendor;
101        self
102    }
103
104    /// Whether to embed the fonts used by the document. Defaults to `false`.
105    ///
106    /// Note that font licenses differ; make sure you may redistribute the
107    /// fonts you embed.
108    pub fn embed_fonts(mut self, embed: bool) -> Self {
109        self.embed_fonts = embed;
110        self
111    }
112
113    /// Whether font embedding also stores fonts that are identical to Typst's
114    /// embedded default fonts. Defaults to `false` since compiling a pack has
115    /// those fonts available anyway.
116    pub fn include_default_fonts(mut self, include: bool) -> Self {
117        self.include_default_fonts = include;
118        self
119    }
120
121    /// Adds a file or directory (absolute, or relative to the project root)
122    /// to the pack in addition to the discovered files. Paths must be inside
123    /// the project root.
124    pub fn include(mut self, path: impl Into<PathBuf>) -> Self {
125        self.include.push(path.into());
126        self
127    }
128
129    /// Adds a directory to scan for fonts during the discovery compile.
130    pub fn font_path(mut self, path: impl Into<PathBuf>) -> Self {
131        self.font_paths.push(path.into());
132        self
133    }
134
135    /// Whether the discovery compile may use system fonts. Defaults to
136    /// `true`.
137    pub fn system_fonts(mut self, system: bool) -> Self {
138        self.system_fonts = system;
139        self
140    }
141
142    /// Values made available to document code as `sys.inputs` during the
143    /// discovery compile.
144    pub fn inputs(mut self, inputs: Dict) -> Self {
145        self.inputs = inputs;
146        self
147    }
148
149    /// Overrides the directory in which locally installed packages are
150    /// searched (namespace/name/version layout).
151    pub fn package_path(mut self, path: impl Into<PathBuf>) -> Self {
152        self.package_path = Some(path.into());
153        self
154    }
155
156    /// Overrides the directory in which downloaded packages are cached.
157    pub fn package_cache_path(mut self, path: impl Into<PathBuf>) -> Self {
158        self.package_cache_path = Some(path.into());
159        self
160    }
161
162    /// Disallows network access during the discovery compile. Defaults to
163    /// `false`.
164    ///
165    /// When enabled, package dependencies must already exist in the local
166    /// package directories; anything that would need to be downloaded fails
167    /// the compile as not found.
168    pub fn offline(mut self, offline: bool) -> Self {
169        self.offline = offline;
170        self
171    }
172
173    /// Sets descriptive metadata recorded in the pack manifest.
174    pub fn metadata(mut self, metadata: Metadata) -> Self {
175        self.metadata = Some(metadata);
176        self
177    }
178
179    /// Controls whether discovery may use configured External Project Resource loaders.
180    pub fn project_resource_policy(mut self, policy: ProjectResourcePolicy) -> Self {
181        self.project_resource_policy = policy;
182        self
183    }
184
185    /// Declares a non-source project resource whose bytes should be omitted from the pack.
186    pub fn external_resource(mut self, path: impl Into<String>) -> Self {
187        self.external_resources.insert(path.into());
188        self
189    }
190
191    /// Adds a loader for External Project Resources used during discovery.
192    ///
193    /// Loaders are only consulted under
194    /// [`AllowExternalFallback`](ProjectResourcePolicy::AllowExternalFallback);
195    /// registering one does not change the default strict policy.
196    pub fn external_resource_loader(
197        mut self,
198        loader: impl FileLoader + Send + Sync + 'static,
199    ) -> Self {
200        self.external_resource_loaders.push(Box::new(loader));
201        self
202    }
203
204    /// Runs the discovery compile and assembles the pack.
205    pub fn pack(self) -> Result<PackOutcome, PackerError> {
206        let explicit_external_resources = self
207            .external_resources
208            .iter()
209            .map(|path| valid_path(path))
210            .collect::<Result<BTreeSet<_>, _>>()?;
211        let root = self
212            .root
213            .canonicalize()
214            .map_err(|err| PackerError::io("failed to resolve project root", err))?;
215        let entrypoint_abs = if self.entrypoint.is_absolute() {
216            self.entrypoint.clone()
217        } else {
218            root.join(&self.entrypoint)
219        };
220        let entrypoint_abs = entrypoint_abs
221            .canonicalize()
222            .map_err(|err| PackerError::io("failed to resolve entrypoint", err))?;
223        let entrypoint = VirtualPath::virtualize(&root, &entrypoint_abs)
224            .map_err(|_| PackerError::OutsideRoot(entrypoint_abs.clone()))?;
225
226        // Build the discovery world.
227        let data = match &self.package_path {
228            Some(path) => Some(FsPackages::new(path.clone())),
229            None => FsPackages::system_data(),
230        };
231        let cache = match &self.package_cache_path {
232            Some(path) => Some(FsPackages::new(path.clone())),
233            None => FsPackages::system_cache(),
234        };
235        let universe = if self.offline {
236            UniversePackages::new(crate::world::OfflineDownloader)
237        } else {
238            UniversePackages::new(SystemDownloader::new(USER_AGENT))
239        };
240        let packages = SystemPackages::from_parts(data, cache, universe);
241
242        let mut fonts = FontStore::new();
243        for path in &self.font_paths {
244            fonts.extend(typst_kit::fonts::scan(path));
245        }
246        #[cfg(feature = "embedded-fonts")]
247        fonts.extend(typst_kit::fonts::embedded());
248        if self.system_fonts {
249            fonts.extend(typst_kit::fonts::system());
250        }
251
252        let primary = Arc::new(PrimaryLoader {
253            system: SystemFiles::new(FsRoot::new(root.clone()), packages),
254            cache: Mutex::new(HashMap::new()),
255        });
256        let mut world = DiscoveryWorld {
257            root: root.clone(),
258            library: LazyHash::new(Library::builder().with_inputs(self.inputs.clone()).build()),
259            main: RootedPath::new(VirtualRoot::Project, entrypoint.clone()).intern(),
260            sources: FileStore::new(Arc::clone(&primary)),
261            files: FileStore::new(DiscoveryLoader {
262                primary,
263                policy: self.project_resource_policy,
264                external_loaders: self.external_resource_loaders,
265                external_resources: Mutex::new(explicit_external_resources.clone()),
266                explicit_external_resources,
267            }),
268            fonts,
269            time: Time::system(),
270            #[cfg(test)]
271            source_request_hook: None,
272        };
273
274        // Discovery compile.
275        let Warned { output, warnings } = typst::compile::<PagedDocument>(&world);
276        let document = match output {
277            Ok(document) => document,
278            Err(errors) => {
279                return Err(PackerError::Compile {
280                    world: Box::new(world),
281                    errors,
282                    warnings,
283                });
284            }
285        };
286
287        let mut report = PackReport {
288            files: Vec::new(),
289            external_resources: Vec::new(),
290            packages_vendored: Vec::new(),
291            packages_external: Vec::new(),
292            fonts: Vec::new(),
293            warnings: Vec::new(),
294            compile_warnings: warnings,
295        };
296
297        // Partition the observed dependencies.
298        let source_dependencies: Vec<FileId> = {
299            let (_, iter) = world.sources.dependencies();
300            iter.collect()
301        };
302        let file_dependencies: Vec<FileId> = {
303            let (_, iter) = world.files.dependencies();
304            iter.collect()
305        };
306        enum ProjectFileOrigin {
307            Source,
308            File,
309        }
310        let mut project_files: Vec<(FileId, ProjectFileOrigin)> = Vec::new();
311        let mut package_files: BTreeMap<String, (PackageSpec, FileId)> = BTreeMap::new();
312        for id in source_dependencies {
313            match id.root() {
314                VirtualRoot::Project => project_files.push((id, ProjectFileOrigin::Source)),
315                VirtualRoot::Package(spec) => {
316                    package_files
317                        .entry(spec.to_string())
318                        .or_insert_with(|| (spec.clone(), id));
319                }
320            }
321        }
322        for id in file_dependencies {
323            match id.root() {
324                VirtualRoot::Project if world.files.loader().is_external(id) => {}
325                VirtualRoot::Project if project_files.iter().any(|(source, _)| *source == id) => {}
326                VirtualRoot::Project => project_files.push((id, ProjectFileOrigin::File)),
327                VirtualRoot::Package(spec) => {
328                    package_files
329                        .entry(spec.to_string())
330                        .or_insert_with(|| (spec.clone(), id));
331                }
332            }
333        }
334
335        let mut builder = Pack::builder(entrypoint.get_without_slash());
336
337        for path in world.files.loader().external_resources() {
338            report.external_resources.push(path.clone());
339            builder = builder.external_resource(path)?;
340        }
341
342        // Project files, from the compile's own cache.
343        project_files.sort_by_key(|(id, _)| id.vpath().get_with_slash().to_owned());
344        for (id, origin) in project_files {
345            let path = id.vpath().get_without_slash();
346            let data = match origin {
347                ProjectFileOrigin::Source => world.sources.file(id),
348                ProjectFileOrigin::File => world.files.file(id),
349            };
350            match data {
351                Ok(data) => {
352                    report.files.push(path.to_owned());
353                    builder = builder.file(path, data.to_vec())?;
354                }
355                Err(_) => {
356                    // Accessed but unreadable (e.g. probed and missing).
357                    // The compile succeeded without it, so just skip it.
358                }
359            }
360        }
361
362        // A typst.toml next to the entrypoint travels along: it carries
363        // template/package metadata that tooling may want after extraction.
364        if !report.files.iter().any(|path| path == "typst.toml")
365            && let Ok(data) = std::fs::read(root.join("typst.toml"))
366        {
367            report.files.push("typst.toml".to_owned());
368            builder = builder.file("typst.toml", data)?;
369        }
370
371        // Explicitly included files and directories.
372        for path in &self.include {
373            let absolute = if path.is_absolute() {
374                path.clone()
375            } else {
376                root.join(path)
377            };
378            let absolute = absolute.canonicalize().map_err(|err| {
379                PackerError::io(
380                    &format!("failed to resolve include `{}`", path.display()),
381                    err,
382                )
383            })?;
384            let mut selected: Vec<PathBuf> = Vec::new();
385            if absolute.is_dir() {
386                for entry in walkdir::WalkDir::new(&absolute).sort_by_file_name() {
387                    let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
388                    if !entry.file_type().is_file() {
389                        continue;
390                    }
391                    if entry.path().extension().is_some_and(|ext| ext == "typk") {
392                        report.warnings.push(format!(
393                            "skipped pack file `{}` inside included directory",
394                            entry.path().display()
395                        ));
396                        continue;
397                    }
398                    selected.push(entry.path().to_owned());
399                }
400            } else {
401                selected.push(absolute);
402            }
403            for file in selected {
404                let vpath = VirtualPath::virtualize(&root, &file)
405                    .map_err(|_| PackerError::OutsideRoot(file.clone()))?;
406                let data = std::fs::read(&file).map_err(|err| {
407                    PackerError::io(&format!("failed to read `{}`", file.display()), err)
408                })?;
409                let path = vpath.get_without_slash().to_owned();
410                if !report.files.contains(&path) {
411                    report.files.push(path.clone());
412                }
413                builder = builder.file(path, data)?;
414            }
415        }
416
417        // Packages.
418        for (spec, id) in package_files.values() {
419            if self.vendor_packages {
420                let package_root =
421                    world
422                        .files
423                        .loader()
424                        .root(*id)
425                        .map_err(|err| PackerError::Package {
426                            spec: spec.clone(),
427                            message: err.to_string(),
428                        })?;
429                for entry in walkdir::WalkDir::new(package_root.path()).sort_by_file_name() {
430                    let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
431                    if !entry.file_type().is_file() {
432                        continue;
433                    }
434                    let vpath = VirtualPath::virtualize(package_root.path(), entry.path())
435                        .map_err(|_| PackerError::OutsideRoot(entry.path().to_owned()))?;
436                    let data = std::fs::read(entry.path()).map_err(|err| {
437                        PackerError::io(
438                            &format!("failed to read `{}`", entry.path().display()),
439                            err,
440                        )
441                    })?;
442                    builder =
443                        builder.package_file(spec.clone(), vpath.get_without_slash(), data)?;
444                }
445                report.packages_vendored.push(spec.clone());
446            } else {
447                builder = builder.external_package(spec.clone());
448                report.packages_external.push(spec.clone());
449            }
450        }
451
452        // Fonts actually used by the rendered document.
453        if self.embed_fonts {
454            let mut used: Vec<Font> = Vec::new();
455            for page in document.pages() {
456                collect_fonts(&page.frame, &mut used);
457            }
458            for font in used {
459                if !self.include_default_fonts && is_default_font(&font) {
460                    continue;
461                }
462                builder = builder.font(font.data().to_vec(), font.index())?;
463            }
464        }
465
466        if let Some(metadata) = self.metadata {
467            builder = builder.metadata(metadata);
468        }
469
470        let pack = builder.build()?;
471        report.fonts = pack
472            .fonts()
473            .iter()
474            .map(|font| font.entry.path.clone())
475            .collect();
476
477        Ok(PackOutcome {
478            pack,
479            report,
480            world,
481        })
482    }
483}
484
485/// The result of a successful [`Packer::pack`] run.
486pub struct PackOutcome {
487    /// The assembled pack.
488    pub pack: Pack,
489    /// The packed and externally supplied parts of the compilation contract.
490    pub report: PackReport,
491    /// The world used for the discovery compile. Kept so that the compile
492    /// warnings in the report can be rendered with source context.
493    pub world: DiscoveryWorld,
494}
495
496/// A summary of the compilation contract discovered by a [`Packer`].
497#[derive(Debug, Clone)]
498pub struct PackReport {
499    /// Root-relative paths of the packed project files.
500    pub files: Vec<String>,
501    /// Root-relative paths of observed or explicitly declared External Project Resources.
502    pub external_resources: Vec<String>,
503    /// Packages stored inside the pack.
504    pub packages_vendored: Vec<PackageSpec>,
505    /// Observed dependencies that were not vendored.
506    pub packages_external: Vec<PackageSpec>,
507    /// Archive paths of embedded fonts.
508    pub fonts: Vec<String>,
509    /// Non-fatal problems encountered while packing.
510    pub warnings: Vec<String>,
511    /// Warnings emitted by the discovery compile.
512    pub compile_warnings: EcoVec<SourceDiagnostic>,
513}
514
515/// A failure while packing a project directory.
516#[derive(Debug, thiserror::Error)]
517pub enum PackerError {
518    #[error("{message}: {source}")]
519    Io {
520        message: String,
521        #[source]
522        source: std::io::Error,
523    },
524    #[error("`{0}` is outside the project root and cannot be packed")]
525    OutsideRoot(PathBuf),
526    #[error("the discovery compile failed with {} error(s)", errors.len())]
527    Compile {
528        /// The world the compile ran in, for rendering the diagnostics.
529        world: Box<DiscoveryWorld>,
530        errors: EcoVec<SourceDiagnostic>,
531        warnings: EcoVec<SourceDiagnostic>,
532    },
533    #[error("failed to load package {spec}: {message}")]
534    Package { spec: PackageSpec, message: String },
535    #[error("failed to walk directory: {0}")]
536    Walk(String),
537    #[error(transparent)]
538    Build(#[from] PackBuildError),
539}
540
541impl PackerError {
542    fn io(message: &str, source: std::io::Error) -> Self {
543        Self::Io {
544            message: message.to_owned(),
545            source,
546        }
547    }
548}
549
550/// The file-system-backed world used for the discovery compile.
551///
552/// This is exposed so that its compile diagnostics can be rendered with
553/// source context; it is not meant to be constructed directly.
554pub struct DiscoveryWorld {
555    root: PathBuf,
556    library: LazyHash<Library>,
557    main: FileId,
558    sources: FileStore<Arc<PrimaryLoader>>,
559    files: FileStore<DiscoveryLoader>,
560    fonts: FontStore,
561    time: Time,
562    #[cfg(test)]
563    source_request_hook: Option<Arc<dyn Fn(FileId) + Send + Sync>>,
564}
565
566impl DiscoveryWorld {
567    /// The canonicalized project root.
568    pub fn root(&self) -> &Path {
569        &self.root
570    }
571
572    #[cfg(test)]
573    pub(crate) fn set_source_request_hook(
574        &mut self,
575        hook: impl Fn(FileId) + Send + Sync + 'static,
576    ) {
577        self.source_request_hook = Some(Arc::new(hook));
578    }
579}
580
581impl fmt::Debug for DiscoveryWorld {
582    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
583        f.debug_struct("DiscoveryWorld")
584            .field("root", &self.root)
585            .finish_non_exhaustive()
586    }
587}
588
589impl World for DiscoveryWorld {
590    fn library(&self) -> &LazyHash<Library> {
591        &self.library
592    }
593
594    fn book(&self) -> &LazyHash<FontBook> {
595        self.fonts.book()
596    }
597
598    fn main(&self) -> FileId {
599        self.main
600    }
601
602    fn source(&self, id: FileId) -> FileResult<Source> {
603        if matches!(id.root(), VirtualRoot::Project) && self.files.loader().is_explicit_external(id)
604        {
605            return Err(FileError::NotFound(PathBuf::from(
606                id.vpath().get_without_slash(),
607            )));
608        }
609        #[cfg(test)]
610        if let Some(hook) = &self.source_request_hook {
611            hook(id);
612        }
613        self.sources.source(id)
614    }
615
616    fn file(&self, id: FileId) -> FileResult<Bytes> {
617        self.files.file(id)
618    }
619
620    fn font(&self, index: usize) -> Option<Font> {
621        self.fonts.font(index)
622    }
623
624    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
625        self.time.today(offset)
626    }
627}
628
629struct PrimaryLoader {
630    system: SystemFiles,
631    cache: Mutex<HashMap<FileId, Arc<OnceLock<FileResult<Bytes>>>>>,
632}
633
634impl PrimaryLoader {
635    fn root(&self, id: FileId) -> FileResult<FsRoot> {
636        self.system.root(id)
637    }
638}
639
640impl FileLoader for PrimaryLoader {
641    fn load(&self, id: FileId) -> FileResult<Bytes> {
642        let entry = {
643            let mut cache = self.cache.lock().expect("primary file cache lock poisoned");
644            Arc::clone(cache.entry(id).or_default())
645        };
646        entry.get_or_init(|| self.system.load(id)).clone()
647    }
648}
649
650struct DiscoveryLoader {
651    primary: Arc<PrimaryLoader>,
652    policy: ProjectResourcePolicy,
653    external_loaders: Vec<Box<dyn FileLoader + Send + Sync>>,
654    external_resources: Mutex<BTreeSet<String>>,
655    explicit_external_resources: BTreeSet<String>,
656}
657
658impl DiscoveryLoader {
659    fn root(&self, id: FileId) -> FileResult<FsRoot> {
660        self.primary.root(id)
661    }
662
663    fn is_external(&self, id: FileId) -> bool {
664        self.external_resources
665            .lock()
666            .expect("external resource provenance lock poisoned")
667            .contains(id.vpath().get_without_slash())
668    }
669
670    fn is_explicit_external(&self, id: FileId) -> bool {
671        self.explicit_external_resources
672            .contains(id.vpath().get_without_slash())
673    }
674
675    fn external_resources(&self) -> Vec<String> {
676        self.external_resources
677            .lock()
678            .expect("external resource provenance lock poisoned")
679            .iter()
680            .cloned()
681            .collect()
682    }
683}
684
685impl FileLoader for DiscoveryLoader {
686    fn load(&self, id: FileId) -> FileResult<Bytes> {
687        match self.primary.load(id) {
688            Ok(data) => {
689                if matches!(id.root(), VirtualRoot::Project)
690                    && self
691                        .explicit_external_resources
692                        .contains(id.vpath().get_without_slash())
693                {
694                    self.external_resources
695                        .lock()
696                        .expect("external resource provenance lock poisoned")
697                        .insert(id.vpath().get_without_slash().to_owned());
698                }
699                Ok(data)
700            }
701            Err(FileError::NotFound(_))
702                if matches!(id.root(), VirtualRoot::Project)
703                    && self.policy == ProjectResourcePolicy::AllowExternalFallback =>
704            {
705                let data = load_external_resource(&self.external_loaders, id)?;
706                self.external_resources
707                    .lock()
708                    .expect("external resource provenance lock poisoned")
709                    .insert(id.vpath().get_without_slash().to_owned());
710                Ok(data)
711            }
712            Err(err) => Err(err),
713        }
714    }
715}
716
717/// Collects the distinct fonts used in a frame tree.
718fn collect_fonts(frame: &Frame, used: &mut Vec<Font>) {
719    for (_, item) in frame.items() {
720        match item {
721            FrameItem::Group(group) => collect_fonts(&group.frame, used),
722            FrameItem::Text(text) => {
723                let font = text.font.font();
724                if !used.contains(font) {
725                    used.push(font.clone());
726                }
727            }
728            _ => {}
729        }
730    }
731}
732
733/// Whether the font is one of Typst's embedded default fonts.
734fn is_default_font(font: &Font) -> bool {
735    #[cfg(feature = "embedded-fonts")]
736    {
737        typst_kit::fonts::embedded()
738            .any(|(default, _)| default.data().as_slice() == font.data().as_slice())
739    }
740    #[cfg(not(feature = "embedded-fonts"))]
741    {
742        let _ = font;
743        false
744    }
745}