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