diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 36fb044f7..cf8a2c6f1 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -131,6 +131,10 @@ pub struct InitCommand { /// The project directory, defaults to the template's name pub dir: Option, + + /// Arguments related to storage of packages in the system + #[clap(flatten)] + pub package_storage_args: PackageStorageArgs, } /// Processes an input file to extract provided metadata @@ -187,14 +191,9 @@ pub struct SharedArgs { )] pub inputs: Vec<(String, String)>, - /// Adds additional directories to search for fonts - #[clap( - long = "font-path", - env = "TYPST_FONT_PATHS", - value_name = "DIR", - value_delimiter = ENV_PATH_SEP, - )] - pub font_paths: Vec, + /// Common font arguments + #[clap(flatten)] + pub font_args: FontArgs, /// The document's creation date formatted as a UNIX timestamp. /// @@ -214,6 +213,26 @@ pub struct SharedArgs { value_parser = clap::value_parser!(DiagnosticFormat) )] pub diagnostic_format: DiagnosticFormat, + + /// Arguments related to storage of packages in the system + #[clap(flatten)] + pub package_storage_args: PackageStorageArgs, +} + +/// Arguments related to where packages are stored in the system. +#[derive(Debug, Clone, Args)] +pub struct PackageStorageArgs { + /// Custom path to local packages, defaults to system-dependent location + #[clap(long = "package-path", env = "TYPST_PACKAGE_PATH", value_name = "DIR")] + pub package_path: Option, + + /// Custom path to package cache, defaults to system-dependent location + #[clap( + long = "package-cache-path", + env = "TYPST_PACKAGE_CACHE_PATH", + value_name = "DIR" + )] + pub package_cache_path: Option, } /// Parses a UNIX timestamp according to @@ -343,6 +362,18 @@ fn parse_page_number(value: &str) -> Result { /// Lists all discovered fonts in system and custom font paths #[derive(Debug, Clone, Parser)] pub struct FontsCommand { + /// Common font arguments + #[clap(flatten)] + pub font_args: FontArgs, + + /// Also lists style variants of each font family + #[arg(long)] + pub variants: bool, +} + +/// Common arguments to customize available fonts +#[derive(Debug, Clone, Parser)] +pub struct FontArgs { /// Adds additional directories to search for fonts #[clap( long = "font-path", @@ -352,9 +383,10 @@ pub struct FontsCommand { )] pub font_paths: Vec, - /// Also lists style variants of each font family + /// Ensures system fonts won't be searched, unless explicitly included via + /// `--font-path` #[arg(long)] - pub variants: bool, + pub ignore_system_fonts: bool, } /// Which format to use for diagnostics. @@ -385,8 +417,18 @@ pub struct UpdateCommand { /// Reverts to the version from before the last update (only possible if /// `typst update` has previously ran) - #[clap(long, default_value_t = false, exclusive = true)] + #[clap( + long, + default_value_t = false, + conflicts_with = "version", + conflicts_with = "force" + )] pub revert: bool, + + /// Custom path to the backup file created on update and used by `--revert`, + /// defaults to system-dependent location + #[clap(long = "backup-path", env = "TYPST_UPDATE_BACKUP_PATH", value_name = "FILE")] + pub backup_path: Option, } /// Which format to use for the generated output file. diff --git a/crates/typst-cli/src/fonts.rs b/crates/typst-cli/src/fonts.rs index a5e454edb..de9d1fc1b 100644 --- a/crates/typst-cli/src/fonts.rs +++ b/crates/typst-cli/src/fonts.rs @@ -12,7 +12,7 @@ use crate::args::FontsCommand; /// Execute a font listing command. pub fn fonts(command: &FontsCommand) -> StrResult<()> { let mut searcher = FontSearcher::new(); - searcher.search(&command.font_paths); + searcher.search(&command.font_args.font_paths, command.font_args.ignore_system_fonts); for (name, infos) in searcher.book.families() { println!("{name}"); @@ -66,7 +66,7 @@ impl FontSearcher { } /// Search everything that is available. - pub fn search(&mut self, font_paths: &[PathBuf]) { + pub fn search(&mut self, font_paths: &[PathBuf], ignore_system_fonts: bool) { let mut db = Database::new(); // Font paths have highest priority. @@ -74,8 +74,10 @@ impl FontSearcher { db.load_fonts_dir(path); } - // System fonts have second priority. - db.load_system_fonts(); + if !ignore_system_fonts { + // System fonts have second priority. + db.load_system_fonts(); + } for face in db.faces() { let path = match &face.source { diff --git a/crates/typst-cli/src/init.rs b/crates/typst-cli/src/init.rs index b0446bd1e..cb6b66275 100644 --- a/crates/typst-cli/src/init.rs +++ b/crates/typst-cli/src/init.rs @@ -10,9 +10,12 @@ use typst::syntax::package::{ }; use crate::args::InitCommand; +use crate::package::PackageStorage; /// Execute an initialization command. pub fn init(command: &InitCommand) -> StrResult<()> { + let package_storage = PackageStorage::from_args(&command.package_storage_args); + // Parse the package specification. If the user didn't specify the version, // we try to figure it out automatically by downloading the package index // or searching the disk. @@ -20,12 +23,12 @@ pub fn init(command: &InitCommand) -> StrResult<()> { // Try to parse without version, but prefer the error message of the // normal package spec parsing if it fails. let spec: VersionlessPackageSpec = command.template.parse().map_err(|_| err)?; - let version = crate::package::determine_latest_version(&spec)?; + let version = package_storage.determine_latest_version(&spec)?; StrResult::Ok(spec.at(version)) })?; // Find or download the package. - let package_path = crate::package::prepare_package(&spec)?; + let package_path = package_storage.prepare_package(&spec)?; // Parse the manifest. let manifest = parse_manifest(&package_path)?; diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs index 7d3f2264e..9f38cac0b 100644 --- a/crates/typst-cli/src/package.rs +++ b/crates/typst-cli/src/package.rs @@ -2,6 +2,7 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use crate::args::PackageStorageArgs; use codespan_reporting::term::{self, termcolor}; use ecow::eco_format; use termcolor::WriteColor; @@ -14,64 +15,83 @@ use crate::download::{download, download_with_progress}; use crate::terminal; const HOST: &str = "https://packages.typst.org"; +const DEFAULT_PACKAGES_SUBDIR: &str = "typst/packages"; -/// Make a package available in the on-disk cache. -pub fn prepare_package(spec: &PackageSpec) -> PackageResult { - let subdir = - format!("typst/packages/{}/{}/{}", spec.namespace, spec.name, spec.version); +/// Holds information about where packages should be stored. +pub struct PackageStorage { + pub package_cache_path: Option, + pub package_path: Option, +} - if let Some(data_dir) = dirs::data_dir() { - let dir = data_dir.join(&subdir); - if dir.exists() { - return Ok(dir); - } +impl PackageStorage { + pub fn from_args(args: &PackageStorageArgs) -> Self { + let package_cache_path = args.package_cache_path.clone().or_else(|| { + dirs::cache_dir().map(|cache_dir| cache_dir.join(DEFAULT_PACKAGES_SUBDIR)) + }); + let package_path = args.package_path.clone().or_else(|| { + dirs::data_dir().map(|data_dir| data_dir.join(DEFAULT_PACKAGES_SUBDIR)) + }); + Self { package_cache_path, package_path } } - if let Some(cache_dir) = dirs::cache_dir() { - let dir = cache_dir.join(&subdir); - if dir.exists() { - return Ok(dir); - } + /// Make a package available in the on-disk cache. + pub fn prepare_package(&self, spec: &PackageSpec) -> PackageResult { + let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version); - // Download from network if it doesn't exist yet. - if spec.namespace == "preview" { - download_package(spec, &dir)?; + if let Some(packages_dir) = &self.package_path { + let dir = packages_dir.join(&subdir); if dir.exists() { return Ok(dir); } } + + if let Some(cache_dir) = &self.package_cache_path { + let dir = cache_dir.join(&subdir); + if dir.exists() { + return Ok(dir); + } + + // Download from network if it doesn't exist yet. + if spec.namespace == "preview" { + download_package(spec, &dir)?; + if dir.exists() { + return Ok(dir); + } + } + } + + Err(PackageError::NotFound(spec.clone())) } - Err(PackageError::NotFound(spec.clone())) -} - -/// Try to determine the latest version of a package. -pub fn determine_latest_version( - spec: &VersionlessPackageSpec, -) -> StrResult { - if spec.namespace == "preview" { - // For `@preview`, download the package index and find the latest - // version. - download_index()? - .iter() - .filter(|package| package.name == spec.name) - .map(|package| package.version) - .max() - .ok_or_else(|| eco_format!("failed to find package {spec}")) - } else { - // For other namespaces, search locally. We only search in the data - // directory and not the cache directory, because the latter is not - // intended for storage of local packages. - let subdir = format!("typst/packages/{}/{}", spec.namespace, spec.name); - dirs::data_dir() - .into_iter() - .flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok()) - .flatten() - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok()) - .max() - .ok_or_else(|| eco_format!("please specify the desired version")) + /// Try to determine the latest version of a package. + pub fn determine_latest_version( + &self, + spec: &VersionlessPackageSpec, + ) -> StrResult { + if spec.namespace == "preview" { + // For `@preview`, download the package index and find the latest + // version. + download_index()? + .iter() + .filter(|package| package.name == spec.name) + .map(|package| package.version) + .max() + .ok_or_else(|| eco_format!("failed to find package {spec}")) + } else { + // For other namespaces, search locally. We only search in the data + // directory and not the cache directory, because the latter is not + // intended for storage of local packages. + let subdir = format!("{}/{}", spec.namespace, spec.name); + self.package_path + .iter() + .flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok()) + .flatten() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok()) + .max() + .ok_or_else(|| eco_format!("please specify the desired version")) + } } } diff --git a/crates/typst-cli/src/update.rs b/crates/typst-cli/src/update.rs index b33e05194..fa7c3a348 100644 --- a/crates/typst-cli/src/update.rs +++ b/crates/typst-cli/src/update.rs @@ -40,7 +40,14 @@ pub fn update(command: &UpdateCommand) -> StrResult<()> { } } - let backup_path = backup_path()?; + // Full path to the backup file. + let backup_path = command.backup_path.clone().map(Ok).unwrap_or_else(backup_path)?; + + if let Some(backup_dir) = backup_path.parent() { + fs::create_dir_all(backup_dir) + .map_err(|err| eco_format!("failed to create backup directory ({err})"))?; + } + if command.revert { if !backup_path.exists() { bail!( @@ -213,14 +220,19 @@ fn update_needed(release: &Release) -> StrResult { Ok(new_tag > current_tag) } -/// Path to a potential backup file. +/// Path to a potential backup file in the system. /// -/// The backup will be placed in one of the following directories, depending on -/// the platform: +/// The backup will be placed as `typst_backup.part` in one of the following +/// directories, depending on the platform: /// - `$XDG_STATE_HOME` or `~/.local/state` on Linux /// - `$XDG_DATA_HOME` or `~/.local/share` if the above path isn't available /// - `~/Library/Application Support` on macOS /// - `%APPDATA%` on Windows +/// +/// If a custom backup path is provided via the environment variable +/// `TYPST_UPDATE_BACKUP_PATH`, it will be used instead of the default +/// directories determined by the platform. In that case, this function +/// shouldn't be called. fn backup_path() -> StrResult { #[cfg(target_os = "linux")] let root_backup_dir = dirs::state_dir() @@ -231,10 +243,5 @@ fn backup_path() -> StrResult { let root_backup_dir = dirs::data_dir().ok_or("unable to locate local data directory")?; - let backup_dir = root_backup_dir.join("typst"); - - fs::create_dir_all(&backup_dir) - .map_err(|err| eco_format!("failed to create backup directory ({err})"))?; - - Ok(backup_dir.join("typst_backup.part")) + Ok(root_backup_dir.join("typst").join("typst_backup.part")) } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 81fece2a2..9748d9c54 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -19,6 +19,7 @@ use typst_timing::{timed, TimingScope}; use crate::args::{Input, SharedArgs}; use crate::compile::ExportCache; use crate::fonts::{FontSearcher, FontSlot}; +use crate::package::PackageStorage; /// Static `FileId` allocated for stdin. /// This is to ensure that a file is read in the correct way. @@ -41,6 +42,8 @@ pub struct SystemWorld { fonts: Vec, /// Maps file ids to source files and buffers. slots: Mutex>, + /// Holds information about where packages are stored. + package_storage: PackageStorage, /// The current datetime if requested. This is stored here to ensure it is /// always the same within one compilation. /// Reset between compilations if not [`Now::Fixed`]. @@ -103,13 +106,16 @@ impl SystemWorld { }; let mut searcher = FontSearcher::new(); - searcher.search(&command.font_paths); + searcher + .search(&command.font_args.font_paths, command.font_args.ignore_system_fonts); let now = match command.creation_timestamp { Some(time) => Now::Fixed(time), None => Now::System(OnceLock::new()), }; + let package_storage = PackageStorage::from_args(&command.package_storage_args); + Ok(Self { workdir: std::env::current_dir().ok(), root, @@ -118,6 +124,7 @@ impl SystemWorld { book: LazyHash::new(searcher.book), fonts: searcher.fonts, slots: Mutex::new(HashMap::new()), + package_storage, now, export_cache: ExportCache::new(), }) @@ -144,7 +151,9 @@ impl SystemWorld { .get_mut() .values() .filter(|slot| slot.accessed()) - .filter_map(|slot| system_path(&self.root, slot.id).ok()) + .filter_map(|slot| { + system_path(&self.root, slot.id, &self.package_storage).ok() + }) } /// Reset the compilation state in preparation of a new compilation. @@ -183,11 +192,11 @@ impl World for SystemWorld { } fn source(&self, id: FileId) -> FileResult { - self.slot(id, |slot| slot.source(&self.root)) + self.slot(id, |slot| slot.source(&self.root, &self.package_storage)) } fn file(&self, id: FileId) -> FileResult { - self.slot(id, |slot| slot.file(&self.root)) + self.slot(id, |slot| slot.file(&self.root, &self.package_storage)) } fn font(&self, index: usize) -> Option { @@ -259,9 +268,13 @@ impl FileSlot { } /// Retrieve the source for this file. - fn source(&mut self, project_root: &Path) -> FileResult { + fn source( + &mut self, + project_root: &Path, + package_storage: &PackageStorage, + ) -> FileResult { self.source.get_or_init( - || read(self.id, project_root), + || read(self.id, project_root, package_storage), |data, prev| { let name = if prev.is_some() { "reparsing file" } else { "parsing file" }; let _scope = TimingScope::new(name, None); @@ -277,9 +290,15 @@ impl FileSlot { } /// Retrieve the file's bytes. - fn file(&mut self, project_root: &Path) -> FileResult { - self.file - .get_or_init(|| read(self.id, project_root), |data, _| Ok(data.into())) + fn file( + &mut self, + project_root: &Path, + package_storage: &PackageStorage, + ) -> FileResult { + self.file.get_or_init( + || read(self.id, project_root, package_storage), + |data, _| Ok(data.into()), + ) } } @@ -344,13 +363,17 @@ impl SlotCell { /// Resolves the path of a file id on the system, downloading a package if /// necessary. -fn system_path(project_root: &Path, id: FileId) -> FileResult { +fn system_path( + project_root: &Path, + id: FileId, + package_storage: &PackageStorage, +) -> FileResult { // Determine the root path relative to which the file path // will be resolved. let buf; let mut root = project_root; if let Some(spec) = id.package() { - buf = crate::package::prepare_package(spec)?; + buf = package_storage.prepare_package(spec)?; root = &buf; } @@ -363,11 +386,15 @@ fn system_path(project_root: &Path, id: FileId) -> FileResult { /// /// If the ID represents stdin it will read from standard input, /// otherwise it gets the file path of the ID and reads the file from disk. -fn read(id: FileId, project_root: &Path) -> FileResult> { +fn read( + id: FileId, + project_root: &Path, + package_storage: &PackageStorage, +) -> FileResult> { if id == *STDIN_ID { read_from_stdin() } else { - read_from_disk(&system_path(project_root, id)?) + read_from_disk(&system_path(project_root, id, package_storage)?) } } diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs index 9316d4a85..36b3de261 100644 --- a/crates/typst/src/text/mod.rs +++ b/crates/typst/src/text/mod.rs @@ -111,7 +111,8 @@ pub struct TextElem { /// variable to add directories that should be scanned for fonts. The /// priority is: `--font-paths` > system fonts > embedded fonts. Run /// `typst fonts` to see the fonts that Typst has discovered on your - /// system. + /// system. Note that you can pass the `--ignore-system-fonts` parameter + /// to the CLI to ensure Typst won't search for system fonts. /// /// ```example /// #set text(font: "PT Sans")