1#![cfg(feature = "cli")]
4
5use std::fs::File;
6use std::io::BufReader;
7use std::path::{Path, PathBuf};
8use std::process::ExitCode;
9
10use chrono::{Datelike, Timelike};
11use clap::{Args, Parser, Subcommand};
12use typst::diag::{FileError, FileResult, SourceDiagnostic};
13use typst::foundations::{Bytes, Datetime, Dict, IntoValue};
14use typst::syntax::{FileId, VirtualRoot};
15use typst_kit::diagnostics::termcolor::{ColorChoice, StandardStream, WriteColor};
16use typst_kit::diagnostics::{DiagnosticFormat, DiagnosticWorld};
17use typst_kit::files::{FileLoader, FsRoot};
18use typst_pdf::{PdfStandard, PdfStandards, Timestamp};
19
20use crate::compile::{CompileError, CompileOptions, OutputFormat, compile, parse_pages};
21use crate::extract::{ExtractOptions, extract};
22use crate::manifest::Metadata;
23use crate::pack::{FILE_EXTENSION, Pack};
24use crate::packer::{DiscoveryWorld, Packer, PackerError, ProjectResourcePolicy};
25use crate::world::{PackWorld, PackWorldError, SystemPackageLoader};
26
27#[derive(Debug, Parser)]
29#[command(name = "typst-pack", version, about)]
30pub struct Cli {
31 #[command(subcommand)]
32 command: Command,
33}
34
35#[derive(Debug, Subcommand)]
36enum Command {
37 Create(CreateArgs),
39 Inspect(InspectArgs),
41 Extract(ExtractArgs),
43 Compile(CompileArgs),
45}
46
47#[derive(Debug, Args)]
48struct CreateArgs {
49 project: PathBuf,
51
52 #[arg(short, long)]
54 output: Option<PathBuf>,
55
56 #[arg(long)]
58 entrypoint: Option<PathBuf>,
59
60 #[arg(long)]
62 root: Option<PathBuf>,
63
64 #[arg(long)]
67 no_packages: bool,
68
69 #[arg(long)]
71 embed_fonts: bool,
72
73 #[arg(long, requires = "embed_fonts")]
76 include_default_fonts: bool,
77
78 #[arg(long = "include", value_name = "PATH")]
81 include: Vec<PathBuf>,
82
83 #[arg(long = "resource-path", value_name = "DIR")]
85 resource_paths: Vec<PathBuf>,
86
87 #[arg(long = "external-resource", value_name = "PATH")]
89 external_resources: Vec<String>,
90
91 #[arg(long = "input", value_name = "KEY=VALUE")]
94 inputs: Vec<String>,
95
96 #[arg(long = "font-path", value_name = "DIR")]
99 font_paths: Vec<PathBuf>,
100
101 #[arg(long)]
103 ignore_system_fonts: bool,
104
105 #[arg(long, value_name = "DIR")]
107 package_path: Option<PathBuf>,
108
109 #[arg(long, value_name = "DIR", env = "TYPST_PACK_PACKAGE_CACHE_PATH")]
112 package_cache_path: Option<PathBuf>,
113
114 #[arg(long)]
117 offline: bool,
118
119 #[arg(long)]
121 name: Option<String>,
122
123 #[arg(long)]
125 description: Option<String>,
126
127 #[arg(long = "author", value_name = "AUTHOR")]
129 authors: Vec<String>,
130}
131
132#[derive(Debug, Args)]
133struct InspectArgs {
134 pack: PathBuf,
136}
137
138#[derive(Debug, Args)]
139struct ExtractArgs {
140 pack: PathBuf,
142
143 #[arg(short, long)]
145 output: Option<PathBuf>,
146
147 #[arg(long)]
149 packages: bool,
150
151 #[arg(long)]
153 fonts: bool,
154
155 #[arg(long)]
157 all: bool,
158
159 #[arg(long)]
161 force: bool,
162}
163
164#[derive(Debug, Args)]
165struct CompileArgs {
166 pack: PathBuf,
168
169 output: Option<PathBuf>,
174
175 #[arg(short, long, value_parser = ["pdf", "png", "svg", "html"])]
179 format: Option<String>,
180
181 #[arg(
183 long = "features",
184 value_name = "FEATURE",
185 value_delimiter = ',',
186 env = "TYPST_FEATURES",
187 value_parser = ["html"]
188 )]
189 features: Vec<String>,
190
191 #[arg(long)]
193 pages: Option<String>,
194
195 #[arg(long, default_value_t = 144.0)]
197 ppi: f32,
198
199 #[arg(long = "input", value_name = "KEY=VALUE")]
201 inputs: Vec<String>,
202
203 #[arg(long = "resource-path", value_name = "DIR")]
205 resource_paths: Vec<PathBuf>,
206
207 #[arg(long = "font-path", value_name = "DIR")]
209 font_paths: Vec<PathBuf>,
210
211 #[arg(long)]
213 ignore_system_fonts: bool,
214
215 #[arg(long)]
217 ignore_embedded_fonts: bool,
218
219 #[arg(long = "pdf-standard", value_name = "STANDARD")]
221 pdf_standards: Vec<String>,
222
223 #[arg(long, value_name = "UNIX_TIMESTAMP")]
226 creation_timestamp: Option<i64>,
227
228 #[arg(long, value_name = "DIR")]
230 package_path: Option<PathBuf>,
231
232 #[arg(long, value_name = "DIR", env = "TYPST_PACK_PACKAGE_CACHE_PATH")]
235 package_cache_path: Option<PathBuf>,
236
237 #[arg(long)]
240 offline: bool,
241
242 #[arg(long, default_value = "human", value_parser = ["human", "short"])]
244 diagnostic_format: String,
245
246 #[arg(long)]
248 open: bool,
249}
250
251pub fn run() -> ExitCode {
253 let cli = Cli::parse();
254 let result = match cli.command {
255 Command::Create(args) => create(args),
256 Command::Inspect(args) => inspect(args),
257 Command::Extract(args) => extract_command(args),
258 Command::Compile(args) => compile_command(args),
259 };
260 match result {
261 Ok(()) => ExitCode::SUCCESS,
262 Err(error) => {
263 eprintln!("error: {error}");
264 ExitCode::FAILURE
265 }
266 }
267}
268
269fn create(args: CreateArgs) -> Result<(), String> {
270 let project = args
271 .project
272 .canonicalize()
273 .map_err(|err| format!("cannot access `{}`: {err}", args.project.display()))?;
274
275 let (root, entrypoint) = if project.is_dir() {
276 let root = args.root.unwrap_or_else(|| project.clone());
277 let entrypoint = args.entrypoint.unwrap_or_else(|| PathBuf::from("main.typ"));
278 (root, entrypoint)
279 } else {
280 if args.entrypoint.is_some() {
281 return Err("--entrypoint only applies when PROJECT is a directory".into());
282 }
283 let root = match args.root {
284 Some(root) => root,
285 None => project
286 .parent()
287 .ok_or("cannot determine project root")?
288 .to_path_buf(),
289 };
290 (root, project.clone())
291 };
292
293 let output = args.output.unwrap_or_else(|| {
294 let stem = root
295 .file_name()
296 .map(|name| name.to_string_lossy().into_owned())
297 .unwrap_or_else(|| "project".to_owned());
298 PathBuf::from(format!("{stem}.{FILE_EXTENSION}"))
299 });
300
301 let mut packer = Packer::new(&root, &entrypoint)
302 .vendor_packages(!args.no_packages)
303 .embed_fonts(args.embed_fonts)
304 .include_default_fonts(args.include_default_fonts)
305 .system_fonts(!args.ignore_system_fonts)
306 .offline(args.offline)
307 .inputs(parse_inputs(&args.inputs)?);
308 for path in &args.include {
309 packer = packer.include(path);
310 }
311 if !args.resource_paths.is_empty() {
312 packer = packer.project_resource_policy(ProjectResourcePolicy::AllowExternalFallback);
313 }
314 for path in &args.resource_paths {
315 packer = packer.external_resource_loader(ProjectResourceRoot::new(path.clone()));
316 }
317 for path in args.external_resources {
318 packer = packer.external_resource(path);
319 }
320 for path in &args.font_paths {
321 packer = packer.font_path(path);
322 }
323 if let Some(path) = &args.package_path {
324 packer = packer.package_path(path);
325 }
326 if let Some(path) = &args.package_cache_path {
327 packer = packer.package_cache_path(path);
328 }
329 if args.name.is_some() || args.description.is_some() || !args.authors.is_empty() {
330 packer = packer.metadata(Metadata {
331 name: args.name,
332 description: args.description,
333 authors: args.authors,
334 });
335 }
336
337 let outcome = match packer.pack() {
338 Ok(outcome) => outcome,
339 Err(PackerError::Compile {
340 world,
341 errors,
342 warnings,
343 }) => {
344 emit_diagnostics(world.as_ref(), errors.iter().chain(&warnings));
345 return Err("the discovery compile failed".into());
346 }
347 Err(err) => return Err(err.to_string()),
348 };
349
350 emit_diagnostics(&outcome.world, outcome.report.compile_warnings.iter());
351 for warning in &outcome.report.warnings {
352 eprintln!("warning: {warning}");
353 }
354
355 let file = File::create(&output)
356 .map_err(|err| format!("cannot create `{}`: {err}", output.display()))?;
357 outcome
358 .pack
359 .write(std::io::BufWriter::new(file))
360 .map_err(|err| err.to_string())?;
361
362 let report = &outcome.report;
363 println!(
364 "packed {} project file(s), {} package(s), {} font(s) into `{}`",
365 report.files.len(),
366 report.packages_vendored.len(),
367 report.fonts.len(),
368 output.display(),
369 );
370 if !report.packages_external.is_empty() {
371 println!(
372 "note: {} package(s) were not vendored and must be available when compiling:",
373 report.packages_external.len()
374 );
375 for spec in &report.packages_external {
376 println!(" {spec}");
377 }
378 }
379 if !report.external_resources.is_empty() {
380 println!(
381 "note: {} External Project Resource path(s) are declared and must be supplied if requested:",
382 report.external_resources.len()
383 );
384 for path in &report.external_resources {
385 println!(" {path}");
386 }
387 }
388 Ok(())
389}
390
391fn inspect(args: InspectArgs) -> Result<(), String> {
392 let pack = read_pack(&args.pack)?;
393 let manifest = pack.manifest();
394
395 println!("pack: {}", args.pack.display());
396 println!("format version: {}", manifest.format_version);
397 println!("entrypoint: {}", pack.entrypoint());
398 if let Some(metadata) = &manifest.metadata {
399 if let Some(name) = &metadata.name {
400 println!("name: {name}");
401 }
402 if let Some(description) = &metadata.description {
403 println!("description: {description}");
404 }
405 if !metadata.authors.is_empty() {
406 println!("authors: {}", metadata.authors.join(", "));
407 }
408 }
409
410 println!("\npacked project files:");
411 for (path, data) in pack.files() {
412 println!(" {path} ({})", human_size(data.len()));
413 }
414
415 if !manifest.project.external_resources.is_empty() {
416 println!("\nexternal project resources:");
417 for path in &manifest.project.external_resources {
418 println!(" {path}");
419 }
420 }
421
422 let vendored: Vec<_> = pack.packages().collect();
423 if !vendored.is_empty() {
424 println!("\nvendored packages:");
425 for (spec, files) in vendored {
426 let (count, size) = files.fold((0usize, 0usize), |(count, size), (_, data)| {
427 (count + 1, size + data.len())
428 });
429 println!(" {spec} ({count} files, {})", human_size(size));
430 }
431 }
432 if !manifest.packages.external.is_empty() {
433 println!("\nexternal packages (not vendored):");
434 for spec in &manifest.packages.external {
435 println!(" {spec}");
436 }
437 }
438
439 if !pack.fonts().is_empty() {
440 println!("\nembedded fonts:");
441 for font in pack.fonts() {
442 println!(
443 " {} ({}){}",
444 font.entry.path,
445 human_size(font.data.len()),
446 if font.entry.families.is_empty() {
447 String::new()
448 } else {
449 format!(" - {}", font.entry.families.join(", "))
450 }
451 );
452 }
453 }
454
455 Ok(())
456}
457
458fn extract_command(args: ExtractArgs) -> Result<(), String> {
459 let pack = read_pack(&args.pack)?;
460 let output = args
461 .output
462 .unwrap_or_else(|| default_output_dir(&args.pack));
463
464 let report = extract(
465 &pack,
466 &output,
467 &ExtractOptions {
468 packages: args.packages || args.all,
469 fonts: args.fonts || args.all,
470 force: args.force,
471 },
472 )
473 .map_err(|err| err.to_string())?;
474
475 println!(
476 "extracted {} file(s) into `{}`",
477 report.written.len(),
478 output.display()
479 );
480 Ok(())
481}
482
483fn compile_command(args: CompileArgs) -> Result<(), String> {
484 let pack = read_pack(&args.pack)?;
485
486 let format = match &args.format {
487 Some(name) => match name.as_str() {
488 "pdf" => OutputFormat::Pdf,
489 "png" => OutputFormat::Png,
490 "svg" => OutputFormat::Svg,
491 "html" => OutputFormat::Html,
492 other => return Err(format!("unknown format `{other}`")),
493 },
494 None => match args.output.as_ref().and_then(|path| path.extension()) {
495 Some(ext) if ext == "png" => OutputFormat::Png,
496 Some(ext) if ext == "svg" => OutputFormat::Svg,
497 Some(ext) if ext == "pdf" => OutputFormat::Pdf,
498 Some(ext) if ext == "html" || ext == "htm" => OutputFormat::Html,
499 Some(other) => {
500 return Err(format!(
501 "cannot infer output format from extension `{}`; pass --format",
502 other.to_string_lossy()
503 ));
504 }
505 None => OutputFormat::Pdf,
506 },
507 };
508
509 let creation_timestamp = args
510 .creation_timestamp
511 .or_else(|| {
512 std::env::var("SOURCE_DATE_EPOCH")
513 .ok()
514 .and_then(|value| value.parse().ok())
515 })
516 .map(|seconds| {
517 datetime_from_timestamp(seconds)
518 .ok_or_else(|| format!("timestamp {seconds} is out of range"))
519 })
520 .transpose()?;
521
522 let mut builder = PackWorld::builder(pack)
523 .embedded_fonts(!args.ignore_embedded_fonts)
524 .inputs(parse_inputs(&args.inputs)?)
525 .package_loader(system_package_loader(
526 args.package_path.as_deref(),
527 args.package_cache_path.as_deref(),
528 args.offline,
529 ));
530 if args.features.iter().any(|feature| feature == "html") {
531 builder = builder.feature(typst::Feature::Html);
532 }
533 for path in &args.resource_paths {
534 builder = builder.external_resource_loader(ProjectResourceRoot::new(path.clone()));
535 }
536 builder = match creation_timestamp {
537 Some(datetime) => builder.fixed_date(datetime),
538 None => builder.system_date(),
539 };
540 for path in &args.font_paths {
541 builder = builder.extra_fonts(typst_kit::fonts::scan(path));
542 }
543 if !args.ignore_system_fonts {
544 builder = builder.extra_fonts(typst_kit::fonts::system());
545 }
546
547 let world = builder
548 .build()
549 .map_err(|err: PackWorldError| err.to_string())?;
550
551 let mut standards = Vec::new();
552 for name in &args.pdf_standards {
553 standards.push(parse_pdf_standard(name)?);
554 }
555
556 let options = CompileOptions {
557 pages: match &args.pages {
558 Some(text) => parse_pages(text)?,
559 None => Vec::new(),
560 },
561 ppi: Some(args.ppi),
562 pdf_standards: PdfStandards::new(&standards).map_err(|err| err.message().to_string())?,
563 creation_timestamp: creation_timestamp.map(Timestamp::new_utc),
564 };
565
566 let diagnostic_format = match args.diagnostic_format.as_str() {
567 "short" => DiagnosticFormat::Short,
568 _ => DiagnosticFormat::Human,
569 };
570
571 let output = match compile(&world, format, &options) {
572 Ok(output) => {
573 emit_diagnostics_with(&world, output.warnings.iter(), diagnostic_format);
574 output
575 }
576 Err(CompileError::Diagnostics { errors, warnings }) => {
577 emit_diagnostics_with(&world, errors.iter().chain(&warnings), diagnostic_format);
578 return Err("compilation failed".into());
579 }
580 Err(err) => return Err(err.to_string()),
581 };
582
583 let stem = args
584 .pack
585 .file_stem()
586 .map(|stem| stem.to_string_lossy().into_owned())
587 .unwrap_or_else(|| "output".to_owned());
588 let extension = format.extension();
589
590 let targets: Vec<PathBuf> = match &args.output {
591 Some(path) => expand_output_template(path, output.outputs.len())?,
592 None if output.outputs.len() == 1 => {
593 vec![PathBuf::from(format!("{stem}.{extension}"))]
594 }
595 None => {
596 let template = PathBuf::from(format!("{stem}-{{0p}}.{extension}"));
597 expand_output_template(&template, output.outputs.len())?
598 }
599 };
600
601 for (target, data) in targets.iter().zip(&output.outputs) {
602 std::fs::write(target, data)
603 .map_err(|err| format!("cannot write `{}`: {err}", target.display()))?;
604 }
605 println!(
606 "compiled `{}` to {}",
607 args.pack.display(),
608 match targets.as_slice() {
609 [single] => format!("`{}`", single.display()),
610 many => format!("{} files", many.len()),
611 }
612 );
613
614 if args.open
615 && let Some(first) = targets.first()
616 {
617 open::that_detached(first).map_err(|err| err.to_string())?;
618 }
619
620 Ok(())
621}
622
623fn expand_output_template(template: &Path, count: usize) -> Result<Vec<PathBuf>, String> {
625 let text = template.to_string_lossy();
626 let has_placeholder = text.contains("{p}") || text.contains("{0p}") || text.contains("{t}");
627 if !has_placeholder {
628 if count > 1 {
629 return Err(format!(
630 "the document has {count} pages; the output filename must contain \
631 `{{p}}`, `{{0p}}`, or `{{t}}` placeholders"
632 ));
633 }
634 return Ok(vec![template.to_path_buf()]);
635 }
636 let width = count.to_string().len();
637 Ok((1..=count)
638 .map(|page| {
639 PathBuf::from(
640 text.replace("{p}", &page.to_string())
641 .replace("{0p}", &format!("{page:0width$}"))
642 .replace("{t}", &count.to_string()),
643 )
644 })
645 .collect())
646}
647
648fn read_pack(path: &Path) -> Result<Pack, String> {
649 let file =
650 File::open(path).map_err(|err| format!("cannot open `{}`: {err}", path.display()))?;
651 Pack::read(BufReader::new(file)).map_err(|err| err.to_string())
652}
653
654fn default_output_dir(pack: &Path) -> PathBuf {
655 match pack.file_stem() {
656 Some(stem) => PathBuf::from(stem),
657 None => PathBuf::from("extracted"),
658 }
659}
660
661fn parse_inputs(pairs: &[String]) -> Result<Dict, String> {
662 let mut dict = Dict::new();
663 for pair in pairs {
664 let (key, value) = pair
665 .split_once('=')
666 .ok_or_else(|| format!("expected KEY=VALUE, got `{pair}`"))?;
667 dict.insert(key.into(), value.into_value());
668 }
669 Ok(dict)
670}
671
672fn system_package_loader(
673 package_path: Option<&Path>,
674 package_cache_path: Option<&Path>,
675 offline: bool,
676) -> SystemPackageLoader {
677 use typst_kit::downloader::SystemDownloader;
678 use typst_kit::packages::{FsPackages, SystemPackages, UniversePackages};
679
680 let data = match package_path {
681 Some(path) => Some(FsPackages::new(path)),
682 None => FsPackages::system_data(),
683 };
684 let cache = match package_cache_path {
685 Some(path) => Some(FsPackages::new(path)),
686 None => FsPackages::system_cache(),
687 };
688 let universe = if offline {
689 UniversePackages::new(crate::world::OfflineDownloader)
690 } else {
691 UniversePackages::new(SystemDownloader::new(concat!(
692 "typst-pack/",
693 env!("CARGO_PKG_VERSION")
694 )))
695 };
696 SystemPackageLoader(SystemPackages::from_parts(data, cache, universe))
697}
698
699struct ProjectResourceRoot(FsRoot);
700
701impl ProjectResourceRoot {
702 fn new(root: PathBuf) -> Self {
703 Self(FsRoot::new(root))
704 }
705}
706
707impl FileLoader for ProjectResourceRoot {
708 fn load(&self, id: FileId) -> FileResult<Bytes> {
709 match id.root() {
710 VirtualRoot::Project => self.0.load(id.vpath()),
711 VirtualRoot::Package(_) => Err(FileError::NotFound(PathBuf::from(
712 id.vpath().get_without_slash(),
713 ))),
714 }
715 }
716}
717
718fn parse_pdf_standard(name: &str) -> Result<PdfStandard, String> {
719 Ok(match name {
720 "1.4" => PdfStandard::V_1_4,
721 "1.5" => PdfStandard::V_1_5,
722 "1.6" => PdfStandard::V_1_6,
723 "1.7" => PdfStandard::V_1_7,
724 "2.0" => PdfStandard::V_2_0,
725 "a-1b" => PdfStandard::A_1b,
726 "a-1a" => PdfStandard::A_1a,
727 "a-2b" => PdfStandard::A_2b,
728 "a-2u" => PdfStandard::A_2u,
729 "a-2a" => PdfStandard::A_2a,
730 "a-3b" => PdfStandard::A_3b,
731 "a-3u" => PdfStandard::A_3u,
732 "a-3a" => PdfStandard::A_3a,
733 "a-4" => PdfStandard::A_4,
734 "a-4f" => PdfStandard::A_4f,
735 "a-4e" => PdfStandard::A_4e,
736 "ua-1" => PdfStandard::Ua_1,
737 other => return Err(format!("unknown PDF standard `{other}`")),
738 })
739}
740
741fn datetime_from_timestamp(seconds: i64) -> Option<Datetime> {
743 let utc = chrono::DateTime::from_timestamp(seconds, 0)?;
744 Datetime::from_ymd_hms(
745 utc.year(),
746 utc.month().try_into().ok()?,
747 utc.day().try_into().ok()?,
748 utc.hour().try_into().ok()?,
749 utc.minute().try_into().ok()?,
750 utc.second().try_into().ok()?,
751 )
752}
753
754fn emit_diagnostics<'a>(
755 world: &dyn DiagnosticWorld,
756 diagnostics: impl IntoIterator<Item = &'a SourceDiagnostic>,
757) {
758 emit_diagnostics_with(world, diagnostics, DiagnosticFormat::Human);
759}
760
761fn emit_diagnostics_with<'a>(
762 world: &dyn DiagnosticWorld,
763 diagnostics: impl IntoIterator<Item = &'a SourceDiagnostic>,
764 format: DiagnosticFormat,
765) {
766 let mut diagnostics = diagnostics.into_iter().peekable();
767 if diagnostics.peek().is_none() {
768 return;
769 }
770 let mut stream = StandardStream::stderr(ColorChoice::Auto);
771 let _ = typst_kit::diagnostics::emit(&mut stream, world, diagnostics, format);
772 let _ = stream.reset();
773}
774
775fn display_file_id(id: FileId) -> String {
777 match id.root() {
778 VirtualRoot::Project => id.vpath().get_without_slash().to_owned(),
779 VirtualRoot::Package(spec) => {
780 format!("{spec}{}", id.vpath().get_with_slash())
781 }
782 }
783}
784
785impl DiagnosticWorld for DiscoveryWorld {
786 fn name(&self, id: FileId) -> String {
787 display_file_id(id)
788 }
789}
790
791impl DiagnosticWorld for PackWorld {
792 fn name(&self, id: FileId) -> String {
793 display_file_id(id)
794 }
795}
796
797fn human_size(bytes: usize) -> String {
798 const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
799 let mut size = bytes as f64;
800 let mut unit = 0;
801 while size >= 1024.0 && unit < UNITS.len() - 1 {
802 size /= 1024.0;
803 unit += 1;
804 }
805 if unit == 0 {
806 format!("{bytes} {}", UNITS[0])
807 } else {
808 format!("{size:.1} {}", UNITS[unit])
809 }
810}