1#![cfg(feature = "fs")]
4
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex, OnceLock};
9
10use ecow::EcoVec;
11use typst::diag::{FileError, FileResult, SourceDiagnostic, Warned};
12use typst::foundations::{Bytes, Datetime, Dict, Duration};
13use typst::layout::{Frame, FrameItem};
14use typst::syntax::package::PackageSpec;
15use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
16use typst::text::{Font, FontBook};
17use typst::utils::LazyHash;
18use typst::{Library, LibraryExt, World};
19use typst_kit::datetime::Time;
20use typst_kit::downloader::SystemDownloader;
21use typst_kit::files::{FileLoader, FileStore, FsRoot, SystemFiles};
22use typst_kit::fonts::FontStore;
23use typst_kit::packages::{FsPackages, SystemPackages, UniversePackages};
24use typst_layout::PagedDocument;
25
26use crate::manifest::Metadata;
27use crate::pack::{Pack, PackBuildError, valid_path};
28use crate::world::load_external_resource;
29
30const USER_AGENT: &str = concat!("typst-pack/", env!("CARGO_PKG_VERSION"));
32
33#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
35pub enum ProjectResourcePolicy {
36 #[default]
38 DisallowExternalFallback,
39 AllowExternalFallback,
41}
42
43pub struct Packer {
54 root: PathBuf,
55 entrypoint: PathBuf,
56 vendor_packages: bool,
57 embed_fonts: bool,
58 include_default_fonts: bool,
59 include: Vec<PathBuf>,
60 font_paths: Vec<PathBuf>,
61 system_fonts: bool,
62 inputs: Dict,
63 package_path: Option<PathBuf>,
64 package_cache_path: Option<PathBuf>,
65 offline: bool,
66 metadata: Option<Metadata>,
67 project_resource_policy: ProjectResourcePolicy,
68 external_resources: BTreeSet<String>,
69 external_resource_loaders: Vec<Box<dyn FileLoader + Send + Sync>>,
70}
71
72impl Packer {
73 pub fn new(root: impl Into<PathBuf>, entrypoint: impl Into<PathBuf>) -> Self {
76 Self {
77 root: root.into(),
78 entrypoint: entrypoint.into(),
79 vendor_packages: true,
80 embed_fonts: false,
81 include_default_fonts: false,
82 include: Vec::new(),
83 font_paths: Vec::new(),
84 system_fonts: true,
85 inputs: Dict::new(),
86 package_path: None,
87 package_cache_path: None,
88 offline: false,
89 metadata: None,
90 project_resource_policy: ProjectResourcePolicy::default(),
91 external_resources: BTreeSet::new(),
92 external_resource_loaders: Vec::new(),
93 }
94 }
95
96 pub fn vendor_packages(mut self, vendor: bool) -> Self {
100 self.vendor_packages = vendor;
101 self
102 }
103
104 pub fn embed_fonts(mut self, embed: bool) -> Self {
109 self.embed_fonts = embed;
110 self
111 }
112
113 pub fn include_default_fonts(mut self, include: bool) -> Self {
117 self.include_default_fonts = include;
118 self
119 }
120
121 pub fn include(mut self, path: impl Into<PathBuf>) -> Self {
125 self.include.push(path.into());
126 self
127 }
128
129 pub fn font_path(mut self, path: impl Into<PathBuf>) -> Self {
131 self.font_paths.push(path.into());
132 self
133 }
134
135 pub fn system_fonts(mut self, system: bool) -> Self {
138 self.system_fonts = system;
139 self
140 }
141
142 pub fn inputs(mut self, inputs: Dict) -> Self {
145 self.inputs = inputs;
146 self
147 }
148
149 pub fn package_path(mut self, path: impl Into<PathBuf>) -> Self {
152 self.package_path = Some(path.into());
153 self
154 }
155
156 pub fn package_cache_path(mut self, path: impl Into<PathBuf>) -> Self {
158 self.package_cache_path = Some(path.into());
159 self
160 }
161
162 pub fn offline(mut self, offline: bool) -> Self {
169 self.offline = offline;
170 self
171 }
172
173 pub fn metadata(mut self, metadata: Metadata) -> Self {
175 self.metadata = Some(metadata);
176 self
177 }
178
179 pub fn project_resource_policy(mut self, policy: ProjectResourcePolicy) -> Self {
181 self.project_resource_policy = policy;
182 self
183 }
184
185 pub fn external_resource(mut self, path: impl Into<String>) -> Self {
187 self.external_resources.insert(path.into());
188 self
189 }
190
191 pub fn external_resource_loader(
197 mut self,
198 loader: impl FileLoader + Send + Sync + 'static,
199 ) -> Self {
200 self.external_resource_loaders.push(Box::new(loader));
201 self
202 }
203
204 pub fn pack(self) -> Result<PackOutcome, PackerError> {
206 let explicit_external_resources = self
207 .external_resources
208 .iter()
209 .map(|path| valid_path(path))
210 .collect::<Result<BTreeSet<_>, _>>()?;
211 let root = self
212 .root
213 .canonicalize()
214 .map_err(|err| PackerError::io("failed to resolve project root", err))?;
215 let entrypoint_abs = if self.entrypoint.is_absolute() {
216 self.entrypoint.clone()
217 } else {
218 root.join(&self.entrypoint)
219 };
220 let entrypoint_abs = entrypoint_abs
221 .canonicalize()
222 .map_err(|err| PackerError::io("failed to resolve entrypoint", err))?;
223 let entrypoint = VirtualPath::virtualize(&root, &entrypoint_abs)
224 .map_err(|_| PackerError::OutsideRoot(entrypoint_abs.clone()))?;
225
226 let data = match &self.package_path {
228 Some(path) => Some(FsPackages::new(path.clone())),
229 None => FsPackages::system_data(),
230 };
231 let cache = match &self.package_cache_path {
232 Some(path) => Some(FsPackages::new(path.clone())),
233 None => FsPackages::system_cache(),
234 };
235 let universe = if self.offline {
236 UniversePackages::new(crate::world::OfflineDownloader)
237 } else {
238 UniversePackages::new(SystemDownloader::new(USER_AGENT))
239 };
240 let packages = SystemPackages::from_parts(data, cache, universe);
241
242 let mut fonts = FontStore::new();
243 for path in &self.font_paths {
244 fonts.extend(typst_kit::fonts::scan(path));
245 }
246 #[cfg(feature = "embedded-fonts")]
247 fonts.extend(typst_kit::fonts::embedded());
248 if self.system_fonts {
249 fonts.extend(typst_kit::fonts::system());
250 }
251
252 let primary = Arc::new(PrimaryLoader {
253 system: SystemFiles::new(FsRoot::new(root.clone()), packages),
254 cache: Mutex::new(HashMap::new()),
255 });
256 let mut world = DiscoveryWorld {
257 root: root.clone(),
258 library: LazyHash::new(Library::builder().with_inputs(self.inputs.clone()).build()),
259 main: RootedPath::new(VirtualRoot::Project, entrypoint.clone()).intern(),
260 sources: FileStore::new(Arc::clone(&primary)),
261 files: FileStore::new(DiscoveryLoader {
262 primary,
263 policy: self.project_resource_policy,
264 external_loaders: self.external_resource_loaders,
265 external_resources: Mutex::new(explicit_external_resources.clone()),
266 explicit_external_resources,
267 }),
268 fonts,
269 time: Time::system(),
270 #[cfg(test)]
271 source_request_hook: None,
272 };
273
274 let Warned { output, warnings } = typst::compile::<PagedDocument>(&world);
276 let document = match output {
277 Ok(document) => document,
278 Err(errors) => {
279 return Err(PackerError::Compile {
280 world: Box::new(world),
281 errors,
282 warnings,
283 });
284 }
285 };
286
287 let mut report = PackReport {
288 files: Vec::new(),
289 external_resources: Vec::new(),
290 packages_vendored: Vec::new(),
291 packages_external: Vec::new(),
292 fonts: Vec::new(),
293 warnings: Vec::new(),
294 compile_warnings: warnings,
295 };
296
297 let source_dependencies: Vec<FileId> = {
299 let (_, iter) = world.sources.dependencies();
300 iter.collect()
301 };
302 let file_dependencies: Vec<FileId> = {
303 let (_, iter) = world.files.dependencies();
304 iter.collect()
305 };
306 enum ProjectFileOrigin {
307 Source,
308 File,
309 }
310 let mut project_files: Vec<(FileId, ProjectFileOrigin)> = Vec::new();
311 let mut package_files: BTreeMap<String, (PackageSpec, FileId)> = BTreeMap::new();
312 for id in source_dependencies {
313 match id.root() {
314 VirtualRoot::Project => project_files.push((id, ProjectFileOrigin::Source)),
315 VirtualRoot::Package(spec) => {
316 package_files
317 .entry(spec.to_string())
318 .or_insert_with(|| (spec.clone(), id));
319 }
320 }
321 }
322 for id in file_dependencies {
323 match id.root() {
324 VirtualRoot::Project if world.files.loader().is_external(id) => {}
325 VirtualRoot::Project if project_files.iter().any(|(source, _)| *source == id) => {}
326 VirtualRoot::Project => project_files.push((id, ProjectFileOrigin::File)),
327 VirtualRoot::Package(spec) => {
328 package_files
329 .entry(spec.to_string())
330 .or_insert_with(|| (spec.clone(), id));
331 }
332 }
333 }
334
335 let mut builder = Pack::builder(entrypoint.get_without_slash());
336
337 for path in world.files.loader().external_resources() {
338 report.external_resources.push(path.clone());
339 builder = builder.external_resource(path)?;
340 }
341
342 project_files.sort_by_key(|(id, _)| id.vpath().get_with_slash().to_owned());
344 for (id, origin) in project_files {
345 let path = id.vpath().get_without_slash();
346 let data = match origin {
347 ProjectFileOrigin::Source => world.sources.file(id),
348 ProjectFileOrigin::File => world.files.file(id),
349 };
350 match data {
351 Ok(data) => {
352 report.files.push(path.to_owned());
353 builder = builder.file(path, data.to_vec())?;
354 }
355 Err(_) => {
356 }
359 }
360 }
361
362 if !report.files.iter().any(|path| path == "typst.toml")
365 && let Ok(data) = std::fs::read(root.join("typst.toml"))
366 {
367 report.files.push("typst.toml".to_owned());
368 builder = builder.file("typst.toml", data)?;
369 }
370
371 for path in &self.include {
373 let absolute = if path.is_absolute() {
374 path.clone()
375 } else {
376 root.join(path)
377 };
378 let absolute = absolute.canonicalize().map_err(|err| {
379 PackerError::io(
380 &format!("failed to resolve include `{}`", path.display()),
381 err,
382 )
383 })?;
384 let mut selected: Vec<PathBuf> = Vec::new();
385 if absolute.is_dir() {
386 for entry in walkdir::WalkDir::new(&absolute).sort_by_file_name() {
387 let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
388 if !entry.file_type().is_file() {
389 continue;
390 }
391 if entry.path().extension().is_some_and(|ext| ext == "typk") {
392 report.warnings.push(format!(
393 "skipped pack file `{}` inside included directory",
394 entry.path().display()
395 ));
396 continue;
397 }
398 selected.push(entry.path().to_owned());
399 }
400 } else {
401 selected.push(absolute);
402 }
403 for file in selected {
404 let vpath = VirtualPath::virtualize(&root, &file)
405 .map_err(|_| PackerError::OutsideRoot(file.clone()))?;
406 let data = std::fs::read(&file).map_err(|err| {
407 PackerError::io(&format!("failed to read `{}`", file.display()), err)
408 })?;
409 let path = vpath.get_without_slash().to_owned();
410 if !report.files.contains(&path) {
411 report.files.push(path.clone());
412 }
413 builder = builder.file(path, data)?;
414 }
415 }
416
417 for (spec, id) in package_files.values() {
419 if self.vendor_packages {
420 let package_root =
421 world
422 .files
423 .loader()
424 .root(*id)
425 .map_err(|err| PackerError::Package {
426 spec: spec.clone(),
427 message: err.to_string(),
428 })?;
429 for entry in walkdir::WalkDir::new(package_root.path()).sort_by_file_name() {
430 let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
431 if !entry.file_type().is_file() {
432 continue;
433 }
434 let vpath = VirtualPath::virtualize(package_root.path(), entry.path())
435 .map_err(|_| PackerError::OutsideRoot(entry.path().to_owned()))?;
436 let data = std::fs::read(entry.path()).map_err(|err| {
437 PackerError::io(
438 &format!("failed to read `{}`", entry.path().display()),
439 err,
440 )
441 })?;
442 builder =
443 builder.package_file(spec.clone(), vpath.get_without_slash(), data)?;
444 }
445 report.packages_vendored.push(spec.clone());
446 } else {
447 builder = builder.external_package(spec.clone());
448 report.packages_external.push(spec.clone());
449 }
450 }
451
452 if self.embed_fonts {
454 let mut used: Vec<Font> = Vec::new();
455 for page in document.pages() {
456 collect_fonts(&page.frame, &mut used);
457 }
458 for font in used {
459 if !self.include_default_fonts && is_default_font(&font) {
460 continue;
461 }
462 builder = builder.font(font.data().to_vec(), font.index())?;
463 }
464 }
465
466 if let Some(metadata) = self.metadata {
467 builder = builder.metadata(metadata);
468 }
469
470 let pack = builder.build()?;
471 report.fonts = pack
472 .fonts()
473 .iter()
474 .map(|font| font.entry.path.clone())
475 .collect();
476
477 Ok(PackOutcome {
478 pack,
479 report,
480 world,
481 })
482 }
483}
484
485pub struct PackOutcome {
487 pub pack: Pack,
489 pub report: PackReport,
491 pub world: DiscoveryWorld,
494}
495
496#[derive(Debug, Clone)]
498pub struct PackReport {
499 pub files: Vec<String>,
501 pub external_resources: Vec<String>,
503 pub packages_vendored: Vec<PackageSpec>,
505 pub packages_external: Vec<PackageSpec>,
507 pub fonts: Vec<String>,
509 pub warnings: Vec<String>,
511 pub compile_warnings: EcoVec<SourceDiagnostic>,
513}
514
515#[derive(Debug, thiserror::Error)]
517pub enum PackerError {
518 #[error("{message}: {source}")]
519 Io {
520 message: String,
521 #[source]
522 source: std::io::Error,
523 },
524 #[error("`{0}` is outside the project root and cannot be packed")]
525 OutsideRoot(PathBuf),
526 #[error("the discovery compile failed with {} error(s)", errors.len())]
527 Compile {
528 world: Box<DiscoveryWorld>,
530 errors: EcoVec<SourceDiagnostic>,
531 warnings: EcoVec<SourceDiagnostic>,
532 },
533 #[error("failed to load package {spec}: {message}")]
534 Package { spec: PackageSpec, message: String },
535 #[error("failed to walk directory: {0}")]
536 Walk(String),
537 #[error(transparent)]
538 Build(#[from] PackBuildError),
539}
540
541impl PackerError {
542 fn io(message: &str, source: std::io::Error) -> Self {
543 Self::Io {
544 message: message.to_owned(),
545 source,
546 }
547 }
548}
549
550pub struct DiscoveryWorld {
555 root: PathBuf,
556 library: LazyHash<Library>,
557 main: FileId,
558 sources: FileStore<Arc<PrimaryLoader>>,
559 files: FileStore<DiscoveryLoader>,
560 fonts: FontStore,
561 time: Time,
562 #[cfg(test)]
563 source_request_hook: Option<Arc<dyn Fn(FileId) + Send + Sync>>,
564}
565
566impl DiscoveryWorld {
567 pub fn root(&self) -> &Path {
569 &self.root
570 }
571
572 #[cfg(test)]
573 pub(crate) fn set_source_request_hook(
574 &mut self,
575 hook: impl Fn(FileId) + Send + Sync + 'static,
576 ) {
577 self.source_request_hook = Some(Arc::new(hook));
578 }
579}
580
581impl fmt::Debug for DiscoveryWorld {
582 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
583 f.debug_struct("DiscoveryWorld")
584 .field("root", &self.root)
585 .finish_non_exhaustive()
586 }
587}
588
589impl World for DiscoveryWorld {
590 fn library(&self) -> &LazyHash<Library> {
591 &self.library
592 }
593
594 fn book(&self) -> &LazyHash<FontBook> {
595 self.fonts.book()
596 }
597
598 fn main(&self) -> FileId {
599 self.main
600 }
601
602 fn source(&self, id: FileId) -> FileResult<Source> {
603 if matches!(id.root(), VirtualRoot::Project) && self.files.loader().is_explicit_external(id)
604 {
605 return Err(FileError::NotFound(PathBuf::from(
606 id.vpath().get_without_slash(),
607 )));
608 }
609 #[cfg(test)]
610 if let Some(hook) = &self.source_request_hook {
611 hook(id);
612 }
613 self.sources.source(id)
614 }
615
616 fn file(&self, id: FileId) -> FileResult<Bytes> {
617 self.files.file(id)
618 }
619
620 fn font(&self, index: usize) -> Option<Font> {
621 self.fonts.font(index)
622 }
623
624 fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
625 self.time.today(offset)
626 }
627}
628
629struct PrimaryLoader {
630 system: SystemFiles,
631 cache: Mutex<HashMap<FileId, Arc<OnceLock<FileResult<Bytes>>>>>,
632}
633
634impl PrimaryLoader {
635 fn root(&self, id: FileId) -> FileResult<FsRoot> {
636 self.system.root(id)
637 }
638}
639
640impl FileLoader for PrimaryLoader {
641 fn load(&self, id: FileId) -> FileResult<Bytes> {
642 let entry = {
643 let mut cache = self.cache.lock().expect("primary file cache lock poisoned");
644 Arc::clone(cache.entry(id).or_default())
645 };
646 entry.get_or_init(|| self.system.load(id)).clone()
647 }
648}
649
650struct DiscoveryLoader {
651 primary: Arc<PrimaryLoader>,
652 policy: ProjectResourcePolicy,
653 external_loaders: Vec<Box<dyn FileLoader + Send + Sync>>,
654 external_resources: Mutex<BTreeSet<String>>,
655 explicit_external_resources: BTreeSet<String>,
656}
657
658impl DiscoveryLoader {
659 fn root(&self, id: FileId) -> FileResult<FsRoot> {
660 self.primary.root(id)
661 }
662
663 fn is_external(&self, id: FileId) -> bool {
664 self.external_resources
665 .lock()
666 .expect("external resource provenance lock poisoned")
667 .contains(id.vpath().get_without_slash())
668 }
669
670 fn is_explicit_external(&self, id: FileId) -> bool {
671 self.explicit_external_resources
672 .contains(id.vpath().get_without_slash())
673 }
674
675 fn external_resources(&self) -> Vec<String> {
676 self.external_resources
677 .lock()
678 .expect("external resource provenance lock poisoned")
679 .iter()
680 .cloned()
681 .collect()
682 }
683}
684
685impl FileLoader for DiscoveryLoader {
686 fn load(&self, id: FileId) -> FileResult<Bytes> {
687 match self.primary.load(id) {
688 Ok(data) => {
689 if matches!(id.root(), VirtualRoot::Project)
690 && self
691 .explicit_external_resources
692 .contains(id.vpath().get_without_slash())
693 {
694 self.external_resources
695 .lock()
696 .expect("external resource provenance lock poisoned")
697 .insert(id.vpath().get_without_slash().to_owned());
698 }
699 Ok(data)
700 }
701 Err(FileError::NotFound(_))
702 if matches!(id.root(), VirtualRoot::Project)
703 && self.policy == ProjectResourcePolicy::AllowExternalFallback =>
704 {
705 let data = load_external_resource(&self.external_loaders, id)?;
706 self.external_resources
707 .lock()
708 .expect("external resource provenance lock poisoned")
709 .insert(id.vpath().get_without_slash().to_owned());
710 Ok(data)
711 }
712 Err(err) => Err(err),
713 }
714 }
715}
716
717fn collect_fonts(frame: &Frame, used: &mut Vec<Font>) {
719 for (_, item) in frame.items() {
720 match item {
721 FrameItem::Group(group) => collect_fonts(&group.frame, used),
722 FrameItem::Text(text) => {
723 let font = text.font.font();
724 if !used.contains(font) {
725 used.push(font.clone());
726 }
727 }
728 _ => {}
729 }
730 }
731}
732
733fn is_default_font(font: &Font) -> bool {
735 #[cfg(feature = "embedded-fonts")]
736 {
737 typst_kit::fonts::embedded()
738 .any(|(default, _)| default.data().as_slice() == font.data().as_slice())
739 }
740 #[cfg(not(feature = "embedded-fonts"))]
741 {
742 let _ = font;
743 false
744 }
745}