1use 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
19pub const FILE_EXTENSION: &str = "typk";
21
22const PROJECT_PREFIX: &str = "project/";
23const PACKAGES_PREFIX: &str = "packages/";
24
25#[derive(Debug, Clone)]
33pub struct Pack {
34 manifest: Manifest,
35 files: BTreeMap<String, Bytes>,
36 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#[derive(Debug, Clone)]
49pub struct PackFont {
50 pub entry: FontManifest,
52 pub data: Bytes,
54}
55
56impl Pack {
57 pub fn builder(entrypoint: impl Into<String>) -> PackBuilder {
62 PackBuilder::new(entrypoint)
63 }
64
65 pub fn manifest(&self) -> &Manifest {
67 &self.manifest
68 }
69
70 pub fn entrypoint(&self) -> &str {
72 &self.manifest.project.entrypoint
73 }
74
75 pub fn files(&self) -> impl Iterator<Item = (&str, &Bytes)> {
77 self.files.iter().map(|(path, data)| (path.as_str(), data))
78 }
79
80 pub fn file(&self, path: &str) -> Option<&Bytes> {
82 self.files.get(path)
83 }
84
85 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 pub fn package_file(&self, spec: &PackageSpec, path: &str) -> Option<&Bytes> {
102 self.packages.get(&spec.to_string())?.files.get(path)
103 }
104
105 pub fn has_package(&self, spec: &PackageSpec) -> bool {
107 self.packages.contains_key(&spec.to_string())
108 }
109
110 pub fn fonts(&self) -> &[PackFont] {
112 &self.fonts
113 }
114
115 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 }
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 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, PackReadError> {
214 Self::read(Cursor::new(bytes.into()))
215 }
216
217 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 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
265fn 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
276fn 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#[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#[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#[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 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 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 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 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 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 pub fn metadata(mut self, metadata: Metadata) -> Self {
419 self.metadata = Some(metadata);
420 self
421 }
422
423 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 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 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#[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}