1use 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
19pub const FILE_EXTENSION: &str = "typk";
21
22const PROJECT_PREFIX: &str = "project/";
23const PACKAGES_PREFIX: &str = "packages/";
24
25#[derive(Debug, Clone)]
32pub struct Pack {
33 manifest: Manifest,
34 files: BTreeMap<String, Bytes>,
35 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#[derive(Debug, Clone)]
48pub struct PackFont {
49 pub entry: FontManifest,
51 pub data: Bytes,
53}
54
55impl Pack {
56 pub fn builder(entrypoint: impl Into<String>) -> PackBuilder {
61 PackBuilder::new(entrypoint)
62 }
63
64 pub fn manifest(&self) -> &Manifest {
66 &self.manifest
67 }
68
69 pub fn entrypoint(&self) -> &str {
71 &self.manifest.project.entrypoint
72 }
73
74 pub fn files(&self) -> impl Iterator<Item = (&str, &Bytes)> {
76 self.files.iter().map(|(path, data)| (path.as_str(), data))
77 }
78
79 pub fn file(&self, path: &str) -> Option<&Bytes> {
81 self.files.get(path)
82 }
83
84 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 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 pub fn package_file(&self, spec: &PackageSpec, path: &str) -> Option<&Bytes> {
114 self.packages.get(&spec.to_string())?.files.get(path)
115 }
116
117 pub fn has_package(&self, spec: &PackageSpec) -> bool {
119 self.packages.contains_key(&spec.to_string())
120 }
121
122 pub fn fonts(&self) -> &[PackFont] {
124 &self.fonts
125 }
126
127 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 }
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 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, PackReadError> {
235 Self::read(Cursor::new(bytes.into()))
236 }
237
238 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 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
286fn 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
297fn 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#[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#[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#[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 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 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 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 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 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 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 pub fn metadata(mut self, metadata: Metadata) -> Self {
450 self.metadata = Some(metadata);
451 self
452 }
453
454 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 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 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#[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}