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