1#![cfg(feature = "fs")]
4
5use std::collections::BTreeMap;
6use std::fmt;
7use std::path::{Path, PathBuf};
8
9use ecow::EcoVec;
10use typst::diag::{FileResult, SourceDiagnostic, Warned};
11use typst::foundations::{Bytes, Datetime, Dict, Duration};
12use typst::layout::{Frame, FrameItem};
13use typst::syntax::package::PackageSpec;
14use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
15use typst::text::{Font, FontBook};
16use typst::utils::LazyHash;
17use typst::{Library, LibraryExt, World};
18use typst_kit::datetime::Time;
19use typst_kit::downloader::SystemDownloader;
20use typst_kit::files::{FileStore, FsRoot, SystemFiles};
21use typst_kit::fonts::FontStore;
22use typst_kit::packages::{FsPackages, SystemPackages, UniversePackages};
23use typst_layout::PagedDocument;
24
25use crate::manifest::Metadata;
26use crate::pack::{Pack, PackBuildError};
27
28const USER_AGENT: &str = concat!("typst-pack/", env!("CARGO_PKG_VERSION"));
30
31pub struct Packer {
40 root: PathBuf,
41 entrypoint: PathBuf,
42 vendor_packages: bool,
43 embed_fonts: bool,
44 include_default_fonts: bool,
45 include: Vec<PathBuf>,
46 font_paths: Vec<PathBuf>,
47 system_fonts: bool,
48 inputs: Dict,
49 package_path: Option<PathBuf>,
50 package_cache_path: Option<PathBuf>,
51 offline: bool,
52 metadata: Option<Metadata>,
53}
54
55impl Packer {
56 pub fn new(root: impl Into<PathBuf>, entrypoint: impl Into<PathBuf>) -> Self {
59 Self {
60 root: root.into(),
61 entrypoint: entrypoint.into(),
62 vendor_packages: true,
63 embed_fonts: false,
64 include_default_fonts: false,
65 include: Vec::new(),
66 font_paths: Vec::new(),
67 system_fonts: true,
68 inputs: Dict::new(),
69 package_path: None,
70 package_cache_path: None,
71 offline: false,
72 metadata: None,
73 }
74 }
75
76 pub fn vendor_packages(mut self, vendor: bool) -> Self {
80 self.vendor_packages = vendor;
81 self
82 }
83
84 pub fn embed_fonts(mut self, embed: bool) -> Self {
89 self.embed_fonts = embed;
90 self
91 }
92
93 pub fn include_default_fonts(mut self, include: bool) -> Self {
97 self.include_default_fonts = include;
98 self
99 }
100
101 pub fn include(mut self, path: impl Into<PathBuf>) -> Self {
105 self.include.push(path.into());
106 self
107 }
108
109 pub fn font_path(mut self, path: impl Into<PathBuf>) -> Self {
111 self.font_paths.push(path.into());
112 self
113 }
114
115 pub fn system_fonts(mut self, system: bool) -> Self {
118 self.system_fonts = system;
119 self
120 }
121
122 pub fn inputs(mut self, inputs: Dict) -> Self {
125 self.inputs = inputs;
126 self
127 }
128
129 pub fn package_path(mut self, path: impl Into<PathBuf>) -> Self {
132 self.package_path = Some(path.into());
133 self
134 }
135
136 pub fn package_cache_path(mut self, path: impl Into<PathBuf>) -> Self {
138 self.package_cache_path = Some(path.into());
139 self
140 }
141
142 pub fn offline(mut self, offline: bool) -> Self {
149 self.offline = offline;
150 self
151 }
152
153 pub fn metadata(mut self, metadata: Metadata) -> Self {
155 self.metadata = Some(metadata);
156 self
157 }
158
159 pub fn pack(self) -> Result<PackOutcome, PackerError> {
161 let root = self
162 .root
163 .canonicalize()
164 .map_err(|err| PackerError::io("failed to resolve project root", err))?;
165 let entrypoint_abs = if self.entrypoint.is_absolute() {
166 self.entrypoint.clone()
167 } else {
168 root.join(&self.entrypoint)
169 };
170 let entrypoint_abs = entrypoint_abs
171 .canonicalize()
172 .map_err(|err| PackerError::io("failed to resolve entrypoint", err))?;
173 let entrypoint = VirtualPath::virtualize(&root, &entrypoint_abs)
174 .map_err(|_| PackerError::OutsideRoot(entrypoint_abs.clone()))?;
175
176 let data = match &self.package_path {
178 Some(path) => Some(FsPackages::new(path.clone())),
179 None => FsPackages::system_data(),
180 };
181 let cache = match &self.package_cache_path {
182 Some(path) => Some(FsPackages::new(path.clone())),
183 None => FsPackages::system_cache(),
184 };
185 let universe = if self.offline {
186 UniversePackages::new(crate::world::OfflineDownloader)
187 } else {
188 UniversePackages::new(SystemDownloader::new(USER_AGENT))
189 };
190 let packages = SystemPackages::from_parts(data, cache, universe);
191
192 let mut fonts = FontStore::new();
193 for path in &self.font_paths {
194 fonts.extend(typst_kit::fonts::scan(path));
195 }
196 #[cfg(feature = "embedded-fonts")]
197 fonts.extend(typst_kit::fonts::embedded());
198 if self.system_fonts {
199 fonts.extend(typst_kit::fonts::system());
200 }
201
202 let mut world = DiscoveryWorld {
203 root: root.clone(),
204 library: LazyHash::new(Library::builder().with_inputs(self.inputs.clone()).build()),
205 main: RootedPath::new(VirtualRoot::Project, entrypoint.clone()).intern(),
206 store: FileStore::new(SystemFiles::new(FsRoot::new(root.clone()), packages)),
207 fonts,
208 time: Time::system(),
209 };
210
211 let Warned { output, warnings } = typst::compile::<PagedDocument>(&world);
213 let document = match output {
214 Ok(document) => document,
215 Err(errors) => {
216 return Err(PackerError::Compile {
217 world: Box::new(world),
218 errors,
219 warnings,
220 });
221 }
222 };
223
224 let mut report = PackReport {
225 files: Vec::new(),
226 packages_vendored: Vec::new(),
227 packages_external: Vec::new(),
228 fonts: Vec::new(),
229 warnings: Vec::new(),
230 compile_warnings: warnings,
231 };
232
233 let dependencies: Vec<FileId> = {
235 let (_, iter) = world.store.dependencies();
236 iter.collect()
237 };
238 let mut project_files: Vec<FileId> = Vec::new();
239 let mut package_files: BTreeMap<String, (PackageSpec, FileId)> = BTreeMap::new();
240 for id in dependencies {
241 match id.root() {
242 VirtualRoot::Project => project_files.push(id),
243 VirtualRoot::Package(spec) => {
244 package_files
245 .entry(spec.to_string())
246 .or_insert_with(|| (spec.clone(), id));
247 }
248 }
249 }
250
251 let mut builder = Pack::builder(entrypoint.get_without_slash());
252
253 project_files.sort_by_key(|id| id.vpath().get_with_slash().to_owned());
255 for id in project_files {
256 let path = id.vpath().get_without_slash();
257 match world.store.file(id) {
258 Ok(data) => {
259 report.files.push(path.to_owned());
260 builder = builder.file(path, data.to_vec())?;
261 }
262 Err(_) => {
263 }
266 }
267 }
268
269 if !report.files.iter().any(|path| path == "typst.toml")
272 && let Ok(data) = std::fs::read(root.join("typst.toml"))
273 {
274 report.files.push("typst.toml".to_owned());
275 builder = builder.file("typst.toml", data)?;
276 }
277
278 for path in &self.include {
280 let absolute = if path.is_absolute() {
281 path.clone()
282 } else {
283 root.join(path)
284 };
285 let absolute = absolute.canonicalize().map_err(|err| {
286 PackerError::io(
287 &format!("failed to resolve include `{}`", path.display()),
288 err,
289 )
290 })?;
291 let mut selected: Vec<PathBuf> = Vec::new();
292 if absolute.is_dir() {
293 for entry in walkdir::WalkDir::new(&absolute).sort_by_file_name() {
294 let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
295 if !entry.file_type().is_file() {
296 continue;
297 }
298 if entry.path().extension().is_some_and(|ext| ext == "typk") {
299 report.warnings.push(format!(
300 "skipped pack file `{}` inside included directory",
301 entry.path().display()
302 ));
303 continue;
304 }
305 selected.push(entry.path().to_owned());
306 }
307 } else {
308 selected.push(absolute);
309 }
310 for file in selected {
311 let vpath = VirtualPath::virtualize(&root, &file)
312 .map_err(|_| PackerError::OutsideRoot(file.clone()))?;
313 let data = std::fs::read(&file).map_err(|err| {
314 PackerError::io(&format!("failed to read `{}`", file.display()), err)
315 })?;
316 let path = vpath.get_without_slash().to_owned();
317 if !report.files.contains(&path) {
318 report.files.push(path.clone());
319 }
320 builder = builder.file(path, data)?;
321 }
322 }
323
324 for (spec, id) in package_files.values() {
326 if self.vendor_packages {
327 let package_root =
328 world
329 .store
330 .loader()
331 .root(*id)
332 .map_err(|err| PackerError::Package {
333 spec: spec.clone(),
334 message: err.to_string(),
335 })?;
336 for entry in walkdir::WalkDir::new(package_root.path()).sort_by_file_name() {
337 let entry = entry.map_err(|err| PackerError::Walk(err.to_string()))?;
338 if !entry.file_type().is_file() {
339 continue;
340 }
341 let vpath = VirtualPath::virtualize(package_root.path(), entry.path())
342 .map_err(|_| PackerError::OutsideRoot(entry.path().to_owned()))?;
343 let data = std::fs::read(entry.path()).map_err(|err| {
344 PackerError::io(
345 &format!("failed to read `{}`", entry.path().display()),
346 err,
347 )
348 })?;
349 builder =
350 builder.package_file(spec.clone(), vpath.get_without_slash(), data)?;
351 }
352 report.packages_vendored.push(spec.clone());
353 } else {
354 builder = builder.external_package(spec.clone());
355 report.packages_external.push(spec.clone());
356 }
357 }
358
359 if self.embed_fonts {
361 let mut used: Vec<Font> = Vec::new();
362 for page in document.pages() {
363 collect_fonts(&page.frame, &mut used);
364 }
365 for font in used {
366 if !self.include_default_fonts && is_default_font(&font) {
367 continue;
368 }
369 builder = builder.font(font.data().to_vec(), font.index())?;
370 }
371 }
372
373 if let Some(metadata) = self.metadata {
374 builder = builder.metadata(metadata);
375 }
376
377 let pack = builder.build()?;
378 report.fonts = pack
379 .fonts()
380 .iter()
381 .map(|font| font.entry.path.clone())
382 .collect();
383
384 Ok(PackOutcome {
385 pack,
386 report,
387 world,
388 })
389 }
390}
391
392pub struct PackOutcome {
394 pub pack: Pack,
396 pub report: PackReport,
398 pub world: DiscoveryWorld,
401}
402
403#[derive(Debug, Clone)]
405pub struct PackReport {
406 pub files: Vec<String>,
408 pub packages_vendored: Vec<PackageSpec>,
410 pub packages_external: Vec<PackageSpec>,
412 pub fonts: Vec<String>,
414 pub warnings: Vec<String>,
416 pub compile_warnings: EcoVec<SourceDiagnostic>,
418}
419
420#[derive(Debug, thiserror::Error)]
422pub enum PackerError {
423 #[error("{message}: {source}")]
424 Io {
425 message: String,
426 #[source]
427 source: std::io::Error,
428 },
429 #[error("`{0}` is outside the project root and cannot be packed")]
430 OutsideRoot(PathBuf),
431 #[error("the discovery compile failed with {} error(s)", errors.len())]
432 Compile {
433 world: Box<DiscoveryWorld>,
435 errors: EcoVec<SourceDiagnostic>,
436 warnings: EcoVec<SourceDiagnostic>,
437 },
438 #[error("failed to load package {spec}: {message}")]
439 Package { spec: PackageSpec, message: String },
440 #[error("failed to walk directory: {0}")]
441 Walk(String),
442 #[error(transparent)]
443 Build(#[from] PackBuildError),
444}
445
446impl PackerError {
447 fn io(message: &str, source: std::io::Error) -> Self {
448 Self::Io {
449 message: message.to_owned(),
450 source,
451 }
452 }
453}
454
455pub struct DiscoveryWorld {
460 root: PathBuf,
461 library: LazyHash<Library>,
462 main: FileId,
463 store: FileStore<SystemFiles>,
464 fonts: FontStore,
465 time: Time,
466}
467
468impl DiscoveryWorld {
469 pub fn root(&self) -> &Path {
471 &self.root
472 }
473}
474
475impl fmt::Debug for DiscoveryWorld {
476 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477 f.debug_struct("DiscoveryWorld")
478 .field("root", &self.root)
479 .finish_non_exhaustive()
480 }
481}
482
483impl World for DiscoveryWorld {
484 fn library(&self) -> &LazyHash<Library> {
485 &self.library
486 }
487
488 fn book(&self) -> &LazyHash<FontBook> {
489 self.fonts.book()
490 }
491
492 fn main(&self) -> FileId {
493 self.main
494 }
495
496 fn source(&self, id: FileId) -> FileResult<Source> {
497 self.store.source(id)
498 }
499
500 fn file(&self, id: FileId) -> FileResult<Bytes> {
501 self.store.file(id)
502 }
503
504 fn font(&self, index: usize) -> Option<Font> {
505 self.fonts.font(index)
506 }
507
508 fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
509 self.time.today(offset)
510 }
511}
512
513fn collect_fonts(frame: &Frame, used: &mut Vec<Font>) {
515 for (_, item) in frame.items() {
516 match item {
517 FrameItem::Group(group) => collect_fonts(&group.frame, used),
518 FrameItem::Text(text) => {
519 let font = text.font.font();
520 if !used.contains(font) {
521 used.push(font.clone());
522 }
523 }
524 _ => {}
525 }
526 }
527}
528
529fn is_default_font(font: &Font) -> bool {
531 #[cfg(feature = "embedded-fonts")]
532 {
533 typst_kit::fonts::embedded()
534 .any(|(default, _)| default.data().as_slice() == font.data().as_slice())
535 }
536 #[cfg(not(feature = "embedded-fonts"))]
537 {
538 let _ = font;
539 false
540 }
541}