Skip to main content

typst_pack/
cli.rs

1//! The `typst-pack` command line interface.
2
3#![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/// Pack, inspect, extract, and compile portable Typst project packs.
28#[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    /// Packs a Typst project into a single portable file.
38    Create(CreateArgs),
39    /// Shows what is inside a pack.
40    Inspect(InspectArgs),
41    /// Extracts a pack into a directory.
42    Extract(ExtractArgs),
43    /// Compiles a pack to PDF, PNG, or SVG.
44    Compile(CompileArgs),
45}
46
47#[derive(Debug, Args)]
48struct CreateArgs {
49    /// The project directory or the entrypoint .typ file.
50    project: PathBuf,
51
52    /// Path to write the pack to [default: <project name>.typk].
53    #[arg(short, long)]
54    output: Option<PathBuf>,
55
56    /// The entrypoint, relative to the project root [default: main.typ].
57    #[arg(long)]
58    entrypoint: Option<PathBuf>,
59
60    /// The project root directory [default: the entrypoint's directory].
61    #[arg(long)]
62    root: Option<PathBuf>,
63
64    /// Do not store package files in the pack; record them as external
65    /// dependencies instead.
66    #[arg(long)]
67    no_packages: bool,
68
69    /// Embed the fonts used by the document into the pack.
70    #[arg(long)]
71    embed_fonts: bool,
72
73    /// When embedding fonts, also embed fonts identical to Typst's default
74    /// embedded fonts.
75    #[arg(long, requires = "embed_fonts")]
76    include_default_fonts: bool,
77
78    /// Additional files or directories (inside the project root) to pack
79    /// beyond what the discovery compile finds.
80    #[arg(long = "include", value_name = "PATH")]
81    include: Vec<PathBuf>,
82
83    /// Directories that act as roots for External Project Resources during discovery.
84    #[arg(long = "resource-path", value_name = "DIR")]
85    resource_paths: Vec<PathBuf>,
86
87    /// Root-relative resource paths to declare as external even if discovery does not load them.
88    #[arg(long = "external-resource", value_name = "PATH")]
89    external_resources: Vec<String>,
90
91    /// String key-value pairs visible through `sys.inputs` during the
92    /// discovery compile.
93    #[arg(long = "input", value_name = "KEY=VALUE")]
94    inputs: Vec<String>,
95
96    /// Additional directories to search for fonts during the discovery
97    /// compile.
98    #[arg(long = "font-path", value_name = "DIR")]
99    font_paths: Vec<PathBuf>,
100
101    /// Do not use system fonts during the discovery compile.
102    #[arg(long)]
103    ignore_system_fonts: bool,
104
105    /// Custom path to local packages, defaults to system-dependent location.
106    #[arg(long, value_name = "DIR")]
107    package_path: Option<PathBuf>,
108
109    /// Custom path to the package cache, defaults to system-dependent
110    /// location.
111    #[arg(long, value_name = "DIR", env = "TYPST_PACK_PACKAGE_CACHE_PATH")]
112    package_cache_path: Option<PathBuf>,
113
114    /// Disallow network access; package dependencies must already exist in
115    /// the local package directories.
116    #[arg(long)]
117    offline: bool,
118
119    /// A human-readable name recorded in the pack metadata.
120    #[arg(long)]
121    name: Option<String>,
122
123    /// A description recorded in the pack metadata.
124    #[arg(long)]
125    description: Option<String>,
126
127    /// Authors recorded in the pack metadata.
128    #[arg(long = "author", value_name = "AUTHOR")]
129    authors: Vec<String>,
130}
131
132#[derive(Debug, Args)]
133struct InspectArgs {
134    /// The pack file to inspect.
135    pack: PathBuf,
136}
137
138#[derive(Debug, Args)]
139struct ExtractArgs {
140    /// The pack file to extract.
141    pack: PathBuf,
142
143    /// The directory to extract into [default: <pack name>/].
144    #[arg(short, long)]
145    output: Option<PathBuf>,
146
147    /// Also extract vendored packages to packages/.
148    #[arg(long)]
149    packages: bool,
150
151    /// Also extract embedded fonts to fonts/.
152    #[arg(long)]
153    fonts: bool,
154
155    /// Extract everything (same as --packages --fonts).
156    #[arg(long)]
157    all: bool,
158
159    /// Overwrite existing files.
160    #[arg(long)]
161    force: bool,
162}
163
164#[derive(Debug, Args)]
165struct CompileArgs {
166    /// The pack file to compile.
167    pack: PathBuf,
168
169    /// Path to the output file. For PNG and SVG output of multi-page
170    /// documents, the filename must contain `{p}`, `{0p}`, or `{t}`
171    /// placeholders (page number, zero-padded page number, total pages)
172    /// [default: <pack name>.<format extension>].
173    output: Option<PathBuf>,
174
175    /// The output format. `html` is experimental and additionally requires
176    /// `--features html` [default: inferred from the output extension, or
177    /// pdf].
178    #[arg(short, long, value_parser = ["pdf", "png", "svg", "html"])]
179    format: Option<String>,
180
181    /// Enables experimental Typst features.
182    #[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    /// Which pages to export, e.g. `1,3-5,9-`.
192    #[arg(long)]
193    pages: Option<String>,
194
195    /// The PPI (pixels per inch) for PNG export.
196    #[arg(long, default_value_t = 144.0)]
197    ppi: f32,
198
199    /// String key-value pairs visible through `sys.inputs`.
200    #[arg(long = "input", value_name = "KEY=VALUE")]
201    inputs: Vec<String>,
202
203    /// Directories that act as roots for External Project Resources.
204    #[arg(long = "resource-path", value_name = "DIR")]
205    resource_paths: Vec<PathBuf>,
206
207    /// Additional directories to search for fonts.
208    #[arg(long = "font-path", value_name = "DIR")]
209    font_paths: Vec<PathBuf>,
210
211    /// Do not use system fonts.
212    #[arg(long)]
213    ignore_system_fonts: bool,
214
215    /// Do not use Typst's embedded default fonts.
216    #[arg(long)]
217    ignore_embedded_fonts: bool,
218
219    /// One or more PDF standards that Typst will enforce conformance with.
220    #[arg(long = "pdf-standard", value_name = "STANDARD")]
221    pdf_standards: Vec<String>,
222
223    /// The document's creation date as a UNIX timestamp (for reproducible
224    /// builds). Falls back to the SOURCE_DATE_EPOCH environment variable.
225    #[arg(long, value_name = "UNIX_TIMESTAMP")]
226    creation_timestamp: Option<i64>,
227
228    /// Custom path to local packages, defaults to system-dependent location.
229    #[arg(long, value_name = "DIR")]
230    package_path: Option<PathBuf>,
231
232    /// Custom path to the package cache, defaults to system-dependent
233    /// location.
234    #[arg(long, value_name = "DIR", env = "TYPST_PACK_PACKAGE_CACHE_PATH")]
235    package_cache_path: Option<PathBuf>,
236
237    /// Disallow network access; packages that are not vendored in the pack
238    /// must already exist in the local package directories.
239    #[arg(long)]
240    offline: bool,
241
242    /// The format to emit diagnostics in.
243    #[arg(long, default_value = "human", value_parser = ["human", "short"])]
244    diagnostic_format: String,
245
246    /// Opens the output file with the default viewer after compilation.
247    #[arg(long)]
248    open: bool,
249}
250
251/// Runs the CLI and returns the process exit code.
252pub 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
623/// Expands `{p}`, `{0p}`, and `{t}` placeholders into one path per page.
624fn 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
741/// Converts a UNIX timestamp to a Typst datetime.
742fn 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
775/// Formats a file ID with its package prefix, if any.
776fn 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}