Skip to main content

typst_pack/
pack.rs

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