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;
6use std::fmt;
7use std::path::{Path, PathBuf};
8
9use ecow::EcoVec;
10use typst::diag::{FileResult, SourceDiagnostic, Warned};
11use typst::foundations::{Bytes, Datetime, Dict, Duration};
12use typst::layout::{Frame, FrameItem};
13use typst::syntax::package::PackageSpec;
14use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
15use typst::text::{Font, FontBook};
16use typst::utils::LazyHash;
17use typst::{Library, LibraryExt, World};
18use typst_kit::datetime::Time;
19use typst_kit::downloader::SystemDownloader;
20use typst_kit::files::{FileStore, FsRoot, SystemFiles};
21use typst_kit::fonts::FontStore;
22use typst_kit::packages::{FsPackages, SystemPackages, UniversePackages};
23use typst_layout::PagedDocument;
24
25use crate::manifest::Metadata;
26use crate::pack::{Pack, PackBuildError};
27
28/// The user agent used when downloading packages from Typst Universe.
29const USER_AGENT: &str = concat!("typst-pack/", env!("CARGO_PKG_VERSION"));
30
31/// Packs a Typst project directory into a [`Pack`].
32///
33/// The packer performs a discovery compile of the project and records every
34/// file Typst actually reads: sources, images, data files, and package
35/// files. Only those files end up in the pack. Files that a compile with
36/// different inputs or a different date would read (e.g. behind conditional
37/// imports) are not discovered; add them explicitly with
38/// [`include`](Self::include).
39pub struct Packer {
40    root: PathBuf,
41    entrypoint: PathBuf,
42    vendor_packages: bool,
43    embed_fonts: bool,
44    include_default_fonts: bool,
45    include: Vec<PathBuf>,
46    font_paths: Vec<PathBuf>,
47    system_fonts: bool,
48    inputs: Dict,
49    package_path: Option<PathBuf>,
50    package_cache_path: Option<PathBuf>,
51    offline: bool,
52    metadata: Option<Metadata>,
53}
54
55impl Packer {
56    /// Creates a packer for the project in `root` with the given entrypoint
57    /// (absolute, or relative to `root`).
58    pub fn new(root: impl Into<PathBuf>, entrypoint: impl Into<PathBuf>) -> Self {
59        Self {
60            root: root.into(),
61            entrypoint: entrypoint.into(),
62            vendor_packages: true,
63            embed_fonts: false,
64            include_default_fonts: false,
65            include: Vec::new(),
66            font_paths: Vec::new(),
67            system_fonts: true,
68            inputs: Dict::new(),
69            package_path: None,
70            package_cache_path: None,
71            offline: false,
72            metadata: None,
73        }
74    }
75
76    /// Whether to store the files of all observed package dependencies inside
77    /// the pack. Defaults to `true`; when disabled, dependencies are recorded
78    /// as external and must be resolvable when the pack is compiled.
79    pub fn vendor_packages(mut self, vendor: bool) -> Self {
80        self.vendor_packages = vendor;
81        self
82    }
83
84    /// Whether to embed the fonts used by the document. Defaults to `false`.
85    ///
86    /// Note that font licenses differ; make sure you may redistribute the
87    /// fonts you embed.
88    pub fn embed_fonts(mut self, embed: bool) -> Self {
89        self.embed_fonts = embed;
90        self
91    }
92
93    /// Whether font embedding also stores fonts that are identical to Typst's
94    /// embedded default fonts. Defaults to `false` since compiling a pack has
95    /// those fonts available anyway.
96    pub fn include_default_fonts(mut self, include: bool) -> Self {
97        self.include_default_fonts = include;
98        self
99    }
100
101    /// Adds a file or directory (absolute, or relative to the project root)
102    /// to the pack in addition to the discovered files. Paths must be inside
103    /// the project root.
104    pub fn include(mut self, path: impl Into<PathBuf>) -> Self {
105        self.include.push(path.into());
106        self
107    }
108
109    /// Adds a directory to scan for fonts during the discovery compile.
110    pub fn font_path(mut self, path: impl Into<PathBuf>) -> Self {
111        self.font_paths.push(path.into());
112        self
113    }
114
115    /// Whether the discovery compile may use system fonts. Defaults to
116    /// `true`.
117    pub fn system_fonts(mut self, system: bool) -> Self {
118        self.system_fonts = system;
119        self
120    }
121
122    /// Values made available to document code as `sys.inputs` during the
123    /// discovery compile.
124    pub fn inputs(mut self, inputs: Dict) -> Self {
125        self.inputs = inputs;
126        self
127    }
128
129    /// Overrides the directory in which locally installed packages are
130    /// searched (namespace/name/version layout).
131    pub fn package_path(mut self, path: impl Into<PathBuf>) -> Self {
132        self.package_path = Some(path.into());
133        self
134    }
135
136    /// Overrides the directory in which downloaded packages are cached.
137    pub fn package_cache_path(mut self, path: impl Into<PathBuf>) -> Self {
138        self.package_cache_path = Some(path.into());
139        self
140    }
141
142    /// Disallows network access during the discovery compile. Defaults to
143    /// `false`.
144    ///
145    /// When enabled, package dependencies must already exist in the local
146    /// package directories; anything that would need to be downloaded fails
147    /// the compile as not found.
148    pub fn offline(mut self, offline: bool) -> Self {
149        self.offline = offline;
150        self
151    }
152
153    /// Sets descriptive metadata recorded in the pack manifest.
154    pub fn metadata(mut self, metadata: Metadata) -> Self {
155        self.metadata = Some(metadata);
156        self
157    }
158
159    /// Runs the discovery compile and assembles the pack.
160    pub fn pack(self) -> Result<PackOutcome, PackerError> {
161        let root = self
162            .root
163            .canonicalize()
164            .map_err(|err| PackerError::io("failed to resolve project root", err))?;
165        let entrypoint_abs = if self.entrypoint.is_absolute() {
166            self.entrypoint.clone()
167        } else {
168            root.join(&self.entrypoint)
169        };
170        let entrypoint_abs = entrypoint_abs
171            .canonicalize()
172            .map_err(|err| PackerError::io("failed to resolve entrypoint", err))?;
173        let entrypoint = VirtualPath::virtualize(&root, &entrypoint_abs)
174            .map_err(|_| PackerError::OutsideRoot(entrypoint_abs.clone()))?;
175
176        // Build the discovery world.
177        let data = match &self.package_path {
178            Some(path) => Some(FsPackages::new(path.clone())),
179            None => FsPackages::system_data(),
180        };
181        let cache = match &self.package_cache_path {
182            Some(path) => Some(FsPackages::new(path.clone())),
183            None => FsPackages::system_cache(),
184        };
185        let universe = if self.offline {
186            UniversePackages::new(crate::world::OfflineDownloader)
187        } else {
188            UniversePackages::new(SystemDownloader::new(USER_AGENT))
189        };
190        let packages = SystemPackages::from_parts(data, cache, universe);
191
192        let mut fonts = FontStore::new();
193        for path in &self.font_paths {
194            fonts.extend(typst_kit::fonts::scan(path));
195        }
196        #[cfg(feature = "embedded-fonts")]
197        fonts.extend(typst_kit::fonts::embedded());
198        if self.system_fonts {
199            fonts.extend(typst_kit::fonts::system());
200        }
201
202        let mut world = DiscoveryWorld {
203            root: root.clone(),
204            library: LazyHash::new(Library::builder().with_inputs(self.inputs.clone()).build()),
205            main: RootedPath::new(VirtualRoot::Project, entrypoint.clone()).intern(),
206            store: FileStore::new(SystemFiles::new(FsRoot::new(root.clone()), packages)),
207            fonts,
208            time: Time::system(),
209        };
210
211        // Discovery compile.
212        let Warned { output, warnings } = typst::compile::<PagedDocument>(&world);
213        let document = match output {
214            Ok(document) => document,
215            Err(errors) => {
216                return Err(PackerError::Compile {
217                    world: Box::new(world),
218                    errors,
219                    warnings,
220                });
221            }
222        };
223
224        let mut report = PackReport {
225            files: Vec::new(),
226            packages_vendored: Vec::new(),
227            packages_external: Vec::new(),
228            fonts: Vec::new(),
229            warnings: Vec::new(),
230            compile_warnings: warnings,
231        };
232
233        // Partition the observed dependencies.
234        let dependencies: Vec<FileId> = {
235            let (_, iter) = world.store.dependencies();
236            iter.collect()
237        };
238        let mut project_files: Vec<FileId> = Vec::new();
239        let mut package_files: BTreeMap<String, (PackageSpec, FileId)> = BTreeMap::new();
240        for id in dependencies {
241            match id.root() {
242                VirtualRoot::Project => project_files.push(id),
243                VirtualRoot::Package(spec) => {
244                    package_files
245                        .entry(spec.to_string())
246                        .or_insert_with(|| (spec.clone(), id));
247                }
248            }
249        }
250
251        let mut builder = Pack::builder(entrypoint.get_without_slash());
252
253        // Project files, from the compile's own cache.
254        project_files.sort_by_key(|id| id.vpath().get_with_slash().to_owned());
255        for id in project_files {
256            let path = id.vpath().get_without_slash();
257            match world.store.file(id) {
258                Ok(data) => {
259                    report.files.push(path.to_owned());
260                    builder = builder.file(path, data.to_vec())?;
261                }
262                Err(_) => {
263                    // Accessed but unreadable (e.g. probed and missing).
264                    // The compile succeeded without it, so just skip it.
265                }
266            }
267        }
268
269        // A typst.toml next to the entrypoint travels along: it carries
270        // template/package metadata that tooling may want after extraction.
271        if !report.files.iter().any(|path| path == "typst.toml")
272            && let Ok(data) = std::fs::read(root.join("typst.toml"))
273        {
274            report.files.push("typst.toml".to_owned());
275            builder = builder.file("typst.toml", data)?;
276        }
277
278        // Explicitly included files and directories.
279        for path in &self.include {
280            let absolute = if path.is_absolute() {
281                path.clone()
282            } else {
283                root.join(path)
284            };
285            let absolute = absolute.canonicalize().map_err(|err| {
286                PackerError::io(
287                    &format!("failed to resolve include `{}`", path.display()),
288                    err,
289                )
290            })?;
291            let mut selected: Vec<PathBuf> = Vec::new();
292            if absolute.is_dir() {
293                for entry in walkdir::WalkDir::new(&absolute).sort_by_file_name() {
294                    let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
295                    if !entry.file_type().is_file() {
296                        continue;
297                    }
298                    if entry.path().extension().is_some_and(|ext| ext == "typk") {
299                        report.warnings.push(format!(
300                            "skipped pack file `{}` inside included directory",
301                            entry.path().display()
302                        ));
303                        continue;
304                    }
305                    selected.push(entry.path().to_owned());
306                }
307            } else {
308                selected.push(absolute);
309            }
310            for file in selected {
311                let vpath = VirtualPath::virtualize(&root, &file)
312                    .map_err(|_| PackerError::OutsideRoot(file.clone()))?;
313                let data = std::fs::read(&file).map_err(|err| {
314                    PackerError::io(&format!("failed to read `{}`", file.display()), err)
315                })?;
316                let path = vpath.get_without_slash().to_owned();
317                if !report.files.contains(&path) {
318                    report.files.push(path.clone());
319                }
320                builder = builder.file(path, data)?;
321            }
322        }
323
324        // Packages.
325        for (spec, id) in package_files.values() {
326            if self.vendor_packages {
327                let package_root =
328                    world
329                        .store
330                        .loader()
331                        .root(*id)
332                        .map_err(|err| PackerError::Package {
333                            spec: spec.clone(),
334                            message: err.to_string(),
335                        })?;
336                for entry in walkdir::WalkDir::new(package_root.path()).sort_by_file_name() {
337                    let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
338                    if !entry.file_type().is_file() {
339                        continue;
340                    }
341                    let vpath = VirtualPath::virtualize(package_root.path(), entry.path())
342                        .map_err(|_| PackerError::OutsideRoot(entry.path().to_owned()))?;
343                    let data = std::fs::read(entry.path()).map_err(|err| {
344                        PackerError::io(
345                            &format!("failed to read `{}`", entry.path().display()),
346                            err,
347                        )
348                    })?;
349                    builder =
350                        builder.package_file(spec.clone(), vpath.get_without_slash(), data)?;
351                }
352                report.packages_vendored.push(spec.clone());
353            } else {
354                builder = builder.external_package(spec.clone());
355                report.packages_external.push(spec.clone());
356            }
357        }
358
359        // Fonts actually used by the rendered document.
360        if self.embed_fonts {
361            let mut used: Vec<Font> = Vec::new();
362            for page in document.pages() {
363                collect_fonts(&page.frame, &mut used);
364            }
365            for font in used {
366                if !self.include_default_fonts && is_default_font(&font) {
367                    continue;
368                }
369                builder = builder.font(font.data().to_vec(), font.index())?;
370            }
371        }
372
373        if let Some(metadata) = self.metadata {
374            builder = builder.metadata(metadata);
375        }
376
377        let pack = builder.build()?;
378        report.fonts = pack
379            .fonts()
380            .iter()
381            .map(|font| font.entry.path.clone())
382            .collect();
383
384        Ok(PackOutcome {
385            pack,
386            report,
387            world,
388        })
389    }
390}
391
392/// The result of a successful [`Packer::pack`] run.
393pub struct PackOutcome {
394    /// The assembled pack.
395    pub pack: Pack,
396    /// What went into the pack.
397    pub report: PackReport,
398    /// The world used for the discovery compile. Kept so that the compile
399    /// warnings in the report can be rendered with source context.
400    pub world: DiscoveryWorld,
401}
402
403/// A summary of what a [`Packer`] put into a pack.
404#[derive(Debug, Clone)]
405pub struct PackReport {
406    /// Root-relative paths of the packed project files.
407    pub files: Vec<String>,
408    /// Packages stored inside the pack.
409    pub packages_vendored: Vec<PackageSpec>,
410    /// Observed dependencies that were not vendored.
411    pub packages_external: Vec<PackageSpec>,
412    /// Archive paths of embedded fonts.
413    pub fonts: Vec<String>,
414    /// Non-fatal problems encountered while packing.
415    pub warnings: Vec<String>,
416    /// Warnings emitted by the discovery compile.
417    pub compile_warnings: EcoVec<SourceDiagnostic>,
418}
419
420/// A failure while packing a project directory.
421#[derive(Debug, thiserror::Error)]
422pub enum PackerError {
423    #[error("{message}: {source}")]
424    Io {
425        message: String,
426        #[source]
427        source: std::io::Error,
428    },
429    #[error("`{0}` is outside the project root and cannot be packed")]
430    OutsideRoot(PathBuf),
431    #[error("the discovery compile failed with {} error(s)", errors.len())]
432    Compile {
433        /// The world the compile ran in, for rendering the diagnostics.
434        world: Box<DiscoveryWorld>,
435        errors: EcoVec<SourceDiagnostic>,
436        warnings: EcoVec<SourceDiagnostic>,
437    },
438    #[error("failed to load package {spec}: {message}")]
439    Package { spec: PackageSpec, message: String },
440    #[error("failed to walk directory: {0}")]
441    Walk(String),
442    #[error(transparent)]
443    Build(#[from] PackBuildError),
444}
445
446impl PackerError {
447    fn io(message: &str, source: std::io::Error) -> Self {
448        Self::Io {
449            message: message.to_owned(),
450            source,
451        }
452    }
453}
454
455/// The file-system-backed world used for the discovery compile.
456///
457/// This is exposed so that its compile diagnostics can be rendered with
458/// source context; it is not meant to be constructed directly.
459pub struct DiscoveryWorld {
460    root: PathBuf,
461    library: LazyHash<Library>,
462    main: FileId,
463    store: FileStore<SystemFiles>,
464    fonts: FontStore,
465    time: Time,
466}
467
468impl DiscoveryWorld {
469    /// The canonicalized project root.
470    pub fn root(&self) -> &Path {
471        &self.root
472    }
473}
474
475impl fmt::Debug for DiscoveryWorld {
476    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477        f.debug_struct("DiscoveryWorld")
478            .field("root", &self.root)
479            .finish_non_exhaustive()
480    }
481}
482
483impl World for DiscoveryWorld {
484    fn library(&self) -> &LazyHash<Library> {
485        &self.library
486    }
487
488    fn book(&self) -> &LazyHash<FontBook> {
489        self.fonts.book()
490    }
491
492    fn main(&self) -> FileId {
493        self.main
494    }
495
496    fn source(&self, id: FileId) -> FileResult<Source> {
497        self.store.source(id)
498    }
499
500    fn file(&self, id: FileId) -> FileResult<Bytes> {
501        self.store.file(id)
502    }
503
504    fn font(&self, index: usize) -> Option<Font> {
505        self.fonts.font(index)
506    }
507
508    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
509        self.time.today(offset)
510    }
511}
512
513/// Collects the distinct fonts used in a frame tree.
514fn collect_fonts(frame: &Frame, used: &mut Vec<Font>) {
515    for (_, item) in frame.items() {
516        match item {
517            FrameItem::Group(group) => collect_fonts(&group.frame, used),
518            FrameItem::Text(text) => {
519                let font = text.font.font();
520                if !used.contains(font) {
521                    used.push(font.clone());
522                }
523            }
524            _ => {}
525        }
526    }
527}
528
529/// Whether the font is one of Typst's embedded default fonts.
530fn is_default_font(font: &Font) -> bool {
531    #[cfg(feature = "embedded-fonts")]
532    {
533        typst_kit::fonts::embedded()
534            .any(|(default, _)| default.data().as_slice() == font.data().as_slice())
535    }
536    #[cfg(not(feature = "embedded-fonts"))]
537    {
538        let _ = font;
539        false
540    }
541}