Skip to main content

typst_pack/
pack.rs

1//! The in-memory pack model and its archive serialization.
2
3use std::collections::BTreeMap;
4use std::io::{Cursor, Read, Seek, Write};
5use std::str::FromStr;
6
7use typst::foundations::Bytes;
8use typst::syntax::VirtualPath;
9use typst::syntax::package::PackageSpec;
10use typst::text::FontInfo;
11use zip::write::SimpleFileOptions;
12use zip::{ZipArchive, ZipWriter};
13
14use crate::manifest::{
15    FORMAT_VERSION, FontManifest, MANIFEST_PATH, Manifest, ManifestError, Metadata,
16    PackagesManifest, ProjectManifest,
17};
18
19/// The conventional file extension for packs.
20pub const FILE_EXTENSION: &str = "typk";
21
22const PROJECT_PREFIX: &str = "project/";
23const PACKAGES_PREFIX: &str = "packages/";
24
25/// A portable pack of a Typst project.
26///
27/// A pack holds everything needed to compile one Typst project: the project
28/// files (sources, images, data files), optionally the files of the packages
29/// the project imports, and optionally the fonts it uses. Its archive form is
30/// a Zip file with a `typst-pack.toml` manifest, conventionally named
31/// `*.typk`.
32#[derive(Debug, Clone)]
33pub struct Pack {
34    manifest: Manifest,
35    files: BTreeMap<String, Bytes>,
36    /// Vendored packages, keyed by spec string for deterministic order.
37    packages: BTreeMap<String, PackageFiles>,
38    fonts: Vec<PackFont>,
39}
40
41#[derive(Debug, Clone)]
42struct PackageFiles {
43    spec: PackageSpec,
44    files: BTreeMap<String, Bytes>,
45}
46
47/// A font embedded in a pack.
48#[derive(Debug, Clone)]
49pub struct PackFont {
50    /// The manifest entry describing this font.
51    pub entry: FontManifest,
52    /// The raw font file data.
53    pub data: Bytes,
54}
55
56impl Pack {
57    /// Starts building a pack from in-memory data.
58    ///
59    /// `entrypoint` is the root-relative path of the main file, e.g.
60    /// `main.typ`.
61    pub fn builder(entrypoint: impl Into<String>) -> PackBuilder {
62        PackBuilder::new(entrypoint)
63    }
64
65    /// The pack manifest.
66    pub fn manifest(&self) -> &Manifest {
67        &self.manifest
68    }
69
70    /// The root-relative path of the entrypoint file.
71    pub fn entrypoint(&self) -> &str {
72        &self.manifest.project.entrypoint
73    }
74
75    /// The project files, keyed by root-relative path.
76    pub fn files(&self) -> impl Iterator<Item = (&str, &Bytes)> {
77        self.files.iter().map(|(path, data)| (path.as_str(), data))
78    }
79
80    /// Looks up a project file by root-relative path.
81    pub fn file(&self, path: &str) -> Option<&Bytes> {
82        self.files.get(path)
83    }
84
85    /// The vendored packages and their files.
86    pub fn packages(
87        &self,
88    ) -> impl Iterator<Item = (&PackageSpec, impl Iterator<Item = (&str, &Bytes)>)> {
89        self.packages.values().map(|package| {
90            (
91                &package.spec,
92                package
93                    .files
94                    .iter()
95                    .map(|(path, data)| (path.as_str(), data)),
96            )
97        })
98    }
99
100    /// Looks up a vendored package file.
101    pub fn package_file(&self, spec: &PackageSpec, path: &str) -> Option<&Bytes> {
102        self.packages.get(&spec.to_string())?.files.get(path)
103    }
104
105    /// Whether the pack vendors the given package.
106    pub fn has_package(&self, spec: &PackageSpec) -> bool {
107        self.packages.contains_key(&spec.to_string())
108    }
109
110    /// The fonts embedded in the pack.
111    pub fn fonts(&self) -> &[PackFont] {
112        &self.fonts
113    }
114
115    /// Reads a pack from a seekable reader.
116    pub fn read<R: Read + Seek>(reader: R) -> Result<Self, PackReadError> {
117        let mut archive = ZipArchive::new(reader)?;
118
119        let manifest = {
120            let mut entry = archive
121                .by_name(MANIFEST_PATH)
122                .map_err(|_| PackReadError::MissingManifest)?;
123            let mut text = String::new();
124            entry.read_to_string(&mut text)?;
125            Manifest::from_toml(&text)?
126        };
127
128        let mut files = BTreeMap::new();
129        let mut packages: BTreeMap<String, PackageFiles> = BTreeMap::new();
130        let mut fonts_by_path: BTreeMap<String, Bytes> = BTreeMap::new();
131        let font_paths: Vec<&str> = manifest
132            .fonts
133            .iter()
134            .map(|font| font.path.as_str())
135            .collect();
136
137        for index in 0..archive.len() {
138            let mut entry = archive.by_index(index)?;
139            if entry.is_dir() {
140                continue;
141            }
142            let Some(name) = entry.enclosed_name() else {
143                return Err(PackReadError::UnsafeEntry(entry.name().to_owned()));
144            };
145            let name = name.to_string_lossy().replace('\\', "/");
146
147            if name == MANIFEST_PATH {
148                continue;
149            } else if let Some(path) = name.strip_prefix(PROJECT_PREFIX) {
150                let path = normalize_path(path, &name)?;
151                let mut data = Vec::new();
152                entry.read_to_end(&mut data)?;
153                files.insert(path, Bytes::new(data));
154            } else if let Some(rest) = name.strip_prefix(PACKAGES_PREFIX) {
155                let (spec, path) = split_package_entry(rest, &name)?;
156                let key = spec.to_string();
157                if !manifest.packages.vendored.contains(&key) {
158                    return Err(PackReadError::UndeclaredPackage(key));
159                }
160                let mut data = Vec::new();
161                entry.read_to_end(&mut data)?;
162                packages
163                    .entry(key)
164                    .or_insert_with(|| PackageFiles {
165                        spec,
166                        files: BTreeMap::new(),
167                    })
168                    .files
169                    .insert(path, Bytes::new(data));
170            } else if font_paths.contains(&name.as_str()) {
171                let mut data = Vec::new();
172                entry.read_to_end(&mut data)?;
173                fonts_by_path
174                    .entry(name)
175                    .or_insert_with(|| Bytes::new(data));
176            }
177            // Unknown top-level entries are ignored for forward compatibility.
178        }
179
180        if !files.contains_key(&manifest.project.entrypoint) {
181            return Err(PackReadError::MissingEntrypoint(
182                manifest.project.entrypoint.clone(),
183            ));
184        }
185
186        for spec in manifest.vendored_packages()? {
187            if !packages.contains_key(&spec.to_string()) {
188                return Err(PackReadError::MissingPackage(spec.to_string()));
189            }
190        }
191
192        let mut fonts = Vec::new();
193        for entry in &manifest.fonts {
194            let data = fonts_by_path
195                .get(&entry.path)
196                .cloned()
197                .ok_or_else(|| PackReadError::MissingFont(entry.path.clone()))?;
198            fonts.push(PackFont {
199                entry: entry.clone(),
200                data,
201            });
202        }
203
204        Ok(Self {
205            manifest,
206            files,
207            packages,
208            fonts,
209        })
210    }
211
212    /// Reads a pack from a byte buffer.
213    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, PackReadError> {
214        Self::read(Cursor::new(bytes.into()))
215    }
216
217    /// Writes the pack archive to a seekable writer.
218    pub fn write<W: Write + Seek>(&self, writer: W) -> Result<(), PackWriteError> {
219        let mut zip = ZipWriter::new(writer);
220        let options =
221            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
222
223        zip.start_file(MANIFEST_PATH, options)?;
224        zip.write_all(self.manifest.to_toml().as_bytes())?;
225
226        for (path, data) in &self.files {
227            zip.start_file(format!("{PROJECT_PREFIX}{path}"), options)?;
228            zip.write_all(data)?;
229        }
230
231        for package in self.packages.values() {
232            let spec = &package.spec;
233            for (path, data) in &package.files {
234                zip.start_file(
235                    format!(
236                        "{PACKAGES_PREFIX}{}/{}/{}/{path}",
237                        spec.namespace, spec.name, spec.version
238                    ),
239                    options,
240                )?;
241                zip.write_all(data)?;
242            }
243        }
244
245        let mut written = std::collections::BTreeSet::new();
246        for font in &self.fonts {
247            if written.insert(&font.entry.path) {
248                zip.start_file(&font.entry.path, options)?;
249                zip.write_all(&font.data)?;
250            }
251        }
252
253        zip.finish()?;
254        Ok(())
255    }
256
257    /// Serializes the pack archive to a byte buffer.
258    pub fn to_bytes(&self) -> Result<Vec<u8>, PackWriteError> {
259        let mut buffer = Cursor::new(Vec::new());
260        self.write(&mut buffer)?;
261        Ok(buffer.into_inner())
262    }
263}
264
265/// Normalizes an archive path into a root-relative virtual path string.
266fn normalize_path(path: &str, entry: &str) -> Result<String, PackReadError> {
267    match VirtualPath::new(path) {
268        Ok(vpath) => Ok(vpath.get_without_slash().to_owned()),
269        Err(err) => Err(PackReadError::InvalidEntry {
270            entry: entry.to_owned(),
271            message: err.to_string(),
272        }),
273    }
274}
275
276/// Splits `namespace/name/version/rest...` into a package spec and file path.
277fn split_package_entry(rest: &str, entry: &str) -> Result<(PackageSpec, String), PackReadError> {
278    let mut parts = rest.splitn(4, '/');
279    let (Some(namespace), Some(name), Some(version), Some(path)) =
280        (parts.next(), parts.next(), parts.next(), parts.next())
281    else {
282        return Err(PackReadError::InvalidEntry {
283            entry: entry.to_owned(),
284            message: "expected packages/<namespace>/<name>/<version>/<path>".into(),
285        });
286    };
287    let spec = PackageSpec::from_str(&format!("@{namespace}/{name}:{version}")).map_err(|err| {
288        PackReadError::InvalidEntry {
289            entry: entry.to_owned(),
290            message: err.to_string(),
291        }
292    })?;
293    let path = normalize_path(path, entry)?;
294    Ok((spec, path))
295}
296
297/// A failure while reading a pack archive.
298#[derive(Debug, thiserror::Error)]
299pub enum PackReadError {
300    #[error("failed to read archive: {0}")]
301    Zip(#[from] zip::result::ZipError),
302    #[error("i/o error while reading archive: {0}")]
303    Io(#[from] std::io::Error),
304    #[error("the archive contains no {MANIFEST_PATH} manifest (is this a Typst pack?)")]
305    MissingManifest,
306    #[error(transparent)]
307    Manifest(#[from] ManifestError),
308    #[error("archive entry `{0}` has an unsafe path")]
309    UnsafeEntry(String),
310    #[error("invalid archive entry `{entry}`: {message}")]
311    InvalidEntry { entry: String, message: String },
312    #[error("package `{0}` has files in the archive but is not declared in the manifest")]
313    UndeclaredPackage(String),
314    #[error("the manifest declares vendored package `{0}` but the archive has no files for it")]
315    MissingPackage(String),
316    #[error("entrypoint `{0}` is missing from the archive")]
317    MissingEntrypoint(String),
318    #[error("font file `{0}` is declared in the manifest but missing from the archive")]
319    MissingFont(String),
320}
321
322/// A failure while writing a pack archive.
323#[derive(Debug, thiserror::Error)]
324pub enum PackWriteError {
325    #[error("failed to write archive: {0}")]
326    Zip(#[from] zip::result::ZipError),
327    #[error("i/o error while writing archive: {0}")]
328    Io(#[from] std::io::Error),
329}
330
331/// Builds a [`Pack`] from in-memory data.
332///
333/// This is the constructor to use when the project does not live on a file
334/// system, for example in a web editor. For packing a project directory, use
335/// [`Packer`](crate::Packer) instead (requires the `fs` feature).
336#[derive(Debug)]
337pub struct PackBuilder {
338    entrypoint: String,
339    files: BTreeMap<String, Bytes>,
340    packages: BTreeMap<String, PackageFiles>,
341    external_packages: Vec<PackageSpec>,
342    fonts: Vec<PackFont>,
343    metadata: Option<Metadata>,
344}
345
346impl PackBuilder {
347    /// Creates a builder for a pack with the given entrypoint path.
348    pub fn new(entrypoint: impl Into<String>) -> Self {
349        Self {
350            entrypoint: entrypoint.into(),
351            files: BTreeMap::new(),
352            packages: BTreeMap::new(),
353            external_packages: Vec::new(),
354            fonts: Vec::new(),
355            metadata: None,
356        }
357    }
358
359    /// Adds a project file under a root-relative path.
360    pub fn file(
361        mut self,
362        path: impl AsRef<str>,
363        data: impl Into<Vec<u8>>,
364    ) -> Result<Self, PackBuildError> {
365        let path = valid_path(path.as_ref())?;
366        self.files.insert(path, Bytes::new(data.into()));
367        Ok(self)
368    }
369
370    /// Adds a file of a vendored package.
371    pub fn package_file(
372        mut self,
373        spec: PackageSpec,
374        path: impl AsRef<str>,
375        data: impl Into<Vec<u8>>,
376    ) -> Result<Self, PackBuildError> {
377        let path = valid_path(path.as_ref())?;
378        self.packages
379            .entry(spec.to_string())
380            .or_insert_with(|| PackageFiles {
381                spec,
382                files: BTreeMap::new(),
383            })
384            .files
385            .insert(path, Bytes::new(data.into()));
386        Ok(self)
387    }
388
389    /// Records a package dependency that is intentionally not vendored.
390    pub fn external_package(mut self, spec: PackageSpec) -> Self {
391        if !self.external_packages.contains(&spec) {
392            self.external_packages.push(spec);
393        }
394        self
395    }
396
397    /// Embeds a font file.
398    ///
399    /// `index` is the face index for font collections and zero otherwise. The
400    /// entry name and family list are derived from the font data.
401    pub fn font(mut self, data: impl Into<Vec<u8>>, index: u32) -> Result<Self, PackBuildError> {
402        let data = data.into();
403        let info = FontInfo::new(&data, index).ok_or(PackBuildError::UnrecognizedFont)?;
404        let family = info.family.to_string();
405        let path = self.font_path(&family, &data);
406        self.fonts.push(PackFont {
407            entry: FontManifest {
408                path,
409                index,
410                families: vec![family],
411            },
412            data: Bytes::new(data),
413        });
414        Ok(self)
415    }
416
417    /// Sets descriptive metadata.
418    pub fn metadata(mut self, metadata: Metadata) -> Self {
419        self.metadata = Some(metadata);
420        self
421    }
422
423    /// Finishes the pack.
424    pub fn build(self) -> Result<Pack, PackBuildError> {
425        let entrypoint = valid_path(&self.entrypoint)?;
426        if !self.files.contains_key(&entrypoint) {
427            return Err(PackBuildError::MissingEntrypoint(entrypoint));
428        }
429
430        let manifest = Manifest {
431            format_version: FORMAT_VERSION,
432            project: ProjectManifest { entrypoint },
433            packages: PackagesManifest {
434                vendored: self.packages.keys().cloned().collect(),
435                external: self
436                    .external_packages
437                    .iter()
438                    .map(|spec| spec.to_string())
439                    .collect(),
440            },
441            fonts: self.fonts.iter().map(|font| font.entry.clone()).collect(),
442            metadata: self.metadata,
443        };
444
445        Ok(Pack {
446            manifest,
447            files: self.files,
448            packages: self.packages,
449            fonts: self.fonts,
450        })
451    }
452
453    /// Picks a unique archive path for a font file.
454    fn font_path(&self, family: &str, data: &[u8]) -> String {
455        let extension = match data.get(..4) {
456            Some(b"OTTO") => "otf",
457            Some(b"ttcf") => "ttc",
458            _ => "ttf",
459        };
460        let stem: String = family
461            .to_lowercase()
462            .chars()
463            .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
464            .collect();
465        let stem = stem.trim_matches('-');
466        let stem = if stem.is_empty() { "font" } else { stem };
467
468        // Reuse the path if the identical file is already embedded (e.g.
469        // several faces of one collection), otherwise disambiguate.
470        let mut candidate = format!("fonts/{stem}.{extension}");
471        let mut counter = 1;
472        loop {
473            match self.fonts.iter().find(|font| font.entry.path == candidate) {
474                None => return candidate,
475                Some(existing) if existing.data.as_slice() == data => return candidate,
476                Some(_) => {
477                    counter += 1;
478                    candidate = format!("fonts/{stem}-{counter}.{extension}");
479                }
480            }
481        }
482    }
483}
484
485fn valid_path(path: &str) -> Result<String, PackBuildError> {
486    match VirtualPath::new(path) {
487        Ok(vpath) => Ok(vpath.get_without_slash().to_owned()),
488        Err(err) => Err(PackBuildError::InvalidPath {
489            path: path.to_owned(),
490            message: err.to_string(),
491        }),
492    }
493}
494
495/// A failure while building a pack in memory.
496#[derive(Debug, thiserror::Error)]
497pub enum PackBuildError {
498    #[error("invalid project path `{path}`: {message}")]
499    InvalidPath { path: String, message: String },
500    #[error("entrypoint `{0}` was not added as a file")]
501    MissingEntrypoint(String),
502    #[error("font data could not be parsed as a font")]
503    UnrecognizedFont,
504}