mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 09:11:10 +00:00
craft-font-catalog, craft-font-store and craft-font-manager: installed fonts, the font store and the Font Manager panel
This commit is contained in:
1 parent
3c574ddc61
commit
06a4ec194a
51 files changed
+11156
-5
No files matched your search
@@ -8,6 +8,7 @@ author, source and license. Library code and original assets are under the repos
|
||||
|---|---|---|---|
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 |
|
||||
| `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 |
|
||||
| `crates/font-store/index/google-fonts-index.json.gz` | google-fonts-index contributors; generated from the METADATA.pb files of [google/fonts](https://github.com/google/fonts) at `45e3d5269e4a17d823f6d84470bc084dc5ae46f6` | [aiongg/google-fonts-index](https://github.com/aiongg/google-fonts-index/tree/8d52c53eaa40f17e7441f8234389cdbed196eb7b) | Apache-2.0 |
|
||||
| `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) |
|
||||
| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 |
|
||||
| `crates/text/assets/hyphen/en-us.dic` | Grady Ward (Moby Hyphenator II), converted by DesignCraft | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204) | Public domain |
|
||||
|
||||
Generated
+80
-5
@@ -779,6 +779,50 @@ dependencies = [
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-catalog"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fontique",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"windows-registry",
|
||||
"write-fonts 0.54.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-manager"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-font-catalog",
|
||||
"craft-font-store",
|
||||
"craft-i18n",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"egui_system_font_provider",
|
||||
"epaint_default_fonts",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"craft-font-catalog",
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"write-fonts 0.54.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1399,6 +1443,20 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"epaint",
|
||||
"fontique",
|
||||
"log",
|
||||
"poll-promise",
|
||||
"skrifa 0.44.0",
|
||||
"sys-locale",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
@@ -3812,6 +3870,16 @@ dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poll-promise"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f6a58fecbf9da8965bcdb20ce4fd29788d1acee68ddbb64f0ba1b81bccdb7df"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -5691,6 +5759,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6075,6 +6144,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -6600,6 +6678,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55716cfa7e8bcdb90289e07dfac478ccb2e3ec0f9a604fe682c7a03a7984d705"
|
||||
dependencies = [
|
||||
"font-types 0.12.7",
|
||||
"indexmap",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"read-fonts 0.45.1",
|
||||
]
|
||||
@@ -7037,8 +7117,3 @@ source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f9
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
@@ -26,6 +26,9 @@ thiserror = "2"
|
||||
craft-atomic = { path = "crates/atomic" }
|
||||
craft-cms = { path = "crates/cms" }
|
||||
craft-docpath = { path = "crates/docpath" }
|
||||
craft-font-catalog = { path = "crates/font-catalog" }
|
||||
craft-font-manager = { path = "crates/font-manager" }
|
||||
craft-font-store = { path = "crates/font-store" }
|
||||
craft-fonts-build = { path = "crates/fonts-build" }
|
||||
craft-i18n = { path = "crates/i18n" }
|
||||
craft-jpeg = { path = "crates/jpeg" }
|
||||
|
||||
@@ -37,6 +37,19 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
|
||||
document may be opened. Paths stored in documents are followed only when they are local or on the
|
||||
document's own network share; other network paths, device paths and non-file URLs are refused.
|
||||
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
|
||||
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
|
||||
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
|
||||
between threads, and rescans when fonts are installed or removed.
|
||||
- [`craft-font-manager`](crates/font-manager/README.md): Craft Font Manager as a library: search
|
||||
and filters over the Google Fonts index, installed fonts by family, the font store interface,
|
||||
and (`egui` feature) `FontsPanel`, the whole interface as one panel for the launcher's Fonts tab
|
||||
and the standalone app.
|
||||
- [`craft-font-store`](crates/font-store/README.md): the suite's font store: the pinned Google
|
||||
Fonts and Noto indexes plus indexes the user adds (never curated), exact lookup by family and
|
||||
style or PostScript name, and installs from each source's URLs (jsDelivr, then raw GitHub)
|
||||
checked by size, sha256 and parsing, with the licence, a manifest, and removal. Off until the
|
||||
user turns downloading on; agents can't.
|
||||
- [`craft-fonts-build`](crates/fonts-build/README.md): embeds the optional craft-fonts faces
|
||||
from an app's `build.rs` (`CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, script filters) as a table
|
||||
of `EmbeddedFace`s. No dependencies; the app picks the faces.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "craft-font-catalog"
|
||||
description = "Installed fonts for the Craft apps: discovery on every platform, family, style and PostScript lookup, and loading shared between threads."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["system"]
|
||||
# The platform's font service through fontique: CoreText on macOS, DirectWrite on Windows,
|
||||
# fontconfig on Linux and FreeBSD. The same version and features craft-ui's system fonts use, so
|
||||
# the apps build fontique once.
|
||||
system = ["dep:fontique"]
|
||||
|
||||
[dependencies]
|
||||
skrifa = { version = "0.47", default-features = false, features = ["std"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.54"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,83 @@
|
||||
# craft-font-catalog
|
||||
|
||||
The installed fonts for the Craft apps: discovery on every platform, lookup by family and style or by any other name, and loading shared between threads.
|
||||
|
||||
## What it finds
|
||||
|
||||
A `Catalog` reads, in this order:
|
||||
|
||||
| Where | Source | Platforms |
|
||||
|---|---|---|
|
||||
| The platform's font folders (`font_dirs`) | `System` | all |
|
||||
| The suite's font store (`font_store_dir`) | `Store` | all |
|
||||
| The app's own folders (`Catalog::set_app_dirs`, for example `office_font_dirs`) | `App` | all |
|
||||
| Files the platform's font service knows (`system` feature, through fontique) | `System` | macOS, Windows, Linux, FreeBSD |
|
||||
| The registry's font list | `System` | Windows |
|
||||
| Files from injected listers (`Catalog::with_lister`) | `System` | all |
|
||||
|
||||
The font folders:
|
||||
|
||||
- macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`, `~/Library/Fonts`, `/System/Library/Assets` and the font assets macOS downloads on demand (`AssetsV2/com_apple_MobileAsset_Font*`).
|
||||
- Windows: `%WINDIR%\Fonts` and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`.
|
||||
- Linux and the BSDs: `/usr/share/fonts`, `/usr/local/share/fonts`, the Flatpak host folders (`/run/host/fonts`, `/run/host/local-fonts`, `/run/host/user-fonts`), `~/.local/share/fonts`, `~/.fonts`, `$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`, and the `<dir>` and `<remap-dir>` entries of the fontconfig configuration with its includes and the user's own. Inside a Flatpak sandbox, also the host's folders from `/run/host/font-dirs.xml` and from the host's configuration under `/run/host/etc/fonts`.
|
||||
- Haiku: the system's and the user's `data/fonts` folders.
|
||||
|
||||
The font service covers fonts outside those folders:
|
||||
|
||||
- macOS: CoreText's available fonts, which include fonts that Adobe Fonts, RightFont and other font managers activate from their own folders.
|
||||
- Windows: DirectWrite's system font collection, which includes per-user fonts and the fonts that font services such as Adobe Fonts load at run time. The registry adds fonts that programs register by full path.
|
||||
- Linux and FreeBSD: fontconfig, loaded at run time (`fontconfig-dlopen`), so a system without it still runs.
|
||||
|
||||
`FontEnv` holds the environment all of this depends on (`HOME`, the XDG variables, `FONTCONFIG_FILE`, `WINDIR`, `LOCALAPPDATA`, `CRAFT_FONT_STORE`, a file system root), so tests can describe any system without touching the process environment.
|
||||
|
||||
## The font store
|
||||
|
||||
`font_store_dir` is the one per-user folder that the suite's font store installs fonts into and every app's catalog reads. No other program reads it.
|
||||
|
||||
| Platform | Folder |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/Craft Fonts` |
|
||||
| Windows | `%LOCALAPPDATA%\Craft Fonts` |
|
||||
| Linux, BSDs, Haiku | `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`) |
|
||||
|
||||
`CRAFT_FONT_STORE` replaces it, for portable installs and tests.
|
||||
|
||||
## Scanning
|
||||
|
||||
The scan reads each file's table directory and its `name`, `OS/2` and `fvar` tables, at most 1 MiB per table, never the whole file. Faces without outline tables (`glyf`, `CFF `, `CFF2`, `VARC`) are left out. Files in folders count when they have a font extension (`.ttf`, `.otf`, `.ttc`, `.otc`); files a font service or lister names count whatever their name. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic.
|
||||
|
||||
Each face (`FaceInfo`) has its family and style, legacy family and style, family names in other languages, full name, PostScript name, weight, width, italic, a named instance's axis settings, `fsType`, the OS/2 code page bits, its file and index, and its source. Each named instance of a variable font is a face of its own.
|
||||
|
||||
## Lookup
|
||||
|
||||
- `families()` and `styles(family)`.
|
||||
- `family(name)` and `resolve(name, style)` accept any name of a family: its own name ignoring ASCII case, its names in other languages and its legacy names ignoring case and anything but letters and digits. `resolve` also accepts a legacy family with its legacy style (`Roboto Light` / `Regular`), a full name, and a PostScript name.
|
||||
- Within a family, `resolve` matches the style by name (any of its names). Otherwise it picks the nearest weight, with italic differing counting as 1000, then the nearest width. It returns `Match::Exact` or `Match::NearestStyle`, so the app decides about substitutes.
|
||||
- `by_postscript_name(name)`.
|
||||
|
||||
## Loading and threads
|
||||
|
||||
- The first scan runs on first use, or on a background thread after `scan_in_background`. Lookups wait for it.
|
||||
- Lookups read an immutable snapshot, so a rescan never blocks them.
|
||||
- `load_family` reads a family's files once. Other threads asking for the same family wait and share the result; other families load at the same time. Faces of one file share its bytes (`Arc<[u8]>`).
|
||||
- `add_bytes` and `add_file` add fonts from documents, downloads or the app's bundle, within `Limits`. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces, for example when a document closes.
|
||||
|
||||
## Changes
|
||||
|
||||
- `rescan` scans again, reading only new and changed files.
|
||||
- `installed_fonts_changed` compares the modification times of every folder the scan read (and the folders of files a font service named) and, on Windows, the registry's font list. It costs a few hundred `stat` calls, not a scan.
|
||||
- `rescan_if_changed` does both; apps call it when their window is activated.
|
||||
- `watch(interval, on_change)` polls on a background thread.
|
||||
- `generation` counts the catalog's changes.
|
||||
|
||||
## One enumeration for the UI and documents
|
||||
|
||||
With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback can query the same collection instead of building its own, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
cargo run -p craft-font-catalog --example fonts # counts, folders, faces outside them
|
||||
cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces
|
||||
cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Lists what the catalog finds on this computer, to check discovery by hand.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p craft-font-catalog --example fonts # counts, folders, font store
|
||||
//! cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces
|
||||
//! cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed
|
||||
//! ```
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use craft_font_catalog::{Catalog, font_dirs, font_store_dir};
|
||||
|
||||
fn main() {
|
||||
let catalog = Catalog::global();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let start = Instant::now();
|
||||
let families = catalog.families();
|
||||
let faces = catalog.faces();
|
||||
println!("{} families, {} faces, first scan {:?}", families.len(), faces.len(), start.elapsed());
|
||||
println!("font store: {:?}", font_store_dir(catalog.env()));
|
||||
for dir in font_dirs(catalog.env()).iter().filter(|d| d.is_dir()) {
|
||||
println!("folder: {}", dir.display());
|
||||
}
|
||||
let outside: Vec<_> =
|
||||
faces.iter().filter(|f| f.path.as_ref().is_some_and(|p| !font_dirs(catalog.env()).iter().any(|d| p.starts_with(d)))).collect();
|
||||
println!("{} faces outside the font folders (font service, store, registry):", outside.len());
|
||||
for f in outside.iter().take(40) {
|
||||
println!(" {} {} ({:?}) {}", f.family, f.style, f.source, f.path.as_deref().map(|p| p.display().to_string()).unwrap_or_default());
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--watch") => {
|
||||
let _watch = catalog.watch(Duration::from_secs(2), |c| println!("changed: {} families", c.families().len()));
|
||||
println!("watching; install or remove a font, Ctrl+C to stop");
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
Some(name) => {
|
||||
for f in catalog.styles(name) {
|
||||
println!(
|
||||
"{} / {}: weight {} width {} italic {} ps {:?} {:?} #{} {:?}",
|
||||
f.family, f.style, f.weight, f.width, f.italic, f.postscript_name, f.path, f.index, f.source
|
||||
);
|
||||
}
|
||||
if let Some((f, m)) = catalog.resolve(name, args.get(1).map_or("Regular", String::as_str)) {
|
||||
println!("resolve: {} {} ({m:?})", f.family, f.style);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
//! The catalog: every installed face, found by any of its names, loaded once.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::scan::{self, Face, FaceInfo, Source, norm, style_italic, style_weight, style_width};
|
||||
use crate::{FontEnv, Os, font_dirs, font_store_dir};
|
||||
|
||||
/// Lists font files that the platform's font service knows outside the font folders. Called by
|
||||
/// every scan.
|
||||
pub type Lister = Arc<dyn Fn() -> Vec<PathBuf> + Send + Sync>;
|
||||
|
||||
/// How deep the scan goes below a font folder.
|
||||
const MAX_DEPTH: usize = 12;
|
||||
/// The most folder entries one scan looks at.
|
||||
const MAX_ENTRIES: usize = 200_000;
|
||||
/// The most bytes the scan reads from one file (its tables, or a suitcase's resource fork).
|
||||
const MAX_SCAN_READ: u64 = 256 << 20;
|
||||
/// The largest font file loaded.
|
||||
const MAX_LOAD: u64 = 512 << 20;
|
||||
|
||||
/// Why a font file or font data wasn't added or loaded.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FontFileError {
|
||||
/// Reading the file failed.
|
||||
#[error("the font file can't be read: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The file is larger than the limit.
|
||||
#[error("the font file is {size} bytes, more than the {max} allowed")]
|
||||
TooLarge {
|
||||
/// The file's size.
|
||||
size: u64,
|
||||
/// The limit.
|
||||
max: u64,
|
||||
},
|
||||
/// No face in it could be read.
|
||||
#[error("not a font file, or a damaged one")]
|
||||
NotAFont,
|
||||
/// Adding it would pass the limit on added faces.
|
||||
#[error("more than {max} added font faces")]
|
||||
TooManyFaces {
|
||||
/// The limit.
|
||||
max: usize,
|
||||
},
|
||||
/// The face is no longer in the catalog (its data was removed).
|
||||
#[error("the font is no longer available")]
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`]),
|
||||
/// which may come from documents or downloads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// The largest file accepted.
|
||||
pub max_file_bytes: u64,
|
||||
/// The most faces read from one file (a collection).
|
||||
pub max_faces_per_file: u32,
|
||||
/// The most added faces the catalog holds in all.
|
||||
pub max_added_faces: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_file_bytes: 256 << 20, max_faces_per_file: 256, max_added_faces: 1000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`Catalog::resolve`] matched.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Match {
|
||||
/// The family and style asked for, by any of their names.
|
||||
Exact,
|
||||
/// The family, in the style nearest to the one asked for.
|
||||
NearestStyle,
|
||||
}
|
||||
|
||||
/// A face's data: the whole file (or a suitcase font's resource) and the face's index in it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FontData {
|
||||
/// The file's bytes, shared by every face loaded from it.
|
||||
pub bytes: Arc<[u8]>,
|
||||
/// The index to pass to a font parser.
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
/// A loaded face ([`Catalog::load_family`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadedFace {
|
||||
/// The face.
|
||||
pub info: FaceInfo,
|
||||
/// Its data.
|
||||
pub data: FontData,
|
||||
}
|
||||
|
||||
/// A face the scan can't find again, as an alias records it.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Alias {
|
||||
/// The family of this face.
|
||||
Family(usize),
|
||||
/// A legacy family name: this face, under this legacy style.
|
||||
Legacy(usize, String),
|
||||
/// A full or PostScript name: this face.
|
||||
Face(usize),
|
||||
}
|
||||
|
||||
/// One state of the catalog: what lookups read while a scan builds the next one.
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
faces: Vec<Face>,
|
||||
infos: Arc<[FaceInfo]>,
|
||||
families: Arc<[String]>,
|
||||
by_family: HashMap<String, Vec<usize>>,
|
||||
aliases: HashMap<String, Vec<Alias>>,
|
||||
postscript: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Added faces first: a document's or the app's own face wins over an installed face of the
|
||||
/// same family and style.
|
||||
fn build(added: &[Face], scanned: &[Face]) -> Self {
|
||||
let mut faces: Vec<Face> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for f in added.iter().chain(scanned) {
|
||||
let variations: Vec<(u32, u32)> = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect();
|
||||
let key = (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations);
|
||||
if seen.insert(key) {
|
||||
faces.push(f.clone());
|
||||
}
|
||||
}
|
||||
let mut by_family: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript: HashMap<String, usize> = HashMap::new();
|
||||
let mut families: Vec<String> = Vec::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let entry = by_family.entry(f.info.family.to_ascii_lowercase()).or_default();
|
||||
if entry.is_empty() {
|
||||
families.push(f.info.family.clone());
|
||||
}
|
||||
entry.push(i);
|
||||
for k in &f.keys.families {
|
||||
let list = aliases.entry(k.clone()).or_default();
|
||||
if !list
|
||||
.iter()
|
||||
.any(|a| matches!(a, Alias::Family(j) if faces.get(*j).is_some_and(|g| g.info.family.eq_ignore_ascii_case(&f.info.family))))
|
||||
{
|
||||
list.push(Alias::Family(i));
|
||||
}
|
||||
}
|
||||
for (family, style) in &f.keys.legacy {
|
||||
aliases.entry(family.clone()).or_default().push(Alias::Legacy(i, style.clone()));
|
||||
}
|
||||
for k in f.keys.full_names.iter().chain(&f.keys.postscript) {
|
||||
aliases.entry(k.clone()).or_default().push(Alias::Face(i));
|
||||
}
|
||||
for k in &f.keys.postscript {
|
||||
postscript.entry(k.clone()).or_insert(i);
|
||||
}
|
||||
}
|
||||
// Narrow to wide, light to heavy, upright first. The sort is stable, so of two faces alike
|
||||
// the added one stays first and wins a tie in `pick_style`.
|
||||
for list in by_family.values_mut() {
|
||||
list.sort_by(|a, b| {
|
||||
let (Some(a), Some(b)) = (faces.get(*a), faces.get(*b)) else { return std::cmp::Ordering::Equal };
|
||||
(a.info.width.total_cmp(&b.info.width)).then(a.info.weight.total_cmp(&b.info.weight)).then(a.info.italic.cmp(&b.info.italic))
|
||||
});
|
||||
}
|
||||
families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone()));
|
||||
let infos: Arc<[FaceInfo]> = faces.iter().map(|f| f.info.clone()).collect();
|
||||
Self { faces, infos, families: families.into(), by_family, aliases, postscript }
|
||||
}
|
||||
|
||||
fn face(&self, i: usize) -> Option<&Face> {
|
||||
self.faces.get(i)
|
||||
}
|
||||
|
||||
/// The family `name` names: a family name ignoring ASCII case, else any family name of a face
|
||||
/// (another language, legacy) ignoring case and everything but letters and digits.
|
||||
fn family_of(&self, name: &str) -> Option<&[usize]> {
|
||||
if let Some(faces) = self.by_family.get(&name.to_ascii_lowercase()) {
|
||||
return Some(faces);
|
||||
}
|
||||
let aliases = self.aliases.get(&norm(name))?;
|
||||
let i = aliases.iter().find_map(|a| match a {
|
||||
Alias::Family(i) => Some(*i),
|
||||
_ => None,
|
||||
})?;
|
||||
self.by_family.get(&self.face(i)?.info.family.to_ascii_lowercase()).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
fn resolve(&self, name: &str, style: &str) -> Option<(usize, Match)> {
|
||||
if let Some(faces) = self.family_of(name) {
|
||||
return self.pick_style(faces, style);
|
||||
}
|
||||
let aliases = self.aliases.get(&norm(name))?;
|
||||
let legacy: Vec<(usize, &str)> = aliases
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
Alias::Legacy(i, s) => Some((*i, s.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !legacy.is_empty() {
|
||||
let ns = style_key(style);
|
||||
if let Some((i, _)) = legacy.iter().find(|(_, s)| *s == ns) {
|
||||
return Some((*i, Match::Exact));
|
||||
}
|
||||
let faces: Vec<usize> = legacy.iter().map(|(i, _)| *i).collect();
|
||||
return self.pick_style(&faces, style).map(|(i, _)| (i, Match::NearestStyle));
|
||||
}
|
||||
aliases.iter().find_map(|a| match a {
|
||||
Alias::Face(i) => Some((*i, Match::Exact)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The face of `faces` (one family) in `style`: by its style name, then by any other name of
|
||||
/// its style, else the nearest weight with italic differing costing 1000, then the nearest
|
||||
/// width; the first face wins a tie.
|
||||
fn pick_style(&self, faces: &[usize], style: &str) -> Option<(usize, Match)> {
|
||||
let ns = style_key(style);
|
||||
let cands: Vec<(usize, &Face)> = faces.iter().filter_map(|i| Some((*i, self.face(*i)?))).collect();
|
||||
if let Some((i, _)) = cands.iter().find(|(_, f)| norm(&f.info.style) == ns) {
|
||||
return Some((*i, Match::Exact));
|
||||
}
|
||||
if let Some((i, _)) = cands.iter().find(|(_, f)| f.keys.styles.contains(&ns)) {
|
||||
return Some((*i, Match::Exact));
|
||||
}
|
||||
let (tw, ti, twd) = (style_weight(style), style_italic(style), style_width(style));
|
||||
let score = |f: &Face| ((f.info.weight - tw).abs() + if f.info.italic != ti { 1000.0 } else { 0.0 }, (f.info.width - twd).abs());
|
||||
cands
|
||||
.iter()
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let (sa, sb) = (score(a), score(b));
|
||||
sa.0.total_cmp(&sb.0).then(sa.1.total_cmp(&sb.1))
|
||||
})
|
||||
.map(|(i, _)| (*i, Match::NearestStyle))
|
||||
}
|
||||
}
|
||||
|
||||
/// A style name as compared: normalized, with no style meaning `Regular`.
|
||||
fn style_key(style: &str) -> String {
|
||||
let ns = norm(style);
|
||||
if ns.is_empty() { "regular".into() } else { ns }
|
||||
}
|
||||
|
||||
/// What the catalog reads besides the platform's font folders.
|
||||
struct Config {
|
||||
app_dirs: Vec<PathBuf>,
|
||||
listers: Vec<Lister>,
|
||||
font_service: bool,
|
||||
}
|
||||
|
||||
/// A file found by a scan, remembered so a rescan reads only files that changed.
|
||||
struct Cached {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
faces: Vec<Face>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScanState {
|
||||
files: HashMap<PathBuf, Cached>,
|
||||
}
|
||||
|
||||
/// What a change check compares with.
|
||||
#[derive(Default)]
|
||||
struct Stamp {
|
||||
app_dirs: Vec<PathBuf>,
|
||||
/// Every folder the scan listed, and the folders holding the files listers named, with their
|
||||
/// modification times (`None`: missing).
|
||||
dirs: Vec<(PathBuf, Option<SystemTime>)>,
|
||||
/// The fonts registered with Windows.
|
||||
registered: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Added {
|
||||
faces: Vec<Face>,
|
||||
blobs: HashMap<u64, Arc<[u8]>>,
|
||||
next_blob: u64,
|
||||
}
|
||||
|
||||
/// A family's faces, loaded once ([`Catalog::load_family`]).
|
||||
type FamilyLoad = Arc<OnceLock<Arc<[LoadedFace]>>>;
|
||||
|
||||
/// The installed fonts: the platform's font folders, the files its font service knows, the font
|
||||
/// store and the app's own folders, plus faces added from files or bytes.
|
||||
///
|
||||
/// The first scan runs on first use or on a background thread ([`Catalog::scan_in_background`]);
|
||||
/// lookups wait for it. Lookups read an immutable snapshot, so they never wait for a rescan.
|
||||
/// Each family's files load once ([`Catalog::load_family`]): other threads asking for the same
|
||||
/// family wait for that load, other families load at the same time.
|
||||
pub struct Catalog {
|
||||
env: FontEnv,
|
||||
config: RwLock<Config>,
|
||||
index: RwLock<Arc<Index>>,
|
||||
ready: OnceLock<()>,
|
||||
scan: Mutex<ScanState>,
|
||||
stamp: Mutex<Stamp>,
|
||||
generation: AtomicU64,
|
||||
added: Mutex<Added>,
|
||||
loaded: Mutex<HashMap<String, FamilyLoad>>,
|
||||
files: Mutex<HashMap<PathBuf, Weak<[u8]>>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn modified(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// A catalog of the fonts installed on the system `env` describes, the font store, and the
|
||||
/// files the platform's font service lists (with the `system` feature).
|
||||
pub fn new(env: FontEnv) -> Self {
|
||||
Self {
|
||||
env,
|
||||
config: RwLock::new(Config { app_dirs: Vec::new(), listers: Vec::new(), font_service: cfg!(feature = "system") }),
|
||||
index: RwLock::new(Arc::new(Index::default())),
|
||||
ready: OnceLock::new(),
|
||||
scan: Mutex::new(ScanState::default()),
|
||||
stamp: Mutex::new(Stamp::default()),
|
||||
generation: AtomicU64::new(0),
|
||||
added: Mutex::new(Added::default()),
|
||||
loaded: Mutex::new(HashMap::new()),
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The process's catalog, for [`FontEnv::from_process`].
|
||||
pub fn global() -> &'static Arc<Catalog> {
|
||||
static GLOBAL: OnceLock<Arc<Catalog>> = OnceLock::new();
|
||||
GLOBAL.get_or_init(|| Arc::new(Catalog::new(FontEnv::from_process())))
|
||||
}
|
||||
|
||||
/// Whether scans ask the platform's font service (CoreText, DirectWrite, fontconfig) for the
|
||||
/// font files it knows: on by default with the `system` feature, which provides it.
|
||||
#[must_use]
|
||||
pub fn with_font_service(self, on: bool) -> Self {
|
||||
self.write_config().font_service = on && cfg!(feature = "system");
|
||||
self
|
||||
}
|
||||
|
||||
/// Have every scan also read the files `lister` names (any file name; a folder is read with
|
||||
/// its subfolders), as fonts of the system.
|
||||
#[must_use]
|
||||
pub fn with_lister(self, lister: Lister) -> Self {
|
||||
self.write_config().listers.push(lister);
|
||||
self
|
||||
}
|
||||
|
||||
/// The app's own font folders (its Fonts folder, Office's, folders the user picked), read
|
||||
/// with their subfolders from the next scan on. A change shows in
|
||||
/// [`Catalog::installed_fonts_changed`].
|
||||
pub fn set_app_dirs(&self, dirs: Vec<PathBuf>) {
|
||||
self.write_config().app_dirs = dirs;
|
||||
}
|
||||
|
||||
fn write_config(&self) -> std::sync::RwLockWriteGuard<'_, Config> {
|
||||
self.config.write().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn read_config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
|
||||
self.config.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The environment the catalog was made for.
|
||||
pub fn env(&self) -> &FontEnv {
|
||||
&self.env
|
||||
}
|
||||
|
||||
/// Start the first scan on a background thread, so the first lookup doesn't wait for all of
|
||||
/// it. Does nothing once it has run, or when no thread can be started (the first lookup
|
||||
/// then scans).
|
||||
pub fn scan_in_background(self: &Arc<Self>) {
|
||||
if self.ready.get().is_none() {
|
||||
let me = Arc::clone(self);
|
||||
let _ = std::thread::Builder::new().name("font-catalog-scan".into()).spawn(move || me.ensure());
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&self) {
|
||||
self.ready.get_or_init(|| {
|
||||
self.scan_now(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the first scan has finished: lookups won't wait.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.ready.get().is_some()
|
||||
}
|
||||
|
||||
/// Scan again, for fonts installed or removed since; only new and changed files are read.
|
||||
/// Families loaded before load again on their next [`Catalog::load_family`]. Returns the
|
||||
/// number of faces cataloged.
|
||||
pub fn rescan(&self) -> usize {
|
||||
let mut first = false;
|
||||
self.ready.get_or_init(|| {
|
||||
first = true;
|
||||
self.scan_now(false);
|
||||
});
|
||||
if !first {
|
||||
self.scan_now(true);
|
||||
}
|
||||
self.snapshot().faces.len()
|
||||
}
|
||||
|
||||
/// [`Catalog::rescan`] when [`Catalog::installed_fonts_changed`] says so, for an app to call
|
||||
/// when its window is activated. Returns whether it rescanned.
|
||||
pub fn rescan_if_changed(&self) -> bool {
|
||||
let changed = self.installed_fonts_changed();
|
||||
if changed {
|
||||
self.rescan();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Whether fonts were installed or removed since the last scan: a folder the scan read (or
|
||||
/// the folder of a file the font service named) changed, a missing one appeared, the app's
|
||||
/// folders changed, or on Windows the registry's font list changed. It looks at folders,
|
||||
/// not files, so it costs far less than a scan. `false` before the first scan.
|
||||
///
|
||||
/// Fonts a font service registers from a folder no scan has seen are found by the next
|
||||
/// [`Catalog::rescan`] only.
|
||||
pub fn installed_fonts_changed(&self) -> bool {
|
||||
if !self.is_ready() {
|
||||
return false;
|
||||
}
|
||||
let stamp = lock(&self.stamp);
|
||||
if stamp.app_dirs != self.read_config().app_dirs {
|
||||
return true;
|
||||
}
|
||||
if stamp.dirs.iter().any(|(p, t)| modified(p) != *t) {
|
||||
return true;
|
||||
}
|
||||
self.read_config().font_service && self.env.os == Os::Windows && stamp.registered != crate::registry::registered_font_files()
|
||||
}
|
||||
|
||||
/// Poll for installed and removed fonts every `interval` on a background thread: when
|
||||
/// [`Catalog::installed_fonts_changed`], rescan and call `on_change`. Stops when the returned
|
||||
/// [`Watch`] or the catalog is dropped.
|
||||
pub fn watch(self: &Arc<Self>, interval: Duration, on_change: impl Fn(&Catalog) + Send + 'static) -> Watch {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (me, flag) = (Arc::downgrade(self), Arc::clone(&stop));
|
||||
let _ = std::thread::Builder::new().name("font-catalog-watch".into()).spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(interval);
|
||||
if flag.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Some(catalog) = me.upgrade() else { return };
|
||||
if catalog.rescan_if_changed() {
|
||||
on_change(&catalog);
|
||||
}
|
||||
}
|
||||
});
|
||||
Watch { stop }
|
||||
}
|
||||
|
||||
/// Counts the catalog's changes: each scan, and each face added or removed.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Arc<Index> {
|
||||
self.ensure();
|
||||
Arc::clone(&self.index.read().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
fn publish(&self, index: Index) {
|
||||
*self.index.write().unwrap_or_else(PoisonError::into_inner) = Arc::new(index);
|
||||
lock(&self.loaded).clear();
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// The family names, sorted ignoring case.
|
||||
pub fn families(&self) -> Arc<[String]> {
|
||||
Arc::clone(&self.snapshot().families)
|
||||
}
|
||||
|
||||
/// Every face, added faces first.
|
||||
pub fn faces(&self) -> Arc<[FaceInfo]> {
|
||||
Arc::clone(&self.snapshot().infos)
|
||||
}
|
||||
|
||||
/// The family `name` names: its own name ignoring ASCII case, or any other of its names
|
||||
/// (another language, a legacy name) ignoring case and everything but letters and digits.
|
||||
pub fn family(&self, name: &str) -> Option<String> {
|
||||
let index = self.snapshot();
|
||||
let i = *index.family_of(name)?.first()?;
|
||||
index.face(i).map(|f| f.info.family.clone())
|
||||
}
|
||||
|
||||
/// The faces of the family `name` names ([`Catalog::family`]).
|
||||
pub fn styles(&self, family: &str) -> Vec<FaceInfo> {
|
||||
let index = self.snapshot();
|
||||
index.family_of(family).unwrap_or_default().iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect()
|
||||
}
|
||||
|
||||
/// The face for `name` in `style`, and how it matched. `name` is a family name (any of its
|
||||
/// names, as [`Catalog::family`]), a legacy family name (`Roboto Light`, matched with its
|
||||
/// legacy style), a full name or a PostScript name (which name one face; `style` is then
|
||||
/// ignored). Within the family, `style` matches by name; otherwise the nearest weight wins,
|
||||
/// italic differing counting as 1000, then the nearest width. An empty style is `Regular`.
|
||||
/// `None` when nothing has that name.
|
||||
pub fn resolve(&self, name: &str, style: &str) -> Option<(FaceInfo, Match)> {
|
||||
let index = self.snapshot();
|
||||
let (i, matched) = index.resolve(name, style)?;
|
||||
Some((index.face(i)?.info.clone(), matched))
|
||||
}
|
||||
|
||||
/// The face with this PostScript name (a named instance's too), ignoring case and everything
|
||||
/// but letters and digits.
|
||||
pub fn by_postscript_name(&self, name: &str) -> Option<FaceInfo> {
|
||||
let index = self.snapshot();
|
||||
let i = *index.postscript.get(&norm(name))?;
|
||||
index.face(i).map(|f| f.info.clone())
|
||||
}
|
||||
|
||||
/// The data of `face`, read from its file (shared with other faces of the file still loaded).
|
||||
pub fn load(&self, face: &FaceInfo) -> Result<FontData, FontFileError> {
|
||||
if let Some(blob) = face.blob {
|
||||
let bytes = lock(&self.added).blobs.get(&blob).cloned().ok_or(FontFileError::Gone)?;
|
||||
return Ok(FontData { bytes, index: face.index });
|
||||
}
|
||||
let path = face.path.as_deref().ok_or(FontFileError::Gone)?;
|
||||
if crate::suitcase::is_suitcase(path) {
|
||||
let font = crate::suitcase::read_fonts(path).into_iter().nth(face.index as usize).ok_or(FontFileError::NotAFont)?;
|
||||
return Ok(FontData { bytes: font.into(), index: 0 });
|
||||
}
|
||||
Ok(FontData { bytes: self.file_bytes(path)?, index: face.index })
|
||||
}
|
||||
|
||||
fn file_bytes(&self, path: &Path) -> Result<Arc<[u8]>, FontFileError> {
|
||||
if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let size = std::fs::metadata(path)?.len();
|
||||
if size > MAX_LOAD {
|
||||
return Err(FontFileError::TooLarge { size, max: MAX_LOAD });
|
||||
}
|
||||
let bytes: Arc<[u8]> = std::fs::read(path)?.into();
|
||||
let mut files = lock(&self.files);
|
||||
files.retain(|_, w| w.strong_count() > 0);
|
||||
files.insert(path.to_path_buf(), Arc::downgrade(&bytes));
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Every face of the family `family` names ([`Catalog::family`]) with its data. The first
|
||||
/// call reads the family's files; calls for the same family on other threads wait for it and
|
||||
/// share the result, while other families load at the same time. Faces whose files can't be
|
||||
/// read are left out. After a rescan, the next call reads the files again.
|
||||
pub fn load_family(&self, family: &str) -> Arc<[LoadedFace]> {
|
||||
let Some(name) = self.family(family) else { return Arc::from([]) };
|
||||
let cell = Arc::clone(lock(&self.loaded).entry(name.to_ascii_lowercase()).or_default());
|
||||
Arc::clone(
|
||||
cell.get_or_init(|| self.styles(&name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Add the faces of a font file's `bytes` (a font or a collection) for this session, as
|
||||
/// `source`. Returns the faces added.
|
||||
pub fn add_bytes(&self, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {
|
||||
let size = bytes.len() as u64;
|
||||
if size > limits.max_file_bytes {
|
||||
return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });
|
||||
}
|
||||
let mut faces = scan::data_faces(&bytes, limits.max_faces_per_file, None, source);
|
||||
if faces.is_empty() {
|
||||
return Err(FontFileError::NotAFont);
|
||||
}
|
||||
self.ensure();
|
||||
let mut added = lock(&self.added);
|
||||
if added.faces.len() + faces.len() > limits.max_added_faces {
|
||||
return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });
|
||||
}
|
||||
let blob = added.next_blob;
|
||||
added.next_blob += 1;
|
||||
added.blobs.insert(blob, bytes.into());
|
||||
for f in &mut faces {
|
||||
f.info.blob = Some(blob);
|
||||
}
|
||||
Ok(self.push_added(added, faces))
|
||||
}
|
||||
|
||||
/// Add the faces of the font file at `path` for this session, as `source`, read in place.
|
||||
/// Returns the faces added.
|
||||
pub fn add_file(&self, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {
|
||||
let size = std::fs::metadata(path)?.len();
|
||||
if size > limits.max_file_bytes {
|
||||
return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });
|
||||
}
|
||||
let mut faces = scan::file_faces(path, limits.max_file_bytes, source);
|
||||
let mut fonts: Vec<u32> = faces.iter().map(|f| f.info.index).collect();
|
||||
fonts.dedup();
|
||||
if let Some(&keep) = fonts.get(limits.max_faces_per_file as usize) {
|
||||
faces.retain(|f| f.info.index < keep);
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(FontFileError::NotAFont);
|
||||
}
|
||||
self.ensure();
|
||||
let added = lock(&self.added);
|
||||
if added.faces.len() + faces.len() > limits.max_added_faces {
|
||||
return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });
|
||||
}
|
||||
Ok(self.push_added(added, faces))
|
||||
}
|
||||
|
||||
fn push_added(&self, mut added: MutexGuard<'_, Added>, faces: Vec<Face>) -> Vec<FaceInfo> {
|
||||
let infos = faces.iter().map(|f| f.info.clone()).collect();
|
||||
added.faces.extend(faces);
|
||||
let scanned = self.scanned();
|
||||
self.publish(Index::build(&added.faces, &scanned));
|
||||
infos
|
||||
}
|
||||
|
||||
/// Remove every added face of `source` (a closed document's fonts). Returns how many.
|
||||
pub fn remove_added(&self, source: Source) -> usize {
|
||||
let mut added = lock(&self.added);
|
||||
let before = added.faces.len();
|
||||
added.faces.retain(|f| f.info.source != source);
|
||||
let blobs: HashSet<u64> = added.faces.iter().filter_map(|f| f.info.blob).collect();
|
||||
added.blobs.retain(|b, _| blobs.contains(b));
|
||||
let removed = before - added.faces.len();
|
||||
if removed > 0 {
|
||||
let scanned = self.scanned();
|
||||
self.publish(Index::build(&added.faces, &scanned));
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// The faces the last scan found.
|
||||
fn scanned(&self) -> Vec<Face> {
|
||||
let scan = lock(&self.scan);
|
||||
let mut faces: Vec<(&PathBuf, &Cached)> = scan.files.iter().collect();
|
||||
faces.sort_by(|a, b| a.0.cmp(b.0));
|
||||
faces.into_iter().flat_map(|(_, c)| c.faces.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
fn scan_now(&self, refresh: bool) {
|
||||
let mut state = lock(&self.scan);
|
||||
let (app_dirs, listers, service) = {
|
||||
let c = self.read_config();
|
||||
(c.app_dirs.clone(), c.listers.clone(), c.font_service)
|
||||
};
|
||||
let ignore_case = self.env.os == Os::Windows;
|
||||
let mut roots: Vec<(PathBuf, Source)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System)).collect();
|
||||
roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store)));
|
||||
roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App)));
|
||||
|
||||
let mut named: Vec<PathBuf> = Vec::new();
|
||||
let mut registered = Vec::new();
|
||||
if service {
|
||||
named.extend(crate::system::font_files(refresh));
|
||||
if self.env.os == Os::Windows {
|
||||
registered = crate::registry::registered_font_files();
|
||||
named.extend(registered.iter().cloned());
|
||||
}
|
||||
}
|
||||
for lister in &listers {
|
||||
named.extend(lister());
|
||||
}
|
||||
|
||||
let mut walk = Walk { ignore_case, seen_dirs: HashSet::new(), seen_files: HashSet::new(), entries: 0, dirs: Vec::new(), files: Vec::new() };
|
||||
for (root, source) in &roots {
|
||||
walk.dir(root, *source, 0);
|
||||
}
|
||||
let mut parents = HashSet::new();
|
||||
for file in named {
|
||||
if file.is_dir() {
|
||||
walk.dir(&file, Source::System, 0);
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = file.parent()
|
||||
&& parents.insert(parent.to_path_buf())
|
||||
{
|
||||
walk.dirs.push((parent.to_path_buf(), modified(parent)));
|
||||
}
|
||||
walk.file(file, Source::System, true);
|
||||
}
|
||||
|
||||
let mut files = HashMap::new();
|
||||
for (path, source, by_name) in walk.files {
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
let (len, mtime) = (meta.len(), meta.modified().ok());
|
||||
let cached = state.files.remove(&path).filter(|c| c.len == len && c.modified == mtime);
|
||||
let mut faces = match cached {
|
||||
Some(c) => c.faces,
|
||||
None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => scan::file_faces(&path, MAX_SCAN_READ, source),
|
||||
None => continue,
|
||||
};
|
||||
for f in &mut faces {
|
||||
f.info.source = source;
|
||||
}
|
||||
files.insert(path, Cached { len, modified: mtime, faces });
|
||||
}
|
||||
state.files = files;
|
||||
drop(state);
|
||||
|
||||
*lock(&self.stamp) = Stamp { app_dirs, dirs: walk.dirs, registered };
|
||||
let added = lock(&self.added);
|
||||
let scanned = self.scanned();
|
||||
self.publish(Index::build(&added.faces, &scanned));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a [`Catalog::watch`] when dropped.
|
||||
#[must_use = "dropping a Watch stops it"]
|
||||
pub struct Watch {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for Watch {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The files of one scan: each folder and file once, however links lead back to it.
|
||||
struct Walk {
|
||||
ignore_case: bool,
|
||||
seen_dirs: HashSet<PathBuf>,
|
||||
seen_files: HashSet<PathBuf>,
|
||||
entries: usize,
|
||||
dirs: Vec<(PathBuf, Option<SystemTime>)>,
|
||||
/// Each file with where it was found and whether a lister named it.
|
||||
files: Vec<(PathBuf, Source, bool)>,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn key(&self, path: &Path) -> PathBuf {
|
||||
let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if self.ignore_case { PathBuf::from(real.to_string_lossy().to_lowercase()) } else { real }
|
||||
}
|
||||
|
||||
fn dir(&mut self, dir: &Path, source: Source, depth: usize) {
|
||||
let key = self.key(dir);
|
||||
if !self.seen_dirs.insert(key) {
|
||||
return;
|
||||
}
|
||||
// Stamped before it is listed: a font installed meanwhile changes it after this.
|
||||
self.dirs.push((dir.to_path_buf(), modified(dir)));
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut paths: Vec<(PathBuf, bool)> = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
self.entries += 1;
|
||||
if self.entries > MAX_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let path = e.path();
|
||||
let is_dir = e.file_type().is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir()));
|
||||
paths.push((path, is_dir));
|
||||
}
|
||||
paths.sort();
|
||||
for (path, is_dir) in paths {
|
||||
if is_dir {
|
||||
if depth < MAX_DEPTH {
|
||||
self.dir(&path, source, depth + 1);
|
||||
}
|
||||
} else {
|
||||
self.file(path, source, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&mut self, path: PathBuf, source: Source, by_name: bool) {
|
||||
let key = self.key(&path);
|
||||
if self.seen_files.insert(key) {
|
||||
self.files.push((path, source, by_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Where fonts are installed: the platform's font folders and the suite's font store, worked out
|
||||
//! from a [`FontEnv`] value rather than the process environment, so tests can describe any system.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The platform family whose font folders apply.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Os {
|
||||
/// macOS.
|
||||
MacOs,
|
||||
/// Windows.
|
||||
Windows,
|
||||
/// Linux and the BSDs: XDG folders, fontconfig and Flatpak.
|
||||
#[default]
|
||||
Unix,
|
||||
/// Haiku.
|
||||
Haiku,
|
||||
/// The web, Android, iOS and anything else without font folders to scan.
|
||||
None,
|
||||
}
|
||||
|
||||
impl Os {
|
||||
/// The platform this build runs on.
|
||||
pub fn current() -> Self {
|
||||
if cfg!(target_arch = "wasm32") || cfg!(target_os = "android") || cfg!(target_os = "ios") {
|
||||
Self::None
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::MacOs
|
||||
} else if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else if cfg!(target_os = "haiku") {
|
||||
Self::Haiku
|
||||
} else {
|
||||
Self::Unix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment the font folders depend on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FontEnv {
|
||||
/// Which platform's folders to list.
|
||||
pub os: Os,
|
||||
/// The file system root that fixed system folders (`/usr/share/fonts`, `/run/host/fonts`,
|
||||
/// `/System/Library/Fonts`, `/etc/fonts/fonts.conf`) and absolute fontconfig `<dir>` entries
|
||||
/// are under: `/` on a real system, a temporary folder in tests.
|
||||
pub root: PathBuf,
|
||||
/// `HOME` (`USERPROFILE` on Windows).
|
||||
pub home: Option<PathBuf>,
|
||||
/// `XDG_DATA_HOME`.
|
||||
pub xdg_data_home: Option<PathBuf>,
|
||||
/// `XDG_DATA_DIRS`.
|
||||
pub xdg_data_dirs: Option<OsString>,
|
||||
/// `XDG_CONFIG_HOME`.
|
||||
pub xdg_config_home: Option<PathBuf>,
|
||||
/// `FONTCONFIG_FILE`: the main fontconfig file instead of `/etc/fonts/fonts.conf`.
|
||||
pub fontconfig_file: Option<PathBuf>,
|
||||
/// Inside a Flatpak sandbox (`/.flatpak-info` exists): the host's fontconfig folders are read
|
||||
/// too, under `/run/host`.
|
||||
pub flatpak: bool,
|
||||
/// `WINDIR`.
|
||||
pub windir: Option<PathBuf>,
|
||||
/// `LOCALAPPDATA`.
|
||||
pub local_app_data: Option<PathBuf>,
|
||||
/// `CRAFT_FONT_STORE`: the font store folder instead of the platform's ([`font_store_dir`]).
|
||||
pub font_store: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl FontEnv {
|
||||
/// The environment of this process. Empty variables count as unset.
|
||||
pub fn from_process() -> Self {
|
||||
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
|
||||
let path = |k: &str| var(k).map(PathBuf::from);
|
||||
let os = Os::current();
|
||||
let home = if os == Os::Windows { path("USERPROFILE").or_else(|| path("HOME")) } else { path("HOME") };
|
||||
Self {
|
||||
os,
|
||||
root: PathBuf::from("/"),
|
||||
home,
|
||||
xdg_data_home: path("XDG_DATA_HOME"),
|
||||
xdg_data_dirs: var("XDG_DATA_DIRS"),
|
||||
xdg_config_home: path("XDG_CONFIG_HOME"),
|
||||
fontconfig_file: path("FONTCONFIG_FILE"),
|
||||
flatpak: os == Os::Unix && Path::new("/.flatpak-info").exists(),
|
||||
windir: path("WINDIR"),
|
||||
local_app_data: path("LOCALAPPDATA"),
|
||||
font_store: path("CRAFT_FONT_STORE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` (absolute, as on the real system) under [`FontEnv::root`].
|
||||
pub(crate) fn rooted(&self, path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
let relative: PathBuf = path.components().filter(|c| matches!(c, std::path::Component::Normal(_))).collect();
|
||||
if self.root.as_os_str().is_empty() { Path::new("/").join(relative) } else { self.root.join(relative) }
|
||||
}
|
||||
|
||||
/// `$XDG_DATA_HOME`, else `~/.local/share` (a relative `XDG_DATA_HOME` is ignored, as the XDG
|
||||
/// specification says).
|
||||
pub(crate) fn data_home(&self) -> Option<PathBuf> {
|
||||
self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share")))
|
||||
}
|
||||
|
||||
/// `$XDG_CONFIG_HOME`, else `~/.config`.
|
||||
pub(crate) fn config_home(&self) -> Option<PathBuf> {
|
||||
self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config")))
|
||||
}
|
||||
|
||||
fn windows_fonts(&self) -> PathBuf {
|
||||
self.windir.clone().unwrap_or_else(|| PathBuf::from("C:\\Windows")).join("Fonts")
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder name of the font store under the platform's per-user data folder, spelled as the
|
||||
/// suite's other per-user folders are (`ArtCraft Launcher`, `artcraft-launcher`).
|
||||
fn store_name(os: Os) -> &'static str {
|
||||
if matches!(os, Os::MacOs | Os::Windows) { "Craft Fonts" } else { "craft-fonts" }
|
||||
}
|
||||
|
||||
/// The suite's font store: one per-user folder of fonts installed by Craft Font Manager or an
|
||||
/// app's "install missing font", read by every app's catalog and by no other program.
|
||||
///
|
||||
/// - macOS: `~/Library/Application Support/Craft Fonts`
|
||||
/// - Windows: `%LOCALAPPDATA%\Craft Fonts`
|
||||
/// - Linux, the BSDs and Haiku: `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`)
|
||||
///
|
||||
/// `CRAFT_FONT_STORE` ([`FontEnv::font_store`]) replaces it. `None` where there is no per-user
|
||||
/// folder (the web), or when the variables it is built from are unset.
|
||||
pub fn font_store_dir(env: &FontEnv) -> Option<PathBuf> {
|
||||
if let Some(dir) = &env.font_store {
|
||||
return Some(dir.clone());
|
||||
}
|
||||
let base = match env.os {
|
||||
Os::MacOs => env.home.as_ref()?.join("Library/Application Support"),
|
||||
Os::Windows => env.local_app_data.clone()?,
|
||||
Os::Unix | Os::Haiku => env.data_home()?,
|
||||
Os::None => return None,
|
||||
};
|
||||
Some(base.join(store_name(env.os)))
|
||||
}
|
||||
|
||||
/// The platform's font folders, the system's and the user's, without duplicates. Folders that
|
||||
/// don't exist are kept: a scan skips them, and a change check notices when one appears.
|
||||
///
|
||||
/// - macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`,
|
||||
/// `~/Library/Fonts`, `/System/Library/Assets`, and the font assets macOS downloads on demand
|
||||
/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, also under
|
||||
/// `PreinstalledAssetsV2/InstallWithOs`).
|
||||
/// - Windows: `%WINDIR%\Fonts` and the current user's `%LOCALAPPDATA%\Microsoft\Windows\Fonts`.
|
||||
/// The registry's font list is read by the catalog itself.
|
||||
/// - Linux and the BSDs: `/usr/share/fonts`, `/usr/local/share/fonts`, the Flatpak host folders
|
||||
/// (`/run/host/fonts`, `/run/host/local-fonts`, `/run/host/user-fonts`), `~/.local/share/fonts`,
|
||||
/// `~/.fonts`, `$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`, and the `<dir>` and
|
||||
/// `<remap-dir>` entries of the fontconfig configuration ([`crate::fontconfig_dirs`]).
|
||||
/// - Haiku: the system's and the user's `data/fonts` folders, packaged and non-packaged.
|
||||
pub fn font_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
match env.os {
|
||||
Os::MacOs => {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(|d| env.rooted(d)));
|
||||
dirs.extend(env.home.as_ref().map(|h| h.join("Library/Fonts")));
|
||||
dirs.push(env.rooted("/System/Library/Assets"));
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
let mut assets: Vec<PathBuf> = std::fs::read_dir(env.rooted(parent))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font"))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
assets.sort();
|
||||
dirs.extend(assets);
|
||||
}
|
||||
}
|
||||
Os::Windows => {
|
||||
dirs.push(env.windows_fonts());
|
||||
dirs.extend(env.local_app_data.as_ref().map(|l| l.join("Microsoft\\Windows\\Fonts")));
|
||||
}
|
||||
Os::Unix => {
|
||||
dirs.extend(
|
||||
["/usr/share/fonts", "/usr/local/share/fonts", "/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"]
|
||||
.map(|d| env.rooted(d)),
|
||||
);
|
||||
if let Some(h) = &env.home {
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
dirs.extend(env.data_home().map(|d| d.join("fonts")));
|
||||
let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| env.rooted(p).join("fonts")));
|
||||
dirs.extend(crate::fontconfig::fontconfig_dirs(env));
|
||||
}
|
||||
Os::Haiku => {
|
||||
dirs.extend(["/boot/system/data/fonts", "/boot/system/non-packaged/data/fonts"].map(|d| env.rooted(d)));
|
||||
if let Some(h) = &env.home {
|
||||
dirs.push(h.join("config/data/fonts"));
|
||||
dirs.push(h.join("config/non-packaged/data/fonts"));
|
||||
}
|
||||
}
|
||||
Os::None => {}
|
||||
}
|
||||
dedup_dirs(dirs, env.os == Os::Windows)
|
||||
}
|
||||
|
||||
/// The folders Microsoft Office keeps its own fonts in (Calibri, Cambria, Aptos, and the cloud
|
||||
/// fonts it downloads), which other apps don't see. Apps that open Office documents add them to
|
||||
/// their catalog ([`crate::Catalog::set_app_dirs`]).
|
||||
pub fn office_font_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
match env.os {
|
||||
Os::MacOs => {
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
env.home.iter().map(|h| h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")).collect();
|
||||
dirs.extend(
|
||||
["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"]
|
||||
.map(|app| env.rooted(format!("/Applications/{app}.app/Contents/Resources/DFonts"))),
|
||||
);
|
||||
dirs
|
||||
}
|
||||
Os::Windows => env.local_app_data.iter().map(|l| l.join("Microsoft\\FontCache\\4\\CloudFonts")).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `dirs` without repeats (also one folder reached through a link), keeping the first. Windows
|
||||
/// paths compare ignoring case.
|
||||
pub(crate) fn dedup_dirs(dirs: Vec<PathBuf>, ignore_case: bool) -> Vec<PathBuf> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dirs.into_iter()
|
||||
.filter(|d| {
|
||||
let key = std::fs::canonicalize(d).unwrap_or_else(|_| d.clone());
|
||||
seen.insert(if ignore_case { PathBuf::from(key.to_string_lossy().to_lowercase()) } else { key })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The font folders named by the fontconfig configuration, read with a small tag scanner (no
|
||||
//! fontconfig library, no XML crate): only `<dir>`, `<remap-dir>` and `<include>` matter here.
|
||||
//! The work is bounded: include depth, number and size of the files read, and every file is read
|
||||
//! at most once.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::FontEnv;
|
||||
|
||||
const MAX_INCLUDE_DEPTH: u32 = 8;
|
||||
const MAX_FILES: usize = 512;
|
||||
const MAX_FILE_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// The folders the fontconfig configuration for `env` names, in the order it names them.
|
||||
///
|
||||
/// The main file is `FONTCONFIG_FILE`, else the first of `/etc/fonts/fonts.conf` and
|
||||
/// `/usr/local/etc/fonts/fonts.conf` (FreeBSD) that exists. The user's own configuration
|
||||
/// (`$XDG_CONFIG_HOME/fontconfig/fonts.conf` and `conf.d`) is read too, in case the main file
|
||||
/// doesn't include it. In a Flatpak sandbox ([`FontEnv::flatpak`]) the host's folders are added:
|
||||
/// those `/run/host/font-dirs.xml` maps, and those of the host's own configuration when the
|
||||
/// sandbox can see it (`/run/host/etc/fonts/fonts.conf`), under `/run/host`.
|
||||
pub fn fontconfig_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut reader = Reader { env, out: Vec::new(), seen: HashSet::new(), host: None };
|
||||
let root = match &env.fontconfig_file {
|
||||
Some(f) => Some(f.clone()),
|
||||
None => ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(|p| env.rooted(p)).into_iter().find(|p| p.is_file()),
|
||||
};
|
||||
if let Some(root) = root {
|
||||
reader.file(&root, 0);
|
||||
}
|
||||
if let Some(c) = env.config_home() {
|
||||
reader.include(&c.join("fontconfig/fonts.conf"), 0);
|
||||
reader.include(&c.join("fontconfig/conf.d"), 0);
|
||||
}
|
||||
if env.flatpak {
|
||||
reader.file(&env.rooted("/run/host/font-dirs.xml"), 0);
|
||||
reader.host = Some(env.rooted("/run/host"));
|
||||
reader.file(&env.rooted("/run/host/etc/fonts/fonts.conf"), 0);
|
||||
}
|
||||
reader.out
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
env: &'a FontEnv,
|
||||
out: Vec<PathBuf>,
|
||||
seen: HashSet<PathBuf>,
|
||||
/// While reading the host's configuration from a sandbox: where the host's root is.
|
||||
host: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Read a configuration file or, for a folder, its `NN-name.conf` files in name order.
|
||||
fn include(&mut self, path: &Path, depth: u32) {
|
||||
if path.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(path) else { return };
|
||||
let mut files: Vec<PathBuf> = rd
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
self.file(&f, depth);
|
||||
}
|
||||
} else {
|
||||
self.file(path, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&mut self, path: &Path, depth: u32) {
|
||||
if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if !self.seen.insert(key) {
|
||||
return;
|
||||
}
|
||||
if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
for el in elements(&text) {
|
||||
match el.name {
|
||||
"dir" | "remap-dir" => {
|
||||
if let Some(p) = self.resolve(&el, &base, false) {
|
||||
self.out.push(p);
|
||||
}
|
||||
}
|
||||
"include" => {
|
||||
if let Some(p) = self.resolve(&el, &base, true) {
|
||||
self.include(&p, depth.saturating_add(1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders)
|
||||
/// or config (includes) home, `prefix="relative"` and plain relative paths the including
|
||||
/// file's folder, `prefix="default"`/`"cwd"` the working folder. Absolute paths are under the
|
||||
/// root, or under the host's root while reading the host's configuration.
|
||||
fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option<PathBuf> {
|
||||
let v = el.text.trim();
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = v.strip_prefix('~') {
|
||||
return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/')));
|
||||
}
|
||||
let p = Path::new(v);
|
||||
if p.is_absolute() {
|
||||
return Some(match &self.host {
|
||||
Some(host) => host.join(p.strip_prefix("/").unwrap_or(p)),
|
||||
None => self.env.rooted(p),
|
||||
});
|
||||
}
|
||||
match el.attr("prefix") {
|
||||
Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)),
|
||||
Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)),
|
||||
_ => Some(base.join(p)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Element<'a> {
|
||||
name: &'a str,
|
||||
attrs: &'a str,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Element<'_> {
|
||||
fn attr(&self, key: &str) -> Option<&str> {
|
||||
let mut rest = self.attrs;
|
||||
while let Some(eq) = rest.find('=') {
|
||||
let k = rest.get(..eq)?.trim();
|
||||
let after = rest.get(eq + 1..)?.trim_start();
|
||||
let q = after.chars().next()?;
|
||||
if q != '"' && q != '\'' {
|
||||
return None;
|
||||
}
|
||||
let body = after.get(1..)?;
|
||||
let end = body.find(q)?;
|
||||
if k == key {
|
||||
return body.get(..end);
|
||||
}
|
||||
rest = body.get(end + 1..)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<dir>`, `<remap-dir>` and `<include>` elements of a fontconfig file, comments skipped.
|
||||
fn elements(src: &str) -> Vec<Element<'_>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(off) = src.get(i..).and_then(|s| s.find('<')) {
|
||||
let start = i + off;
|
||||
let Some(tail) = src.get(start..) else { break };
|
||||
if tail.starts_with("<!--") {
|
||||
match tail.find("-->") {
|
||||
Some(e) => {
|
||||
i = start + e + 3;
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let Some(close) = tail.find('>') else { break };
|
||||
let inner = tail.get(1..close).unwrap_or_default();
|
||||
i = start + close + 1;
|
||||
let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len());
|
||||
let name = inner.get(..name_len).unwrap_or_default();
|
||||
if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let attrs = inner.get(name_len..).unwrap_or_default();
|
||||
let end_tag = format!("</{name}>");
|
||||
let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break };
|
||||
let body = src.get(i..i + body_len).unwrap_or_default();
|
||||
i += body_len + end_tag.len();
|
||||
out.push(Element { name, attrs, text: decode_entities(body) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_entities(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! The installed fonts, for every Craft app: discovery on every platform, lookup by family and
|
||||
//! style or by any other name, and loading shared between threads.
|
||||
//!
|
||||
//! A [`Catalog`] scans the platform's font folders ([`font_dirs`]), the suite's font store
|
||||
//! ([`font_store_dir`]), the app's own folders ([`Catalog::set_app_dirs`]) and, with the `system`
|
||||
//! feature, the files the platform's font service knows ([`system`]): CoreText on macOS,
|
||||
//! DirectWrite on Windows, fontconfig on Linux and FreeBSD. On Windows it also reads the
|
||||
//! registry's font list. The scan reads each file's table directory and its `name`, `OS/2` and
|
||||
//! `fvar` tables, never whole files, and a rescan reads only files that changed.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use craft_font_catalog::{Catalog, Match};
|
||||
//!
|
||||
//! let catalog = Catalog::global();
|
||||
//! catalog.scan_in_background();
|
||||
//! if let Some((face, Match::Exact)) = catalog.resolve("Source Serif 4", "Bold Italic") {
|
||||
//! let data = catalog.load(&face);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod catalog;
|
||||
mod env;
|
||||
mod fontconfig;
|
||||
mod registry;
|
||||
mod scan;
|
||||
mod suitcase;
|
||||
pub mod system;
|
||||
|
||||
pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch};
|
||||
pub use env::{FontEnv, Os, font_dirs, font_store_dir, office_font_dirs};
|
||||
pub use fontconfig::fontconfig_dirs;
|
||||
pub use scan::{FaceInfo, LocalizedName, Source, is_font_file, style_italic, style_weight, style_width};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The fonts registered with Windows in the registry: one value per face, holding a file name in
|
||||
//! the Windows font folder or a full path. Programs that install fonts into their own folders
|
||||
//! register them with full paths, which only the registry lists.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The full paths the registry lists for the machine's and the current user's fonts, sorted and
|
||||
/// without repeats. Bare file names (files in the Windows font folder) are left out: the scan
|
||||
/// reads that folder. Empty elsewhere than on Windows.
|
||||
pub(crate) fn registered_font_files() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const MAX_REGISTERED: usize = 1 << 16;
|
||||
const KEY: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts";
|
||||
use windows_registry::{CURRENT_USER, LOCAL_MACHINE};
|
||||
let mut files: Vec<PathBuf> = [LOCAL_MACHINE, CURRENT_USER]
|
||||
.into_iter()
|
||||
.filter_map(|root| root.open(KEY).ok())
|
||||
.flat_map(|key| key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::<Vec<_>>()).unwrap_or_default())
|
||||
.take(MAX_REGISTERED)
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
Vec::new()
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Reading face records from font files: the table directory and the `name`, `OS/2` and `fvar`
|
||||
//! tables, never the whole file.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use skrifa::raw::{FileRef, TableProvider};
|
||||
use skrifa::{MetadataProvider, string::StringId};
|
||||
|
||||
/// Where a face comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Source {
|
||||
/// Installed on the system: a platform font folder or the platform's font service.
|
||||
System,
|
||||
/// The suite's font store ([`crate::font_store_dir`]).
|
||||
Store,
|
||||
/// A folder the app added ([`crate::Catalog::set_app_dirs`]).
|
||||
App,
|
||||
/// Bundled with the app and added as bytes.
|
||||
Bundled,
|
||||
/// Added by the user to this app.
|
||||
User,
|
||||
/// Embedded in, or shipped beside, an open document.
|
||||
Document,
|
||||
}
|
||||
|
||||
/// A name in one language, from the font's `name` table.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LocalizedName {
|
||||
/// The record's BCP 47 language tag (`ja`, `zh-Hant`), when the font says.
|
||||
pub language: Option<String>,
|
||||
/// The name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One face of an installed or added font: one face of a file, or one named instance of a
|
||||
/// variable font.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FaceInfo {
|
||||
/// The family: the typographic family name (name ID 16), else the family name (ID 1), in
|
||||
/// English when the font has an English record.
|
||||
pub family: String,
|
||||
/// The style: the typographic subfamily (ID 17), else the subfamily (ID 2), else `Regular`.
|
||||
/// A named instance's subfamily name.
|
||||
pub style: String,
|
||||
/// The legacy family name (ID 1) when it differs from [`FaceInfo::family`] (`Roboto Light`
|
||||
/// in the family `Roboto`).
|
||||
pub legacy_family: Option<String>,
|
||||
/// The legacy style (ID 2) that goes with [`FaceInfo::legacy_family`].
|
||||
pub legacy_style: Option<String>,
|
||||
/// The family's names in other languages (`ヒラギノ角ゴシック`).
|
||||
pub localized_families: Vec<LocalizedName>,
|
||||
/// The full name (ID 4) of the face itself; `None` for a named instance.
|
||||
pub full_name: Option<String>,
|
||||
/// The PostScript name (ID 6, or a named instance's own), as PDF and other documents name fonts.
|
||||
pub postscript_name: Option<String>,
|
||||
/// The weight, 1 to 1000 (400 regular, 700 bold): `usWeightClass`, a named instance's `wght`,
|
||||
/// else the weight the style name implies.
|
||||
pub weight: f32,
|
||||
/// The width as a percentage of normal (100; 75 condensed, 125 expanded): `usWidthClass`, a
|
||||
/// named instance's `wdth`, else the width the style name implies.
|
||||
pub width: f32,
|
||||
/// Italic or oblique.
|
||||
pub italic: bool,
|
||||
/// A named instance's axis settings (tag, user units); empty for the face itself.
|
||||
pub variations: Vec<([u8; 4], f32)>,
|
||||
/// The face has variation axes.
|
||||
pub variable: bool,
|
||||
/// The OS/2 `fsType` embedding permissions (0 without an OS/2 table).
|
||||
pub fs_type: u16,
|
||||
/// The OS/2 code page bits (`ulCodePageRange1`, `ulCodePageRange2`).
|
||||
pub code_pages: [u32; 2],
|
||||
/// The file the face is in; `None` for a face added as bytes.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The face's number in its file: its index in a collection (0 in a single font), or for a
|
||||
/// Mac suitcase font the number of its `sfnt` resource.
|
||||
pub index: u32,
|
||||
/// Where it comes from.
|
||||
pub source: Source,
|
||||
/// For a face added as bytes: which of the catalog's added blobs holds it.
|
||||
pub(crate) blob: Option<u64>,
|
||||
}
|
||||
|
||||
/// A face record with the normalized names it answers to.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Face {
|
||||
pub info: FaceInfo,
|
||||
pub keys: Keys,
|
||||
}
|
||||
|
||||
/// Every name a face answers to besides its family and style, normalized ([`norm`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Keys {
|
||||
/// Its family in every language, typographic else legacy.
|
||||
pub families: Vec<String>,
|
||||
/// Its style in every language, and a variable font's default instance's name.
|
||||
pub styles: Vec<String>,
|
||||
/// Legacy family and style pairs (`Hiragino Sans W3` / `Regular`), per language record.
|
||||
pub legacy: Vec<(String, String)>,
|
||||
pub full_names: Vec<String>,
|
||||
pub postscript: Vec<String>,
|
||||
}
|
||||
|
||||
const MAX_AXES: usize = 64;
|
||||
const MAX_INSTANCES: usize = 512;
|
||||
/// The most faces read from a (possibly damaged) collection.
|
||||
pub(crate) const MAX_FACES: u32 = 256;
|
||||
/// The largest table the scan reads.
|
||||
const MAX_TABLE: u32 = 1 << 20;
|
||||
/// Tables holding outlines a renderer draws. A face with none of them (outlines in Apple's `hvgl`
|
||||
/// table, bitmaps only) draws nothing and is left out.
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
pub(crate) fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The weight a style name implies (400 when it names none).
|
||||
pub fn style_weight(style: &str) -> f32 {
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("extralight", 200.0),
|
||||
("ultralight", 200.0),
|
||||
("semibold", 600.0),
|
||||
("demibold", 600.0),
|
||||
("extrabold", 800.0),
|
||||
("ultrabold", 800.0),
|
||||
("hairline", 100.0),
|
||||
("thin", 100.0),
|
||||
("light", 300.0),
|
||||
("medium", 500.0),
|
||||
("bold", 700.0),
|
||||
("black", 900.0),
|
||||
("heavy", 900.0),
|
||||
];
|
||||
let s = norm(style);
|
||||
TABLE.iter().find(|(k, _)| s.contains(k)).map_or(400.0, |(_, w)| *w)
|
||||
}
|
||||
|
||||
/// Whether a style name says italic or oblique.
|
||||
pub fn style_italic(style: &str) -> bool {
|
||||
let s = norm(style);
|
||||
s.contains("italic") || s.contains("oblique") || s == "it"
|
||||
}
|
||||
|
||||
/// The width a style name implies, in percent of normal (100 when it names none).
|
||||
pub fn style_width(style: &str) -> f32 {
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("ultracondensed", 50.0),
|
||||
("extracondensed", 62.5),
|
||||
("semicondensed", 87.5),
|
||||
("condensed", 75.0),
|
||||
("narrow", 75.0),
|
||||
("ultraexpanded", 200.0),
|
||||
("extraexpanded", 150.0),
|
||||
("semiexpanded", 112.5),
|
||||
("expanded", 125.0),
|
||||
("extended", 125.0),
|
||||
("wide", 125.0),
|
||||
];
|
||||
let s = norm(style);
|
||||
TABLE.iter().find(|(k, _)| s.contains(k)).map_or(100.0, |(_, w)| *w)
|
||||
}
|
||||
|
||||
/// `usWidthClass` (1–9) as a percentage of normal.
|
||||
fn width_class_percent(class: u16) -> Option<f32> {
|
||||
const PERCENT: [f32; 9] = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0];
|
||||
PERCENT.get(usize::from(class).checked_sub(1)?).copied()
|
||||
}
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// Every string of `id`, with its language.
|
||||
fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, String)> {
|
||||
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
|
||||
localized(font, id).into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
fn sort_dedup(v: &mut Vec<String>) {
|
||||
v.retain(|k| !k.is_empty());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
}
|
||||
|
||||
/// The faces `font` (face `index` of its file) offers: the face itself, then the named instances
|
||||
/// of a variable font. Empty when it has no family name.
|
||||
pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&Path>, source: Source) -> Vec<Face> {
|
||||
let Some(family) = name(font, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) else { return vec![] };
|
||||
let style = name(font, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
|
||||
let typo_family = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME);
|
||||
let typo_style = all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME);
|
||||
let legacy_family = localized(font, StringId::FAMILY_NAME);
|
||||
let legacy_style = localized(font, StringId::SUBFAMILY_NAME);
|
||||
|
||||
let family_records = if typo_family.is_empty() { &legacy_family } else { &typo_family };
|
||||
let mut localized_families: Vec<LocalizedName> = Vec::new();
|
||||
for (language, n) in family_records {
|
||||
if *n != family && !localized_families.iter().any(|l| l.name == *n) {
|
||||
localized_families.push(LocalizedName { language: language.clone(), name: n.clone() });
|
||||
}
|
||||
}
|
||||
let mut families: Vec<String> = std::iter::once(family.as_str()).chain(family_records.iter().map(|(_, f)| f.as_str())).map(norm).collect();
|
||||
// A legacy style names the face beside its legacy family only when the font has a
|
||||
// typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family.
|
||||
let style_names: Vec<String> = if typo_style.is_empty() { legacy_style.iter().map(|(_, s)| s.clone()).collect() } else { typo_style };
|
||||
let mut styles: Vec<String> = std::iter::once(style.as_str()).chain(style_names.iter().map(String::as_str)).map(norm).collect();
|
||||
let mut legacy: Vec<(String, String)> = if typo_family.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
legacy_family
|
||||
.iter()
|
||||
.flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s))))
|
||||
.filter(|(f, s)| !f.is_empty() && !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
legacy.sort();
|
||||
legacy.dedup();
|
||||
let mut postscript: Vec<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
|
||||
let mut full_names: Vec<String> = all_names(font, StringId::FULL_NAME).iter().map(|n| norm(n)).collect();
|
||||
for v in [&mut families, &mut styles, &mut full_names, &mut postscript] {
|
||||
sort_dedup(v);
|
||||
}
|
||||
|
||||
let os2 = font.os2().ok();
|
||||
let weight = os2
|
||||
.as_ref()
|
||||
.map(|t| t.us_weight_class())
|
||||
.map(|w| if (1..10).contains(&w) { w * 100 } else { w })
|
||||
.filter(|w| (1..=1000).contains(w))
|
||||
.map_or_else(|| style_weight(&style), f32::from);
|
||||
let width = os2.as_ref().and_then(|t| width_class_percent(t.us_width_class())).unwrap_or_else(|| style_width(&style));
|
||||
let selection_italic = os2.as_ref().is_some_and(|t| {
|
||||
let f = t.fs_selection();
|
||||
f.contains(skrifa::raw::tables::os2::SelectionFlags::ITALIC) || f.contains(skrifa::raw::tables::os2::SelectionFlags::OBLIQUE)
|
||||
});
|
||||
let fs_type = os2.as_ref().map_or(0, |t| t.fs_type());
|
||||
let code_pages = os2.as_ref().map_or([0, 0], |t| [t.ul_code_page_range_1().unwrap_or(0), t.ul_code_page_range_2().unwrap_or(0)]);
|
||||
let axes = font.axes();
|
||||
let legacy_pair = (!typo_family.is_empty())
|
||||
.then(|| name(font, &[StringId::FAMILY_NAME]))
|
||||
.flatten()
|
||||
.filter(|f| *f != family)
|
||||
.map(|f| (f, name(font, &[StringId::SUBFAMILY_NAME])));
|
||||
|
||||
let info = FaceInfo {
|
||||
family: family.clone(),
|
||||
style: style.clone(),
|
||||
legacy_family: legacy_pair.as_ref().map(|(f, _)| f.clone()),
|
||||
legacy_style: legacy_pair.and_then(|(_, s)| s),
|
||||
localized_families,
|
||||
full_name: name(font, &[StringId::FULL_NAME]),
|
||||
postscript_name: name(font, &[StringId::POSTSCRIPT_NAME]),
|
||||
weight,
|
||||
width,
|
||||
italic: selection_italic || style_italic(&style),
|
||||
variations: vec![],
|
||||
variable: !axes.is_empty(),
|
||||
fs_type,
|
||||
code_pages,
|
||||
path: path.map(Path::to_path_buf),
|
||||
index,
|
||||
source,
|
||||
blob: None,
|
||||
};
|
||||
let mut keys = Keys { families, styles, legacy, full_names, postscript };
|
||||
let instances = named_instances(font, &info, &mut keys);
|
||||
std::iter::once(Face { info, keys }).chain(instances).collect()
|
||||
}
|
||||
|
||||
/// The named instances of a variable face (its `fvar` table) as faces of its family, without the
|
||||
/// default instance (the face itself) and without names already taken. The default instance's
|
||||
/// name, when it isn't the face's style (a face `Regular` whose default instance is `Book`), names
|
||||
/// the face too. Damaged tables give fewer instances or none.
|
||||
fn named_instances(font: &skrifa::FontRef<'_>, face: &FaceInfo, keys: &mut Keys) -> Vec<Face> {
|
||||
let axes = font.axes();
|
||||
if axes.is_empty() || axes.len() > MAX_AXES {
|
||||
return vec![];
|
||||
}
|
||||
let tags: Vec<[u8; 4]> = axes.iter().map(|a| a.tag().to_be_bytes()).collect();
|
||||
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
|
||||
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
|
||||
// Technical Note #5902).
|
||||
let prefix = name(font, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| face.family.replace(' ', ""));
|
||||
let mut taken = vec![norm(&face.style)];
|
||||
let mut out = Vec::new();
|
||||
for inst in font.named_instances().iter().take(MAX_INSTANCES) {
|
||||
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
|
||||
if coords.len() != tags.len() || coords.iter().any(|c| !c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let Some(style) = name(font, &[inst.subfamily_name_id()]) else { continue };
|
||||
let ns = norm(&style);
|
||||
if ns.is_empty() || taken.contains(&ns) {
|
||||
continue;
|
||||
}
|
||||
let ps_default = format!("{prefix}-{}", style.replace(' ', ""));
|
||||
taken.push(ns.clone());
|
||||
if coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3) {
|
||||
keys.styles.push(ns);
|
||||
keys.postscript.push(norm(&ps_default));
|
||||
continue;
|
||||
}
|
||||
let axis = |tag: &[u8; 4]| tags.iter().zip(&coords).find(|(t, _)| *t == tag).map(|(_, v)| *v);
|
||||
let weight = axis(b"wght").map_or_else(|| style_weight(&style), |w| w.clamp(1.0, 1000.0));
|
||||
let width = axis(b"wdth").map_or_else(|| style_width(&style), |w| w.clamp(25.0, 400.0));
|
||||
let italic = style_italic(&style) || axis(b"ital").is_some_and(|v| v >= 0.5) || axis(b"slnt").is_some_and(|v| v != 0.0);
|
||||
let own_ps = inst.postscript_name_id().and_then(|id| name(font, &[id]));
|
||||
let mut styles: Vec<String> = std::iter::once(style.clone()).chain(all_names(font, inst.subfamily_name_id())).map(|s| norm(&s)).collect();
|
||||
let mut postscript: Vec<String> =
|
||||
inst.postscript_name_id().map(|id| all_names(font, id)).unwrap_or_default().iter().map(|n| norm(n)).collect();
|
||||
postscript.push(norm(&ps_default));
|
||||
sort_dedup(&mut styles);
|
||||
sort_dedup(&mut postscript);
|
||||
let info = FaceInfo {
|
||||
style,
|
||||
legacy_family: None,
|
||||
legacy_style: None,
|
||||
full_name: None,
|
||||
postscript_name: Some(own_ps.unwrap_or(ps_default)),
|
||||
weight,
|
||||
width,
|
||||
italic,
|
||||
variations: tags.iter().copied().zip(coords.iter().copied()).collect(),
|
||||
..face.clone()
|
||||
};
|
||||
let keys = Keys { families: keys.families.clone(), styles, legacy: vec![], full_names: vec![], postscript };
|
||||
out.push(Face { info, keys });
|
||||
}
|
||||
sort_dedup(&mut keys.styles);
|
||||
sort_dedup(&mut keys.postscript);
|
||||
out
|
||||
}
|
||||
|
||||
/// How many faces `data` (a font file or collection) holds; 0 when it isn't one.
|
||||
pub(crate) fn face_count(data: &[u8]) -> u32 {
|
||||
match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces of every font in `data`, at most `max_faces` font faces read.
|
||||
pub(crate) fn data_faces(data: &[u8], max_faces: u32, path: Option<&Path>, source: Source) -> Vec<Face> {
|
||||
(0..face_count(data).min(max_faces))
|
||||
.filter_map(|i| Some((i, skrifa::FontRef::from_index(data, i).ok()?)))
|
||||
.filter(|(_, f)| OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()))
|
||||
.flat_map(|(i, f)| font_faces(&f, i, path, source))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The faces of the font file at `path`, reading only its table directories and `name`, `fvar`
|
||||
/// and `OS/2` tables, at most `max_read` bytes in all: a scan opens thousands of files, many of
|
||||
/// them megabytes long. A Mac suitcase font's `sfnt` resources are read instead.
|
||||
pub(crate) fn file_faces(path: &Path, max_read: u64, source: Source) -> Vec<Face> {
|
||||
if crate::suitcase::is_suitcase(path) {
|
||||
if crate::suitcase::fork_len(path) > max_read {
|
||||
return vec![];
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
for (resource, font) in (0u32..).zip(crate::suitcase::read_fonts(path)) {
|
||||
for mut face in data_faces(&font, 1, Some(path), source) {
|
||||
face.info.index = resource;
|
||||
faces.push(face);
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => reader_faces(file, MAX_FACES, max_read, path, source),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn reader_faces(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64, path: &Path, source: Source) -> Vec<Face> {
|
||||
use std::io::SeekFrom;
|
||||
let mut read = 0u64;
|
||||
let file_len = file.seek(SeekFrom::End(0)).unwrap_or(0);
|
||||
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
||||
read = read.checked_add(u64::try_from(len).ok()?).filter(|r| *r <= max_read)?;
|
||||
let mut buf = vec![0; len];
|
||||
file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
file.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
};
|
||||
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
|
||||
let Some(head) = read_at(0, 12) else { return vec![] };
|
||||
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
|
||||
// A damaged count is capped by what the file can hold.
|
||||
let fits = u32::try_from(file_len.saturating_sub(12) / 4).unwrap_or(u32::MAX);
|
||||
let n = be32(&head, 8).unwrap_or(0).min(max_faces).min(fits) as usize;
|
||||
read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
vec![0]
|
||||
};
|
||||
(0u32..)
|
||||
.zip(starts)
|
||||
.filter_map(|(index, start)| {
|
||||
let dir = read_at(start.into(), 12)?;
|
||||
let tables = usize::from(u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?));
|
||||
let records = read_at(u64::from(start) + 12, tables * 16)?;
|
||||
let records: Vec<&[u8]> = records.as_chunks::<16>().0.iter().map(|r| r.as_slice()).collect();
|
||||
if !records.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) {
|
||||
return None;
|
||||
}
|
||||
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
|
||||
let rec = records.iter().find(|r| r.starts_with(tag))?;
|
||||
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
||||
if len > MAX_TABLE {
|
||||
return None;
|
||||
}
|
||||
read_at(offset.into(), len as usize)
|
||||
};
|
||||
let name = table(b"name")?;
|
||||
let mut tables: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"name", name)];
|
||||
for tag in [b"fvar", b"OS/2"] {
|
||||
if let Some(t) = table(tag) {
|
||||
tables.push((tag, t));
|
||||
}
|
||||
}
|
||||
let refs: Vec<(&[u8; 4], &[u8])> = tables.iter().map(|(t, d)| (*t, d.as_slice())).collect();
|
||||
let font = sfnt_of(&refs)?;
|
||||
Some(font_faces(&skrifa::FontRef::new(&font).ok()?, index, Some(path), source))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A font file holding `tables` (tag, data), sorted by tag as table directories are.
|
||||
pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
|
||||
let mut tables = tables.to_vec();
|
||||
tables.sort_by_key(|t| *t.0);
|
||||
let n = u16::try_from(tables.len()).ok()?;
|
||||
let mut font = Vec::new();
|
||||
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
for v in [n, 0, 0, 0] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
font.extend_from_slice(*tag);
|
||||
for v in [0, u32::try_from(offset).ok()?, u32::try_from(data.len()).ok()?] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
font.extend_from_slice(data);
|
||||
font.resize(font.len().next_multiple_of(4), 0);
|
||||
}
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// Whether `path` has a font file's extension (`.ttf`, `.otf`, `.ttc`, `.otc`, in any case), as
|
||||
/// the scan picks files in a folder.
|
||||
pub fn is_font_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["ttf", "otf", "ttc", "otc"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Suitcase fonts: classic Mac OS font files whose data fork is empty and whose fonts are `sfnt`
|
||||
//! resources in the resource fork (Microsoft Office installs its fonts this way in
|
||||
//! `/Library/Fonts/Microsoft`). macOS lists them as installed; a reader of the file's data finds
|
||||
//! nothing, so the scan reads the resource fork. The fork's layout is Apple's published resource
|
||||
//! file format (Inside Macintosh: More Macintosh Toolbox, "Resource Manager").
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Caps on what a (possibly damaged) fork can make the scan read.
|
||||
const MAX_FORK: u64 = 256 << 20;
|
||||
const MAX_TYPES: usize = 1 << 12;
|
||||
const MAX_FONTS: usize = 256;
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> Option<usize> {
|
||||
b.get(at..at.checked_add(2)?).and_then(|s| s.try_into().ok()).map(|s| u16::from_be_bytes(s) as usize)
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> Option<usize> {
|
||||
b.get(at..at.checked_add(4)?).and_then(|s| s.try_into().ok()).map(|s| u32::from_be_bytes(s) as usize)
|
||||
}
|
||||
|
||||
/// The font files (`sfnt` resources) of a resource fork, in the order its map lists them. A
|
||||
/// fork that isn't well formed gives the fonts read before the damage, or none.
|
||||
pub(crate) fn sfnt_resources(fork: &[u8]) -> Vec<&[u8]> {
|
||||
let mut fonts = Vec::new();
|
||||
let _ = collect(fork, &mut fonts);
|
||||
fonts
|
||||
}
|
||||
|
||||
fn collect<'a>(fork: &'a [u8], fonts: &mut Vec<&'a [u8]>) -> Option<()> {
|
||||
// The header: where the data and the map start.
|
||||
let data = be32(fork, 0)?;
|
||||
let map = fork.get(be32(fork, 4)?..)?;
|
||||
// The map: its type list starts at the offset kept 24 bytes in, and counts from the map.
|
||||
let types = be16(map, 24)?;
|
||||
// Counts are stored less one, so a list of none is 0xFFFF.
|
||||
let n_types = (be16(map, types)? + 1) & 0xFFFF;
|
||||
let mut total = 0usize;
|
||||
for t in 0..n_types.min(MAX_TYPES) {
|
||||
let at = types + 2 + t * 8;
|
||||
if map.get(at..at.checked_add(4)?)? != b"sfnt" {
|
||||
continue;
|
||||
}
|
||||
let count = (be16(map, at + 4)? + 1) & 0xFFFF;
|
||||
// Each reference: its data's offset is the low three bytes of the word 4 bytes in.
|
||||
let refs = types + be16(map, at + 6)?;
|
||||
for r in 0..count {
|
||||
let start = data.checked_add(be32(map, refs + r * 12 + 4)? & 0x00FF_FFFF)?;
|
||||
let len = be32(fork, start)?;
|
||||
// References can share data: cap the total too, as every font is copied out.
|
||||
total = total.checked_add(len)?;
|
||||
if fonts.len() >= MAX_FONTS || total as u64 > MAX_FORK {
|
||||
return Some(());
|
||||
}
|
||||
fonts.push(fork.get(start.checked_add(4)?..start.checked_add(4)?.checked_add(len)?)?);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The fonts of the suitcase font at `path`, read from its resource fork; none for a file
|
||||
/// without one, and off macOS, the only place such a fork is a part of a file.
|
||||
pub(crate) fn read_fonts(path: &Path) -> Vec<Vec<u8>> {
|
||||
let Some(fork) = read_fork(path) else { return vec![] };
|
||||
sfnt_resources(&fork).into_iter().map(<[u8]>::to_vec).collect()
|
||||
}
|
||||
|
||||
/// Whether the file at `path` is a suitcase font: nothing in its data fork and something in its
|
||||
/// resource fork.
|
||||
pub(crate) fn is_suitcase(path: &Path) -> bool {
|
||||
// Off macOS no file has a fork: don't look at every font file twice.
|
||||
cfg!(target_os = "macos") && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) && fork_len(path) > 0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fork_path(path: &Path) -> std::path::PathBuf {
|
||||
path.join("..namedfork/rsrc")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn fork_len(path: &Path) -> u64 {
|
||||
std::fs::metadata(fork_path(path)).map_or(0, |m| m.len())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_fork(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut fork = Vec::new();
|
||||
std::fs::File::open(fork_path(path)).ok()?.take(MAX_FORK).read_to_end(&mut fork).ok()?;
|
||||
Some(fork)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn fork_len(_: &Path) -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn read_fork(_: &Path) -> Option<Vec<u8>> {
|
||||
let _ = MAX_FORK;
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The platform's font service through fontique: CoreText's available fonts on macOS (fonts that
|
||||
//! Adobe Fonts, RightFont and other managers activate from their own folders included),
|
||||
//! DirectWrite's system font collection on Windows (per-user fonts and font-service fonts
|
||||
//! included), and fontconfig on Linux and FreeBSD (Flatpak's host fonts included).
|
||||
//!
|
||||
//! One fontique collection serves the whole process: the catalog lists its files, and the UI's
|
||||
//! font fallback ([`collection`]) queries the same collection, so the system's fonts are
|
||||
//! enumerated once.
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use fontique::{Collection, CollectionOptions, SourceKind};
|
||||
|
||||
use crate::{FontEnv, font_store_dir};
|
||||
|
||||
struct Shared {
|
||||
generation: u64,
|
||||
collection: Collection,
|
||||
}
|
||||
|
||||
static SHARED: Mutex<Option<Shared>> = Mutex::new(None);
|
||||
|
||||
/// A new collection of the system's fonts and the font store's. `None` when fontique panics
|
||||
/// (its DirectWrite backend unwraps a failed factory).
|
||||
fn build() -> Option<Collection> {
|
||||
std::panic::catch_unwind(|| {
|
||||
let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: true });
|
||||
if let Some(store) = font_store_dir(&FontEnv::from_process()).filter(|d| d.is_dir()) {
|
||||
collection.load_fonts_from_paths([store]);
|
||||
}
|
||||
collection
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn with<R>(refresh: bool, f: impl FnOnce(&mut Shared) -> R) -> R {
|
||||
let mut guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let generation = guard.as_ref().map_or(0, |s| s.generation);
|
||||
let shared = match &mut *guard {
|
||||
Some(shared) if !refresh => shared,
|
||||
slot => {
|
||||
let collection = build().unwrap_or_else(|| Collection::new(CollectionOptions { shared: false, system_fonts: false }));
|
||||
slot.insert(Shared { generation: generation + 1, collection })
|
||||
}
|
||||
};
|
||||
f(shared)
|
||||
}
|
||||
|
||||
pub fn collection() -> Collection {
|
||||
with(false, |s| s.collection.clone())
|
||||
}
|
||||
|
||||
pub fn generation() -> u64 {
|
||||
SHARED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map_or(0, |s| s.generation)
|
||||
}
|
||||
|
||||
pub fn refresh() {
|
||||
with(true, |_| ());
|
||||
}
|
||||
|
||||
pub fn font_files(refresh: bool) -> Vec<PathBuf> {
|
||||
with(refresh, |s| {
|
||||
let names: Vec<String> = s.collection.family_names().map(str::to_owned).collect();
|
||||
let mut files: Vec<PathBuf> = names
|
||||
.iter()
|
||||
.filter_map(|name| s.collection.family_by_name(name))
|
||||
.flat_map(|family| {
|
||||
family
|
||||
.fonts()
|
||||
.iter()
|
||||
.filter_map(|font| match font.source().kind() {
|
||||
SourceKind::Path(path) => Some(path.to_path_buf()),
|
||||
SourceKind::Memory(_) => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "system", not(target_arch = "wasm32"))))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn font_files(_refresh: bool) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
/// The process's fontique collection of the system's fonts and the font store's, built on the
|
||||
/// first call (which waits for the enumeration; later calls are cheap clones that share it).
|
||||
/// Clones taken before a rescan keep the fonts as they were: compare [`generation`] to know when
|
||||
/// to take a new one.
|
||||
pub fn collection() -> fontique::Collection {
|
||||
imp::collection()
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
/// Counts the builds of [`collection`]: 0 before the first, one more after each rescan that asked
|
||||
/// the font service again.
|
||||
pub fn generation() -> u64 {
|
||||
imp::generation()
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
/// Enumerate the system's fonts again (a catalog's [`crate::Catalog::rescan`] does this).
|
||||
pub fn refresh() {
|
||||
imp::refresh();
|
||||
}
|
||||
|
||||
/// The font files the platform's font service lists, enumerating again first with `refresh`.
|
||||
pub(crate) fn font_files(refresh: bool) -> Vec<std::path::PathBuf> {
|
||||
imp::font_files(refresh)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, VariationAxisRecord};
|
||||
use write_fonts::tables::name::{Name, NameRecord};
|
||||
use write_fonts::tables::os2::{Os2, SelectionFlags};
|
||||
use write_fonts::types::{Fixed, NameId, Tag};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A test font: only the tables the catalog reads, and an empty `glyf` so it counts as drawable.
|
||||
#[derive(Clone)]
|
||||
struct Spec {
|
||||
family: &'static str,
|
||||
style: &'static str,
|
||||
/// Legacy family and style (name IDs 1 and 2); the names above are then typographic.
|
||||
legacy: Option<(&'static str, &'static str)>,
|
||||
/// Japanese family name.
|
||||
ja: Option<&'static str>,
|
||||
weight: u16,
|
||||
width: u16,
|
||||
italic: bool,
|
||||
fs_type: u16,
|
||||
/// Named instances: style and `wght`; the font gets a `wght` axis from 100 to 900, default 400.
|
||||
instances: Vec<(&'static str, f64)>,
|
||||
outlines: bool,
|
||||
}
|
||||
|
||||
fn spec(family: &'static str, style: &'static str, weight: u16) -> Spec {
|
||||
Spec { family, style, legacy: None, ja: None, weight, width: 5, italic: false, fs_type: 0, instances: vec![], outlines: true }
|
||||
}
|
||||
|
||||
fn font(s: &Spec) -> Vec<u8> {
|
||||
let mut records = Vec::new();
|
||||
let mut add = |lang: u16, id: u16, text: String| records.push(NameRecord::new(3, 1, lang, NameId::new(id), text.into()));
|
||||
let ps = format!("{}-{}", s.family.replace(' ', ""), s.style.replace(' ', ""));
|
||||
match s.legacy {
|
||||
Some((family, style)) => {
|
||||
add(0x409, 1, family.into());
|
||||
add(0x409, 2, style.into());
|
||||
add(0x409, 16, s.family.into());
|
||||
add(0x409, 17, s.style.into());
|
||||
}
|
||||
None => {
|
||||
add(0x409, 1, s.family.into());
|
||||
add(0x409, 2, s.style.into());
|
||||
}
|
||||
}
|
||||
add(0x409, 4, format!("{} {}", s.family, s.style));
|
||||
add(0x409, 6, ps);
|
||||
if let Some(ja) = s.ja {
|
||||
add(0x411, if s.legacy.is_some() { 16 } else { 1 }, ja.into());
|
||||
}
|
||||
let mut instances = Vec::new();
|
||||
for (next_id, (style, wght)) in (256..).zip(&s.instances) {
|
||||
add(0x409, next_id, (*style).into());
|
||||
instances.push(InstanceRecord {
|
||||
subfamily_name_id: NameId::new(next_id),
|
||||
flags: 0,
|
||||
coordinates: vec![Fixed::from_f64(*wght)],
|
||||
post_script_name_id: None,
|
||||
});
|
||||
}
|
||||
records.sort_by_key(|r| (r.platform_id, r.encoding_id, r.language_id, r.name_id));
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_table(&Name::new(records)).unwrap();
|
||||
let os2 = Os2 {
|
||||
us_weight_class: s.weight,
|
||||
us_width_class: s.width,
|
||||
fs_type: s.fs_type,
|
||||
fs_selection: if s.italic { SelectionFlags::ITALIC } else { SelectionFlags::REGULAR },
|
||||
ul_code_page_range_1: Some(1),
|
||||
ul_code_page_range_2: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
builder.add_table(&os2).unwrap();
|
||||
if !instances.is_empty() {
|
||||
let axis = VariationAxisRecord {
|
||||
axis_tag: Tag::new(b"wght"),
|
||||
min_value: Fixed::from_f64(100.0),
|
||||
default_value: Fixed::from_f64(400.0),
|
||||
max_value: Fixed::from_f64(900.0),
|
||||
flags: 0,
|
||||
axis_name_id: NameId::new(255),
|
||||
};
|
||||
builder.add_table(&Fvar::new(AxisInstanceArrays::new(vec![axis], instances))).unwrap();
|
||||
}
|
||||
if s.outlines {
|
||||
builder.add_raw(Tag::new(b"glyf"), vec![0u8; 4]);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// A font collection of `fonts`: their table directories, with offsets moved to the new file.
|
||||
fn collection(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let header = 12 + 4 * fonts.len();
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"ttcf");
|
||||
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
out.extend_from_slice(&(fonts.len() as u32).to_be_bytes());
|
||||
let mut base = header;
|
||||
let mut bodies = Vec::new();
|
||||
for f in fonts {
|
||||
out.extend_from_slice(&(base as u32).to_be_bytes());
|
||||
let mut f = f.clone();
|
||||
let tables = u16::from_be_bytes([f[4], f[5]]) as usize;
|
||||
for t in 0..tables {
|
||||
let at = 12 + 16 * t + 8;
|
||||
let offset = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32;
|
||||
f[at..at + 4].copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
base += f.len();
|
||||
bodies.push(f);
|
||||
}
|
||||
for b in bodies {
|
||||
out.extend(b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("craft-font-catalog-{name}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write(path: &Path, data: impl AsRef<[u8]>) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, data).unwrap();
|
||||
}
|
||||
|
||||
/// A Linux system under `root` with no fontconfig configuration and no other XDG folders.
|
||||
fn linux_env(root: &Path) -> FontEnv {
|
||||
FontEnv {
|
||||
os: Os::Unix,
|
||||
root: root.to_path_buf(),
|
||||
home: Some(root.join("home")),
|
||||
xdg_data_dirs: Some(root.join("share").into_os_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_font_dirs_cover_xdg_flatpak_and_fontconfig() {
|
||||
let root = temp("dirs");
|
||||
write(&root.join("etc/fonts/fonts.conf"), "<fontconfig><dir>/opt/texmf/fonts</dir><dir>/usr/share/fonts</dir></fontconfig>");
|
||||
let mut env = linux_env(&root);
|
||||
env.xdg_data_home = Some(root.join("data"));
|
||||
let dirs = font_dirs(&env);
|
||||
let want: Vec<PathBuf> = vec![
|
||||
root.join("usr/share/fonts"),
|
||||
root.join("usr/local/share/fonts"),
|
||||
root.join("run/host/fonts"),
|
||||
root.join("run/host/local-fonts"),
|
||||
root.join("run/host/user-fonts"),
|
||||
root.join("home/.local/share/fonts"),
|
||||
root.join("home/.fonts"),
|
||||
root.join("data/fonts"),
|
||||
root.join(root.join("share").strip_prefix("/").unwrap()).join("fonts"),
|
||||
root.join("opt/texmf/fonts"),
|
||||
];
|
||||
assert_eq!(dirs, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() {
|
||||
let root = temp("conf");
|
||||
let etc = root.join("etc/fonts");
|
||||
write(
|
||||
&etc.join("fonts.conf"),
|
||||
r#"<?xml version="1.0"?>
|
||||
<fontconfig>
|
||||
<!-- <dir>/commented/out</dir> -->
|
||||
<dir>/sys/fonts</dir>
|
||||
<dir prefix="xdg">fonts</dir>
|
||||
<dir>~/.fonts</dir>
|
||||
<cachedir>/var/cache/fontconfig</cachedir>
|
||||
<dir prefix="relative">local-fonts</dir>
|
||||
<dir salt="x">/a&b/fonts</dir>
|
||||
<include ignore_missing="yes">conf.d</include>
|
||||
<include ignore_missing="yes">missing.conf</include>
|
||||
<include>fonts.conf</include>
|
||||
</fontconfig>"#,
|
||||
);
|
||||
write(&etc.join("conf.d/60-remap.conf"), "<fontconfig><remap-dir as-path=\"/usr/share/fonts\">/remapped/fonts</remap-dir></fontconfig>");
|
||||
write(&etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
|
||||
write(&etc.join("conf.d/nodigit.conf"), "<fontconfig><dir>/no/digit</dir></fontconfig>");
|
||||
write(&etc.join("b.conf"), "<fontconfig><include>fonts.conf</include><dir>/unclosed");
|
||||
write(
|
||||
&root.join("home/.config/fontconfig/fonts.conf"),
|
||||
"<fontconfig><dir>/user/conf/fonts</dir><include>../../../etc/fonts/b.conf</include></fontconfig>",
|
||||
);
|
||||
let home = root.join("home");
|
||||
assert_eq!(
|
||||
fontconfig_dirs(&linux_env(&root)),
|
||||
vec![
|
||||
root.join("sys/fonts"),
|
||||
home.join(".local/share/fonts"),
|
||||
home.join(".fonts"),
|
||||
etc.join("local-fonts"),
|
||||
root.join("a&b/fonts"),
|
||||
root.join("remapped/fonts"),
|
||||
root.join("user/conf/fonts"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_adds_the_hosts_font_folders() {
|
||||
let root = temp("flatpak");
|
||||
write(&root.join("run/host/font-dirs.xml"), "<fontconfig><remap-dir as-path=\"/usr/share/fonts\">/run/host/fonts</remap-dir></fontconfig>");
|
||||
write(&root.join("run/host/etc/fonts/fonts.conf"), "<fontconfig><dir>/usr/share/texmf/fonts</dir><dir>~/MyFonts</dir></fontconfig>");
|
||||
let mut env = linux_env(&root);
|
||||
assert!(fontconfig_dirs(&env).is_empty());
|
||||
env.flatpak = true;
|
||||
assert_eq!(fontconfig_dirs(&env), vec![root.join("run/host/fonts"), root.join("run/host/usr/share/texmf/fonts"), root.join("home/MyFonts")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_and_windows_folders() {
|
||||
let root = temp("mac");
|
||||
for asset in ["com_apple_MobileAsset_Font7/abc.asset", "com_apple_MobileAsset_Other/x"] {
|
||||
std::fs::create_dir_all(root.join("System/Library/AssetsV2").join(asset)).unwrap();
|
||||
}
|
||||
let env = FontEnv { os: Os::MacOs, root: root.clone(), home: Some("/Users/me".into()), ..Default::default() };
|
||||
let dirs = font_dirs(&env);
|
||||
assert!(dirs.contains(&root.join("System/Library/Fonts")));
|
||||
assert!(dirs.contains(&PathBuf::from("/Users/me/Library/Fonts")));
|
||||
assert!(dirs.contains(&root.join("System/Library/AssetsV2/com_apple_MobileAsset_Font7")));
|
||||
assert!(!dirs.iter().any(|d| d.ends_with("com_apple_MobileAsset_Other")));
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/Users/me/Library/Application Support/Craft Fonts")));
|
||||
|
||||
let env = FontEnv {
|
||||
os: Os::Windows,
|
||||
windir: Some("C:\\Windows".into()),
|
||||
local_app_data: Some("C:\\Users\\me\\AppData\\Local".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
font_dirs(&env),
|
||||
vec![PathBuf::from("C:\\Windows").join("Fonts"), PathBuf::from("C:\\Users\\me\\AppData\\Local").join("Microsoft\\Windows\\Fonts")]
|
||||
);
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("C:\\Users\\me\\AppData\\Local").join("Craft Fonts")));
|
||||
assert_eq!(office_font_dirs(&env).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_store_folder() {
|
||||
let env = FontEnv { os: Os::Unix, home: Some("/home/me".into()), ..Default::default() };
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/home/me/.local/share/craft-fonts")));
|
||||
let env = FontEnv { xdg_data_home: Some("/data".into()), ..env };
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/data/craft-fonts")));
|
||||
let env = FontEnv { font_store: Some("/portable/fonts".into()), ..env };
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/portable/fonts")));
|
||||
assert_eq!(font_store_dir(&FontEnv { os: Os::None, home: Some("/h".into()), ..Default::default() }), None);
|
||||
}
|
||||
|
||||
/// A Linux system with fonts in a system folder, the Flatpak host folder, the user's folder and
|
||||
/// the font store, plus a collection, a lister's file without an extension, and files that aren't
|
||||
/// fonts.
|
||||
fn system(root: &Path) -> Arc<Catalog> {
|
||||
let sys = root.join("usr/share/fonts/test");
|
||||
write(&sys.join("TestSans-Regular.ttf"), font(&spec("Test Sans", "Regular", 400)));
|
||||
write(&sys.join("TestSans-Bold.ttf"), font(&spec("Test Sans", "Bold", 700)));
|
||||
write(&sys.join("TestSans-Italic.TTF"), font(&Spec { italic: true, ..spec("Test Sans", "Italic", 400) }));
|
||||
write(&sys.join("TestSans-Light.ttf"), font(&Spec { legacy: Some(("Test Sans Light", "Regular")), ..spec("Test Sans", "Light", 300) }));
|
||||
write(&sys.join("TestSans-Condensed.ttf"), font(&Spec { width: 3, ..spec("Test Sans", "Condensed", 400) }));
|
||||
write(&sys.join("Bitmap.ttf"), font(&Spec { outlines: false, ..spec("Bitmap Only", "Regular", 400) }));
|
||||
write(&sys.join("damaged.otf"), b"OTTO\0\x10garbage");
|
||||
write(&sys.join("notes.txt"), font(&spec("Not Scanned", "Regular", 400)));
|
||||
write(
|
||||
&root.join("run/host/fonts/HostSerif.ttc"),
|
||||
collection(&[font(&spec("Host Serif", "Regular", 400)), font(&spec("Host Serif", "Bold", 700))]),
|
||||
);
|
||||
write(
|
||||
&root.join("home/.local/share/fonts/Gothic.otf"),
|
||||
font(&Spec { legacy: Some(("Test Gothic W3", "Regular")), ja: Some("テストゴシック"), ..spec("Test Gothic", "W3", 300) }),
|
||||
);
|
||||
write(
|
||||
&root.join("home/.local/share/fonts/Variable.ttf"),
|
||||
font(&Spec { instances: vec![("Regular", 400.0), ("Thin", 100.0), ("Black", 900.0)], ..spec("Test Variable", "Regular", 400) }),
|
||||
);
|
||||
write(&root.join("home/.local/share/craft-fonts/Store/StoreFace.ttf"), font(&spec("Store Face", "Regular", 400)));
|
||||
let service = root.join("service/.hidden/f1");
|
||||
write(&service, font(&Spec { fs_type: 2, ..spec("Service Font", "Regular", 400) }));
|
||||
Arc::new(Catalog::new(linux_env(root)).with_font_service(false).with_lister(Arc::new(move || vec![service.clone()])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_finds_folders_store_lister_files_and_collections() {
|
||||
let root = temp("scan");
|
||||
let cat = system(&root);
|
||||
cat.scan_in_background();
|
||||
let families: Vec<String> = cat.families().to_vec();
|
||||
assert_eq!(families, ["Host Serif", "Service Font", "Store Face", "Test Gothic", "Test Sans", "Test Variable"]);
|
||||
assert!(cat.is_ready());
|
||||
let host = cat.styles("host serif");
|
||||
assert_eq!(host.iter().map(|f| (f.style.as_str(), f.index)).collect::<Vec<_>>(), [("Regular", 0), ("Bold", 1)]);
|
||||
assert_eq!(cat.styles("Store Face")[0].source, Source::Store);
|
||||
assert_eq!(host[0].source, Source::System);
|
||||
let service = &cat.styles("Service Font")[0];
|
||||
assert_eq!((service.source, service.fs_type), (Source::System, 2));
|
||||
assert_eq!(cat.styles("Test Sans").len(), 5);
|
||||
let light = cat.resolve("Test Sans", "Light").unwrap().0;
|
||||
assert_eq!((light.legacy_family.as_deref(), light.legacy_style.as_deref()), (Some("Test Sans Light"), Some("Regular")));
|
||||
assert_eq!(light.code_pages, [1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_by_every_name_kind() {
|
||||
let root = temp("lookup");
|
||||
let cat = system(&root);
|
||||
let found = |name: &str, style: &str| cat.resolve(name, style).map(|(f, m)| (f.family, f.style, m));
|
||||
let exact = |family: &str, style: &str| Some((family.to_string(), style.to_string(), Match::Exact));
|
||||
let nearest = |family: &str, style: &str| Some((family.to_string(), style.to_string(), Match::NearestStyle));
|
||||
assert_eq!(found("TEST SANS", "bold"), exact("Test Sans", "Bold"));
|
||||
assert_eq!(found("Test Sans", ""), exact("Test Sans", "Regular"));
|
||||
// Nearest weight, italic differing costing more than any weight.
|
||||
assert_eq!(found("Test Sans", "Semibold"), nearest("Test Sans", "Bold"));
|
||||
assert_eq!(found("Test Sans", "Bold Italic"), nearest("Test Sans", "Italic"));
|
||||
assert_eq!(found("Test Sans", "Thin"), nearest("Test Sans", "Light"));
|
||||
// Equal weights: the nearest width.
|
||||
assert_eq!(found("Test Sans", "Book Condensed"), nearest("Test Sans", "Condensed"));
|
||||
// A legacy family with its legacy style, a localized family, full and PostScript names.
|
||||
assert_eq!(found("Test Sans Light", "Regular"), exact("Test Sans", "Light"));
|
||||
assert_eq!(found("Test Gothic W3", "Regular"), exact("Test Gothic", "W3"));
|
||||
assert_eq!(found("テストゴシック", "W3"), exact("Test Gothic", "W3"));
|
||||
assert_eq!(cat.family("testgothic"), Some("Test Gothic".into()));
|
||||
assert_eq!(found("Test Sans Bold", ""), exact("Test Sans", "Bold"));
|
||||
assert_eq!(found("TestSans-Bold", "Regular"), exact("Test Sans", "Bold"));
|
||||
assert_eq!(cat.by_postscript_name("TestSans-Italic").map(|f| f.style), Some("Italic".into()));
|
||||
// Named instances: by style, by their PostScript names, and the default instance names the face.
|
||||
let thin = cat.resolve("Test Variable", "Thin").unwrap().0;
|
||||
assert_eq!((thin.weight, thin.variations.clone()), (100.0, vec![(*b"wght", 100.0)]));
|
||||
assert_eq!(cat.by_postscript_name("TestVariable-Black").map(|f| f.weight), Some(900.0));
|
||||
assert_eq!(cat.styles("Test Variable").len(), 3);
|
||||
assert_eq!(found("Missing Family", "Regular"), None);
|
||||
assert_eq!(cat.by_postscript_name("Missing-Regular"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_and_removed_fonts_are_seen_without_a_restart() {
|
||||
let root = temp("rescan");
|
||||
let cat = system(&root);
|
||||
assert!(!cat.installed_fonts_changed());
|
||||
assert!(cat.family("Test Sans").is_some());
|
||||
let generation = cat.generation();
|
||||
assert!(!cat.installed_fonts_changed());
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _watch = cat.watch(Duration::from_millis(20), move |c| {
|
||||
let _ = tx.send((c.family("New Face"), c.family("Later")));
|
||||
});
|
||||
write(&root.join("usr/share/fonts/test/new/NewFace.otf"), font(&spec("New Face", "Regular", 400)));
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), (Some("New Face".into()), None));
|
||||
assert!(cat.generation() > generation);
|
||||
// A font store install, then a removal.
|
||||
write(&root.join("home/.local/share/craft-fonts/Later.ttf"), font(&spec("Later", "Regular", 400)));
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), (Some("New Face".into()), Some("Later".into())));
|
||||
assert_eq!(cat.styles("Later")[0].source, Source::Store);
|
||||
std::fs::remove_file(root.join("usr/share/fonts/test/new/NewFace.otf")).unwrap();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), (None, Some("Later".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_folders_are_scanned_after_a_change() {
|
||||
let root = temp("app");
|
||||
let cat = Catalog::new(linux_env(&root)).with_font_service(false);
|
||||
assert!(cat.families().is_empty());
|
||||
write(&root.join("app-fonts/AppFace.ttf"), font(&spec("App Face", "Regular", 400)));
|
||||
cat.set_app_dirs(vec![root.join("app-fonts")]);
|
||||
assert!(cat.rescan_if_changed());
|
||||
assert_eq!(cat.styles("App Face")[0].source, Source::App);
|
||||
assert!(!cat.rescan_if_changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_loads_once_for_every_thread() {
|
||||
let root = temp("load");
|
||||
let cat = system(&root);
|
||||
let loads: Vec<Arc<[LoadedFace]>> = std::thread::scope(|s| {
|
||||
let threads: Vec<_> = (0..8).map(|_| s.spawn(|| cat.load_family("host serif"))).collect();
|
||||
threads.into_iter().map(|t| t.join().unwrap()).collect()
|
||||
});
|
||||
assert!(loads.iter().all(|l| Arc::ptr_eq(l, &loads[0])));
|
||||
let host = &loads[0];
|
||||
assert_eq!(host.len(), 2);
|
||||
// Both faces of the collection share its bytes.
|
||||
assert!(Arc::ptr_eq(&host[0].data.bytes, &host[1].data.bytes));
|
||||
assert_eq!(host[1].data.index, 1);
|
||||
assert!(skrifa::FontRef::from_index(&host[1].data.bytes, 1).is_ok());
|
||||
// Another family loads separately, and a rescan makes the next load read again.
|
||||
assert_eq!(cat.load_family("Test Sans").len(), 5);
|
||||
cat.rescan();
|
||||
assert!(!Arc::ptr_eq(&cat.load_family("Host Serif"), &loads[0]));
|
||||
assert!(cat.load_family("Missing").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_fonts_come_first_and_respect_the_limits() {
|
||||
let root = temp("add");
|
||||
let cat = system(&root);
|
||||
let limits = Limits::default();
|
||||
let doc = font(&spec("Test Sans", "Bold", 700));
|
||||
let added = cat.add_bytes(doc.clone(), Source::Document, &limits).unwrap();
|
||||
assert_eq!(added.len(), 1);
|
||||
let (bold, m) = cat.resolve("Test Sans", "Bold").unwrap();
|
||||
assert_eq!((bold.source, bold.path.clone(), m), (Source::Document, None, Match::Exact));
|
||||
assert_eq!(&*cat.load(&bold).unwrap().bytes, doc.as_slice());
|
||||
assert!(matches!(cat.add_bytes(b"not a font".to_vec(), Source::User, &limits), Err(FontFileError::NotAFont)));
|
||||
let small = Limits { max_file_bytes: 10, ..limits };
|
||||
assert!(matches!(cat.add_bytes(doc.clone(), Source::User, &small), Err(FontFileError::TooLarge { .. })));
|
||||
let few = Limits { max_added_faces: 1, ..limits };
|
||||
assert!(matches!(cat.add_bytes(font(&spec("User Face", "Regular", 400)), Source::User, &few), Err(FontFileError::TooManyFaces { max: 1 })));
|
||||
let file = root.join("elsewhere/UserFace.ttf");
|
||||
write(&file, font(&spec("User Face", "Regular", 400)));
|
||||
let user = cat.add_file(&file, Source::User, &limits).unwrap();
|
||||
assert_eq!(user[0].path.as_deref(), Some(file.as_path()));
|
||||
// A rescan keeps added faces; removing a source's faces brings back the installed face.
|
||||
cat.rescan();
|
||||
assert_eq!(cat.styles("User Face").len(), 1);
|
||||
assert_eq!(cat.remove_added(Source::Document), 1);
|
||||
assert_eq!(cat.resolve("Test Sans", "Bold").unwrap().0.source, Source::System);
|
||||
assert!(matches!(cat.load(&bold), Err(FontFileError::Gone)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_reads_only_what_it_may() {
|
||||
let root = temp("bounded");
|
||||
let path = root.join("Big.ttf");
|
||||
let mut data = font(&spec("Big Face", "Regular", 400));
|
||||
data.resize(data.len() + (1 << 20), 0);
|
||||
write(&path, &data);
|
||||
assert_eq!(crate::scan::file_faces(&path, 1 << 16, Source::System).len(), 1);
|
||||
assert!(crate::scan::file_faces(&path, 64, Source::System).is_empty());
|
||||
// A collection claiming more faces than it has gives the faces it has.
|
||||
let mut ttc = collection(&[font(&spec("A Face", "Regular", 400))]);
|
||||
ttc[8..12].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
write(&root.join("Claims.ttc"), &ttc);
|
||||
assert_eq!(crate::scan::file_faces(&root.join("Claims.ttc"), u64::MAX, Source::System).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suitcase_resources_are_read() {
|
||||
let fonts = vec![font(&spec("Suit A", "Regular", 400)), font(&spec("Suit B", "Bold", 700))];
|
||||
let fork = fork_of(&fonts);
|
||||
let found = crate::suitcase::sfnt_resources(&fork);
|
||||
assert_eq!(found, fonts.iter().map(Vec::as_slice).collect::<Vec<_>>());
|
||||
assert!(crate::suitcase::sfnt_resources(&fork[..300]).is_empty());
|
||||
}
|
||||
|
||||
fn fork_of(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for body in fonts.iter().map(Vec::as_slice).chain([&b"1.0"[..]]) {
|
||||
offsets.push(data.len() as u32);
|
||||
data.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
data.extend_from_slice(body);
|
||||
}
|
||||
let n = fonts.len();
|
||||
// The type list: `vers` (1 resource) then `sfnt`, each entry pointing at its references.
|
||||
let list = 2 + 2 * 8;
|
||||
let mut types = Vec::new();
|
||||
types.extend_from_slice(&1u16.to_be_bytes());
|
||||
types.extend_from_slice(b"vers");
|
||||
types.extend_from_slice(&0u16.to_be_bytes());
|
||||
types.extend_from_slice(&(list as u16).to_be_bytes());
|
||||
types.extend_from_slice(b"sfnt");
|
||||
types.extend_from_slice(&((n - 1) as u16).to_be_bytes());
|
||||
types.extend_from_slice(&((list + 12) as u16).to_be_bytes());
|
||||
let reference = |id: i16, offset: u32| {
|
||||
let mut r = Vec::new();
|
||||
r.extend_from_slice(&id.to_be_bytes());
|
||||
r.extend_from_slice(&0xFFFFu16.to_be_bytes());
|
||||
r.extend_from_slice(&offset.to_be_bytes());
|
||||
r.extend_from_slice(&[0; 4]);
|
||||
r
|
||||
};
|
||||
types.extend(reference(1, offsets[n]));
|
||||
for (i, o) in offsets[..n].iter().enumerate() {
|
||||
types.extend(reference(5000 + i as i16, *o));
|
||||
}
|
||||
// The map: a copy of the header, a handle, attributes, then the offsets of the lists.
|
||||
let mut map = vec![0; 24];
|
||||
map.extend_from_slice(&28u16.to_be_bytes());
|
||||
map.extend_from_slice(&((28 + types.len()) as u16).to_be_bytes());
|
||||
map.extend(types);
|
||||
let mut fork = Vec::new();
|
||||
for v in [256u32, 256 + data.len() as u32, data.len() as u32, map.len() as u32] {
|
||||
fork.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fork.resize(256, 0);
|
||||
fork.extend(data);
|
||||
fork.extend(map);
|
||||
fork
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "craft-font-manager"
|
||||
description = "Craft Font Manager as a library: search and filters over the font indexes (Google Fonts, Noto, user-added sources), installed fonts by family, the font store interface, and the egui panel the launcher and the standalone app show."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# `FontsPanel`: the whole interface as one egui panel.
|
||||
egui = ["dep:egui", "dep:craft-ui", "dep:serde", "dep:log"]
|
||||
|
||||
[dependencies]
|
||||
craft-font-catalog = { workspace = true }
|
||||
craft-i18n = { workspace = true }
|
||||
# The pinned built-in indexes compiled in, for browsing offline.
|
||||
craft-font-store = { workspace = true, features = ["bundled-index"] }
|
||||
# egui's default faces lead the interface font stack.
|
||||
egui = { workspace = true, features = ["default_fonts"], optional = true }
|
||||
# A host that wants installed fonts for characters its interface faces lack (language names in
|
||||
# their own scripts in the language filter) enables craft-ui's `system_fonts` itself.
|
||||
craft-ui = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { version = "0.37", features = ["wgpu", "snapshot"] }
|
||||
# Real fonts for the fake font store to install: egui's own Hack and Ubuntu Light.
|
||||
epaint_default_fonts = "0.37"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# The system's fonts for the Japanese and Arabic screenshots.
|
||||
egui_system_font_provider = "0.37"
|
||||
|
||||
[[test]]
|
||||
name = "flows"
|
||||
required-features = ["egui"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,95 @@
|
||||
# craft-font-manager
|
||||
|
||||
Craft Font Manager as a library: every font the suite's apps can use, and the font collections to
|
||||
add to them: Google Fonts, Noto, and index URLs the user adds. ArtCraft Launcher shows it as its
|
||||
Fonts tab; the standalone app
|
||||
([aiongg/craft-font-manager](https://github.com/aiongg/craft-font-manager)) shows it as its window.
|
||||
|
||||
| Part | Feature | What it does |
|
||||
|---|---|---|
|
||||
| `filter` | always | Search over the index: family names ignoring case and accents, category, language (English name, autonym or id) and script |
|
||||
| `index` | always | The pinned built-in indexes (Google Fonts and Noto) compiled into [`craft-font-store`](../font-store/README.md) (`bundled`), each family's source (`source_label`: its name and whether it is curated), and the categories |
|
||||
| `installed` | always | The installed fonts [`craft-font-catalog`](../font-catalog/README.md) sees, grouped by family and source |
|
||||
| `store` | always | `Installer`, what the interface needs from the font store: the download consent, install, remove, downloads for a preview, the indexes (`indexes`, `fetch_indexes`) and the sources the user added (`user_sources`, `add_user_source`, `remove_user_source`). `SuiteStore` is craft-font-store's store, `FakeStore` a stand-in for tests, `NoStore` one for when the store folder is unknown |
|
||||
| `FontsPanel` | `egui` | The whole interface as one egui panel |
|
||||
|
||||
## The panel
|
||||
|
||||
The layout follows Adobe Fonts' and Google Fonts' browse pages.
|
||||
|
||||
- **Across the top**: Browse and Installed, with their counts, and one search field.
|
||||
- **Filters** at the reading start, each under its label: category, language (searchable by its
|
||||
English name or its own, "Japanese · 日本語"), script, source, and installed or not; each option
|
||||
with its count.
|
||||
- **Family cards**, one per row: the name, style count, category and source on one line with the
|
||||
card's state at its end (Install and Preview, Installed, a progress bar while downloading,
|
||||
Couldn't install with Retry, "Not curated" for a source the user added), and a large sample in
|
||||
the face below. The list is virtual: only the cards on screen are laid out, and their faces load
|
||||
as they come into view. The arrow keys step through the cards and Enter opens one.
|
||||
- **Sample toolbar**: a preset (a sentence in the language filtered by, or in the family's own
|
||||
script when it has no Latin; the alphabet; numerals), the sample text, and the size.
|
||||
- **Details sheet** over the list: every style in its own face with Install or Remove for each,
|
||||
Install all and Remove all, the source, designer, licence, scripts and languages, and what a
|
||||
preview downloads.
|
||||
- **Installed**: system and font store fonts as the same cards, filtered by source. Only font
|
||||
store families can be removed; system fonts are removed with the system's own tools.
|
||||
- **Sources** (`sources_ui`, for a host's settings page): the built-in sources, the index URLs the
|
||||
user added, marked not curated and flagged when the file at their address changed since it was
|
||||
added, adding one, and Update sources.
|
||||
|
||||
`FontsPanel` fills the `Ui` it is given and has no window, menu or interface fonts of its own; the
|
||||
host's `Look` gives it the host's colours and font families. The faces it draws samples in are egui
|
||||
font families of its own, added a few per frame and taken out again past 64, without touching the
|
||||
host's fonts. Right-to-left interfaces mirror through `craft_ui::direction`; sample text keeps its
|
||||
own direction.
|
||||
|
||||
```rust
|
||||
let catalog = Arc::clone(Catalog::global());
|
||||
catalog.scan_in_background();
|
||||
let installer: Arc<dyn Installer> = match SuiteStore::for_user(Arc::clone(&catalog)) {
|
||||
Some(store) => Arc::new(store),
|
||||
None => Arc::new(NoStore),
|
||||
};
|
||||
let index = installer.indexes().map_err(|e| e.to_string());
|
||||
let options = Options { catalog, installer, index, runner: Runner::Thread };
|
||||
let mut panel = FontsPanel::new(options, Settings::default());
|
||||
// each frame
|
||||
panel.ui(ui);
|
||||
```
|
||||
|
||||
| Call | Use |
|
||||
|---|---|
|
||||
| `set_look(look)` | The host's palettes (dark and light) and font families, and whether labels are set in capitals |
|
||||
| `settings()`, `settings_mut()` | What the host saves: the sample text, preset and size, and whether the downloads note was closed (`serde`) |
|
||||
| `downloads_allowed()`, `set_downloads_allowed(on)` | The download consent, for a host's settings page. It is the font store's own setting, shared by every Craft app |
|
||||
| `show_downloads_switch(false)` | For a host with the switch in its own settings |
|
||||
| `sources_ui(ui)` | The font sources, for a host's settings page |
|
||||
| `set_index(index)` | The indexes, after the host fetched them |
|
||||
| `set_language(tag)` | The interface language, the host's own; English when its catalog isn't embedded |
|
||||
| `install_interface_fonts(ctx, craft_faces)` | For a host without interface fonts of its own (the standalone app): craft-ui's font stack for the panel's language, craft-fonts' faces the app embeds with craft-fonts-build, and the interface direction |
|
||||
|
||||
## Network use
|
||||
|
||||
Nothing is downloaded until the user allows it. Downloads are off by default; the first Install or
|
||||
Preview asks first. Files come from each source's repositories at the commit or tag its index
|
||||
records, through jsDelivr (raw GitHub as the fallback), and each is checked against the index's
|
||||
size and sha256 before use. Previews stay in memory and are never installed. A source the user
|
||||
adds is an index at an https URL; its families are never curated.
|
||||
|
||||
## Strings
|
||||
|
||||
Every interface string goes through craft-i18n with the English text as the key. The panel embeds
|
||||
its own Japanese and Arabic catalogs (`src/panel/catalogs/`) until its rows are part of
|
||||
craft-i18n; style and weight names, licence names and product names stay in English.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
cargo test -p craft-font-manager --all-features
|
||||
CRAFT_FONT_MANAGER_SNAPSHOTS=<dir> cargo test -p craft-font-manager --features egui --test flows screenshots -- --test-threads=1
|
||||
```
|
||||
|
||||
`tests/flows.rs` drives the panel with `egui_kittest` against a small index, a font folder made per
|
||||
test and `FakeStore`. They install egui's Hack and Ubuntu Light faces into temporary folders. With
|
||||
`CRAFT_FONT_MANAGER_GOOGLE_FONTS=<google/fonts checkout>`, the fake store also "downloads" the
|
||||
index's files from the checkout, so screenshots show real previews.
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Searching the index: family names, category, language and script.
|
||||
|
||||
use craft_i18n::Folder;
|
||||
|
||||
use crate::index::{Category, Index, categories};
|
||||
|
||||
/// What the catalogue shows.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Query {
|
||||
/// Part of a family name, ignoring case and accents.
|
||||
pub text: String,
|
||||
pub category: Option<Category>,
|
||||
/// A position in [`Index::languages`].
|
||||
pub language: Option<usize>,
|
||||
/// An ISO 15924 code.
|
||||
pub script: Option<String>,
|
||||
}
|
||||
|
||||
/// Search keys for one index, built once.
|
||||
pub struct Search {
|
||||
folder: Folder,
|
||||
families: Vec<String>,
|
||||
/// Per language: its name, autonym and id, folded.
|
||||
languages: Vec<String>,
|
||||
/// Language positions sorted by name.
|
||||
languages_by_name: Vec<usize>,
|
||||
/// Script positions sorted by name.
|
||||
scripts_by_name: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Search {
|
||||
pub fn new(index: &Index) -> Search {
|
||||
let folder = Folder::new("en");
|
||||
let families = index.families().iter().map(|f| folder.key(&f.family)).collect();
|
||||
let languages =
|
||||
index.languages().iter().map(|l| folder.key(&format!("{}\n{}\n{}", l.name, l.autonym.as_deref().unwrap_or(""), l.id))).collect();
|
||||
let mut languages_by_name: Vec<usize> = (0..index.languages().len()).collect();
|
||||
languages_by_name.sort_by_cached_key(|&i| index.languages().get(i).map(|l| folder.key(&l.name)));
|
||||
let mut scripts_by_name: Vec<usize> = (0..index.scripts().len()).collect();
|
||||
scripts_by_name.sort_by_cached_key(|&i| index.scripts().get(i).map(|s| folder.key(&s.name)));
|
||||
Search { folder, families, languages, languages_by_name, scripts_by_name }
|
||||
}
|
||||
|
||||
/// The families `query` selects, as positions in [`Index::families`], in index order.
|
||||
pub fn families(&self, index: &Index, query: &Query) -> Vec<usize> {
|
||||
let text = self.folder.key(query.text.trim());
|
||||
index
|
||||
.families()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, f)| {
|
||||
(text.is_empty() || self.families.get(*i).is_some_and(|key| key.contains(&text)))
|
||||
&& query.category.is_none_or(|c| categories(f).any(|fc| fc == c))
|
||||
&& query.language.is_none_or(|l| f.languages.contains(&l))
|
||||
&& query.script.as_ref().is_none_or(|s| f.scripts.contains(s))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Languages whose English name, autonym or id contains `text`, sorted by English name.
|
||||
pub fn languages(&self, text: &str) -> Vec<usize> {
|
||||
let text = self.folder.key(text.trim());
|
||||
self.languages_by_name.iter().copied().filter(|&i| self.languages.get(i).is_some_and(|key| key.contains(&text))).collect()
|
||||
}
|
||||
|
||||
/// Every script, sorted by English name.
|
||||
pub fn scripts(&self) -> &[usize] {
|
||||
&self.scripts_by_name
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters_combine() {
|
||||
let index = crate::index::bundled().unwrap();
|
||||
let search = Search::new(&index);
|
||||
let names = |q: &Query| search.families(&index, q).into_iter().map(|i| index.families()[i].family.clone()).collect::<Vec<_>>();
|
||||
|
||||
let noto_jp = names(&Query { text: "noto sans jp".into(), ..Query::default() });
|
||||
assert_eq!(noto_jp, ["Noto Sans JP"]);
|
||||
|
||||
let japanese = search.languages("日本語");
|
||||
let ja = *japanese.first().unwrap();
|
||||
assert_eq!(index.languages()[ja].id, "ja_Jpan");
|
||||
let serif_ja = names(&Query { category: Some(Category::Serif), language: Some(ja), ..Query::default() });
|
||||
assert!(serif_ja.contains(&"Noto Serif JP".to_owned()));
|
||||
assert!(!serif_ja.contains(&"Noto Sans JP".to_owned()));
|
||||
|
||||
let arabic = names(&Query { script: Some("Arab".into()), ..Query::default() });
|
||||
assert!(arabic.contains(&"Amiri".to_owned()));
|
||||
assert!(!arabic.contains(&"Roboto".to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! The font indexes (aiongg/craft-font-index: Google Fonts, Noto, and sources the user adds), as
|
||||
//! craft-font-store reads them, and the categories the catalogue filters by.
|
||||
//!
|
||||
//! [`Index::families`] holds every source's families, sorted by name; a family's source is
|
||||
//! [`Index::source_of`] (its name, and whether it is curated: [`Family::is_curated`] is false for
|
||||
//! a source the user added). [`Index::problems`] lists the sources that couldn't be loaded.
|
||||
|
||||
pub use craft_font_store::{Family, FontFile, FontSource, GOOGLE_FONTS, Index, IndexError, Language, Script, SourceProblem};
|
||||
|
||||
/// The pinned built-in indexes compiled into craft-font-store, read without a request.
|
||||
pub fn bundled() -> Result<Index, IndexError> {
|
||||
Index::bundled()
|
||||
}
|
||||
|
||||
/// The display name of a family's source (`Google Fonts`, `Noto`), and whether it is curated.
|
||||
pub fn source_label<'a>(index: &'a Index, family: &'a Family) -> (&'a str, bool) {
|
||||
index.source_of(family).map_or((family.source.as_str(), family.is_curated()), |s| (s.name.as_str(), s.curated))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Category {
|
||||
SansSerif,
|
||||
Serif,
|
||||
Display,
|
||||
Handwriting,
|
||||
Monospace,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
/// The categories the filter offers, in Google Fonts' order.
|
||||
pub const ALL: [Category; 5] = [Category::SansSerif, Category::Serif, Category::Display, Category::Handwriting, Category::Monospace];
|
||||
|
||||
/// The index's name for it (`SANS_SERIF`).
|
||||
pub fn of(name: &str) -> Option<Category> {
|
||||
match name {
|
||||
"SANS_SERIF" => Some(Category::SansSerif),
|
||||
"SERIF" => Some(Category::Serif),
|
||||
"DISPLAY" => Some(Category::Display),
|
||||
"HANDWRITING" => Some(Category::Handwriting),
|
||||
"MONOSPACE" => Some(Category::Monospace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A family's categories that this version knows.
|
||||
pub fn categories(family: &Family) -> impl Iterator<Item = Category> + '_ {
|
||||
family.categories.iter().filter_map(|c| Category::of(c))
|
||||
}
|
||||
|
||||
pub fn script_name<'a>(index: &'a Index, id: &str) -> Option<&'a str> {
|
||||
index.scripts().iter().find(|s| s.id == id).map(|s| s.name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_index_reads() {
|
||||
let index = bundled().unwrap();
|
||||
assert!(index.families().len() > 1000);
|
||||
let roboto = index.family("roboto").unwrap();
|
||||
assert_eq!(categories(roboto).collect::<Vec<_>>(), [Category::SansSerif]);
|
||||
assert_eq!(script_name(&index, "Arab"), Some("Arabic"));
|
||||
assert_eq!(source_label(&index, roboto), ("Google Fonts", true));
|
||||
let cjk = index.family("Noto Sans CJK JP").unwrap();
|
||||
assert_eq!(source_label(&index, cjk), ("Noto", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The installed fonts the catalog sees, grouped by family.
|
||||
|
||||
use craft_font_catalog::{FaceInfo, Source};
|
||||
|
||||
/// One family from one source: a family installed on the system and in the font store is two
|
||||
/// entries, since only the store's can be removed.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InstalledFamily {
|
||||
pub name: String,
|
||||
pub source: Source,
|
||||
/// Upright before italic, then by weight and width.
|
||||
pub faces: Vec<FaceInfo>,
|
||||
}
|
||||
|
||||
impl InstalledFamily {
|
||||
pub fn removable(&self) -> bool {
|
||||
self.source == Source::Store
|
||||
}
|
||||
}
|
||||
|
||||
/// System and font store faces by family, sorted by name ignoring case. Fonts other sources
|
||||
/// add (documents, the app's own) are left out.
|
||||
pub fn group(faces: &[FaceInfo]) -> Vec<InstalledFamily> {
|
||||
let mut families: Vec<InstalledFamily> = Vec::new();
|
||||
let mut sorted: Vec<&FaceInfo> = faces.iter().filter(|f| matches!(f.source, Source::System | Source::Store)).collect();
|
||||
sorted.sort_by_cached_key(|f| (f.family.to_lowercase(), f.source == Source::Store));
|
||||
for face in sorted {
|
||||
match families.last_mut() {
|
||||
Some(last) if last.source == face.source && last.name.eq_ignore_ascii_case(&face.family) => last.faces.push(face.clone()),
|
||||
_ => families.push(InstalledFamily { name: face.family.clone(), source: face.source, faces: vec![face.clone()] }),
|
||||
}
|
||||
}
|
||||
for family in &mut families {
|
||||
family.faces.sort_by(|a, b| (a.italic, a.weight, a.width).partial_cmp(&(b.italic, b.weight, b.width)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
family.faces.dedup_by(|a, b| a.path == b.path && a.index == b.index && a.variations == b.variations);
|
||||
}
|
||||
families
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! Craft Font Manager as a library: the bundled font indexes and searching them, the installed
|
||||
//! fonts by family, and the font store interface installs and removals go through.
|
||||
//!
|
||||
//! With the `egui` feature, [`FontsPanel`] is the whole interface as one panel that fills the `Ui`
|
||||
//! it is given, with no window, menu or eframe of its own, so ArtCraft Launcher can show it as its
|
||||
//! Fonts tab and the standalone app as its window. The host passes in the catalog, the store and
|
||||
//! the index ([`Options`]), saves [`Settings`], and reads or sets the download consent through the
|
||||
//! panel.
|
||||
|
||||
pub mod filter;
|
||||
pub mod index;
|
||||
pub mod installed;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(feature = "egui")]
|
||||
mod panel;
|
||||
|
||||
pub use craft_font_catalog;
|
||||
pub use craft_font_catalog::{Catalog, FaceInfo, FontEnv, Source};
|
||||
#[cfg(feature = "egui")]
|
||||
pub use panel::{FontsPanel, Look, Options, Palette, Preset, Runner, Settings, Tab, install_interface_fonts};
|
||||
@@ -0,0 +1,713 @@
|
||||
//! The panel's state, what the host calls, and the frame: the tabs and search across the top,
|
||||
//! the filters at the reading start, the family cards, and the details sheet over them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::craft_font_catalog::{FontEnv, Limits, Os};
|
||||
use crate::filter::{Query, Search};
|
||||
use crate::index::{Category, Family, Index, categories};
|
||||
use crate::installed::{InstalledFamily, group};
|
||||
use crate::store::{Installer, RemoveRequest, StoreError};
|
||||
use crate::{Catalog, FaceInfo, Source};
|
||||
use craft_i18n::Folder;
|
||||
use craft_ui::direction;
|
||||
use craft_ui::fonts::{CraftFace, I18nScripts, UiFonts};
|
||||
use egui::{CentralPanel, Context, Frame, Margin, Panel, Ui};
|
||||
|
||||
use crate::panel::faces::FaceFonts;
|
||||
use crate::panel::i18n::{self, I18N, LANGUAGES, fmt, tl};
|
||||
use crate::panel::jobs::{Finished, Jobs, Kind, Request, Runner};
|
||||
use crate::panel::look::{Look, Palette};
|
||||
use crate::panel::samples;
|
||||
use crate::panel::widgets::{self, Skin};
|
||||
|
||||
/// Installs the standalone app's interface fonts in `ctx` and sets the interface direction: craft-ui's
|
||||
/// font stack for the panel's interface language, with `craft_faces` (craft-fonts' faces the app
|
||||
/// embeds with craft-fonts-build, or none) and the system's fonts for every other character. A host
|
||||
/// with its own fonts doesn't call it.
|
||||
pub fn install_interface_fonts(ctx: &Context, craft_faces: &[CraftFace]) {
|
||||
let language = I18N.code(i18n::current()).to_owned();
|
||||
UiFonts::new(&I18nScripts).languages(LANGUAGES).ui_language(&language).craft_faces(craft_faces).install(ctx);
|
||||
direction::apply(ctx, direction::resolve(&language, direction::Setting::Language));
|
||||
}
|
||||
|
||||
/// Preview downloads: at most this much per file, and this many faces in a session.
|
||||
const PREVIEW_LIMITS: Limits = Limits { max_file_bytes: 64 << 20, max_faces_per_file: 512, max_added_faces: 20_000 };
|
||||
|
||||
/// How often the download consent is read again from the store's settings file, which another
|
||||
/// Craft app can change.
|
||||
const CONSENT_RECHECK_SECS: f64 = 3.0;
|
||||
|
||||
/// What a sample shows when the user hasn't typed one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Preset {
|
||||
/// A sentence in the language filtered by, or in the family's own script.
|
||||
#[default]
|
||||
Sentence,
|
||||
Alphabet,
|
||||
Numerals,
|
||||
}
|
||||
|
||||
/// What the app keeps between runs.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// The sample text; empty for the preset.
|
||||
pub sample: String,
|
||||
pub preset: Preset,
|
||||
pub size: f32,
|
||||
/// The user closed the note that downloads are off.
|
||||
pub hide_download_note: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings { sample: String::new(), preset: Preset::Sentence, size: 36.0, hide_download_note: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
Installed,
|
||||
Catalogue,
|
||||
}
|
||||
|
||||
/// Which catalogue families show by install state.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Show {
|
||||
#[default]
|
||||
All,
|
||||
Installed,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
/// Which installed families show by where they come from.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Origin {
|
||||
#[default]
|
||||
All,
|
||||
System,
|
||||
Store,
|
||||
}
|
||||
|
||||
/// What the Installed tab's list was worked out for: the search, the source and the catalog's
|
||||
/// generation.
|
||||
type InstalledFilter = (String, Origin, Option<u64>);
|
||||
|
||||
/// What the app is built from; tests pass their own catalog, store and index.
|
||||
pub struct Options {
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub installer: Arc<dyn Installer>,
|
||||
/// The Google Fonts index, or why it couldn't be read.
|
||||
pub index: Result<Index, String>,
|
||||
pub runner: Runner,
|
||||
}
|
||||
|
||||
/// A download, waiting for the user's consent or to be tried again.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Pending {
|
||||
Install(usize),
|
||||
/// One file of a family, by its path in the index.
|
||||
InstallFile(usize, String),
|
||||
Preview(usize),
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
pub(crate) fn family(&self) -> usize {
|
||||
match self {
|
||||
Pending::Install(i) | Pending::InstallFile(i, _) | Pending::Preview(i) => *i,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The family the details sheet shows.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Detail {
|
||||
/// A family of the index, by position.
|
||||
Family(usize),
|
||||
/// An installed family, by name and source.
|
||||
Installed(String, Source),
|
||||
}
|
||||
|
||||
/// Why the last job for a family failed, and what Retry runs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Failure {
|
||||
pub kind: Kind,
|
||||
pub error: String,
|
||||
pub retry: Option<Pending>,
|
||||
}
|
||||
|
||||
pub(crate) struct Catalogue {
|
||||
pub index: Arc<Index>,
|
||||
pub search: Search,
|
||||
}
|
||||
|
||||
pub struct FontsPanel {
|
||||
pub(crate) settings: Settings,
|
||||
pub(crate) tab: Tab,
|
||||
pub(crate) look: Look,
|
||||
pub(crate) skin: Skin,
|
||||
/// The panel shows its own download switch; off when the host has one in its settings.
|
||||
pub(crate) downloads_switch: bool,
|
||||
pub(crate) catalog: Arc<Catalog>,
|
||||
pub(crate) installer: Arc<dyn Installer>,
|
||||
pub(crate) catalogue: Result<Catalogue, String>,
|
||||
pub(crate) query: Query,
|
||||
pub(crate) show: Show,
|
||||
pub(crate) results: Vec<usize>,
|
||||
results_for: Option<(Query, Show, Option<String>, Option<u64>)>,
|
||||
/// Only this source's families (a source ID).
|
||||
pub(crate) source: Option<String>,
|
||||
/// Families per source, in [`Index::sources`] order, for the other filters as set.
|
||||
pub(crate) source_counts: Vec<usize>,
|
||||
/// The indexes being downloaded again (the sources' Update).
|
||||
sources_job: Option<std::sync::mpsc::Receiver<Result<Index, StoreError>>>,
|
||||
/// What the Sources settings show: the URL being typed, and the last problem.
|
||||
pub(crate) source_url: String,
|
||||
pub(crate) source_message: Option<(bool, String)>,
|
||||
/// Families per category (all first) and per install state, for the other filters as set.
|
||||
pub(crate) category_counts: [usize; 6],
|
||||
pub(crate) show_counts: [usize; 3],
|
||||
pub(crate) language_text: String,
|
||||
pub(crate) installed: Vec<InstalledFamily>,
|
||||
/// Installed faces by lower-case family name, every source together.
|
||||
pub(crate) installed_by_name: HashMap<String, Vec<FaceInfo>>,
|
||||
installed_generation: Option<u64>,
|
||||
pub(crate) installed_text: String,
|
||||
pub(crate) origin: Origin,
|
||||
/// [`FontsPanel::installed_shown`] and what it was worked out for.
|
||||
installed_shown: (Vec<usize>, Option<InstalledFilter>),
|
||||
pub(crate) detail: Option<Detail>,
|
||||
/// Downloaded preview files, never installed.
|
||||
pub(crate) previews: Arc<Catalog>,
|
||||
pub(crate) previewed: HashMap<String, Vec<FaceInfo>>,
|
||||
pub(crate) faces: FaceFonts,
|
||||
pub(crate) jobs: Jobs,
|
||||
pub(crate) asking: Option<Pending>,
|
||||
/// By lower-case family name.
|
||||
pub(crate) failed: HashMap<String, Failure>,
|
||||
/// A problem that belongs to no family (the download setting couldn't be saved).
|
||||
pub(crate) notice: Option<String>,
|
||||
focused: bool,
|
||||
pub(crate) folder: Folder,
|
||||
/// The download consent as last read, and when.
|
||||
consent: Option<(bool, f64)>,
|
||||
/// The card that has (or is to get) the keyboard focus, by row.
|
||||
pub(crate) focus_row: Option<usize>,
|
||||
/// Bring the focused row into view on the next frame.
|
||||
pub(crate) scroll_to_focus: bool,
|
||||
/// Give the focus row the keyboard focus on the next frame.
|
||||
pub(crate) move_focus: bool,
|
||||
/// Focus the search field on the next frame.
|
||||
pub(crate) focus_search: bool,
|
||||
}
|
||||
|
||||
/// A family's key in the panel's state: families of the same name can come from two sources.
|
||||
/// A removal goes by name alone (`source` empty).
|
||||
pub(crate) fn family_key(source: &str, family: &str) -> String {
|
||||
format!("{source}\n{}", family.to_lowercase())
|
||||
}
|
||||
|
||||
pub(crate) fn category_name(c: Category) -> &'static str {
|
||||
match c {
|
||||
Category::SansSerif => tl!("Sans serif"),
|
||||
Category::Serif => tl!("Serif"),
|
||||
Category::Display => tl!("Display"),
|
||||
Category::Handwriting => tl!("Handwriting"),
|
||||
Category::Monospace => tl!("Monospace"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_name(s: Source) -> &'static str {
|
||||
match s {
|
||||
Source::Store => tl!("Font store"),
|
||||
_ => tl!("System"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The face closest to an upright regular.
|
||||
pub(crate) fn regular(faces: &[FaceInfo]) -> Option<&FaceInfo> {
|
||||
faces.iter().min_by_key(|f| (f.italic, ((f.weight - 400.0).abs() * 10.0) as i64, ((f.width - 100.0).abs() * 10.0) as i64))
|
||||
}
|
||||
|
||||
pub(crate) fn style_name(face: &FaceInfo) -> &str {
|
||||
if face.style.is_empty() { tl!("Regular") } else { &face.style }
|
||||
}
|
||||
|
||||
/// How many styles a family of the index has: each static file one, a variable file one per
|
||||
/// hundred of weight its `wght` axis covers.
|
||||
pub(crate) fn style_count(family: &Family) -> usize {
|
||||
family
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| match f.axes.iter().find(|a| a.tag == "wght") {
|
||||
Some(a) => ((a.min / 100.0).ceil() as usize..=(a.max / 100.0).floor() as usize).count().max(1),
|
||||
None => 1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn category_label(family: &Family) -> String {
|
||||
categories(family).map(category_name).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
impl FontsPanel {
|
||||
pub fn new(options: Options, settings: Settings) -> FontsPanel {
|
||||
let catalogue = options.index.map(|index| Catalogue { search: Search::new(&index), index: Arc::new(index) });
|
||||
let previews = Arc::new(Catalog::new(FontEnv { os: Os::None, ..FontEnv::default() }).with_font_service(false));
|
||||
let look = Look::default();
|
||||
FontsPanel {
|
||||
settings,
|
||||
tab: Tab::Catalogue,
|
||||
skin: Skin { p: Palette::from_visuals(&egui::Visuals::dark()), look: look.clone() },
|
||||
look,
|
||||
downloads_switch: true,
|
||||
catalog: options.catalog,
|
||||
installer: options.installer,
|
||||
catalogue,
|
||||
query: Query::default(),
|
||||
show: Show::All,
|
||||
results: Vec::new(),
|
||||
results_for: None,
|
||||
source: None,
|
||||
source_counts: Vec::new(),
|
||||
sources_job: None,
|
||||
source_url: String::new(),
|
||||
source_message: None,
|
||||
category_counts: [0; 6],
|
||||
show_counts: [0; 3],
|
||||
language_text: String::new(),
|
||||
installed: Vec::new(),
|
||||
installed_by_name: HashMap::new(),
|
||||
installed_generation: None,
|
||||
installed_text: String::new(),
|
||||
origin: Origin::All,
|
||||
installed_shown: (Vec::new(), None),
|
||||
detail: None,
|
||||
previews,
|
||||
previewed: HashMap::new(),
|
||||
faces: FaceFonts::default(),
|
||||
jobs: Jobs::new(options.runner),
|
||||
asking: None,
|
||||
failed: HashMap::new(),
|
||||
notice: None,
|
||||
focused: true,
|
||||
folder: Folder::new("en"),
|
||||
consent: None,
|
||||
focus_row: None,
|
||||
scroll_to_focus: false,
|
||||
move_focus: false,
|
||||
focus_search: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `index` (after the host fetched the sources, say). The details sheet closes, as
|
||||
/// family positions change.
|
||||
pub fn set_index(&mut self, index: Result<Index, String>) {
|
||||
self.catalogue = index.map(|index| Catalogue { search: Search::new(&index), index: Arc::new(index) });
|
||||
self.results_for = None;
|
||||
self.detail = None;
|
||||
self.asking = None;
|
||||
self.focus_row = None;
|
||||
if let Ok(c) = &self.catalogue
|
||||
&& self.source.as_ref().is_some_and(|s| c.index.source(s).is_none())
|
||||
{
|
||||
self.source = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the indexes again without a request: after a source was added or removed.
|
||||
pub(crate) fn reload_index(&mut self) {
|
||||
let index = self.installer.indexes().map_err(|e| e.0);
|
||||
self.set_index(index);
|
||||
}
|
||||
|
||||
/// Downloads every source's index again on a worker thread, when downloads are allowed.
|
||||
pub(crate) fn fetch_sources(&mut self, ctx: &Context) {
|
||||
if self.sources_job.is_some() || !self.downloads_allowed() {
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let installer = Arc::clone(&self.installer);
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("font-sources".into()).spawn(move || {
|
||||
let _ = tx.send(installer.fetch_indexes());
|
||||
ctx.request_repaint();
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => self.sources_job = Some(rx),
|
||||
Err(e) => self.source_message = Some((true, e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the sources' indexes are being downloaded again.
|
||||
pub fn fetching_sources(&self) -> bool {
|
||||
self.sources_job.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn poll_sources(&mut self) {
|
||||
let Some(rx) = &self.sources_job else { return };
|
||||
match rx.try_recv() {
|
||||
Ok(Ok(index)) => {
|
||||
self.sources_job = None;
|
||||
self.source_message = None;
|
||||
self.set_index(Ok(index));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
self.sources_job = None;
|
||||
self.source_message = Some((true, e.0));
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.sources_job = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws with the host's colours and font families.
|
||||
pub fn set_look(&mut self, look: Look) {
|
||||
self.look = look;
|
||||
}
|
||||
|
||||
/// Whether the panel shows its own "Allow downloads" switch. A host with the switch in its
|
||||
/// own settings turns it off; the panel still offers to allow downloads where one is needed.
|
||||
pub fn show_downloads_switch(&mut self, show: bool) {
|
||||
self.downloads_switch = show;
|
||||
}
|
||||
|
||||
/// The panel's own settings, for the host to save.
|
||||
pub fn settings(&self) -> &Settings {
|
||||
&self.settings
|
||||
}
|
||||
|
||||
pub fn settings_mut(&mut self) -> &mut Settings {
|
||||
&mut self.settings
|
||||
}
|
||||
|
||||
pub fn tab(&self) -> Tab {
|
||||
self.tab
|
||||
}
|
||||
|
||||
pub fn set_tab(&mut self, tab: Tab) {
|
||||
self.tab = tab;
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
/// The installed families, as the Installed tab lists them.
|
||||
pub fn installed(&self) -> &[InstalledFamily] {
|
||||
&self.installed
|
||||
}
|
||||
|
||||
/// The catalogue's families for the current search and filters.
|
||||
pub fn results(&self) -> Vec<String> {
|
||||
let Ok(c) = &self.catalogue else { return Vec::new() };
|
||||
self.results.iter().filter_map(|&i| c.index.families().get(i)).map(|f| f.family.clone()).collect()
|
||||
}
|
||||
|
||||
/// The family the details sheet shows.
|
||||
pub fn details_family(&self) -> Option<String> {
|
||||
match &self.detail {
|
||||
Some(Detail::Family(i)) => self.catalogue.as_ref().ok()?.index.families().get(*i).map(|f| f.family.clone()),
|
||||
Some(Detail::Installed(name, _)) => Some(name.clone()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the interface in `language` (a tag such as `de` or `pt-BR`), the host's interface
|
||||
/// language, when its catalog is embedded; English otherwise.
|
||||
pub fn set_language(&mut self, language: &str) {
|
||||
i18n::set_current(I18N.match_tag(language).unwrap_or(craft_i18n::Lang::EN));
|
||||
}
|
||||
|
||||
/// Has the user allowed downloads from Google Fonts? A suite-wide setting of the font store,
|
||||
/// off until they turn it on or confirm an install or preview.
|
||||
pub fn downloads_allowed(&self) -> bool {
|
||||
self.installer.downloads_allowed()
|
||||
}
|
||||
|
||||
/// Turns downloads on or off, as the user's own action (a host's settings page, say). A
|
||||
/// failure also shows in the panel.
|
||||
pub fn set_downloads_allowed(&mut self, allowed: bool) -> Result<(), StoreError> {
|
||||
let result = self.installer.allow_downloads(allowed);
|
||||
self.consent = None;
|
||||
self.notice = result.as_ref().err().map(|e| fmt("The download setting couldn't be saved: {error}", &[("error", &e.0)]));
|
||||
result
|
||||
}
|
||||
|
||||
/// The consent, read from the store at most every few seconds.
|
||||
pub(crate) fn allowed(&mut self, now: f64) -> bool {
|
||||
match self.consent {
|
||||
Some((allowed, at)) if (0.0..CONSENT_RECHECK_SECS).contains(&(now - at)) => allowed,
|
||||
_ => {
|
||||
let allowed = self.installer.downloads_allowed();
|
||||
self.consent = Some((allowed, now));
|
||||
allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the panel into `ui`, which it fills.
|
||||
pub fn ui(&mut self, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.skin = Skin { p: self.look.palette(&ctx), look: self.look.clone() };
|
||||
self.faces.begin_frame(&ctx);
|
||||
self.poll_jobs();
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
if focused && !self.focused {
|
||||
if self.catalog.is_ready() {
|
||||
self.catalog.rescan_if_changed();
|
||||
}
|
||||
self.consent = None;
|
||||
}
|
||||
self.focused = focused;
|
||||
self.refresh_installed();
|
||||
self.refresh_results();
|
||||
self.keys(&ctx);
|
||||
|
||||
let area = ui.max_rect();
|
||||
let p = self.skin.p;
|
||||
let rtl = direction::is_rtl(ui);
|
||||
let sides = |start: i8, end: i8| if rtl { (end, start) } else { (start, end) };
|
||||
let top = Frame::new().fill(p.bg).inner_margin(Margin { left: 20, right: 20, top: 16, bottom: 14 });
|
||||
Panel::top("craft-fonts.top").frame(top).resizable(false).show(ui, |ui| self.top_bar(ui));
|
||||
let width = if area.width() < 860.0 { 212.0 } else { 248.0 };
|
||||
let (left, right) = sides(20, 18);
|
||||
let filters = Frame::new().fill(p.bg).inner_margin(Margin { left, right, top: 18, bottom: 12 });
|
||||
direction::start_panel(ui, "craft-fonts.filters").frame(filters).resizable(false).exact_size(width).show(ui, |ui| self.sidebar(ui));
|
||||
let main = Frame::new().fill(p.bg).inner_margin(Margin { left: 20, right: 20, top: 16, bottom: 0 });
|
||||
CentralPanel::default().frame(main).show(ui, |ui| self.main(ui));
|
||||
self.details(&ctx, area);
|
||||
self.consent(&ctx);
|
||||
}
|
||||
|
||||
fn keys(&mut self, ctx: &Context) {
|
||||
let (find, escape) = ctx.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::F), i.key_pressed(egui::Key::Escape)));
|
||||
if find {
|
||||
self.focus_search = true;
|
||||
}
|
||||
if escape && self.asking.is_none() && self.detail.is_some() {
|
||||
self.detail = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_jobs(&mut self) {
|
||||
for (job, result) in self.jobs.poll() {
|
||||
let key = family_key(&job.source, &job.family);
|
||||
match result {
|
||||
Ok(Finished::Installed | Finished::Removed) => {
|
||||
self.failed.remove(&key);
|
||||
}
|
||||
Ok(Finished::Previewed(files)) => {
|
||||
self.failed.remove(&key);
|
||||
let mut faces = Vec::new();
|
||||
for file in files {
|
||||
match self.previews.add_bytes(file.bytes, Source::User, &PREVIEW_LIMITS) {
|
||||
Ok(added) => faces.extend(added),
|
||||
Err(e) => log::warn!("preview of {}: {e}", file.path),
|
||||
}
|
||||
}
|
||||
self.previewed.insert(key, faces);
|
||||
}
|
||||
Err(e) => {
|
||||
let position =
|
||||
self.catalogue.as_ref().ok().and_then(|c| {
|
||||
c.index.families().iter().position(|f| f.source == job.source && f.family.eq_ignore_ascii_case(&job.family))
|
||||
});
|
||||
let retry = position.and_then(|i| match job.kind {
|
||||
Kind::Install => Some(job.file.clone().map_or(Pending::Install(i), |path| Pending::InstallFile(i, path))),
|
||||
Kind::Preview => Some(Pending::Preview(i)),
|
||||
Kind::Remove => None,
|
||||
});
|
||||
log::warn!("{} ({:?}): {e}", job.family, job.kind);
|
||||
self.failed.insert(key, Failure { kind: job.kind, error: e.0, retry });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.poll_sources();
|
||||
}
|
||||
|
||||
fn refresh_installed(&mut self) {
|
||||
if !self.catalog.is_ready() {
|
||||
return;
|
||||
}
|
||||
let generation = self.catalog.generation();
|
||||
if self.installed_generation == Some(generation) {
|
||||
return;
|
||||
}
|
||||
self.installed_generation = Some(generation);
|
||||
let faces = self.catalog.faces();
|
||||
self.installed = group(&faces);
|
||||
self.installed_by_name.clear();
|
||||
for family in &self.installed {
|
||||
self.installed_by_name.entry(family.name.to_lowercase()).or_default().extend(family.faces.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_results(&mut self) {
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let key = (self.query.clone(), self.show, self.source.clone(), self.installed_generation);
|
||||
if self.results_for.as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
let from = |i: usize, source: &Option<String>| source.as_ref().is_none_or(|s| c.index.families().get(i).is_some_and(|f| &f.source == s));
|
||||
let installed = |i: usize| c.index.families().get(i).is_some_and(|f| self.installed_by_name.contains_key(&f.family.to_lowercase()));
|
||||
let shows = |show: Show, i: usize| match show {
|
||||
Show::All => true,
|
||||
Show::Installed => installed(i),
|
||||
Show::NotInstalled => !installed(i),
|
||||
};
|
||||
let any_category = Query { category: None, ..self.query.clone() };
|
||||
let mut by_category = [0usize; 6];
|
||||
let mut by_state = [0usize; 3];
|
||||
let mut by_source: Vec<usize> = vec![0; c.index.sources().len()];
|
||||
for i in c.search.families(&c.index, &any_category) {
|
||||
let Some(f) = c.index.families().get(i) else { continue };
|
||||
let in_category = self.query.category.is_none_or(|q| categories(f).any(|fc| fc == q));
|
||||
if in_category
|
||||
&& shows(self.show, i)
|
||||
&& let Some(n) = c.index.sources().iter().position(|s| s.id == f.source).and_then(|n| by_source.get_mut(n))
|
||||
{
|
||||
*n += 1;
|
||||
}
|
||||
if !from(i, &self.source) {
|
||||
continue;
|
||||
}
|
||||
if shows(self.show, i) {
|
||||
by_category[0] += 1;
|
||||
for fc in categories(f) {
|
||||
if let Some(pos) = Category::ALL.iter().position(|x| *x == fc) {
|
||||
by_category[pos + 1] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_category {
|
||||
by_state[0] += 1;
|
||||
by_state[if installed(i) { 1 } else { 2 }] += 1;
|
||||
}
|
||||
}
|
||||
self.category_counts = by_category;
|
||||
self.show_counts = by_state;
|
||||
self.source_counts = by_source;
|
||||
self.results = c.search.families(&c.index, &self.query).into_iter().filter(|&i| shows(self.show, i) && from(i, &self.source)).collect();
|
||||
self.results_for = Some(key);
|
||||
}
|
||||
|
||||
/// The installed families the Installed tab shows, by position in [`FontsPanel::installed`].
|
||||
pub(crate) fn installed_shown(&mut self) -> Vec<usize> {
|
||||
let key = (self.installed_text.clone(), self.origin, self.installed_generation);
|
||||
if self.installed_shown.1.as_ref() != Some(&key) {
|
||||
let text = self.folder.key(self.installed_text.trim());
|
||||
let shown = (0..self.installed.len())
|
||||
.filter(|&i| {
|
||||
let Some(f) = self.installed.get(i) else { return false };
|
||||
let origin = match self.origin {
|
||||
Origin::All => true,
|
||||
Origin::System => f.source != Source::Store,
|
||||
Origin::Store => f.source == Source::Store,
|
||||
};
|
||||
origin && (text.is_empty() || self.folder.key(&f.name).contains(&text))
|
||||
})
|
||||
.collect();
|
||||
self.installed_shown = (shown, Some(key));
|
||||
}
|
||||
self.installed_shown.0.clone()
|
||||
}
|
||||
|
||||
/// The sample for a family with `scripts`: what the user typed, else the preset.
|
||||
pub(crate) fn sample_for<'a>(&self, scripts: impl IntoIterator<Item = &'a str>) -> String {
|
||||
if !self.settings.sample.trim().is_empty() {
|
||||
return self.settings.sample.clone();
|
||||
}
|
||||
match self.settings.preset {
|
||||
Preset::Alphabet => samples::ALPHABET.to_owned(),
|
||||
Preset::Numerals => samples::NUMERALS.to_owned(),
|
||||
Preset::Sentence => {
|
||||
let language = self.catalogue.as_ref().ok().zip(self.query.language).and_then(|(c, l)| c.index.languages().get(l));
|
||||
language.and_then(|l| samples::for_language(&l.id)).unwrap_or_else(|| samples::for_scripts(scripts)).to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `pending` if downloads are allowed, or asks first.
|
||||
pub(crate) fn ask(&mut self, ctx: &Context, pending: Pending) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
if self.allowed(now) {
|
||||
self.download(ctx, pending);
|
||||
} else {
|
||||
self.asking = Some(pending);
|
||||
}
|
||||
}
|
||||
|
||||
fn download(&mut self, ctx: &Context, pending: Pending) {
|
||||
if !self.downloads_allowed() {
|
||||
return;
|
||||
}
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let index = Arc::clone(&c.index);
|
||||
if let Some(f) = index.families().get(pending.family()) {
|
||||
self.failed.remove(&family_key(&f.source, &f.family));
|
||||
}
|
||||
let request = match pending {
|
||||
Pending::Install(i) => Request::Install(index, i),
|
||||
Pending::InstallFile(i, path) => Request::InstallFile(index, i, path),
|
||||
Pending::Preview(i) => Request::Preview(index, i),
|
||||
};
|
||||
self.jobs.start(ctx, Arc::clone(&self.installer), Arc::clone(&self.catalog), request);
|
||||
}
|
||||
|
||||
/// Removes store files: a whole family, or the files of some of its styles.
|
||||
pub(crate) fn remove(&mut self, ctx: &Context, family: &str, faces: &[FaceInfo], whole: bool) {
|
||||
let mut files: Vec<_> = faces.iter().filter(|f| f.source == Source::Store).filter_map(|f| f.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.failed.remove(&family_key("", family));
|
||||
let request = RemoveRequest { family: family.to_owned(), files, whole };
|
||||
self.jobs.start(ctx, Arc::clone(&self.installer), Arc::clone(&self.catalog), Request::Remove(request));
|
||||
}
|
||||
|
||||
fn consent(&mut self, ctx: &Context) {
|
||||
let Some(pending) = self.asking.clone() else { return };
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let Some(family) = c.index.families().get(pending.family()) else { return };
|
||||
let size = match &pending {
|
||||
Pending::InstallFile(_, path) => family.files.iter().find(|f| &f.path == path).map_or(0, |f| f.size),
|
||||
_ => family.size(),
|
||||
};
|
||||
let text = fmt(
|
||||
"Craft Font Manager downloads {family} ({size}) from Google Fonts' GitHub repository through jsDelivr, and checks every file before using it.",
|
||||
&[("family", &family.family), ("size", &i18n::size(size))],
|
||||
);
|
||||
let s = self.skin.clone();
|
||||
let mut allow = false;
|
||||
let mut cancel = false;
|
||||
let frame =
|
||||
Frame::new().fill(s.p.bg).stroke(egui::Stroke::new(1.0, s.p.line_strong)).corner_radius(s.look.corner).inner_margin(Margin::same(24));
|
||||
let response = egui::Modal::new(egui::Id::unique("craft-font-manager.consent")).frame(frame).show(ctx, |ui| {
|
||||
ui.set_max_width(440.0);
|
||||
ui.spacing_mut().item_spacing.y = 10.0;
|
||||
let title = egui::RichText::new(tl!("Download from Google Fonts?")).size(22.0).family(s.look.display.clone()).color(s.p.ink_strong);
|
||||
ui.add(egui::Label::new(title).wrap());
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).color(s.p.ink)).wrap());
|
||||
ui.add(egui::Label::new(egui::RichText::new(tl!("Downloads stay allowed until you turn them off.")).color(s.p.muted)).wrap());
|
||||
ui.add_space(8.0);
|
||||
direction::horizontal(ui, |ui| {
|
||||
allow = widgets::button(ui, &s, tl!("Download"), widgets::Kind::Primary, s.h(), 0.0).clicked();
|
||||
cancel = widgets::button(ui, &s, tl!("Cancel"), widgets::Kind::Secondary, s.h(), 0.0).clicked();
|
||||
});
|
||||
});
|
||||
if allow {
|
||||
self.asking = None;
|
||||
if self.set_downloads_allowed(true).is_ok() {
|
||||
self.download(ctx, pending);
|
||||
}
|
||||
} else if cancel || response.should_close() {
|
||||
self.asking = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn index(&self) -> Option<Arc<Index>> {
|
||||
self.catalogue.as_ref().ok().map(|c| Arc::clone(&c.index))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! The tabs and search, the filters, the sample toolbar and the list of family cards.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ui::direction;
|
||||
use egui::{CornerRadius, FontFamily, FontId, Id, Rect, Role, Sense, StrokeKind, Ui, UiBuilder, WidgetInfo, pos2, vec2};
|
||||
|
||||
use crate::index::{Category, Index, script_name, source_label};
|
||||
use crate::panel::app::{Detail, FontsPanel, Origin, Pending, Preset, Show, Tab, category_label, family_key, regular, source_name, style_count};
|
||||
use crate::panel::faces::{Owner, Shown};
|
||||
use crate::panel::i18n::{self, fmt, tl, tn};
|
||||
use crate::panel::jobs::Kind;
|
||||
use crate::panel::widgets::{self, Cell, Glyph, Skin};
|
||||
use crate::{FaceInfo, Source};
|
||||
|
||||
/// Space round a card's contents.
|
||||
const CARD_PAD: f32 = 20.0;
|
||||
const CARD_TOP: f32 = 14.0;
|
||||
const CARD_GAP: f32 = 8.0;
|
||||
const CARD_BOTTOM: f32 = 18.0;
|
||||
|
||||
/// What a card shows: `key` sets it apart from a family of the same name from another source.
|
||||
struct Card<'a> {
|
||||
key: &'a str,
|
||||
name: &'a str,
|
||||
meta: &'a str,
|
||||
sample: &'a str,
|
||||
curated: bool,
|
||||
index_position: Option<usize>,
|
||||
}
|
||||
|
||||
/// What a card shows at its reading end.
|
||||
enum CardState {
|
||||
Running(Kind, Option<f32>),
|
||||
Failed(Kind, String, Option<Pending>),
|
||||
Installed,
|
||||
Removable(Vec<FaceInfo>),
|
||||
System,
|
||||
Offer { preview: bool },
|
||||
}
|
||||
|
||||
impl FontsPanel {
|
||||
pub(crate) fn top_bar(&mut self, ui: &mut Ui) {
|
||||
let s = self.skin.clone();
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 12.0;
|
||||
let mut tab = self.tab;
|
||||
let browse = self.catalogue.as_ref().map_or(0, |c| c.index.families().len());
|
||||
let options = [
|
||||
Cell { value: Tab::Catalogue, label: tl!("Browse"), count: Some(browse) },
|
||||
Cell { value: Tab::Installed, label: tl!("Installed"), count: Some(self.installed.len()) },
|
||||
];
|
||||
if widgets::cells_row(ui, &s, &mut tab, &options) {
|
||||
self.set_tab(tab);
|
||||
}
|
||||
let (text, hint) = match self.tab {
|
||||
Tab::Catalogue => (&mut self.query.text, tl!("Search fonts by name")),
|
||||
Tab::Installed => (&mut self.installed_text, tl!("Search installed fonts")),
|
||||
};
|
||||
let field = widgets::field(ui, &s, text, hint, tl!("Search"), ui.available_width(), true);
|
||||
if std::mem::take(&mut self.focus_search) {
|
||||
field.request_focus();
|
||||
}
|
||||
if field.changed() {
|
||||
self.focus_row = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar(&mut self, ui: &mut Ui) {
|
||||
let s = self.skin.clone();
|
||||
egui::ScrollArea::vertical().id_salt("craft-fonts.filters").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.with_layout(direction::column(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
match self.tab {
|
||||
Tab::Catalogue => self.catalogue_filters(ui, &s),
|
||||
Tab::Installed => self.installed_filters(ui, &s),
|
||||
}
|
||||
if self.downloads_switch {
|
||||
ui.add_space(14.0);
|
||||
ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), s.hairline());
|
||||
ui.add_space(14.0);
|
||||
widgets::label(ui, &s, tl!("Downloads"), s.p.faint);
|
||||
let now = ui.input(|i| i.time);
|
||||
let mut allowed = self.allowed(now);
|
||||
if ui.checkbox(&mut allowed, tl!("Allow downloads")).changed() {
|
||||
let _ = self.set_downloads_allowed(allowed);
|
||||
}
|
||||
let note = tl!("From Google Fonts' GitHub repository, only when you install or preview a font.");
|
||||
ui.add(egui::Label::new(egui::RichText::new(note).size(12.0).color(s.p.muted)).wrap());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn catalogue_filters(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let index = Arc::clone(&c.index);
|
||||
let width = ui.available_width();
|
||||
widgets::label(ui, s, tl!("Category"), s.p.faint);
|
||||
let mut options = vec![Cell { value: None, label: tl!("All categories"), count: Some(self.category_counts[0]) }];
|
||||
for (n, c) in Category::ALL.iter().enumerate() {
|
||||
options.push(Cell { value: Some(*c), label: crate::panel::app::category_name(*c), count: self.category_counts.get(n + 1).copied() });
|
||||
}
|
||||
if widgets::cells_column(ui, s, &mut self.query.category, &options) {
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Language"), s.p.faint);
|
||||
let language_label = |i: usize| {
|
||||
index.languages().get(i).map_or_else(String::new, |l| match l.autonym.as_deref() {
|
||||
Some(autonym) if autonym != l.name => format!("{} · {autonym}", l.name),
|
||||
_ => l.name.clone(),
|
||||
})
|
||||
};
|
||||
let selected = self.query.language.map_or_else(|| tl!("Any language").to_owned(), language_label);
|
||||
let before = self.query.language;
|
||||
let search = &c.search;
|
||||
let language_text = &mut self.language_text;
|
||||
let query = &mut self.query;
|
||||
widgets::select(ui, "craft-fonts.language", tl!("Language"), &selected, width, |ui| {
|
||||
let field =
|
||||
widgets::field(ui, s, language_text, tl!("Name in English or in the language"), tl!("Find a language"), ui.available_width(), true);
|
||||
if field.gained_focus() || ui.memory(|m| m.focused().is_none()) {
|
||||
field.request_focus();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let matches = search.languages(language_text);
|
||||
let row = ui.spacing().interact_size.y;
|
||||
egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show_rows(ui, row, matches.len() + 1, |ui, range| {
|
||||
for n in range {
|
||||
if n == 0 {
|
||||
ui.selectable_value(&mut query.language, None, tl!("Any language"));
|
||||
} else if let Some(&i) = matches.get(n - 1) {
|
||||
ui.selectable_value(&mut query.language, Some(i), language_label(i));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if self.query.language != before {
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Script"), s.p.faint);
|
||||
let any = tl!("Any script");
|
||||
let selected = self.query.script.as_deref().and_then(|id| script_name(&index, id)).unwrap_or(any).to_owned();
|
||||
let query = &mut self.query;
|
||||
widgets::select(ui, "craft-fonts.script", tl!("Script"), &selected, width, |ui| {
|
||||
ui.selectable_value(&mut query.script, None, any);
|
||||
for &i in c.search.scripts() {
|
||||
if let Some(script) = index.scripts().get(i) {
|
||||
ui.selectable_value(&mut query.script, Some(script.id.clone()), &script.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if index.sources().len() > 1 {
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Source"), s.p.faint);
|
||||
let all: usize = self.source_counts.iter().sum();
|
||||
let mut options = vec![Cell { value: None, label: tl!("All sources"), count: Some(all) }];
|
||||
for (n, source) in index.sources().iter().enumerate() {
|
||||
options.push(Cell { value: Some(source.id.clone()), label: &source.name, count: self.source_counts.get(n).copied() });
|
||||
}
|
||||
let mut chosen = self.source.clone();
|
||||
if widgets::cells_column(ui, s, &mut chosen, &options) {
|
||||
self.source = chosen;
|
||||
self.focus_row = None;
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Show"), s.p.faint);
|
||||
let options = [
|
||||
Cell { value: Show::All, label: tl!("All fonts"), count: Some(self.show_counts[0]) },
|
||||
Cell { value: Show::Installed, label: tl!("Installed"), count: Some(self.show_counts[1]) },
|
||||
Cell { value: Show::NotInstalled, label: tl!("Not installed"), count: Some(self.show_counts[2]) },
|
||||
];
|
||||
widgets::cells_column(ui, s, &mut self.show, &options);
|
||||
|
||||
let filtered = self.query.category.is_some()
|
||||
|| self.query.language.is_some()
|
||||
|| self.query.script.is_some()
|
||||
|| self.show != Show::All
|
||||
|| self.source.is_some();
|
||||
if filtered {
|
||||
ui.add_space(6.0);
|
||||
if widgets::link(ui, s, tl!("Clear filters")).clicked() {
|
||||
self.clear_filters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_filters(&mut self) {
|
||||
self.query.category = None;
|
||||
self.query.language = None;
|
||||
self.query.script = None;
|
||||
self.source = None;
|
||||
self.show = Show::All;
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
fn installed_filters(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
widgets::label(ui, s, tl!("Source"), s.p.faint);
|
||||
let store = self.installed.iter().filter(|f| f.source == Source::Store).count();
|
||||
let options = [
|
||||
Cell { value: Origin::All, label: tl!("All sources"), count: Some(self.installed.len()) },
|
||||
Cell { value: Origin::Store, label: tl!("Font store"), count: Some(store) },
|
||||
Cell { value: Origin::System, label: tl!("System"), count: Some(self.installed.len() - store) },
|
||||
];
|
||||
widgets::cells_column(ui, s, &mut self.origin, &options);
|
||||
ui.add_space(8.0);
|
||||
let note = tl!("Every Craft app uses the fonts in the font store. System fonts are added and removed with your system's own font tools.");
|
||||
ui.add(egui::Label::new(egui::RichText::new(note).size(12.0).color(s.p.muted)).wrap());
|
||||
}
|
||||
|
||||
pub(crate) fn main(&mut self, ui: &mut Ui) {
|
||||
let s = self.skin.clone();
|
||||
self.toolbar(ui, &s);
|
||||
ui.add_space(12.0);
|
||||
if let Some(notice) = self.notice.clone() {
|
||||
widgets::note(ui, &s, |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(notice).color(s.p.bad)).wrap());
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
match self.tab {
|
||||
Tab::Catalogue => {
|
||||
self.download_note(ui, &s);
|
||||
self.catalogue_list(ui, &s);
|
||||
}
|
||||
Tab::Installed => self.installed_list(ui, &s),
|
||||
}
|
||||
}
|
||||
|
||||
fn toolbar(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
let preset = self.settings.preset;
|
||||
let preset_name = |p: Preset| match p {
|
||||
Preset::Sentence => tl!("Sentence"),
|
||||
Preset::Alphabet => tl!("Alphabet"),
|
||||
Preset::Numerals => tl!("Numerals"),
|
||||
};
|
||||
let settings = &mut self.settings;
|
||||
widgets::select(ui, "craft-fonts.preset", tl!("Sample"), preset_name(preset), 128.0, |ui| {
|
||||
for p in [Preset::Sentence, Preset::Alphabet, Preset::Numerals] {
|
||||
if ui.selectable_label(preset == p && settings.sample.is_empty(), preset_name(p)).clicked() {
|
||||
settings.preset = p;
|
||||
settings.sample.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
// The size control at the reading end, the sample text filling the room between.
|
||||
let size_w = 176.0;
|
||||
let hint = self.sample_for(["Latn"]);
|
||||
let field_w = (ui.available_width() - size_w - 10.0).max(120.0);
|
||||
widgets::field(ui, s, &mut self.settings.sample, &hint, tl!("Sample text"), field_w, false);
|
||||
ui.allocate_ui_with_layout(vec2(size_w, s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
widgets::slider(ui, s, &mut self.settings.size, 12.0..=120.0, 112.0, tl!("Size"));
|
||||
let value = fmt("{size} px", &[("size", &i18n::number(self.settings.size.round() as u64))]);
|
||||
widgets::label(ui, s, &value, s.p.muted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn download_note(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
let now = ui.input(|i| i.time);
|
||||
if self.allowed(now) || self.settings.hide_download_note {
|
||||
return;
|
||||
}
|
||||
let mut allow = false;
|
||||
let mut hide = false;
|
||||
let text = tl!("Fonts that aren't installed show a sample once you preview them. Nothing is downloaded until you allow it.");
|
||||
widgets::note_with_actions(
|
||||
ui,
|
||||
s,
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Downloads are off")).color(s.p.ink_strong));
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).size(12.5).color(s.p.muted)).wrap());
|
||||
},
|
||||
&[tl!("Allow downloads")],
|
||||
|ui, n| match n {
|
||||
0 => allow = widgets::button(ui, s, tl!("Allow downloads"), widgets::Kind::Secondary, 28.0, 0.0).clicked(),
|
||||
_ => hide = widgets::glyph_button(ui, s, Glyph::Close, tl!("Hide this note"), 28.0).clicked(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if allow {
|
||||
let _ = self.set_downloads_allowed(true);
|
||||
}
|
||||
if hide {
|
||||
self.settings.hide_download_note = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn row_height(&self) -> f32 {
|
||||
CARD_TOP + self.skin.h() + CARD_GAP + self.settings.size * 1.32 + CARD_BOTTOM
|
||||
}
|
||||
|
||||
/// The scroll area for `count` cards; `card` draws row `n` into the rect it is given.
|
||||
fn cards(&mut self, ui: &mut Ui, count: usize, mut card: impl FnMut(&mut FontsPanel, &mut Ui, usize, Rect) -> egui::Response) {
|
||||
let row = self.row_height();
|
||||
let mut area = egui::ScrollArea::vertical().id_salt(("craft-fonts.cards", self.tab == Tab::Catalogue)).auto_shrink([false, false]);
|
||||
let viewport_h = ui.available_height();
|
||||
if std::mem::take(&mut self.scroll_to_focus)
|
||||
&& let Some(focus) = self.focus_row
|
||||
{
|
||||
let offset = ui.ctx().data(|d| d.get_temp::<f32>(Id::unique(("craft-fonts.offset", self.tab == Tab::Catalogue)))).unwrap_or(0.0);
|
||||
let top = focus as f32 * row;
|
||||
if top < offset {
|
||||
area = area.vertical_scroll_offset(top);
|
||||
} else if top + row > offset + viewport_h {
|
||||
area = area.vertical_scroll_offset(top + row - viewport_h);
|
||||
}
|
||||
}
|
||||
let output = area.show_rows(ui, row, count, |ui, range| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let width = ui.available_width();
|
||||
let mut stepped = false;
|
||||
for n in range {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, row), Sense::hover());
|
||||
// Neighbouring cards share their hairline.
|
||||
let rect = if n == 0 { rect } else { rect.with_min_y(rect.top() - 1.0) };
|
||||
let resp = card(self, ui, n, rect);
|
||||
if self.focus_row == Some(n) && self.move_focus {
|
||||
self.move_focus = false;
|
||||
resp.request_focus();
|
||||
}
|
||||
if resp.has_focus() && !stepped {
|
||||
// The arrow keys step through the cards here, not egui's spatial focus moves.
|
||||
let filter = egui::EventFilter { vertical_arrows: true, ..Default::default() };
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, filter));
|
||||
self.focus_row = Some(n);
|
||||
let step = ui.input(|i| {
|
||||
if i.key_pressed(egui::Key::ArrowDown) {
|
||||
1
|
||||
} else if i.key_pressed(egui::Key::ArrowUp) {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let next = n as i64 + step;
|
||||
if step != 0 && (0..count as i64).contains(&next) {
|
||||
self.focus_row = Some(next as usize);
|
||||
self.scroll_to_focus = true;
|
||||
self.move_focus = true;
|
||||
stepped = true;
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(Id::unique(("craft-fonts.offset", self.tab == Tab::Catalogue)), output.state.clamped_offset().y));
|
||||
}
|
||||
|
||||
fn empty(&mut self, ui: &mut Ui, s: &Skin, title: &str, body: &str, clear: bool) {
|
||||
ui.add_space(48.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 10.0;
|
||||
widgets::label(ui, s, title, s.p.muted);
|
||||
ui.add(egui::Label::new(egui::RichText::new(body).color(s.p.faint)).wrap());
|
||||
if clear {
|
||||
ui.add_space(4.0);
|
||||
if widgets::button(ui, s, tl!("Clear search and filters"), widgets::Kind::Secondary, s.h(), 0.0).clicked() {
|
||||
self.query.text.clear();
|
||||
self.clear_filters();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn catalogue_list(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
let index = match &self.catalogue {
|
||||
Ok(c) => Arc::clone(&c.index),
|
||||
Err(e) => {
|
||||
let e = e.clone();
|
||||
widgets::note(ui, s, |ui| {
|
||||
let text = fmt("The font indexes can't be read: {error}", &[("error", &e)]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).color(s.p.bad)).wrap());
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.source_problems(ui, s, &index);
|
||||
let count = tn(self.results.len() as u64, "{n} family", "{n} families");
|
||||
widgets::label(ui, s, &count, s.p.faint);
|
||||
ui.add_space(8.0);
|
||||
if self.results.is_empty() {
|
||||
self.empty(ui, s, tl!("No fonts match"), tl!("Try another name, or fewer filters."), true);
|
||||
return;
|
||||
}
|
||||
let results = self.results.clone();
|
||||
self.cards(ui, results.len(), |me, ui, n, rect| {
|
||||
let i = results.get(n).copied().unwrap_or_default();
|
||||
me.catalogue_card(ui, &index, i, rect)
|
||||
});
|
||||
}
|
||||
|
||||
/// Sources that couldn't be loaded, in a note above the list; the others show as usual.
|
||||
fn source_problems(&mut self, ui: &mut Ui, s: &Skin, index: &Index) {
|
||||
let problems = index.problems();
|
||||
if problems.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = ui.input(|i| i.time);
|
||||
let allowed = self.allowed(now);
|
||||
let fetching = self.fetching_sources();
|
||||
let buttons: &[&str] = if allowed && !fetching { &[tl!("Try again")] } else { &[] };
|
||||
let mut retry = false;
|
||||
widgets::note_with_actions(
|
||||
ui,
|
||||
s,
|
||||
|ui| {
|
||||
let title = tn(problems.len() as u64, "{n} font source couldn't be loaded", "{n} font sources couldn't be loaded");
|
||||
ui.label(egui::RichText::new(title).color(s.p.ink_strong));
|
||||
for p in problems.iter().take(4) {
|
||||
let line = fmt("{source}: {error}", &[("source", &p.name), ("error", &p.error)]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(line).size(12.5).color(s.p.muted)).wrap());
|
||||
}
|
||||
},
|
||||
buttons,
|
||||
|ui, _| retry = widgets::button(ui, s, tl!("Try again"), widgets::Kind::Secondary, 28.0, 0.0).clicked(),
|
||||
false,
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if retry {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.fetch_sources(&ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_list(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
if !self.catalog.is_ready() {
|
||||
direction::horizontal(ui, |ui| {
|
||||
ui.spinner();
|
||||
ui.label(egui::RichText::new(tl!("Looking for installed fonts…")).color(s.p.muted));
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
return;
|
||||
}
|
||||
let shown = self.installed_shown();
|
||||
let count = tn(shown.len() as u64, "{n} family", "{n} families");
|
||||
widgets::label(ui, s, &count, s.p.faint);
|
||||
ui.add_space(8.0);
|
||||
if shown.is_empty() {
|
||||
let body = if self.origin == Origin::Store && self.installed_text.trim().is_empty() {
|
||||
tl!("Fonts you install from Browse go here, for every Craft app.")
|
||||
} else {
|
||||
tl!("Try another name.")
|
||||
};
|
||||
self.empty(ui, s, tl!("No installed fonts match"), body, false);
|
||||
return;
|
||||
}
|
||||
self.cards(ui, shown.len(), |me, ui, n, rect| {
|
||||
let i = shown.get(n).copied().unwrap_or_default();
|
||||
me.installed_card(ui, i, rect)
|
||||
});
|
||||
}
|
||||
|
||||
fn catalogue_state(&self, source: &str, family: &str, faces_installed: bool) -> CardState {
|
||||
let key = family_key(source, family);
|
||||
if let Some(job) = self.jobs.of(source, family) {
|
||||
return CardState::Running(job.kind, job.fraction());
|
||||
}
|
||||
if let Some(f) = self.failed.get(&key) {
|
||||
return CardState::Failed(f.kind, f.error.clone(), f.retry.clone());
|
||||
}
|
||||
if faces_installed {
|
||||
return CardState::Installed;
|
||||
}
|
||||
CardState::Offer { preview: !self.previewed.contains_key(&key) }
|
||||
}
|
||||
|
||||
fn catalogue_card(&mut self, ui: &mut Ui, index: &Index, i: usize, rect: Rect) -> egui::Response {
|
||||
let s = self.skin.clone();
|
||||
let Some(family) = index.families().get(i) else { return ui.interact(rect, Id::unique(("craft-fonts.card", i)), Sense::hover()) };
|
||||
let key = family_key(&family.source, &family.family);
|
||||
let installed = self.installed_by_name.get(&family.family.to_lowercase()).and_then(|f| regular(f)).cloned();
|
||||
let previewed = self.previewed.get(&key).and_then(|f| regular(f)).cloned();
|
||||
let state = self.catalogue_state(&family.source, &family.family, installed.is_some());
|
||||
let (source, curated) = source_label(index, family);
|
||||
let meta = [tn(style_count(family) as u64, "{n} style", "{n} styles"), category_label(family), source.to_owned()].join(" · ");
|
||||
let sample = self.sample_for(family.scripts.iter().map(String::as_str));
|
||||
let face = match (installed, previewed) {
|
||||
(Some(face), _) => Some((Owner::Installed, face)),
|
||||
(None, Some(face)) => Some((Owner::Preview, face)),
|
||||
(None, None) => None,
|
||||
};
|
||||
let card = Card { key: &key, name: &family.family, meta: &meta, sample: &sample, curated, index_position: Some(i) };
|
||||
let resp = self.card(ui, &s, rect, card, face, state);
|
||||
if resp.clicked() {
|
||||
self.detail = Some(Detail::Family(i));
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
fn installed_card(&mut self, ui: &mut Ui, i: usize, rect: Rect) -> egui::Response {
|
||||
let s = self.skin.clone();
|
||||
let Some(family) = self.installed.get(i).cloned() else { return ui.interact(rect, Id::unique(("craft-fonts.installed", i)), Sense::hover()) };
|
||||
let meta = [tn(family.faces.len() as u64, "{n} style", "{n} styles"), source_name(family.source).to_owned()].join(" · ");
|
||||
let state = match self.jobs.of("", &family.name) {
|
||||
Some(job) => CardState::Running(job.kind, job.fraction()),
|
||||
None => match self.failed.get(&family_key("", &family.name)) {
|
||||
Some(f) => CardState::Failed(f.kind, f.error.clone(), None),
|
||||
None if family.removable() => CardState::Removable(family.faces.clone()),
|
||||
None => CardState::System,
|
||||
},
|
||||
};
|
||||
let face = regular(&family.faces).cloned().map(|f| (Owner::Installed, f));
|
||||
let scripts = self.index().and_then(|index| index.family(&family.name).map(|f| f.scripts.clone())).unwrap_or_default();
|
||||
let sample = self.sample_for(scripts.iter().map(String::as_str));
|
||||
let key = format!("{:?}\n{}", family.source, family.name);
|
||||
let card = Card { key: &key, name: &family.name, meta: &meta, sample: &sample, curated: true, index_position: None };
|
||||
let resp = self.card(ui, &s, rect, card, face, state);
|
||||
if resp.clicked() {
|
||||
let in_index = self.index().and_then(|index| index.families().iter().position(|f| f.family.eq_ignore_ascii_case(&family.name)));
|
||||
self.detail = Some(match in_index {
|
||||
Some(i) if family.source == Source::Store => Detail::Family(i),
|
||||
_ => Detail::Installed(family.name.clone(), family.source),
|
||||
});
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// One family card: its name, styles and source on one line with its state at the end, and
|
||||
/// a sample in the face below.
|
||||
fn card(&mut self, ui: &mut Ui, s: &Skin, rect: Rect, card: Card<'_>, face: Option<(Owner, FaceInfo)>, state: CardState) -> egui::Response {
|
||||
let Card { key, name, meta, sample, curated, index_position } = card;
|
||||
let p = s.p;
|
||||
let rtl = direction::is_rtl(ui);
|
||||
let id = Id::unique(("craft-fonts.card", key));
|
||||
// The whole card opens the details; added first, so the buttons on it keep their clicks.
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, name));
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
let lift = ui.ctx().animate_bool_with_time(id.with("hover"), hovered || resp.has_focus(), ui.style().animation_time * 2.0);
|
||||
let painter = ui.painter_at(rect.expand(1.0));
|
||||
painter.rect(rect, CornerRadius::ZERO, p.bg.lerp_to_gamma(p.sunken, 0.55 * lift), s.hairline(), StrokeKind::Inside);
|
||||
if lift > 0.0 {
|
||||
let inside = rect.shrink(1.0);
|
||||
let w = inside.width() * lift;
|
||||
let x = if rtl { inside.right() - w } else { inside.left() };
|
||||
painter.rect_filled(Rect::from_min_size(pos2(x, inside.top()), vec2(w, 2.0)), CornerRadius::ZERO, p.accent);
|
||||
}
|
||||
widgets::focus_ring(ui, s, rect.shrink(1.0), &resp);
|
||||
|
||||
let inner = rect.shrink2(vec2(CARD_PAD, 0.0));
|
||||
let header = Rect::from_min_size(pos2(inner.left(), rect.top() + CARD_TOP), vec2(inner.width(), s.h()));
|
||||
let mut end = ui.new_child(UiBuilder::new().max_rect(header).layout(direction::trailing(ui)));
|
||||
end.spacing_mut().item_spacing.x = 10.0;
|
||||
self.card_state(&mut end, s, name, index_position, state);
|
||||
if !curated {
|
||||
widgets::badge(&mut end, s, tl!("Not curated"), s.p.warn, false)
|
||||
.on_hover_text(tl!("This family comes from a source you added. Craft doesn't review it."));
|
||||
}
|
||||
let end_w = end.min_rect().width();
|
||||
|
||||
let name_galley = ui.painter().layout_no_wrap(name.to_owned(), FontId::new(16.0, s.look.strong.clone()), p.ink_strong);
|
||||
let name_w = name_galley.size().x.min((header.width() - end_w - 24.0).max(0.0));
|
||||
let start = direction::start_x(ui, header);
|
||||
let f = direction::forward(ui);
|
||||
let name_x = if rtl { start - name_w } else { start };
|
||||
ui.painter().with_clip_rect(header).galley(pos2(name_x, header.center().y - name_galley.size().y / 2.0), name_galley, p.ink_strong);
|
||||
let room = header.width() - name_w - end_w - 40.0;
|
||||
if room > 24.0 {
|
||||
let meta_galley = s.caps_galley(ui, meta, if s.look.caps { 10.5 } else { 12.0 }, s.look.label.clone(), p.faint, Some(room));
|
||||
let x = start + f * (name_w + 14.0);
|
||||
let x = if rtl { x - meta_galley.size().x } else { x };
|
||||
ui.painter().galley(pos2(x, header.center().y - meta_galley.size().y / 2.0 + 1.0), meta_galley, p.faint);
|
||||
}
|
||||
|
||||
let sample_rect = Rect::from_min_max(pos2(inner.left(), header.bottom() + CARD_GAP), pos2(inner.right(), rect.bottom() - CARD_BOTTOM + 4.0));
|
||||
self.paint_sample(ui, s, sample_rect, face.as_ref(), sample);
|
||||
resp
|
||||
}
|
||||
|
||||
fn card_state(&mut self, ui: &mut Ui, s: &Skin, name: &str, index_position: Option<usize>, state: CardState) {
|
||||
let p = s.p;
|
||||
let h = s.h() - 4.0;
|
||||
let ctx = ui.ctx().clone();
|
||||
match state {
|
||||
CardState::Running(kind, fraction) => {
|
||||
let what = match kind {
|
||||
Kind::Install => tl!("Installing"),
|
||||
Kind::Remove => tl!("Removing"),
|
||||
Kind::Preview => tl!("Downloading"),
|
||||
};
|
||||
let text = match fraction {
|
||||
Some(f) => format!("{what} {}", fmt("{percent} %", &[("percent", &i18n::number((f * 100.0).round() as u64))])),
|
||||
None => format!("{what}…"),
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, h), Sense::hover());
|
||||
let galley = s.label_galley(ui, &text, p.muted);
|
||||
widgets::paint_end(ui, rect.with_max_y(rect.bottom() - 8.0), galley, p.muted);
|
||||
let bar = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 3.0), rect.max);
|
||||
widgets::progress_bar(ui, s, bar, fraction);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::ProgressIndicator, true, &text));
|
||||
}
|
||||
CardState::Failed(kind, error, retry) => {
|
||||
if let Some(retry) = retry
|
||||
&& widgets::button(ui, s, tl!("Retry"), widgets::Kind::Secondary, h, 0.0).clicked()
|
||||
{
|
||||
self.ask(&ctx, retry);
|
||||
}
|
||||
let what = match kind {
|
||||
Kind::Install => tl!("Couldn't install"),
|
||||
Kind::Remove => tl!("Couldn't remove"),
|
||||
Kind::Preview => tl!("Couldn't download"),
|
||||
};
|
||||
widgets::label(ui, s, what, p.bad).on_hover_text(error);
|
||||
}
|
||||
CardState::Installed => {
|
||||
widgets::badge(ui, s, tl!("Installed"), p.good, true);
|
||||
}
|
||||
CardState::System => {}
|
||||
CardState::Removable(faces) => {
|
||||
let remove = widgets::button(ui, s, tl!("Remove"), widgets::Kind::Danger, h, 0.0);
|
||||
widgets::name(ui, &remove, &fmt("Remove {family}", &[("family", name)]));
|
||||
if remove.clicked() {
|
||||
self.remove(&ctx, name, &faces, true);
|
||||
}
|
||||
}
|
||||
CardState::Offer { preview } => {
|
||||
let Some(i) = index_position else { return };
|
||||
let install = widgets::button(ui, s, tl!("Install"), widgets::Kind::Secondary, h, 0.0);
|
||||
widgets::name(ui, &install, &fmt("Install {family}", &[("family", name)]));
|
||||
if install.clicked() {
|
||||
self.ask(&ctx, Pending::Install(i));
|
||||
}
|
||||
if preview {
|
||||
let button = widgets::link(ui, s, tl!("Preview"));
|
||||
widgets::name(ui, &button, &fmt("Preview {family}", &[("family", name)]));
|
||||
if button.clicked() {
|
||||
self.ask(&ctx, Pending::Preview(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in the face, on one line; the interface font in a faint ink where the face isn't
|
||||
/// here (not installed and not previewed).
|
||||
pub(crate) fn paint_sample(&mut self, ui: &mut Ui, s: &Skin, rect: Rect, face: Option<&(Owner, FaceInfo)>, text: &str) {
|
||||
let size = self.settings.size;
|
||||
let (family, color) = match face {
|
||||
Some((owner, face)) => {
|
||||
let catalog = match owner {
|
||||
Owner::Installed => Arc::clone(&self.catalog),
|
||||
Owner::Preview => Arc::clone(&self.previews),
|
||||
};
|
||||
match self.faces.family(ui.ctx(), &catalog, *owner, face) {
|
||||
Shown::Ready(family) => (family, s.p.ink),
|
||||
Shown::Loading => return,
|
||||
Shown::Unavailable => (FontFamily::Proportional, s.p.faint),
|
||||
}
|
||||
}
|
||||
None => (FontFamily::Proportional, s.p.faint.gamma_multiply(0.8)),
|
||||
};
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), FontId::new(size, family), color);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(rect.width());
|
||||
let galley = ui.painter().layout_job(job);
|
||||
// Sample text keeps its own direction whatever the interface's.
|
||||
let x = if craft_i18n::base_direction(text) == Some(craft_i18n::Direction::Rtl) { rect.right() - galley.size().x } else { rect.left() };
|
||||
let y = rect.center().y - galley.size().y / 2.0;
|
||||
ui.painter().with_clip_rect(rect.expand2(vec2(0.0, 6.0))).galley(pos2(x, y), galley, color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# Arabic (ar) catalog for Craft Font Manager's panel.
|
||||
# Format: context<TAB>source<TAB>translation; @plural rows list the locale's plural forms.
|
||||
# Style and weight names (Regular, Bold, Italic), licence names and product names stay in English.
|
||||
All categories كل الفئات
|
||||
All fonts كل الخطوط
|
||||
All sources كل المصادر
|
||||
Allow downloads السماح بالتنزيل
|
||||
Alphabet الأبجدية
|
||||
Any language أي لغة
|
||||
Any script أي نظام كتابة
|
||||
Browse تصفّح
|
||||
Cancel إلغاء
|
||||
Category الفئة
|
||||
Clear filters مسح عوامل التصفية
|
||||
Clear search and filters مسح البحث وعوامل التصفية
|
||||
Close إغلاق
|
||||
Couldn't download تعذّر التنزيل
|
||||
Couldn't install تعذّر التثبيت
|
||||
Couldn't remove تعذّرت الإزالة
|
||||
Designer المصمم
|
||||
Display عرض
|
||||
Download from Google Fonts? هل تريد التنزيل من Google Fonts؟
|
||||
Download size حجم التنزيل
|
||||
Download تنزيل
|
||||
Downloading جارٍ التنزيل
|
||||
Downloads are off التنزيل متوقف
|
||||
Downloads stay allowed until you turn them off. يبقى التنزيل مسموحًا حتى توقفه.
|
||||
Downloads التنزيلات
|
||||
Every Craft app uses the fonts in the font store. System fonts are added and removed with your system's own font tools. تستخدم كل تطبيقات Craft الخطوط الموجودة في متجر الخطوط. تُضاف خطوط النظام وتُزال بأدوات الخطوط في نظامك.
|
||||
Files الملفات
|
||||
Find a language ابحث عن لغة
|
||||
Font store متجر الخطوط
|
||||
Fonts that aren't installed show a sample once you preview them. Nothing is downloaded until you allow it. تظهر عينة الخط غير المثبّت بعد معاينته. لا يُنزَّل شيء حتى تسمح بذلك.
|
||||
Fonts you install from Browse go here, for every Craft app. تظهر هنا الخطوط التي تثبّتها من «تصفّح»، لكل تطبيقات Craft.
|
||||
From Google Fonts' GitHub repository, only when you install or preview a font. من مستودع Google Fonts على GitHub، فقط عند تثبيت خط أو معاينته.
|
||||
Handwriting خط اليد
|
||||
Hide this note إخفاء هذه الملاحظة
|
||||
Install all تثبيت الكل
|
||||
Install تثبيت
|
||||
Installed on this system مثبّت على هذا النظام
|
||||
Installed مثبّت
|
||||
Installing جارٍ التثبيت
|
||||
Language اللغة
|
||||
Looking for installed fonts… جارٍ البحث عن الخطوط المثبّتة…
|
||||
Monospace أحادي المسافة
|
||||
Name in English or in the language الاسم بالإنجليزية أو باللغة نفسها
|
||||
No fonts match لا توجد خطوط مطابقة
|
||||
No installed fonts match لا توجد خطوط مثبّتة مطابقة
|
||||
Not installed غير مثبّت
|
||||
Numerals الأرقام والرموز
|
||||
Preview معاينة
|
||||
Remove all إزالة الكل
|
||||
Remove إزالة
|
||||
Removed with your system's font tools يُزال بأدوات الخطوط في نظامك
|
||||
Removing جارٍ الإزالة
|
||||
Retry إعادة المحاولة
|
||||
Sample text نص العينة
|
||||
Sample العينة
|
||||
Sans serif بلا زوائد
|
||||
Script نظام الكتابة
|
||||
Scripts أنظمة الكتابة
|
||||
Search fonts by name ابحث في الخطوط بالاسم
|
||||
Search installed fonts ابحث في الخطوط المثبّتة
|
||||
Search بحث
|
||||
Sentence جملة
|
||||
Serif بزوائد
|
||||
Show إظهار
|
||||
Size الحجم
|
||||
Source المصدر
|
||||
Styles الأنماط
|
||||
System fonts are removed with your system's own font tools. تُزال خطوط النظام بأدوات الخطوط في نظامك.
|
||||
System النظام
|
||||
Try another name, or fewer filters. جرّب اسمًا آخر أو عوامل تصفية أقل.
|
||||
Try another name. جرّب اسمًا آخر.
|
||||
Designed by {designer} تصميم {designer}
|
||||
Install {family} تثبيت {family}
|
||||
Install {style} تثبيت {style}
|
||||
Languages · {n} اللغات · {n}
|
||||
Preview downloads this font ({size}) for this session without installing it. تنزّل المعاينة هذا الخط ({size}) لهذه الجلسة فقط دون تثبيته.
|
||||
Preview {family} معاينة {family}
|
||||
Remove {family} إزالة {family}
|
||||
Remove {style} إزالة {style}
|
||||
The Google Fonts index can't be read: {error} تتعذّر قراءة فهرس Google Fonts: {error}
|
||||
The download setting couldn't be saved: {error} تعذّر حفظ إعداد التنزيل: {error}
|
||||
{list} and {n} more {list} و{n} غيرها
|
||||
{percent} % {percent}٪
|
||||
{size} download تنزيل {size}
|
||||
{size} px {size} بكسل
|
||||
Craft Font Manager downloads {family} ({size}) from Google Fonts' GitHub repository through jsDelivr, and checks every file before using it. ينزّل Craft Font Manager الخط {family} ({size}) من مستودع Google Fonts على GitHub عبر jsDelivr، ويتحقق من كل ملف قبل استخدامه.
|
||||
@plural {n} style|{n} styles {n} نمط|{n} نمط|{n} نمط|{n} أنماط|{n} نمطًا|{n} نمط
|
||||
@plural {n} style installed|{n} styles installed {n} نمط مثبّت|{n} نمط مثبّت|{n} نمط مثبّت|{n} أنماط مثبّتة|{n} نمطًا مثبّتًا|{n} نمط مثبّت
|
||||
@plural {n} family|{n} families {n} عائلة|{n} عائلة|{n} عائلة|{n} عائلات|{n} عائلة|{n} عائلة
|
||||
Licence الترخيص
|
||||
Try again إعادة المحاولة
|
||||
Not curated غير مراجَع
|
||||
This family comes from a source you added. Craft doesn't review it. تأتي هذه العائلة من مصدر أضفته. لا يراجعها Craft.
|
||||
The font indexes can't be read: {error} تتعذّر قراءة فهارس الخطوط: {error}
|
||||
{source}: {error} {source}: {error}
|
||||
This family comes from a source you added ({url}). Craft doesn't review its fonts or their licences. تأتي هذه العائلة من مصدر أضفته ({url}). لا يراجع Craft خطوطه ولا تراخيصها.
|
||||
Changed since added تغيّر منذ إضافته
|
||||
The index at this address isn't the one first downloaded from it. Check the source before installing from it. الفهرس على هذا العنوان ليس الذي نُزِّل منه أول مرة. تحقّق من المصدر قبل التثبيت منه.
|
||||
Add a source إضافة مصدر
|
||||
Add إضافة
|
||||
Index address عنوان الفهرس
|
||||
A source you add isn't reviewed: add only indexes you trust. Every font is still checked against its index before it is used. المصدر الذي تضيفه لا يُراجَع: أضف فقط الفهارس التي تثق بها. يُتحقَّق من كل خط مقابل فهرسه قبل استخدامه.
|
||||
Downloading the sources… جارٍ تنزيل المصادر…
|
||||
Update sources تحديث المصادر
|
||||
Allow downloads to update اسمح بالتنزيل للتحديث
|
||||
Added. It downloads once downloads are allowed. أُضيف. يُنزَّل عند السماح بالتنزيل.
|
||||
Remove {source} إزالة {source}
|
||||
The source couldn't be added: {error} تعذّرت إضافة المصدر: {error}
|
||||
The source couldn't be removed: {error} تعذّرت إزالة المصدر: {error}
|
||||
@plural {n} font source couldn't be loaded|{n} font sources couldn't be loaded تعذّر تحميل {n} مصدر خطوط|تعذّر تحميل {n} مصدر خطوط|تعذّر تحميل {n} مصدر خطوط|تعذّر تحميل {n} مصادر خطوط|تعذّر تحميل {n} مصدرًا للخطوط|تعذّر تحميل {n} مصدر خطوط
|
||||
|
@@ -0,0 +1,113 @@
|
||||
# Japanese (ja) catalog for Craft Font Manager's panel.
|
||||
# Format: context<TAB>source<TAB>translation; @plural rows list the locale's plural forms.
|
||||
# Style and weight names (Regular, Bold, Italic), licence names and product names stay in English.
|
||||
All categories すべてのカテゴリ
|
||||
All fonts すべてのフォント
|
||||
All sources すべての入手元
|
||||
Allow downloads ダウンロードを許可
|
||||
Alphabet アルファベット
|
||||
Any language すべての言語
|
||||
Any script すべての文字体系
|
||||
Browse 探す
|
||||
Cancel キャンセル
|
||||
Category カテゴリ
|
||||
Clear filters 絞り込みを解除
|
||||
Clear search and filters 検索と絞り込みを解除
|
||||
Close 閉じる
|
||||
Couldn't download ダウンロードできませんでした
|
||||
Couldn't install インストールできませんでした
|
||||
Couldn't remove 削除できませんでした
|
||||
Designer デザイナー
|
||||
Display ディスプレイ
|
||||
Download from Google Fonts? Google Fonts からダウンロードしますか?
|
||||
Download size ダウンロードサイズ
|
||||
Download ダウンロード
|
||||
Downloading ダウンロード中
|
||||
Downloads are off ダウンロードはオフです
|
||||
Downloads stay allowed until you turn them off. ダウンロードは、オフにするまで許可されたままです。
|
||||
Downloads ダウンロード
|
||||
Every Craft app uses the fonts in the font store. System fonts are added and removed with your system's own font tools. フォントストアのフォントはすべての Craft アプリで使えます。システムフォントの追加と削除は、システムのフォント管理ツールで行います。
|
||||
Files ファイル
|
||||
Find a language 言語を検索
|
||||
Font store フォントストア
|
||||
Fonts that aren't installed show a sample once you preview them. Nothing is downloaded until you allow it. インストールしていないフォントは、プレビューするとサンプルが表示されます。許可するまで何もダウンロードされません。
|
||||
Fonts you install from Browse go here, for every Craft app. 「探す」からインストールしたフォントがここに表示され、すべての Craft アプリで使えます。
|
||||
From Google Fonts' GitHub repository, only when you install or preview a font. フォントをインストールまたはプレビューするときにだけ、Google Fonts の GitHub リポジトリからダウンロードします。
|
||||
Handwriting 手書き
|
||||
Hide this note このお知らせを隠す
|
||||
Install all すべてインストール
|
||||
Install インストール
|
||||
Installed on this system このシステムにインストール済み
|
||||
Installed インストール済み
|
||||
Installing インストール中
|
||||
Language 言語
|
||||
Looking for installed fonts… インストール済みのフォントを探しています…
|
||||
Monospace 等幅
|
||||
Name in English or in the language 英語名またはその言語での名前
|
||||
No fonts match 一致するフォントはありません
|
||||
No installed fonts match 一致するインストール済みフォントはありません
|
||||
Not installed 未インストール
|
||||
Numerals 数字と記号
|
||||
Preview プレビュー
|
||||
Remove all すべて削除
|
||||
Remove 削除
|
||||
Removed with your system's font tools システムのフォントツールで削除します
|
||||
Removing 削除中
|
||||
Retry 再試行
|
||||
Sample text サンプルテキスト
|
||||
Sample サンプル
|
||||
Sans serif サンセリフ
|
||||
Script 文字体系
|
||||
Scripts 文字体系
|
||||
Search fonts by name フォントを名前で検索
|
||||
Search installed fonts インストール済みのフォントを検索
|
||||
Search 検索
|
||||
Sentence 文
|
||||
Serif セリフ
|
||||
Show 表示
|
||||
Size サイズ
|
||||
Source 入手元
|
||||
Styles スタイル
|
||||
System fonts are removed with your system's own font tools. システムフォントは、システムのフォント管理ツールで削除します。
|
||||
System システム
|
||||
Try another name, or fewer filters. 別の名前を試すか、絞り込みを減らしてください。
|
||||
Try another name. 別の名前を試してください。
|
||||
Designed by {designer} デザイン: {designer}
|
||||
Install {family} {family} をインストール
|
||||
Install {style} {style} をインストール
|
||||
Languages · {n} 言語 · {n}
|
||||
Preview downloads this font ({size}) for this session without installing it. プレビューでは、このフォント ({size}) をこのセッションの間だけダウンロードし、インストールはしません。
|
||||
Preview {family} {family} をプレビュー
|
||||
Remove {family} {family} を削除
|
||||
Remove {style} {style} を削除
|
||||
The Google Fonts index can't be read: {error} Google Fonts の一覧を読み込めません: {error}
|
||||
The download setting couldn't be saved: {error} ダウンロードの設定を保存できませんでした: {error}
|
||||
{list} and {n} more {list} ほか {n} 言語
|
||||
{percent} % {percent}%
|
||||
{size} download ダウンロード {size}
|
||||
{size} px {size}px
|
||||
Craft Font Manager downloads {family} ({size}) from Google Fonts' GitHub repository through jsDelivr, and checks every file before using it. Craft Font Manager は {family} ({size}) を Google Fonts の GitHub リポジトリから jsDelivr 経由でダウンロードし、使う前にすべてのファイルを確認します。
|
||||
@plural {n} style|{n} styles {n} スタイル
|
||||
@plural {n} style installed|{n} styles installed インストール済み {n} スタイル
|
||||
@plural {n} family|{n} families {n} ファミリー
|
||||
Licence ライセンス
|
||||
Try again もう一度試す
|
||||
Not curated 未審査
|
||||
This family comes from a source you added. Craft doesn't review it. このファミリーは追加したソースのものです。Craft は審査していません。
|
||||
The font indexes can't be read: {error} フォントの一覧を読み込めません: {error}
|
||||
{source}: {error} {source}: {error}
|
||||
This family comes from a source you added ({url}). Craft doesn't review its fonts or their licences. このファミリーは追加したソース ({url}) のものです。Craft はフォントもライセンスも審査していません。
|
||||
Changed since added 追加後に変更あり
|
||||
The index at this address isn't the one first downloaded from it. Check the source before installing from it. このアドレスの一覧は、最初にダウンロードしたものと異なります。インストールする前にソースを確認してください。
|
||||
Add a source ソースを追加
|
||||
Add 追加
|
||||
Index address 一覧のアドレス
|
||||
A source you add isn't reviewed: add only indexes you trust. Every font is still checked against its index before it is used. 追加したソースは審査されません。信頼できる一覧だけを追加してください。どのフォントも、使う前に一覧と照合されます。
|
||||
Downloading the sources… ソースをダウンロードしています…
|
||||
Update sources ソースを更新
|
||||
Allow downloads to update 更新するにはダウンロードを許可してください
|
||||
Added. It downloads once downloads are allowed. 追加しました。ダウンロードを許可するとダウンロードされます。
|
||||
Remove {source} {source} を削除
|
||||
The source couldn't be added: {error} ソースを追加できませんでした: {error}
|
||||
The source couldn't be removed: {error} ソースを削除できませんでした: {error}
|
||||
@plural {n} font source couldn't be loaded|{n} font sources couldn't be loaded {n} 件のフォントソースを読み込めませんでした
|
||||
|
@@ -0,0 +1,532 @@
|
||||
//! The details sheet: a family's every style in its own face, its facts and languages, and
|
||||
//! installing or removing it whole or a style at a time.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ui::direction;
|
||||
use egui::{
|
||||
Align, Color32, Context, CornerRadius, FontFamily, FontId, Id, Layout, Margin, Order, Rect, RichText, Sense, Stroke, StrokeKind, Ui, UiBuilder,
|
||||
vec2,
|
||||
};
|
||||
|
||||
use crate::index::{Family, FontFile, Index, script_name, source_label};
|
||||
use crate::panel::app::{Detail, FontsPanel, Pending, category_label, family_key, regular, source_name, style_count, style_name};
|
||||
use crate::panel::faces::Owner;
|
||||
use crate::panel::i18n::{self, fmt, tl, tn};
|
||||
use crate::panel::jobs::Kind;
|
||||
use crate::panel::widgets::{self, Glyph, Skin};
|
||||
use crate::{FaceInfo, Source};
|
||||
|
||||
/// From this width the sheet reads in two columns.
|
||||
const COLUMNS_FROM: f32 = 760.0;
|
||||
|
||||
/// What a style row offers at its end.
|
||||
enum RowAction {
|
||||
None,
|
||||
Install(String),
|
||||
Remove(Box<FaceInfo>),
|
||||
Running(Option<f32>),
|
||||
}
|
||||
|
||||
struct StyleRow {
|
||||
label: String,
|
||||
face: Option<(Owner, FaceInfo)>,
|
||||
action: RowAction,
|
||||
}
|
||||
|
||||
fn weight_name(weight: u16) -> &'static str {
|
||||
match weight {
|
||||
0..=149 => tl!("Thin"),
|
||||
150..=249 => tl!("ExtraLight"),
|
||||
250..=349 => tl!("Light"),
|
||||
350..=449 => tl!("Regular"),
|
||||
450..=549 => tl!("Medium"),
|
||||
550..=649 => tl!("SemiBold"),
|
||||
650..=749 => tl!("Bold"),
|
||||
750..=849 => tl!("ExtraBold"),
|
||||
_ => tl!("Black"),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Bold Italic", or "Variable 100–900" for a variable file.
|
||||
fn file_label(file: &FontFile) -> String {
|
||||
let base = match file.axes.iter().find(|a| a.tag == "wght") {
|
||||
Some(a) => fmt("Variable {from}–{to}", &[("from", &(a.min.round() as u32).to_string()), ("to", &(a.max.round() as u32).to_string())]),
|
||||
None => weight_name(file.weight).to_owned(),
|
||||
};
|
||||
match (file.is_italic(), file.weight) {
|
||||
(true, 350..=449) if file.axes.is_empty() => tl!("Italic").to_owned(),
|
||||
(true, _) => format!("{base} {}", tl!("Italic")),
|
||||
(false, _) => base,
|
||||
}
|
||||
}
|
||||
|
||||
fn licence_name(id: &str) -> &str {
|
||||
match id {
|
||||
"OFL" => tl!("SIL Open Font License"),
|
||||
"APACHE2" => tl!("Apache License 2.0"),
|
||||
"UFL" => tl!("Ubuntu Font Licence"),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(face: &FaceInfo) -> Option<String> {
|
||||
face.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The index file a face comes from: by PostScript name, else by weight and slant.
|
||||
fn file_of<'a>(family: &'a Family, face: &FaceInfo) -> Option<&'a FontFile> {
|
||||
let by_name = file_name(face).and_then(|n| family.files.iter().find(|f| f.file_name() == n));
|
||||
let by_postscript = || face.postscript_name.as_ref().and_then(|ps| family.files.iter().find(|f| f.postscript.contains(ps)));
|
||||
by_name.or_else(by_postscript).or_else(|| family.files.iter().find(|f| f.has_weight(face.weight.round() as u16, face.italic)))
|
||||
}
|
||||
|
||||
impl FontsPanel {
|
||||
pub(crate) fn details(&mut self, ctx: &Context, area: Rect) {
|
||||
let open = self.detail.is_some();
|
||||
let k = ctx.animate_bool_with_time(Id::unique("craft-fonts.sheet"), open, ctx.global_style().animation_time * 1.5);
|
||||
let Some(detail) = self.detail.clone() else { return };
|
||||
let s = self.skin.clone();
|
||||
let dark = ctx.theme() == egui::Theme::Dark;
|
||||
let width = (area.width() - 48.0).clamp(300.0, 1040.0).min(area.width());
|
||||
let height = (area.height() - 32.0).max(200.0).min(area.height());
|
||||
let sheet = Rect::from_center_size(area.center() + vec2(0.0, (1.0 - k) * 16.0), vec2(width, height));
|
||||
let mut close = false;
|
||||
egui::Area::new(Id::unique("craft-fonts.details")).order(Order::Foreground).fixed_pos(area.min).constrain(false).show(ctx, |ui| {
|
||||
let (rect, scrim) = ui.allocate_exact_size(area.size(), Sense::click());
|
||||
let alpha = if dark { 165.0 } else { 100.0 } * k;
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, Color32::from_black_alpha(alpha.round() as u8));
|
||||
close |= scrim.clicked();
|
||||
// Clicks on the sheet's own empty space stay on the sheet.
|
||||
let _ = ui.interact(sheet, Id::unique("craft-fonts.sheet-bg"), Sense::click());
|
||||
let painter = ui.painter();
|
||||
painter.add(
|
||||
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if dark { 140 } else { 36 }) }
|
||||
.as_shape(sheet, s.look.corner),
|
||||
);
|
||||
painter.rect(sheet, s.look.corner, s.p.bg, Stroke::new(1.0, s.p.line_strong), StrokeKind::Inside);
|
||||
let mut child = ui.new_child(UiBuilder::new().max_rect(sheet).layout(direction::column(ui)));
|
||||
child.set_opacity(k.clamp(0.0, 1.0));
|
||||
child.set_clip_rect(sheet);
|
||||
egui::ScrollArea::vertical().id_salt(("craft-fonts.sheet-scroll", format!("{detail:?}"))).auto_shrink([false, false]).show(
|
||||
&mut child,
|
||||
|ui| {
|
||||
egui::Frame::new().inner_margin(Margin::same(24)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.with_layout(direction::column(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
close |= match &detail {
|
||||
Detail::Family(i) => self.family_sheet(ui, &s, *i),
|
||||
Detail::Installed(name, source) => self.installed_sheet(ui, &s, name, *source),
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
if close {
|
||||
self.detail = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The eyebrow, the close button and the title; returns whether Close was clicked.
|
||||
fn sheet_header(&mut self, ui: &mut Ui, s: &Skin, eyebrow: &str, title: &str, byline: Option<String>, face: Option<(Owner, FaceInfo)>) -> bool {
|
||||
let mut close = false;
|
||||
// "Aa" in the face, large, at the header's reading end under the close button.
|
||||
if let Some((owner, face)) = face {
|
||||
let catalog = match owner {
|
||||
Owner::Installed => Arc::clone(&self.catalog),
|
||||
Owner::Preview => Arc::clone(&self.previews),
|
||||
};
|
||||
if let crate::panel::faces::Shown::Ready(family) = self.faces.family(ui.ctx(), &catalog, owner, &face) {
|
||||
let top = ui.cursor().top();
|
||||
let end = direction::end_x(ui, ui.max_rect()) - direction::forward(ui) * (s.h() + 24.0);
|
||||
let anchor = if direction::is_rtl(ui) { egui::Align2::LEFT_TOP } else { egui::Align2::RIGHT_TOP };
|
||||
ui.painter().text(egui::pos2(end, top - 14.0), anchor, "Aa", FontId::new(96.0, family), s.p.ink_strong);
|
||||
}
|
||||
}
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
widgets::label(ui, s, eyebrow, s.p.faint);
|
||||
ui.with_layout(direction::trailing(ui), |ui| {
|
||||
close = widgets::glyph_button(ui, s, Glyph::Close, tl!("Close"), s.h()).clicked();
|
||||
});
|
||||
});
|
||||
let title = RichText::new(title).size(30.0).family(s.look.display.clone()).color(s.p.ink_strong).extra_letter_spacing(-0.4);
|
||||
ui.add(egui::Label::new(title).wrap());
|
||||
if let Some(by) = byline {
|
||||
ui.label(RichText::new(by).color(s.p.muted));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
close
|
||||
}
|
||||
|
||||
fn family_sheet(&mut self, ui: &mut Ui, s: &Skin, i: usize) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(index) = self.index() else { return true };
|
||||
let Some(family) = index.families().get(i).cloned() else { return true };
|
||||
let key = family_key(&family.source, &family.family);
|
||||
let installed: Vec<FaceInfo> = self.installed_by_name.get(&family.family.to_lowercase()).cloned().unwrap_or_default();
|
||||
let store: Vec<FaceInfo> = installed.iter().filter(|f| f.source == Source::Store).cloned().collect();
|
||||
let on_system = !installed.is_empty() && store.is_empty();
|
||||
let previewed = self.previewed.get(&key).cloned();
|
||||
let job = self.jobs.of(&family.source, &family.family).map(|j| (j.kind, j.file.clone(), j.fraction()));
|
||||
let (source, curated) = source_label(&index, &family);
|
||||
let eyebrow = [source.to_owned(), category_label(&family)].join(" · ");
|
||||
let by = (!family.designer.is_empty()).then(|| fmt("Designed by {designer}", &[("designer", &family.designer)]));
|
||||
let hero = regular(&installed).cloned().map(|f| (Owner::Installed, f));
|
||||
let hero = hero.or_else(|| previewed.as_deref().and_then(regular).cloned().map(|f| (Owner::Preview, f)));
|
||||
let close = self.sheet_header(ui, s, &eyebrow, &family.family, by, hero);
|
||||
|
||||
// Install or remove the whole family.
|
||||
let size = i18n::size(family.size());
|
||||
let all_installed =
|
||||
!installed.is_empty() && (on_system || family.files.iter().all(|f| store.iter().any(|x| file_name(x).as_deref() == Some(f.file_name()))));
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
match &job {
|
||||
Some((kind, None, fraction)) => {
|
||||
let what = match kind {
|
||||
Kind::Install => tl!("Installing"),
|
||||
Kind::Remove => tl!("Removing"),
|
||||
Kind::Preview => tl!("Downloading"),
|
||||
};
|
||||
self.progress(ui, s, what, *fraction, 220.0);
|
||||
}
|
||||
_ => {
|
||||
if all_installed {
|
||||
widgets::badge(ui, s, if on_system { tl!("Installed on this system") } else { tl!("Installed") }, s.p.good, true);
|
||||
} else if widgets::button(ui, s, tl!("Install all"), widgets::Kind::Primary, s.h(), 0.0).clicked() {
|
||||
self.ask(&ctx, Pending::Install(i));
|
||||
}
|
||||
if !store.is_empty() && widgets::button(ui, s, tl!("Remove all"), widgets::Kind::Danger, s.h(), 0.0).clicked() {
|
||||
self.remove(&ctx, &family.family, &store, true);
|
||||
}
|
||||
if installed.is_empty()
|
||||
&& previewed.is_none()
|
||||
&& widgets::button(ui, s, tl!("Preview"), widgets::Kind::Secondary, s.h(), 0.0).clicked()
|
||||
{
|
||||
self.ask(&ctx, Pending::Preview(i));
|
||||
}
|
||||
widgets::label(ui, s, &fmt("{size} download", &[("size", &size)]), s.p.faint);
|
||||
}
|
||||
}
|
||||
});
|
||||
if installed.is_empty() && previewed.is_none() {
|
||||
let note = fmt("Preview downloads this font ({size}) for this session without installing it.", &[("size", &size)]);
|
||||
ui.label(RichText::new(note).size(12.5).color(s.p.muted));
|
||||
}
|
||||
if !curated {
|
||||
ui.add_space(4.0);
|
||||
let added = index.source_of(&family).and_then(|f| f.user_url.clone()).unwrap_or_default();
|
||||
widgets::note(ui, s, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.label(RichText::new(tl!("Not curated")).color(s.p.warn));
|
||||
let text = fmt(
|
||||
"This family comes from a source you added ({url}). Craft doesn't review its fonts or their licences.",
|
||||
&[("url", &added)],
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(text).size(12.5).color(s.p.muted)).wrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(failure) = self.failed.get(&key).cloned() {
|
||||
ui.add_space(4.0);
|
||||
let what = match failure.kind {
|
||||
Kind::Install => tl!("Couldn't install"),
|
||||
Kind::Remove => tl!("Couldn't remove"),
|
||||
Kind::Preview => tl!("Couldn't download"),
|
||||
};
|
||||
let buttons: &[&str] = if failure.retry.is_some() { &[tl!("Retry")] } else { &[] };
|
||||
let mut retry = false;
|
||||
widgets::note_with_actions(
|
||||
ui,
|
||||
s,
|
||||
|ui| {
|
||||
ui.label(RichText::new(what).color(s.p.bad));
|
||||
ui.add(egui::Label::new(RichText::new(&failure.error).size(12.5).color(s.p.muted)).wrap());
|
||||
},
|
||||
buttons,
|
||||
|ui, _| retry = widgets::button(ui, s, tl!("Retry"), widgets::Kind::Secondary, 28.0, 0.0).clicked(),
|
||||
false,
|
||||
);
|
||||
if retry && let Some(pending) = failure.retry.clone() {
|
||||
self.ask(&ctx, pending);
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
|
||||
let rows = self.family_rows(&family, &installed, &store, on_system, previewed.as_deref(), &job);
|
||||
let mut facts = family_facts(&index, &family, &size);
|
||||
let source_fact = if curated { source.to_owned() } else { format!("{source} · {}", tl!("Not curated")) };
|
||||
facts.insert(0, (tl!("Source").to_owned(), source_fact));
|
||||
// Most speakers first: the list is cut short.
|
||||
let mut languages: Vec<_> = family.languages(&index).filter(|l| !l.historical).collect();
|
||||
languages.sort_by(|a, b| b.population.cmp(&a.population).then_with(|| a.name.cmp(&b.name)));
|
||||
let languages: Vec<String> = languages.into_iter().map(|l| l.name.clone()).collect();
|
||||
self.sheet_body(ui, s, i, &family.family, &family.scripts, rows, facts, languages);
|
||||
close
|
||||
}
|
||||
|
||||
fn family_rows(
|
||||
&self,
|
||||
family: &Family,
|
||||
installed: &[FaceInfo],
|
||||
store: &[FaceInfo],
|
||||
on_system: bool,
|
||||
previewed: Option<&[FaceInfo]>,
|
||||
job: &Option<(Kind, Option<String>, Option<f32>)>,
|
||||
) -> Vec<StyleRow> {
|
||||
let running = |path: &str| match job {
|
||||
Some((Kind::Install, Some(p), fraction)) if p == path => Some(*fraction),
|
||||
_ => None,
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
for face in installed {
|
||||
let action = match job {
|
||||
Some((Kind::Remove, Some(p), _)) if face.path.as_ref().is_some_and(|x| x.display().to_string() == *p) => RowAction::Running(None),
|
||||
_ if face.source == Source::Store => RowAction::Remove(Box::new(face.clone())),
|
||||
_ => RowAction::None,
|
||||
};
|
||||
rows.push(StyleRow { label: style_name(face).to_owned(), face: Some((Owner::Installed, face.clone())), action });
|
||||
}
|
||||
if on_system {
|
||||
return rows;
|
||||
}
|
||||
for file in &family.files {
|
||||
if store.iter().any(|x| file_name(x).as_deref() == Some(file.file_name())) {
|
||||
continue;
|
||||
}
|
||||
let preview_faces: Vec<&FaceInfo> =
|
||||
previewed.unwrap_or_default().iter().filter(|f| file_of(family, f).is_some_and(|x| x.path == file.path)).collect();
|
||||
let action = match running(&file.path) {
|
||||
Some(fraction) => RowAction::Running(fraction),
|
||||
None => RowAction::Install(file.path.clone()),
|
||||
};
|
||||
if preview_faces.is_empty() {
|
||||
rows.push(StyleRow { label: file_label(file), face: None, action });
|
||||
} else {
|
||||
for (n, face) in preview_faces.iter().enumerate() {
|
||||
// A variable file's instances install together: the file's action on its first.
|
||||
let action = if n == 0 { action_clone(&action) } else { RowAction::None };
|
||||
rows.push(StyleRow { label: style_name(face).to_owned(), face: Some((Owner::Preview, (*face).clone())), action });
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn installed_sheet(&mut self, ui: &mut Ui, s: &Skin, name: &str, source: Source) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(family) = self.installed.iter().find(|f| f.name == name && f.source == source).cloned() else { return true };
|
||||
let hero = regular(&family.faces).cloned().map(|f| (Owner::Installed, f));
|
||||
let close = self.sheet_header(ui, s, source_name(source), &family.name, None, hero);
|
||||
let job = self.jobs.of("", &family.name).map(|j| (j.kind, j.file.clone(), j.fraction()));
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
if let Some((Kind::Remove, None, _)) = &job {
|
||||
self.progress(ui, s, tl!("Removing"), None, 220.0);
|
||||
return;
|
||||
}
|
||||
let remove =
|
||||
ui.add_enabled_ui(family.removable(), |ui| widgets::button(ui, s, tl!("Remove all"), widgets::Kind::Danger, s.h(), 0.0)).inner;
|
||||
if !family.removable() {
|
||||
let _ = remove.on_disabled_hover_text(tl!("System fonts are removed with your system's own font tools."));
|
||||
widgets::label(ui, s, tl!("Removed with your system's font tools"), s.p.faint);
|
||||
} else if remove.clicked() {
|
||||
self.remove(&ctx, &family.name, &family.faces, true);
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
let rows = family
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| StyleRow {
|
||||
label: style_name(face).to_owned(),
|
||||
face: Some((Owner::Installed, face.clone())),
|
||||
action: if face.source == Source::Store { RowAction::Remove(Box::new(face.clone())) } else { RowAction::None },
|
||||
})
|
||||
.collect();
|
||||
let mut files: Vec<_> = family.faces.iter().filter_map(|f| f.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
let facts = vec![
|
||||
(tl!("Source").to_owned(), source_name(source).to_owned()),
|
||||
(tl!("Styles").to_owned(), i18n::number(family.faces.len() as u64)),
|
||||
(tl!("Files").to_owned(), i18n::number(files.len() as u64)),
|
||||
];
|
||||
let scripts = self.index().and_then(|index| index.family(&family.name).map(|f| f.scripts.clone())).unwrap_or_default();
|
||||
self.sheet_body(ui, s, usize::MAX, &family.name, &scripts, rows, facts, Vec::new());
|
||||
close
|
||||
}
|
||||
|
||||
fn progress(&self, ui: &mut Ui, s: &Skin, what: &str, fraction: Option<f32>, width: f32) {
|
||||
let text = match fraction {
|
||||
Some(f) => format!("{what} {}", fmt("{percent} %", &[("percent", &i18n::number((f * 100.0).round() as u64))])),
|
||||
None => format!("{what}…"),
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, s.h()), Sense::hover());
|
||||
let galley = s.label_galley(ui, &text, s.p.muted);
|
||||
widgets::paint_start(ui, rect.with_max_y(rect.bottom() - 10.0), galley, s.p.muted);
|
||||
widgets::progress_bar(ui, s, egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 4.0), rect.max), fraction);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ProgressIndicator, true, &text));
|
||||
}
|
||||
|
||||
/// The styles, and beside them (or below, when narrow) the facts and languages.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_body(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
s: &Skin,
|
||||
family_index: usize,
|
||||
family: &str,
|
||||
scripts: &[String],
|
||||
rows: Vec<StyleRow>,
|
||||
facts: Vec<(String, String)>,
|
||||
languages: Vec<String>,
|
||||
) {
|
||||
let total = ui.available_width();
|
||||
let wide = total >= COLUMNS_FROM;
|
||||
let gap = 32.0;
|
||||
let side_w = if wide { (total * 0.32).clamp(240.0, 320.0) } else { total };
|
||||
let styles_w = if wide { total - side_w - gap } else { total };
|
||||
let rtl = direction::is_rtl(ui);
|
||||
let layout =
|
||||
if wide { if rtl { Layout::right_to_left(Align::Min) } else { Layout::left_to_right(Align::Min) } } else { direction::column(ui) };
|
||||
ui.allocate_ui_with_layout(vec2(total, 0.0), layout, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(gap, 14.0);
|
||||
ui.allocate_ui_with_layout(vec2(styles_w, 0.0), direction::column(ui), |ui| {
|
||||
ui.set_width(styles_w);
|
||||
self.style_rows(ui, s, family_index, family, scripts, rows);
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(side_w, 0.0), direction::column(ui), |ui| {
|
||||
ui.set_width(side_w);
|
||||
facts_box(ui, s, &facts);
|
||||
if !languages.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
let title = fmt("Languages · {n}", &[("n", &i18n::number(languages.len() as u64))]);
|
||||
widgets::label(ui, s, &title, s.p.faint);
|
||||
const SHOWN: usize = 60;
|
||||
let mut text = languages.iter().take(SHOWN).cloned().collect::<Vec<_>>().join(", ");
|
||||
if languages.len() > SHOWN {
|
||||
text = fmt("{list} and {n} more", &[("list", &text), ("n", &i18n::number((languages.len() - SHOWN) as u64))]);
|
||||
}
|
||||
ui.add(egui::Label::new(RichText::new(text).size(13.0).color(s.p.muted)).wrap());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn style_rows(&mut self, ui: &mut Ui, s: &Skin, family_index: usize, family: &str, scripts: &[String], rows: Vec<StyleRow>) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let installed = rows.iter().filter(|r| matches!(r.face, Some((Owner::Installed, _)))).count();
|
||||
let title = if installed == rows.len() {
|
||||
tn(rows.len() as u64, "{n} style installed", "{n} styles installed")
|
||||
} else {
|
||||
tn(rows.len() as u64, "{n} style", "{n} styles")
|
||||
};
|
||||
widgets::label(ui, s, &title, s.p.faint);
|
||||
let sample = self.sample_for(scripts.iter().map(String::as_str));
|
||||
let size = self.settings.size.max(24.0);
|
||||
let width = ui.available_width();
|
||||
for row in rows {
|
||||
let top = ui.cursor().top();
|
||||
ui.painter().hline(ui.max_rect().x_range(), top, s.hairline());
|
||||
ui.add_space(10.0);
|
||||
ui.allocate_ui_with_layout(vec2(width, 28.0), direction::row(ui), |ui| {
|
||||
widgets::label(ui, s, &row.label, s.p.muted);
|
||||
ui.with_layout(direction::trailing(ui), |ui| match &row.action {
|
||||
RowAction::None => {}
|
||||
RowAction::Running(fraction) => self.progress(ui, s, tl!("Installing"), *fraction, 140.0),
|
||||
RowAction::Install(path) => {
|
||||
let install = widgets::button(ui, s, tl!("Install"), widgets::Kind::Secondary, 28.0, 0.0);
|
||||
widgets::name(ui, &install, &fmt("Install {style}", &[("style", &row.label)]));
|
||||
if install.clicked() && family_index != usize::MAX {
|
||||
self.ask(&ctx, Pending::InstallFile(family_index, path.clone()));
|
||||
}
|
||||
}
|
||||
RowAction::Remove(face) => {
|
||||
let remove = widgets::button(ui, s, tl!("Remove"), widgets::Kind::Danger, 28.0, 0.0);
|
||||
widgets::name(ui, &remove, &fmt("Remove {style}", &[("style", &row.label)]));
|
||||
if remove.clicked() {
|
||||
self.remove(&ctx, family, std::slice::from_ref(face), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
self.wrapped_sample(ui, s, row.face.as_ref(), &sample, size, width);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in the face, wrapped to `width`; faint in the interface font when the face isn't here.
|
||||
fn wrapped_sample(&mut self, ui: &mut Ui, s: &Skin, face: Option<&(Owner, FaceInfo)>, text: &str, size: f32, width: f32) {
|
||||
let (family, color) = match face {
|
||||
Some((owner, face)) => {
|
||||
let catalog = match owner {
|
||||
Owner::Installed => Arc::clone(&self.catalog),
|
||||
Owner::Preview => Arc::clone(&self.previews),
|
||||
};
|
||||
match self.faces.family(ui.ctx(), &catalog, *owner, face) {
|
||||
crate::panel::faces::Shown::Ready(family) => (family, s.p.ink),
|
||||
crate::panel::faces::Shown::Loading => {
|
||||
ui.allocate_space(vec2(width, size * 1.3));
|
||||
return;
|
||||
}
|
||||
crate::panel::faces::Shown::Unavailable => (FontFamily::Proportional, s.p.faint),
|
||||
}
|
||||
}
|
||||
None => (FontFamily::Proportional, s.p.faint),
|
||||
};
|
||||
let size = if face.is_some() { size } else { size.min(20.0) };
|
||||
// Sample text keeps its own direction whatever the interface's.
|
||||
let align = if craft_i18n::base_direction(text) == Some(craft_i18n::Direction::Rtl) { Align::Max } else { Align::Min };
|
||||
ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(align), |ui| {
|
||||
ui.add(egui::Label::new(RichText::new(text).font(FontId::new(size, family)).color(color)).wrap());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn action_clone(action: &RowAction) -> RowAction {
|
||||
match action {
|
||||
RowAction::None => RowAction::None,
|
||||
RowAction::Install(p) => RowAction::Install(p.clone()),
|
||||
RowAction::Remove(f) => RowAction::Remove(f.clone()),
|
||||
RowAction::Running(f) => RowAction::Running(*f),
|
||||
}
|
||||
}
|
||||
|
||||
fn family_facts(index: &Index, family: &Family, size: &str) -> Vec<(String, String)> {
|
||||
let scripts: Vec<&str> = family.scripts.iter().map(|id| script_name(index, id).unwrap_or(id)).collect();
|
||||
let mut facts = Vec::new();
|
||||
if !family.designer.is_empty() {
|
||||
facts.push((tl!("Designer").to_owned(), family.designer.clone()));
|
||||
}
|
||||
facts.push((tl!("Licence").to_owned(), licence_name(&family.license).to_owned()));
|
||||
facts.push((tl!("Styles").to_owned(), i18n::number(style_count(family) as u64)));
|
||||
facts.push((tl!("Files").to_owned(), i18n::number(family.files.len() as u64)));
|
||||
facts.push((tl!("Download size").to_owned(), size.to_owned()));
|
||||
if !scripts.is_empty() {
|
||||
facts.push((tl!("Scripts").to_owned(), scripts.join(", ")));
|
||||
}
|
||||
facts
|
||||
}
|
||||
|
||||
/// Facts in a hairline box, each a small label over its value.
|
||||
fn facts_box(ui: &mut Ui, s: &Skin, facts: &[(String, String)]) {
|
||||
egui::Frame::new().stroke(s.hairline()).corner_radius(s.look.corner).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
for (n, (label, value)) in facts.iter().enumerate() {
|
||||
if n > 0 {
|
||||
ui.add_space(6.0);
|
||||
let y = ui.cursor().top();
|
||||
ui.painter().hline(ui.max_rect().x_range(), y, s.hairline());
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
widgets::label(ui, s, label, s.p.faint);
|
||||
ui.add(egui::Label::new(RichText::new(value).color(s.p.ink)).wrap());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Drawing text in a font face itself: each face shown becomes an egui font family of its own.
|
||||
//!
|
||||
//! Adding a font makes egui rebuild its fonts, so faces are added a few per frame, and once more
|
||||
//! than [`MAX_REGISTERED`] are loaded they are all taken out again, which keeps memory to the faces
|
||||
//! on screen. Only the panel's own faces are touched: the host's fonts stay as they are.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Catalog, FaceInfo};
|
||||
use egui::epaint::text::{Blob, FontInsert, FontPriority, InsertFontFamily, VariationCoords};
|
||||
use egui::{Context, FontData, FontFamily, FontTweak};
|
||||
|
||||
const MAX_REGISTERED: usize = 64;
|
||||
const PER_FRAME: usize = 6;
|
||||
/// Larger files are not drawn: a whole CJK family can be hundreds of megabytes.
|
||||
const MAX_FILE_BYTES: u64 = 48 << 20;
|
||||
|
||||
/// Whose catalog a face belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Owner {
|
||||
/// The installed fonts.
|
||||
Installed,
|
||||
/// Files downloaded for a preview.
|
||||
Preview,
|
||||
}
|
||||
|
||||
pub enum Shown {
|
||||
Ready(FontFamily),
|
||||
Loading,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FaceFonts {
|
||||
registered: HashMap<String, FontFamily>,
|
||||
failed: HashSet<String>,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
fn key(owner: Owner, face: &FaceInfo) -> String {
|
||||
let file = match &face.path {
|
||||
Some(path) => path.display().to_string(),
|
||||
None => face.postscript_name.clone().or_else(|| face.full_name.clone()).unwrap_or_else(|| face.family.clone()),
|
||||
};
|
||||
let coords: String = face.variations.iter().map(|(tag, v)| format!(" {}={v}", String::from_utf8_lossy(tag))).collect();
|
||||
format!("face:{owner:?}:{file}#{}{coords}", face.index)
|
||||
}
|
||||
|
||||
impl FaceFonts {
|
||||
pub fn begin_frame(&mut self, ctx: &Context) {
|
||||
self.budget = PER_FRAME;
|
||||
if self.registered.len() > MAX_REGISTERED {
|
||||
let mut definitions = ctx.fonts(|f| f.definitions().clone());
|
||||
definitions.font_data.retain(|name, _| !self.registered.contains_key(name));
|
||||
definitions.families.retain(|family, _| !self.registered.values().any(|f| f == family));
|
||||
ctx.set_fonts(definitions);
|
||||
self.registered.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The family that draws `face`, once egui has loaded it.
|
||||
pub fn family(&mut self, ctx: &Context, catalog: &Catalog, owner: Owner, face: &FaceInfo) -> Shown {
|
||||
let key = key(owner, face);
|
||||
if self.failed.contains(&key) {
|
||||
return Shown::Unavailable;
|
||||
}
|
||||
if let Some(family) = self.registered.get(&key) {
|
||||
let loaded = ctx.fonts(|f| f.definitions().families.contains_key(family));
|
||||
if loaded {
|
||||
return Shown::Ready(family.clone());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
return Shown::Loading;
|
||||
}
|
||||
if self.budget == 0 {
|
||||
ctx.request_repaint();
|
||||
return Shown::Loading;
|
||||
}
|
||||
self.budget -= 1;
|
||||
let too_large = face.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).is_some_and(|m| m.len() > MAX_FILE_BYTES);
|
||||
let data = if too_large { None } else { catalog.load(face).ok() };
|
||||
let Some(data) = data else {
|
||||
self.failed.insert(key);
|
||||
return Shown::Unavailable;
|
||||
};
|
||||
let blob: Blob = Arc::new(data.bytes);
|
||||
let tweak = FontTweak { coords: VariationCoords::new(face.variations.iter().map(|(tag, v)| (*tag, *v))), ..Default::default() };
|
||||
let family = FontFamily::Name(key.as_str().into());
|
||||
let insert = InsertFontFamily { family: family.clone(), priority: FontPriority::Highest };
|
||||
ctx.add_font(FontInsert::new(&key, FontData::from_blob(blob, data.index).tweak(tweak), vec![insert]));
|
||||
self.registered.insert(key, family);
|
||||
ctx.request_repaint();
|
||||
Shown::Loading
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Interface strings through craft-i18n: the English text is the lookup key.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use craft_i18n::{Embedded, FileSize, I18n, Lang};
|
||||
|
||||
/// The embedded translation catalogs: the panel's own Japanese and Arabic rows, until its rows
|
||||
/// are part of craft-i18n.
|
||||
static CATALOGS: &Embedded = &[("ja", "", include_str!("catalogs/ja.tsv")), ("ar", "", include_str!("catalogs/ar.tsv"))];
|
||||
|
||||
/// The interface languages offered, in menu order.
|
||||
pub const LANGUAGES: &[&str] = &["en", "ja", "ar"];
|
||||
|
||||
pub static I18N: LazyLock<I18n> = LazyLock::new(|| {
|
||||
let (i18n, problems) = I18n::new(CATALOGS, LANGUAGES);
|
||||
for problem in problems {
|
||||
log::warn!("translation catalog: {problem}; that string shows in English");
|
||||
}
|
||||
i18n
|
||||
});
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: Cell<Lang> = const { Cell::new(Lang::EN) };
|
||||
}
|
||||
|
||||
/// The interface language.
|
||||
pub fn current() -> Lang {
|
||||
CURRENT.get()
|
||||
}
|
||||
|
||||
pub fn set_current(lang: Lang) {
|
||||
CURRENT.set(lang);
|
||||
}
|
||||
|
||||
/// Translates a literal.
|
||||
macro_rules! tl {
|
||||
($s:expr) => {
|
||||
$crate::panel::i18n::I18N.t($crate::panel::i18n::current(), $s)
|
||||
};
|
||||
}
|
||||
pub(crate) use tl;
|
||||
|
||||
/// Translates `template` and fills its `{name}` placeholders.
|
||||
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
|
||||
craft_i18n::fmt(I18N.t(current(), template), args)
|
||||
}
|
||||
|
||||
/// A count with its noun, `{n}` in the English forms.
|
||||
pub fn tn(n: u64, one: &str, other: &str) -> String {
|
||||
I18N.trn(current(), n, one, other)
|
||||
}
|
||||
|
||||
/// A download size, `1.5 MB`.
|
||||
pub fn size(bytes: u64) -> String {
|
||||
I18N.file_size(current(), bytes, FileSize::DECIMAL)
|
||||
}
|
||||
|
||||
/// A count on its own, in the region's digits and grouping.
|
||||
pub fn number(n: u64) -> String {
|
||||
I18N.format().integer(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every row reads, and every string the panel shows has a row in each catalog.
|
||||
#[test]
|
||||
fn catalogs_cover_the_interface() {
|
||||
let (_, problems) = I18n::new(CATALOGS, LANGUAGES);
|
||||
assert!(problems.is_empty(), "{problems:#?}");
|
||||
// English-only by design: style and weight names, licence and product names.
|
||||
let english = [
|
||||
"Thin",
|
||||
"ExtraLight",
|
||||
"Light",
|
||||
"Regular",
|
||||
"Medium",
|
||||
"SemiBold",
|
||||
"Bold",
|
||||
"ExtraBold",
|
||||
"Black",
|
||||
"Italic",
|
||||
"Variable {from}–{to}",
|
||||
"SIL Open Font License",
|
||||
"Apache License 2.0",
|
||||
"Ubuntu Font Licence",
|
||||
"Google Fonts",
|
||||
"{source}: {error}",
|
||||
];
|
||||
let sources = [include_str!("app.rs"), include_str!("browse.rs"), include_str!("details.rs"), include_str!("sources.rs")];
|
||||
let mut keys = Vec::new();
|
||||
for source in sources {
|
||||
for marker in ["tl!(\"", "fmt(\"", "fmt(\n \""] {
|
||||
for part in source.split(marker).skip(1) {
|
||||
if let Some(end) = part.find('"') {
|
||||
keys.push(part[..end].to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys.retain(|k| !english.contains(&k.as_str()));
|
||||
assert!(keys.len() > 60, "{}", keys.len());
|
||||
for lang in ["ja", "ar"] {
|
||||
let lang = I18N.match_tag(lang).unwrap_or(Lang::EN);
|
||||
let missing: Vec<_> = keys.iter().filter(|k| I18N.t(lang, k) == k.as_str()).collect();
|
||||
assert!(missing.is_empty(), "{}: {missing:#?}", I18N.code(lang));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Installs, removals and preview downloads, one at a time on a worker thread.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use crate::Catalog;
|
||||
use crate::index::Index;
|
||||
use crate::store::{Downloaded, Installer, Progress, RemoveRequest, StoreError};
|
||||
|
||||
type Task = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// Where jobs run.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Runner {
|
||||
/// On a worker thread.
|
||||
Thread,
|
||||
/// Right away, on the calling thread (tests).
|
||||
Inline,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Install,
|
||||
Remove,
|
||||
Preview,
|
||||
}
|
||||
|
||||
pub struct Running {
|
||||
/// The family's source; empty for a removal, which goes by the installed files.
|
||||
pub source: String,
|
||||
pub family: String,
|
||||
pub kind: Kind,
|
||||
/// One file's index path or store path, when the job is for one style.
|
||||
pub file: Option<String>,
|
||||
pub progress: Progress,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
/// How far along, when the size is known.
|
||||
pub fn fraction(&self) -> Option<f32> {
|
||||
(self.progress.total > 0).then(|| (self.progress.done as f32 / self.progress.total as f32).clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Finished {
|
||||
Installed,
|
||||
Removed,
|
||||
Previewed(Vec<Downloaded>),
|
||||
}
|
||||
|
||||
enum Msg {
|
||||
Progress(u64, Progress),
|
||||
Done(u64, Result<Finished, StoreError>),
|
||||
}
|
||||
|
||||
pub enum Request {
|
||||
/// A family of the index, by position.
|
||||
Install(Arc<Index>, usize),
|
||||
/// One file of a family of the index, by its path in the index.
|
||||
InstallFile(Arc<Index>, usize, String),
|
||||
Remove(RemoveRequest),
|
||||
/// A family of the index, by position.
|
||||
Preview(Arc<Index>, usize),
|
||||
}
|
||||
|
||||
pub struct Jobs {
|
||||
worker: Option<Sender<Task>>,
|
||||
tx: Sender<Msg>,
|
||||
rx: Receiver<Msg>,
|
||||
next: u64,
|
||||
running: BTreeMap<u64, Running>,
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub fn new(runner: Runner) -> Jobs {
|
||||
let (tx, rx) = channel();
|
||||
let worker = match runner {
|
||||
Runner::Inline => None,
|
||||
Runner::Thread => {
|
||||
let (worker, tasks) = channel::<Task>();
|
||||
let spawned = std::thread::Builder::new().name("font-jobs".into()).spawn(move || {
|
||||
for task in tasks {
|
||||
task();
|
||||
}
|
||||
});
|
||||
if let Err(e) = &spawned {
|
||||
log::warn!("no worker thread ({e}); font jobs run on the interface thread");
|
||||
}
|
||||
spawned.ok().map(|_| worker)
|
||||
}
|
||||
};
|
||||
Jobs { worker, tx, rx, next: 0, running: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// The job running for `family` of `source` (any source when empty), if any.
|
||||
pub fn of(&self, source: &str, family: &str) -> Option<&Running> {
|
||||
self.running.values().find(|r| r.family.eq_ignore_ascii_case(family) && (source.is_empty() || r.source.is_empty() || r.source == source))
|
||||
}
|
||||
|
||||
/// Runs `request` through `installer`, then rescans `catalog` after an install or removal.
|
||||
pub fn start(&mut self, ctx: &egui::Context, installer: Arc<dyn Installer>, catalog: Arc<Catalog>, request: Request) {
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
let (source, family, kind, file, total) = match &request {
|
||||
Request::Install(index, i) | Request::Preview(index, i) => {
|
||||
let Some(f) = index.families().get(*i) else { return };
|
||||
let kind = if matches!(request, Request::Install(..)) { Kind::Install } else { Kind::Preview };
|
||||
(f.source.clone(), f.family.clone(), kind, None, f.size())
|
||||
}
|
||||
Request::InstallFile(index, i, path) => {
|
||||
let Some(f) = index.families().get(*i) else { return };
|
||||
let size = f.files.iter().find(|x| &x.path == path).map_or(0, |x| x.size);
|
||||
(f.source.clone(), f.family.clone(), Kind::Install, Some(path.clone()), size)
|
||||
}
|
||||
Request::Remove(r) => {
|
||||
let file = if r.whole { None } else { r.files.first().map(|p| p.display().to_string()) };
|
||||
(String::new(), r.family.clone(), Kind::Remove, file, 0)
|
||||
}
|
||||
};
|
||||
self.running.insert(id, Running { source, family, kind, file, progress: Progress { done: 0, total } });
|
||||
let tx = self.tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let task: Task = Box::new(move || {
|
||||
let mut progress = |p: Progress| {
|
||||
let _ = tx.send(Msg::Progress(id, p));
|
||||
ctx.request_repaint();
|
||||
};
|
||||
let family = |index: &Index, i: usize| index.families().get(i).cloned().ok_or_else(|| StoreError("no such family".into()));
|
||||
let result = match request {
|
||||
Request::Install(index, i) => {
|
||||
family(&index, i).and_then(|f| installer.install(&index, &f, &mut progress)).map(|()| Finished::Installed)
|
||||
}
|
||||
Request::InstallFile(index, i, path) => {
|
||||
family(&index, i).and_then(|f| installer.install_file(&index, &f, &path, &mut progress)).map(|()| Finished::Installed)
|
||||
}
|
||||
Request::Remove(r) => installer.remove(&r).map(|()| Finished::Removed),
|
||||
Request::Preview(index, i) => family(&index, i).and_then(|f| installer.download(&index, &f, &mut progress)).map(Finished::Previewed),
|
||||
};
|
||||
if matches!(result, Ok(Finished::Installed | Finished::Removed)) {
|
||||
catalog.rescan();
|
||||
}
|
||||
let _ = tx.send(Msg::Done(id, result));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
match &self.worker {
|
||||
Some(worker) => {
|
||||
if let Err(e) = worker.send(task) {
|
||||
(e.0)();
|
||||
}
|
||||
}
|
||||
None => task(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the progress reports, and returns the jobs that ended.
|
||||
pub fn poll(&mut self) -> Vec<(Running, Result<Finished, StoreError>)> {
|
||||
let mut done = Vec::new();
|
||||
while let Ok(msg) = self.rx.try_recv() {
|
||||
match msg {
|
||||
Msg::Progress(id, p) => {
|
||||
if let Some(job) = self.running.get_mut(&id) {
|
||||
job.progress = p;
|
||||
}
|
||||
}
|
||||
Msg::Done(id, result) => {
|
||||
if let Some(job) = self.running.remove(&id) {
|
||||
done.push((job, result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The colours and type the panel draws with, so it reads as part of the app that shows it.
|
||||
|
||||
use egui::{Color32, Context, CornerRadius, FontFamily, Stroke, Visuals};
|
||||
|
||||
/// Colours for one appearance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Palette {
|
||||
/// Page and card surface.
|
||||
pub bg: Color32,
|
||||
/// Fields, wells, hovered rows.
|
||||
pub sunken: Color32,
|
||||
/// Body text.
|
||||
pub ink: Color32,
|
||||
/// Headings and family names.
|
||||
pub ink_strong: Color32,
|
||||
/// Secondary text.
|
||||
pub muted: Color32,
|
||||
/// Labels and tertiary text.
|
||||
pub faint: Color32,
|
||||
/// Hairlines.
|
||||
pub line: Color32,
|
||||
/// Outlined buttons and stronger rules.
|
||||
pub line_strong: Color32,
|
||||
/// Selection and progress.
|
||||
pub accent: Color32,
|
||||
/// The accent for text and focus rings.
|
||||
pub accent_ink: Color32,
|
||||
/// Primary buttons and the chosen option.
|
||||
pub invert_bg: Color32,
|
||||
pub invert_fg: Color32,
|
||||
pub good: Color32,
|
||||
pub warn: Color32,
|
||||
pub bad: Color32,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// A palette from egui's own visuals, for a host that sets none.
|
||||
pub fn from_visuals(v: &Visuals) -> Palette {
|
||||
let ink = v.text_color();
|
||||
Palette {
|
||||
bg: v.panel_fill,
|
||||
sunken: v.extreme_bg_color,
|
||||
ink,
|
||||
ink_strong: v.strong_text_color(),
|
||||
muted: ink.gamma_multiply(0.75),
|
||||
faint: ink.gamma_multiply(0.5),
|
||||
line: v.widgets.noninteractive.bg_stroke.color,
|
||||
line_strong: v.widgets.inactive.fg_stroke.color.gamma_multiply(0.6),
|
||||
accent: v.selection.bg_fill,
|
||||
accent_ink: v.hyperlink_color,
|
||||
invert_bg: v.strong_text_color(),
|
||||
invert_fg: v.panel_fill,
|
||||
good: if v.dark_mode { Color32::from_rgb(0x3f, 0xd6, 0xb8) } else { Color32::from_rgb(0x0a, 0x75, 0x63) },
|
||||
warn: v.warn_fg_color,
|
||||
bad: v.error_fg_color,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hairline(&self) -> Stroke {
|
||||
Stroke::new(1.0, self.line)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the panel looks: the host's colours for both appearances and the font families it
|
||||
/// registered. [`Look::default`] follows egui's visuals and default families.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Look {
|
||||
/// `None`: from the context's visuals.
|
||||
pub dark: Option<Palette>,
|
||||
pub light: Option<Palette>,
|
||||
/// Page titles.
|
||||
pub display: FontFamily,
|
||||
/// Family names on cards.
|
||||
pub strong: FontFamily,
|
||||
/// Labels, counts and badges.
|
||||
pub label: FontFamily,
|
||||
/// Button text.
|
||||
pub button: FontFamily,
|
||||
/// Labels and buttons set upper case and spaced out.
|
||||
pub caps: bool,
|
||||
pub corner: CornerRadius,
|
||||
/// Buttons, fields and option cells.
|
||||
pub control_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Look {
|
||||
fn default() -> Self {
|
||||
Look {
|
||||
dark: None,
|
||||
light: None,
|
||||
display: FontFamily::Proportional,
|
||||
strong: FontFamily::Proportional,
|
||||
label: FontFamily::Proportional,
|
||||
button: FontFamily::Proportional,
|
||||
caps: false,
|
||||
corner: CornerRadius::same(3),
|
||||
control_height: 30.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// The palette for the context's current appearance.
|
||||
pub fn palette(&self, ctx: &Context) -> Palette {
|
||||
let dark = ctx.theme() == egui::Theme::Dark;
|
||||
match if dark { self.dark } else { self.light } {
|
||||
Some(p) => p,
|
||||
None => Palette::from_visuals(&ctx.global_style().visuals),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! The egui interface: the installed fonts, the Google Fonts catalogue, and installing and
|
||||
//! removing through the font store.
|
||||
|
||||
mod i18n;
|
||||
|
||||
mod app;
|
||||
mod browse;
|
||||
mod details;
|
||||
mod faces;
|
||||
mod jobs;
|
||||
mod look;
|
||||
mod samples;
|
||||
mod sources;
|
||||
mod widgets;
|
||||
|
||||
pub use app::{FontsPanel, Options, Preset, Settings, Tab, install_interface_fonts};
|
||||
pub use jobs::Runner;
|
||||
pub use look::{Look, Palette};
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Sample text: a sentence in the language the catalogue is filtered by, or in a family's own
|
||||
//! script when it has no Latin, so a sample never comes out as boxes.
|
||||
|
||||
/// The English pangram, the sample for Latin.
|
||||
pub const PANGRAM: &str = "The quick brown fox jumps over the lazy dog";
|
||||
pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz";
|
||||
pub const NUMERALS: &str = "0123456789 & ? ! @ # % ( ) [ ] { } $ € £ ¥";
|
||||
|
||||
/// Sentences by language code (gflanguages' code before the script).
|
||||
const BY_LANGUAGE: &[(&str, &str)] = &[
|
||||
("ar", "صف خلق خود كمثل الشمس إذ بزغت يحظى الضجيع بها نجلاء معطار"),
|
||||
("bn", "সমস্ত মানুষ স্বাধীনভাবে সমান মর্যাদা এবং অধিকার নিয়ে জন্মগ্রহণ করে"),
|
||||
("cs", "Příliš žluťoučký kůň úpěl ďábelské ódy"),
|
||||
("de", "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich"),
|
||||
("el", "Ξεσκεπάζω την ψυχοφθόρα βδελυγμία"),
|
||||
("en", PANGRAM),
|
||||
("es", "El veloz murciélago hindú comía feliz cardillo y kiwi"),
|
||||
("fa", "همه افراد بشر آزاد به دنیا میآیند"),
|
||||
("fr", "Portez ce vieux whisky au juge blond qui fume"),
|
||||
("he", "דג סקרן שט בים מאוכזב ולפתע מצא חברה"),
|
||||
("hi", "सभी मनुष्यों को गौरव और अधिकारों के मामले में जन्मजात स्वतन्त्रता प्राप्त है"),
|
||||
("hu", "Árvíztűrő tükörfúrógép"),
|
||||
("it", "Quel vituperabile xenofobo zelante assaggia il whisky ed esclama: alleluja!"),
|
||||
("ja", "いろはにほへと ちりぬるを 色は匂へど 散りぬるを"),
|
||||
("ko", "다람쥐 헌 쳇바퀴에 타고파"),
|
||||
("pl", "Pchnąć w tę łódź jeża lub ośm skrzyń fig"),
|
||||
("pt", "Luís argüia à Júlia que «brações, fé, chá, óxido, pôr, zângão» eram palavras do português"),
|
||||
("ru", "Съешь же ещё этих мягких французских булок, да выпей чаю"),
|
||||
("ta", "மனிதப் பிறவியினர் சகலரும் சுதந்திரமாகவே பிறக்கின்றனர்"),
|
||||
("th", "เป็นมนุษย์สุดประเสริฐเลิศคุณค่า"),
|
||||
("tr", "Pijamalı hasta yağız şoföre çabucak güvendi"),
|
||||
("uk", "Чуєш їх, доцю, га? Кумедна ж ти, прощайся без ґольфів!"),
|
||||
("ur", "تمام انسان آزاد اور حقوق و عزت کے اعتبار سے برابر پیدا ہوئے ہیں"),
|
||||
("vi", "Tôi có thể ăn thủy tinh mà không hại gì"),
|
||||
];
|
||||
|
||||
const SIMPLIFIED: &str = "天地玄黄,宇宙洪荒。日月盈昃,辰宿列张。";
|
||||
const TRADITIONAL: &str = "天地玄黃,宇宙洪荒。日月盈昃,辰宿列張。";
|
||||
|
||||
/// Sentences by ISO 15924 script.
|
||||
const BY_SCRIPT: &[(&str, &str)] = &[
|
||||
("Arab", "ar"),
|
||||
("Beng", "bn"),
|
||||
("Cyrl", "ru"),
|
||||
("Deva", "hi"),
|
||||
("Grek", "el"),
|
||||
("Hang", "ko"),
|
||||
("Hebr", "he"),
|
||||
("Jpan", "ja"),
|
||||
("Kana", "ja"),
|
||||
("Hira", "ja"),
|
||||
("Kore", "ko"),
|
||||
("Latn", "en"),
|
||||
("Taml", "ta"),
|
||||
("Thai", "th"),
|
||||
];
|
||||
|
||||
fn language(code: &str) -> Option<&'static str> {
|
||||
BY_LANGUAGE.iter().find(|(c, _)| *c == code).map(|(_, s)| *s)
|
||||
}
|
||||
|
||||
/// A sentence in the language with gflanguages id `id` (`ja_Jpan`, `zh_Hant`), or in its script.
|
||||
pub fn for_language(id: &str) -> Option<&'static str> {
|
||||
let mut parts = id.split('_');
|
||||
let code = parts.next().unwrap_or(id);
|
||||
let script = parts.next().unwrap_or("");
|
||||
if code == "zh" {
|
||||
return Some(if script == "Hant" { TRADITIONAL } else { SIMPLIFIED });
|
||||
}
|
||||
language(code).or_else(|| for_script(script))
|
||||
}
|
||||
|
||||
/// A sentence in the ISO 15924 script `script`.
|
||||
pub fn for_script(script: &str) -> Option<&'static str> {
|
||||
match script {
|
||||
"Hans" | "Hani" => Some(SIMPLIFIED),
|
||||
"Hant" => Some(TRADITIONAL),
|
||||
_ => BY_SCRIPT.iter().find(|(s, _)| *s == script).and_then(|(_, code)| language(code)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample for a family with `scripts`: the pangram when it has Latin, else a sentence in the
|
||||
/// first of its scripts there is one for.
|
||||
pub fn for_scripts<'a>(scripts: impl IntoIterator<Item = &'a str>) -> &'static str {
|
||||
let mut first = None;
|
||||
for script in scripts {
|
||||
if script == "Latn" {
|
||||
return PANGRAM;
|
||||
}
|
||||
first = first.or_else(|| for_script(script));
|
||||
}
|
||||
first.unwrap_or(PANGRAM)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn samples_follow_language_then_script() {
|
||||
assert_eq!(for_language("ja_Jpan"), language("ja"));
|
||||
assert_eq!(for_language("zh_Hant"), Some(TRADITIONAL));
|
||||
assert_eq!(for_language("xx_Arab"), language("ar"));
|
||||
assert_eq!(for_language("xx_Zzzz"), None);
|
||||
assert_eq!(for_scripts(["Arab", "Latn"]), PANGRAM);
|
||||
assert_eq!(for_scripts(["Zzzz", "Hebr"]), language("he").unwrap_or_default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The font sources, for a host's settings page: the built-in ones, the index URLs the user
|
||||
//! added (never curated, and flagged when their file changed since it was added), adding one,
|
||||
//! and downloading them all again.
|
||||
|
||||
use craft_ui::direction;
|
||||
use egui::{RichText, Ui, vec2};
|
||||
|
||||
use crate::panel::app::FontsPanel;
|
||||
use crate::panel::i18n::{fmt, tl, tn};
|
||||
use crate::panel::widgets::{self, Skin};
|
||||
|
||||
impl FontsPanel {
|
||||
/// Draws the font sources into `ui`, in the panel's look: a host shows it in its settings.
|
||||
pub fn sources_ui(&mut self, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let s = Skin { p: self.look.palette(&ctx), look: self.look.clone() };
|
||||
self.poll_sources();
|
||||
let index = self.index();
|
||||
let user = self.installer.user_sources();
|
||||
let now = ui.input(|i| i.time);
|
||||
let allowed = self.allowed(now);
|
||||
let mut remove = None;
|
||||
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
let width = ui.available_width();
|
||||
egui::Frame::new().stroke(s.hairline()).corner_radius(s.look.corner).show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let builtin = index.iter().flat_map(|i| i.sources().iter()).filter(|src| src.user_url.is_none());
|
||||
let mut first = true;
|
||||
for src in builtin {
|
||||
row(ui, &s, &mut first, |ui| {
|
||||
ui.label(RichText::new(&src.name).color(s.p.ink_strong));
|
||||
widgets::label(ui, &s, &tn(src.families as u64, "{n} family", "{n} families"), s.p.faint);
|
||||
});
|
||||
}
|
||||
for added in &user {
|
||||
let source = index.iter().flat_map(|i| i.sources().iter()).find(|src| src.user_url.as_deref() == Some(added.url.as_str()));
|
||||
let problem = index.iter().flat_map(|i| i.problems().iter()).find(|p| p.source == added.url);
|
||||
row(ui, &s, &mut first, |ui| {
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h() - 6.0), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
ui.label(RichText::new(source.map_or(added.url.as_str(), |src| src.name.as_str())).color(s.p.ink_strong));
|
||||
ui.with_layout(direction::trailing(ui), |ui| {
|
||||
let button = widgets::button(ui, &s, tl!("Remove"), widgets::Kind::Danger, 28.0, 0.0);
|
||||
widgets::name(ui, &button, &fmt("Remove {source}", &[("source", &added.url)]));
|
||||
if button.clicked() {
|
||||
remove = Some(added.url.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
widgets::badge(ui, &s, tl!("Not curated"), s.p.warn, false);
|
||||
if source.is_some_and(|src| src.changed) {
|
||||
widgets::badge(ui, &s, tl!("Changed since added"), s.p.bad, true).on_hover_text(tl!(
|
||||
"The index at this address isn't the one first downloaded from it. Check the source before installing from it."
|
||||
));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(&added.url).size(12.0).color(s.p.muted));
|
||||
match (source, problem) {
|
||||
(_, Some(p)) => {
|
||||
ui.label(RichText::new(&p.error).size(12.0).color(if p.error == "not downloaded yet" { s.p.faint } else { s.p.bad }));
|
||||
}
|
||||
(Some(src), None) => {
|
||||
widgets::label(ui, &s, &tn(src.families as u64, "{n} family", "{n} families"), s.p.faint);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
widgets::label(ui, &s, tl!("Add a source"), s.p.faint);
|
||||
let mut add = false;
|
||||
ui.allocate_ui_with_layout(vec2(width, s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let button_w = s.button_galley(ui, tl!("Add"), s.p.ink).size().x + 28.0;
|
||||
let field = widgets::field(
|
||||
ui,
|
||||
&s,
|
||||
&mut self.source_url,
|
||||
"https://example.org/fonts/index.json",
|
||||
tl!("Index address"),
|
||||
ui.available_width() - button_w - 8.0,
|
||||
false,
|
||||
);
|
||||
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let enabled = !self.source_url.trim().is_empty();
|
||||
let clicked = ui.add_enabled_ui(enabled, |ui| widgets::button(ui, &s, tl!("Add"), widgets::Kind::Secondary, s.h(), 0.0)).inner.clicked();
|
||||
add = enabled && (clicked || enter);
|
||||
});
|
||||
let warning =
|
||||
tl!("A source you add isn't reviewed: add only indexes you trust. Every font is still checked against its index before it is used.");
|
||||
ui.add(egui::Label::new(RichText::new(warning).size(12.0).color(s.p.muted)).wrap());
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.allocate_ui_with_layout(vec2(width, s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
if self.fetching_sources() {
|
||||
ui.add(egui::Spinner::new().size(16.0).color(s.p.muted));
|
||||
ui.label(RichText::new(tl!("Downloading the sources…")).color(s.p.muted));
|
||||
} else {
|
||||
let update =
|
||||
ui.add_enabled_ui(allowed, |ui| widgets::button(ui, &s, tl!("Update sources"), widgets::Kind::Secondary, s.h(), 0.0)).inner;
|
||||
if !allowed {
|
||||
widgets::label(ui, &s, tl!("Allow downloads to update"), s.p.faint);
|
||||
}
|
||||
if update.clicked() {
|
||||
self.fetch_sources(&ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((error, text)) = &self.source_message {
|
||||
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(if *error { s.p.bad } else { s.p.muted })).wrap());
|
||||
}
|
||||
|
||||
if add {
|
||||
let url = self.source_url.trim().to_owned();
|
||||
match self.installer.add_user_source(&url) {
|
||||
Ok(()) => {
|
||||
self.source_url.clear();
|
||||
self.source_message = (!allowed).then(|| (false, tl!("Added. It downloads once downloads are allowed.").to_owned()));
|
||||
self.reload_index();
|
||||
self.fetch_sources(&ctx);
|
||||
}
|
||||
Err(e) => self.source_message = Some((true, fmt("The source couldn't be added: {error}", &[("error", &e.0)]))),
|
||||
}
|
||||
}
|
||||
if let Some(url) = remove {
|
||||
match self.installer.remove_user_source(&url) {
|
||||
Ok(()) => {
|
||||
self.source_message = None;
|
||||
self.reload_index();
|
||||
}
|
||||
Err(e) => self.source_message = Some((true, fmt("The source couldn't be removed: {error}", &[("error", &e.0)]))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One source in the list: a hairline above every one but the first.
|
||||
fn row(ui: &mut Ui, s: &Skin, first: &mut bool, add: impl FnOnce(&mut Ui)) {
|
||||
if !std::mem::take(first) {
|
||||
let y = ui.cursor().top();
|
||||
ui.painter().hline(ui.max_rect().x_range(), y, s.hairline());
|
||||
}
|
||||
egui::Frame::new().inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
ui.with_layout(direction::column(ui), add);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! The panel's controls, drawn in the host's [`Look`]: labels, buttons, option cells, badges,
|
||||
//! fields, progress and the few glyphs it needs (drawn, so no font has to carry them).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::text::{LayoutJob, TextWrapping};
|
||||
use egui::{
|
||||
Align2, Color32, ComboBox, CursorIcon, FontFamily, FontId, Galley, Painter, Pos2, Rect, Response, Role, Sense, Stroke, StrokeKind, TextEdit,
|
||||
TextFormat, Ui, Vec2, WidgetInfo, pos2, vec2,
|
||||
};
|
||||
|
||||
use crate::panel::look::{Look, Palette};
|
||||
|
||||
/// The look and this frame's palette.
|
||||
#[derive(Clone)]
|
||||
pub struct Skin {
|
||||
pub look: Look,
|
||||
pub p: Palette,
|
||||
}
|
||||
|
||||
/// Letter spacing suits Latin, Greek, Cyrillic and CJK; it breaks up joined scripts.
|
||||
fn spaceable(text: &str) -> bool {
|
||||
text.chars().all(|c| (c as u32) < 0x0590 || (c as u32) >= 0x2E80)
|
||||
}
|
||||
|
||||
impl Skin {
|
||||
pub fn h(&self) -> f32 {
|
||||
self.look.control_height
|
||||
}
|
||||
|
||||
/// `text` on one line, upper case and spaced out when the look says so.
|
||||
pub fn caps_galley(&self, ui: &Ui, text: &str, size: f32, family: FontFamily, color: Color32, max_width: Option<f32>) -> Arc<Galley> {
|
||||
// Small capitals and letter spacing suit Latin, Greek, Cyrillic and CJK; joined scripts
|
||||
// get neither, and a size step up to read as well at label size.
|
||||
let joined = !spaceable(text);
|
||||
let size = if joined { size * 1.15 } else { size };
|
||||
let (text, spacing) = if self.look.caps { (text.to_uppercase(), if joined { 0.0 } else { size * 0.14 }) } else { (text.to_owned(), 0.0) };
|
||||
let format = TextFormat { font_id: FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
|
||||
let mut job = LayoutJob::single_section(text, format);
|
||||
if let Some(w) = max_width {
|
||||
job.wrap = TextWrapping::truncate_at_width(w.max(0.0));
|
||||
}
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// A small label: a section title, a count, a fact.
|
||||
pub fn label_galley(&self, ui: &Ui, text: &str, color: Color32) -> Arc<Galley> {
|
||||
self.caps_galley(ui, text, if self.look.caps { 11.0 } else { 12.0 }, self.look.label.clone(), color, None)
|
||||
}
|
||||
|
||||
pub fn button_galley(&self, ui: &Ui, text: &str, color: Color32) -> Arc<Galley> {
|
||||
self.caps_galley(ui, text, if self.look.caps { 11.0 } else { 13.0 }, self.look.button.clone(), color, None)
|
||||
}
|
||||
|
||||
pub fn hairline(&self) -> Stroke {
|
||||
self.p.hairline()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints `galley` at the reading start of `rect`, centred vertically.
|
||||
pub fn paint_start(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
let x = if craft_ui::direction::is_rtl(ui) { rect.right() - galley.size().x } else { rect.left() };
|
||||
ui.painter().galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, color);
|
||||
}
|
||||
|
||||
/// Paints `galley` at the reading end of `rect`, centred vertically.
|
||||
pub fn paint_end(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
let x = if craft_ui::direction::is_rtl(ui) { rect.left() } else { rect.right() - galley.size().x };
|
||||
ui.painter().galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, color);
|
||||
}
|
||||
|
||||
pub fn paint_centred(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color);
|
||||
}
|
||||
|
||||
/// A small label.
|
||||
pub fn label(ui: &mut Ui, s: &Skin, text: &str, color: Color32) -> Response {
|
||||
let galley = s.label_galley(ui, text, color);
|
||||
// Joined scripts can draw a pixel or two past their measured width: a little room each side.
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(4.0, 0.0), Sense::hover());
|
||||
paint_start(ui, rect.shrink2(vec2(2.0, 0.0)), galley, color);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A focus ring inside `rect`, while `resp` has the keyboard focus.
|
||||
pub fn focus_ring(ui: &Ui, s: &Skin, rect: Rect, resp: &Response) {
|
||||
if resp.has_focus() {
|
||||
ui.painter().rect_stroke(rect, s.look.corner, Stroke::new(2.0, s.p.accent_ink), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Solid, in the inverted colours.
|
||||
Primary,
|
||||
/// Outlined.
|
||||
Secondary,
|
||||
/// Outlined in the error colour.
|
||||
Danger,
|
||||
}
|
||||
|
||||
/// A button `height` tall and at least `min_width` wide.
|
||||
pub fn button(ui: &mut Ui, s: &Skin, text: &str, kind: Kind, height: f32, min_width: f32) -> Response {
|
||||
let p = s.p;
|
||||
let probe = s.button_galley(ui, text, p.ink);
|
||||
let size = vec2((probe.size().x + 28.0).max(min_width), height);
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let enabled = ui.is_enabled();
|
||||
let hovered = resp.hovered() && enabled;
|
||||
let (fill, stroke, color) = match kind {
|
||||
Kind::Primary => (p.invert_bg.gamma_multiply(if hovered { 0.82 } else { 1.0 }), Stroke::NONE, p.invert_fg),
|
||||
Kind::Secondary if hovered => (p.invert_bg, Stroke::NONE, p.invert_fg),
|
||||
Kind::Secondary => (Color32::TRANSPARENT, Stroke::new(1.0, p.line_strong), p.ink),
|
||||
Kind::Danger if hovered => (p.bad, Stroke::NONE, Color32::WHITE),
|
||||
Kind::Danger => (Color32::TRANSPARENT, Stroke::new(1.0, p.bad.gamma_multiply(0.7)), p.bad),
|
||||
};
|
||||
let fade = |c: Color32| if enabled { c } else { c.gamma_multiply(0.45) };
|
||||
ui.painter().rect(rect, s.look.corner, fade(fill), Stroke::new(stroke.width, fade(stroke.color)), StrokeKind::Inside);
|
||||
let galley = s.button_galley(ui, text, fade(color));
|
||||
paint_centred(ui, rect, galley, fade(color));
|
||||
focus_ring(ui, s, rect.expand(2.0), &resp);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, text));
|
||||
if enabled { resp.on_hover_cursor(CursorIcon::PointingHand) } else { resp }
|
||||
}
|
||||
|
||||
/// A text-only action in the accent colour: "Clear filters", "Retry".
|
||||
pub fn link(ui: &mut Ui, s: &Skin, text: &str) -> Response {
|
||||
let galley = s.label_galley(ui, text, s.p.accent_ink);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(4.0, 6.0), Sense::click());
|
||||
let color = if resp.hovered() { s.p.ink_strong } else { s.p.accent_ink };
|
||||
paint_start(ui, rect.shrink2(vec2(2.0, 0.0)), galley, color);
|
||||
if resp.hovered() {
|
||||
let y = rect.bottom() - 1.5;
|
||||
ui.painter().hline(rect.x_range(), y, Stroke::new(1.0, color));
|
||||
}
|
||||
focus_ring(ui, s, rect.expand(3.0), &resp);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, text));
|
||||
resp.on_hover_cursor(CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// One option in a group of cells: its value, its label and an optional count.
|
||||
pub struct Cell<'a, T> {
|
||||
pub value: T,
|
||||
pub label: &'a str,
|
||||
pub count: Option<usize>,
|
||||
}
|
||||
|
||||
fn count_text(n: usize) -> String {
|
||||
crate::panel::i18n::number(n as u64)
|
||||
}
|
||||
|
||||
/// Options stacked in a column of hairline cells the width of `ui`; the chosen one inverted.
|
||||
/// Returns whether the choice changed.
|
||||
pub fn cells_column<T: PartialEq + Clone>(ui: &mut Ui, s: &Skin, value: &mut T, options: &[Cell<'_, T>]) -> bool {
|
||||
let p = s.p;
|
||||
let mut changed = false;
|
||||
let width = ui.available_width();
|
||||
let h = s.h() - 2.0;
|
||||
let mut rects = Vec::with_capacity(options.len());
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for cell in options {
|
||||
let on = *value == cell.value;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, h), Sense::click());
|
||||
let (fill, ink, faint) = match (on, resp.hovered()) {
|
||||
(true, _) => (p.invert_bg, p.invert_fg, p.invert_fg.gamma_multiply(0.6)),
|
||||
(false, true) => (p.sunken, p.ink_strong, p.muted),
|
||||
(false, false) => (Color32::TRANSPARENT, p.ink, p.faint),
|
||||
};
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, fill);
|
||||
let inner = rect.shrink2(vec2(12.0, 0.0));
|
||||
let galley = s.label_galley(ui, cell.label, ink);
|
||||
paint_start(ui, inner, galley, ink);
|
||||
if let Some(n) = cell.count {
|
||||
let g = s.label_galley(ui, &count_text(n), faint);
|
||||
paint_end(ui, inner, g, faint);
|
||||
}
|
||||
focus_ring(ui, s, rect.shrink(1.0), &resp);
|
||||
resp.widget_info(|| {
|
||||
let mut info = WidgetInfo::selected(Role::RadioButton, true, on, cell.label);
|
||||
info.value = cell.count.map(|n| n as f64);
|
||||
info
|
||||
});
|
||||
if resp.on_hover_cursor(CursorIcon::PointingHand).clicked() && !on {
|
||||
*value = cell.value.clone();
|
||||
changed = true;
|
||||
}
|
||||
rects.push(rect);
|
||||
}
|
||||
});
|
||||
if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_stroke(first.union(*last), s.look.corner, s.hairline(), StrokeKind::Inside);
|
||||
for r in rects.iter().skip(1) {
|
||||
painter.hline(r.x_range(), r.top(), s.hairline());
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Options side by side in hairline cells, in reading order; the chosen one inverted.
|
||||
pub fn cells_row<T: PartialEq + Clone>(ui: &mut Ui, s: &Skin, value: &mut T, options: &[Cell<'_, T>]) -> bool {
|
||||
let p = s.p;
|
||||
let mut changed = false;
|
||||
let rtl = craft_ui::direction::is_rtl(ui) || ui.layout().prefer_right_to_left();
|
||||
let mut rects = Vec::with_capacity(options.len());
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for cell in options {
|
||||
let on = *value == cell.value;
|
||||
let (ink, faint) = if on { (p.invert_fg, p.invert_fg.gamma_multiply(0.6)) } else { (p.muted, p.faint) };
|
||||
let label = s.label_galley(ui, cell.label, ink);
|
||||
let count = cell.count.map(|n| s.label_galley(ui, &count_text(n), faint));
|
||||
let w = label.size().x + count.as_ref().map_or(0.0, |g| g.size().x + 8.0) + 28.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, s.h()), Sense::click());
|
||||
let fill = match (on, resp.hovered()) {
|
||||
(true, _) => p.invert_bg,
|
||||
(false, true) => p.sunken,
|
||||
(false, false) => Color32::TRANSPARENT,
|
||||
};
|
||||
let ink = if !on && resp.hovered() { p.ink_strong } else { ink };
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, fill);
|
||||
let inner = rect.shrink2(vec2(14.0, 0.0));
|
||||
let label_w = label.size().x;
|
||||
paint_start(ui, inner, label, ink);
|
||||
if let Some(g) = count {
|
||||
let rest = if rtl { inner.with_max_x(inner.right() - label_w - 8.0) } else { inner.with_min_x(inner.left() + label_w + 8.0) };
|
||||
paint_start(ui, rest, g, faint);
|
||||
}
|
||||
focus_ring(ui, s, rect.shrink(1.0), &resp);
|
||||
resp.widget_info(|| {
|
||||
let mut info = WidgetInfo::selected(Role::Tab, true, on, cell.label);
|
||||
info.value = cell.count.map(|n| n as f64);
|
||||
info
|
||||
});
|
||||
if resp.on_hover_cursor(CursorIcon::PointingHand).clicked() && !on {
|
||||
*value = cell.value.clone();
|
||||
changed = true;
|
||||
}
|
||||
rects.push(rect);
|
||||
}
|
||||
});
|
||||
if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_stroke(first.union(*last), s.look.corner, s.hairline(), StrokeKind::Inside);
|
||||
for r in rects.iter().skip(1) {
|
||||
let x = if rtl { r.right() } else { r.left() };
|
||||
painter.vline(x, first.y_range(), s.hairline());
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A small square status label on the sunken surface, with an optional dot before it.
|
||||
pub fn badge(ui: &mut Ui, s: &Skin, text: &str, color: Color32, dot: bool) -> Response {
|
||||
let galley = s.caps_galley(ui, text, 10.0, s.look.label.clone(), color, None);
|
||||
let dot_w = if dot { 12.0 } else { 0.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0 + dot_w, 22.0), Sense::hover());
|
||||
ui.painter().rect(rect, s.look.corner, s.p.sunken, s.hairline(), StrokeKind::Inside);
|
||||
let rtl = craft_ui::direction::is_rtl(ui);
|
||||
let inner = rect.shrink2(vec2(8.0, 0.0));
|
||||
if dot {
|
||||
let x = if rtl { inner.right() - 3.0 } else { inner.left() + 3.0 };
|
||||
ui.painter().circle_filled(pos2(x, rect.center().y), 3.0, color);
|
||||
}
|
||||
let text_rect = if rtl { inner.with_max_x(inner.right() - dot_w) } else { inner.with_min_x(inner.left() + dot_w) };
|
||||
paint_start(ui, text_rect, galley, color);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A slim bar along the bottom of `rect`: `fraction` done, or a moving segment when unknown.
|
||||
pub fn progress_bar(ui: &Ui, s: &Skin, rect: Rect, fraction: Option<f32>) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(rect, egui::CornerRadius::ZERO, s.p.line);
|
||||
let rtl = craft_ui::direction::is_rtl(ui);
|
||||
match fraction {
|
||||
Some(f) => {
|
||||
let w = rect.width() * f.clamp(0.0, 1.0);
|
||||
let bar = if rtl {
|
||||
Rect::from_min_max(pos2(rect.right() - w, rect.top()), rect.max)
|
||||
} else {
|
||||
Rect::from_min_size(rect.min, vec2(w, rect.height()))
|
||||
};
|
||||
painter.rect_filled(bar, egui::CornerRadius::ZERO, s.p.accent);
|
||||
}
|
||||
None => {
|
||||
let w = rect.width();
|
||||
let x = (ui.input(|i| i.time) * 0.8).fract() as f32 * w;
|
||||
for left in [x, x - w] {
|
||||
let seg = Rect::from_min_size(pos2(rect.left() + left, rect.top()), vec2(w * 0.3, rect.height())).intersect(rect);
|
||||
if seg.is_positive() {
|
||||
painter.rect_filled(seg, egui::CornerRadius::ZERO, s.p.accent);
|
||||
}
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Glyph {
|
||||
Search,
|
||||
Close,
|
||||
Info,
|
||||
}
|
||||
|
||||
/// Draws `glyph` centred on `c`, `size` across.
|
||||
pub fn glyph(painter: &Painter, glyph: Glyph, c: Pos2, size: f32, color: Color32) {
|
||||
let stroke = Stroke::new(1.5, color);
|
||||
let r = size / 2.0;
|
||||
match glyph {
|
||||
Glyph::Search => {
|
||||
let centre = c + vec2(-r * 0.2, -r * 0.2);
|
||||
painter.circle_stroke(centre, r * 0.6, stroke);
|
||||
painter.line_segment([centre + vec2(r * 0.45, r * 0.45), c + vec2(r, r)], stroke);
|
||||
}
|
||||
Glyph::Close => {
|
||||
let k = r * 0.75;
|
||||
painter.line_segment([c + vec2(-k, -k), c + vec2(k, k)], stroke);
|
||||
painter.line_segment([c + vec2(-k, k), c + vec2(k, -k)], stroke);
|
||||
}
|
||||
Glyph::Info => {
|
||||
painter.circle_stroke(c, r * 0.85, Stroke::new(1.2, color));
|
||||
painter.line_segment([c + vec2(0.0, -r * 0.05), c + vec2(0.0, r * 0.45)], Stroke::new(1.4, color));
|
||||
painter.circle_filled(c + vec2(0.0, -r * 0.38), 1.0, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A square button with a drawn glyph, outlined, inverting on hover.
|
||||
pub fn glyph_button(ui: &mut Ui, s: &Skin, g: Glyph, tip: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let painter = ui.painter();
|
||||
if hovered {
|
||||
painter.rect_filled(rect, s.look.corner, s.p.invert_bg);
|
||||
} else {
|
||||
painter.rect_stroke(rect, s.look.corner, s.hairline(), StrokeKind::Inside);
|
||||
}
|
||||
let color = if hovered { s.p.invert_fg } else { s.p.muted };
|
||||
glyph(painter, g, rect.center(), 12.0, color);
|
||||
focus_ring(ui, s, rect, &resp);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, tip));
|
||||
resp.on_hover_text(tip).on_hover_cursor(CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Names a widget for screen readers (and kittest) where it has no label of its own.
|
||||
pub fn name(ui: &Ui, resp: &Response, label: &str) {
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(label));
|
||||
}
|
||||
|
||||
/// A single-line field `width` wide, named `label`, with a search glyph at its start when
|
||||
/// `search`.
|
||||
pub fn field(ui: &mut Ui, s: &Skin, text: &mut String, hint: &str, label: &str, width: f32, search: bool) -> Response {
|
||||
let rtl = craft_ui::direction::is_rtl(ui);
|
||||
let pad = if search { 34.0 } else { 10.0 };
|
||||
let margin = if rtl {
|
||||
egui::Margin { left: 10, right: pad as i8, top: 0, bottom: 0 }
|
||||
} else {
|
||||
egui::Margin { left: pad as i8, right: 10, top: 0, bottom: 0 }
|
||||
};
|
||||
let edit = TextEdit::singleline(text)
|
||||
.hint_text(hint)
|
||||
.desired_width(width)
|
||||
.min_size(vec2(0.0, s.h()))
|
||||
.align(if rtl { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER })
|
||||
.margin(margin);
|
||||
let resp = ui.add(edit);
|
||||
if search {
|
||||
let x = if rtl { resp.rect.right() - 18.0 } else { resp.rect.left() + 18.0 };
|
||||
glyph(ui.painter(), Glyph::Search, pos2(x, resp.rect.center().y), 13.0, s.p.faint);
|
||||
}
|
||||
name(ui, &resp, label);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A drop-down `width` wide showing `selected`, named `label`.
|
||||
pub fn select<R>(ui: &mut Ui, id_salt: &str, label: &str, selected: &str, width: f32, add: impl FnOnce(&mut Ui) -> R) -> Option<R> {
|
||||
let inner = ComboBox::from_id_salt(id_salt).selected_text(selected).width(width).height(380.0).truncate().show_ui(ui, add);
|
||||
name(ui, &inner.response, label);
|
||||
inner.inner
|
||||
}
|
||||
|
||||
/// An inline note in a sunken, hairline box: an info glyph, then `add`.
|
||||
pub fn note<R>(ui: &mut Ui, s: &Skin, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
egui::Frame::new()
|
||||
.fill(s.p.sunken)
|
||||
.stroke(s.hairline())
|
||||
.corner_radius(s.look.corner)
|
||||
.inner_margin(egui::Margin { left: 14, right: 12, top: 10, bottom: 10 })
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
craft_ui::direction::horizontal(ui, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(16.0, s.h().min(28.0)), Sense::hover());
|
||||
glyph(ui.painter(), Glyph::Info, rect.center(), 14.0, s.p.muted);
|
||||
ui.add_space(4.0);
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A [`note`] with `add` beside buttons at its reading end: one per label in `buttons`, then a
|
||||
/// close glyph when `closable`. `button(ui, n)` draws the `n`th, the close glyph last.
|
||||
pub fn note_with_actions(ui: &mut Ui, s: &Skin, add: impl FnOnce(&mut Ui), buttons: &[&str], mut button: impl FnMut(&mut Ui, usize), closable: bool) {
|
||||
let gap = 10.0;
|
||||
let actions_w: f32 =
|
||||
buttons.iter().map(|b| s.button_galley(ui, b, s.p.ink).size().x + 28.0 + gap).sum::<f32>() + if closable { 28.0 + gap } else { 0.0 };
|
||||
note(ui, s, |ui| {
|
||||
let text_w = (ui.available_width() - actions_w - 8.0).max(120.0);
|
||||
ui.allocate_ui_with_layout(vec2(text_w, 0.0), craft_ui::direction::column(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
add(ui);
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 28.0), craft_ui::direction::trailing(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = gap;
|
||||
if closable {
|
||||
button(ui, buttons.len());
|
||||
}
|
||||
for n in (0..buttons.len()).rev() {
|
||||
button(ui, n);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A slim slider `width` wide for whole numbers in `range`, named `label`: drag or click the
|
||||
/// track, or step with the arrow keys (Shift for ten). It reads left to right in every interface
|
||||
/// direction, as sliders do.
|
||||
pub fn slider(ui: &mut Ui, s: &Skin, value: &mut f32, range: std::ops::RangeInclusive<f32>, width: f32, label: &str) -> Response {
|
||||
let (lo, hi) = (*range.start(), *range.end());
|
||||
let (rect, mut resp) = ui.allocate_exact_size(vec2(width, s.h()), Sense::click_and_drag());
|
||||
let track = Rect::from_center_size(rect.center(), vec2(width - 14.0, 2.0));
|
||||
let before = *value;
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
let t = ((pos.x - track.left()) / track.width()).clamp(0.0, 1.0);
|
||||
*value = (lo + t * (hi - lo)).round();
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
|
||||
});
|
||||
let step = ui.input(|i| {
|
||||
let n = if i.modifiers.shift { 10.0 } else { 1.0 };
|
||||
let up = i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::ArrowUp);
|
||||
let down = i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowDown);
|
||||
if up {
|
||||
n
|
||||
} else if down {
|
||||
-n
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
*value = (*value + step).clamp(lo, hi);
|
||||
}
|
||||
if *value != before {
|
||||
resp.mark_changed();
|
||||
}
|
||||
let t = ((*value - lo) / (hi - lo)).clamp(0.0, 1.0);
|
||||
let x = track.left() + t * track.width();
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(track, egui::CornerRadius::ZERO, s.p.line_strong);
|
||||
painter.rect_filled(Rect::from_min_max(track.min, pos2(x, track.bottom())), egui::CornerRadius::ZERO, s.p.ink);
|
||||
let handle = Rect::from_center_size(pos2(x, rect.center().y), vec2(12.0, 12.0));
|
||||
let hovered = resp.hovered() || resp.dragged();
|
||||
painter.rect(
|
||||
handle,
|
||||
egui::CornerRadius::ZERO,
|
||||
if hovered { s.p.ink_strong } else { s.p.invert_bg },
|
||||
Stroke::new(2.0, s.p.bg),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
if resp.has_focus() {
|
||||
painter.rect_stroke(handle.expand(4.0), egui::CornerRadius::ZERO, Stroke::new(2.0, s.p.accent_ink), StrokeKind::Inside);
|
||||
}
|
||||
resp.widget_info(|| WidgetInfo::slider(true, f64::from(*value), label));
|
||||
resp.on_hover_cursor(CursorIcon::ResizeHorizontal)
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! What Craft Font Manager needs from the suite's font store: the download consent, installing a
|
||||
//! family from the index, removing one, and downloading a family's files for a preview without
|
||||
//! installing them.
|
||||
//!
|
||||
//! [`SuiteStore`] is craft-font-store's store; [`FakeStore`] stands in for it in tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
|
||||
use craft_font_catalog::Catalog;
|
||||
pub use craft_font_store::UserSource;
|
||||
use craft_font_store::{Cancel, Client, Face, FontStore, Network, PINNED_INDEX, verify_font};
|
||||
|
||||
use crate::index::{Family, Index};
|
||||
|
||||
/// Store files to remove: a whole family, or some of its styles.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoveRequest {
|
||||
pub family: String,
|
||||
/// The files the catalog found for it in the font store.
|
||||
pub files: Vec<PathBuf>,
|
||||
/// The whole family, its licence included; otherwise only `files`.
|
||||
pub whole: bool,
|
||||
}
|
||||
|
||||
/// Bytes downloaded so far, of `total`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// A downloaded and verified file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Downloaded {
|
||||
pub path: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An error to show the user, in English.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoreError(pub String);
|
||||
|
||||
impl std::fmt::Display for StoreError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StoreError {}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> StoreError {
|
||||
StoreError(e.to_string())
|
||||
}
|
||||
|
||||
/// The font store. Calls block; the app makes the downloading ones on a worker thread.
|
||||
pub trait Installer: Send + Sync {
|
||||
/// Has the user allowed downloads? The setting is the whole suite's.
|
||||
fn downloads_allowed(&self) -> bool;
|
||||
|
||||
/// Turns downloads on or off, as the user's own action.
|
||||
fn allow_downloads(&self, allowed: bool) -> Result<(), StoreError>;
|
||||
|
||||
/// Downloads, verifies and installs every file of `family`, with its licence.
|
||||
fn install(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError>;
|
||||
|
||||
/// Installs one file of `family` (`path` as the index has it), with the family's licence.
|
||||
fn install_file(&self, index: &Index, family: &Family, path: &str, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError>;
|
||||
|
||||
/// Deletes a family's files from the store.
|
||||
fn remove(&self, request: &RemoveRequest) -> Result<(), StoreError>;
|
||||
|
||||
/// Downloads and verifies `family`'s files without installing them.
|
||||
fn download(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError>;
|
||||
|
||||
/// The indexes without a request: the built-in ones compiled in, and the sources the user
|
||||
/// added as last fetched.
|
||||
fn indexes(&self) -> Result<Index, StoreError> {
|
||||
crate::index::bundled().map_err(err)
|
||||
}
|
||||
|
||||
/// Downloads the indexes: the built-in ones at the pinned commit and the user's sources. A
|
||||
/// source that fails is in [`Index::problems`]; the others load.
|
||||
fn fetch_indexes(&self) -> Result<Index, StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
/// The index URLs the user added as sources. Their families are never curated.
|
||||
fn user_sources(&self) -> Vec<UserSource> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Adds an index URL (https, `.json` or `.json.gz`) as a source, as the user's own action.
|
||||
/// The next [`Installer::fetch_indexes`] downloads it.
|
||||
fn add_user_source(&self, _url: &str) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
/// Removes a source the user added. Families installed from it stay installed.
|
||||
fn remove_user_source(&self, _url: &str) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
}
|
||||
|
||||
const NO_FOLDER: &str = "there is no folder for the font store";
|
||||
|
||||
/// craft-font-store's store, in the user's font store folder.
|
||||
pub struct SuiteStore {
|
||||
store: FontStore,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl SuiteStore {
|
||||
/// The store the catalog's environment names, rescanning `catalog` after each change.
|
||||
pub fn for_user(catalog: Arc<Catalog>) -> Option<SuiteStore> {
|
||||
let store = FontStore::for_user(catalog.env())?.with_catalog(catalog);
|
||||
Some(SuiteStore { store, client: Client::default() })
|
||||
}
|
||||
|
||||
/// The store's source and name for the family holding `files`, which can differ from the
|
||||
/// name in the fonts themselves.
|
||||
fn installed_name(&self, request: &RemoveRequest) -> Option<(String, String)> {
|
||||
let dir = self.store.dir();
|
||||
let installed = self.store.installed();
|
||||
let lower = request.family.to_lowercase();
|
||||
installed
|
||||
.iter()
|
||||
.find(|f| request.files.iter().any(|path| path.starts_with(dir.join(&f.dir))))
|
||||
.or_else(|| installed.iter().find(|f| f.family.to_lowercase() == lower))
|
||||
.map(|f| (f.source.clone(), f.family.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Installer for SuiteStore {
|
||||
fn downloads_allowed(&self) -> bool {
|
||||
self.store.network() == Network::On
|
||||
}
|
||||
|
||||
fn allow_downloads(&self, allowed: bool) -> Result<(), StoreError> {
|
||||
if allowed { self.store.enable_network() } else { self.store.disable_network() }.map_err(err)
|
||||
}
|
||||
|
||||
fn install(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
let mut report = |p: craft_font_store::Progress| progress(Progress { done: p.done, total: p.total });
|
||||
self.store.install_family(index, family, &Cancel::new(), &mut report).map(drop).map_err(err)
|
||||
}
|
||||
|
||||
fn install_file(&self, index: &Index, family: &Family, path: &str, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
let file = family.files.iter().find(|f| f.path == path).ok_or_else(|| StoreError(format!("{path} isn't in the index")))?;
|
||||
let mut report = |p: craft_font_store::Progress| progress(Progress { done: p.done, total: p.total });
|
||||
self.store.install_face(index, Face { family, file }, &Cancel::new(), &mut report).map(drop).map_err(err)
|
||||
}
|
||||
|
||||
fn remove(&self, request: &RemoveRequest) -> Result<(), StoreError> {
|
||||
let (source, name) = self.installed_name(request).ok_or_else(|| err(craft_font_store::Error::NotInstalled(request.family.clone())))?;
|
||||
if request.whole {
|
||||
return self.store.remove_family(&source, &name).map_err(err);
|
||||
}
|
||||
let files: Vec<String> = request.files.iter().filter_map(|f| f.file_name()).map(|f| f.to_string_lossy().into_owned()).collect();
|
||||
self.store.remove_files(&source, &name, &files).map_err(err)
|
||||
}
|
||||
|
||||
fn download(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
if !self.downloads_allowed() {
|
||||
return Err(err(craft_font_store::Error::NetworkOff));
|
||||
}
|
||||
let total = family.size();
|
||||
let cancel = Cancel::new();
|
||||
let mut before = 0u64;
|
||||
let mut out = Vec::new();
|
||||
for file in &family.files {
|
||||
let mut reasons = Vec::new();
|
||||
for url in index.file_urls(family, file) {
|
||||
let mut on_bytes = |n: u64| progress(Progress { done: before.saturating_add(n), total });
|
||||
match self.client.get(&url, file.size, &cancel, &mut on_bytes) {
|
||||
Ok(bytes) => match verify_font(file, &bytes) {
|
||||
Ok(()) => {
|
||||
out.push(Downloaded { path: file.path.clone(), bytes });
|
||||
reasons.clear();
|
||||
break;
|
||||
}
|
||||
Err(e) => reasons.push(format!("{url}: {e}")),
|
||||
},
|
||||
Err(e) => reasons.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
if !reasons.is_empty() {
|
||||
return Err(StoreError(format!("downloading {} failed: {}", file.file_name(), reasons.join("; "))));
|
||||
}
|
||||
before = before.saturating_add(file.size);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn indexes(&self) -> Result<Index, StoreError> {
|
||||
Ok(self.store.offline_indexes())
|
||||
}
|
||||
|
||||
fn fetch_indexes(&self) -> Result<Index, StoreError> {
|
||||
self.store.fetch_indexes(&PINNED_INDEX, &Cancel::new()).map_err(err)
|
||||
}
|
||||
|
||||
fn user_sources(&self) -> Vec<UserSource> {
|
||||
self.store.user_sources()
|
||||
}
|
||||
|
||||
fn add_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
self.store.add_user_source(url).map_err(err)
|
||||
}
|
||||
|
||||
fn remove_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
self.store.remove_user_source(url).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// The store when the system names no folder for it (no home folder): nothing installs.
|
||||
pub struct NoStore;
|
||||
|
||||
impl Installer for NoStore {
|
||||
fn downloads_allowed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn allow_downloads(&self, _: bool) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn install(&self, _: &Index, _: &Family, _: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn install_file(&self, _: &Index, _: &Family, _: &str, _: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn remove(&self, _: &RemoveRequest) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn download(&self, _: &Index, _: &Family, _: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`FakeStore`] was asked to do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Call {
|
||||
Install(String),
|
||||
/// The family and the file's path in the index.
|
||||
InstallFile(String, String),
|
||||
Remove(String),
|
||||
Download(String),
|
||||
}
|
||||
|
||||
/// A font store without a network: "downloads" come from a table of file bytes by index path,
|
||||
/// and installs write them into a folder as the real store does, `<dir>/<family>/<file>`. It
|
||||
/// refuses to download while downloads are off, like the real one.
|
||||
pub struct FakeStore {
|
||||
dir: PathBuf,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
allowed: AtomicBool,
|
||||
calls: Mutex<Vec<Call>>,
|
||||
failure: Mutex<Option<String>>,
|
||||
/// A google/fonts checkout to read files missing from `files` from.
|
||||
folder: Option<PathBuf>,
|
||||
/// The built-in indexes; the bundled ones when not given.
|
||||
builtin: Option<Index>,
|
||||
/// Index files user sources "download", by URL.
|
||||
served_indexes: HashMap<String, Vec<u8>>,
|
||||
user: Mutex<Vec<UserSource>>,
|
||||
/// User sources downloaded so far, and those whose file changed since.
|
||||
fetched: Mutex<Vec<String>>,
|
||||
changed: Mutex<Vec<String>>,
|
||||
/// Downloads stop halfway while this is true.
|
||||
held: (Mutex<bool>, Condvar),
|
||||
}
|
||||
|
||||
impl FakeStore {
|
||||
pub fn new(dir: impl Into<PathBuf>, files: HashMap<String, Vec<u8>>) -> FakeStore {
|
||||
FakeStore {
|
||||
dir: dir.into(),
|
||||
files,
|
||||
allowed: AtomicBool::new(false),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
failure: Mutex::new(None),
|
||||
folder: None,
|
||||
builtin: None,
|
||||
served_indexes: HashMap::new(),
|
||||
user: Mutex::new(Vec::new()),
|
||||
fetched: Mutex::new(Vec::new()),
|
||||
changed: Mutex::new(Vec::new()),
|
||||
held: (Mutex::new(false), Condvar::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses `index` as the built-in indexes.
|
||||
pub fn with_index(mut self, index: Index) -> FakeStore {
|
||||
self.builtin = Some(index);
|
||||
self
|
||||
}
|
||||
|
||||
/// Has the index at `url` "download" as `bytes` once added as a source.
|
||||
pub fn serving_index(mut self, url: &str, bytes: Vec<u8>) -> FakeStore {
|
||||
self.served_indexes.insert(url.to_owned(), bytes);
|
||||
self
|
||||
}
|
||||
|
||||
/// The index at `url` now differs from the one first downloaded.
|
||||
pub fn change_index(&self, url: &str) {
|
||||
if let Ok(mut changed) = self.changed.lock() {
|
||||
changed.push(url.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// Also "downloads" files from a google/fonts checkout, by their path in the index.
|
||||
pub fn serving(mut self, checkout: impl Into<PathBuf>) -> FakeStore {
|
||||
self.folder = Some(checkout.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The next download fails with `error`, as when the network is down.
|
||||
pub fn fail_next(&self, error: &str) {
|
||||
if let Ok(mut failure) = self.failure.lock() {
|
||||
*failure = Some(error.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads report half their bytes, then wait until [`FakeStore::release`] (with
|
||||
/// `Runner::Thread`).
|
||||
pub fn hold(&self) {
|
||||
if let Ok(mut held) = self.held.0.lock() {
|
||||
*held = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release(&self) {
|
||||
if let Ok(mut held) = self.held.0.lock() {
|
||||
*held = false;
|
||||
}
|
||||
self.held.1.notify_all();
|
||||
}
|
||||
|
||||
fn wait_while_held(&self) {
|
||||
let Ok(mut held) = self.held.0.lock() else { return };
|
||||
while *held {
|
||||
match self.held.1.wait(held) {
|
||||
Ok(h) => held = h,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything asked of it, downloads refused for lack of consent included.
|
||||
pub fn calls(&self) -> Vec<Call> {
|
||||
self.calls.lock().map(|c| c.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn record(&self, call: Call) {
|
||||
if let Ok(mut calls) = self.calls.lock() {
|
||||
calls.push(call);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch(&self, family: &Family, only: Option<&str>, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
if !self.downloads_allowed() {
|
||||
return Err(StoreError("downloading fonts is turned off".into()));
|
||||
}
|
||||
if let Some(error) = self.failure.lock().ok().and_then(|mut f| f.take()) {
|
||||
return Err(StoreError(error));
|
||||
}
|
||||
let wanted: Vec<_> = family.files.iter().filter(|f| only.is_none_or(|p| p == f.path)).collect();
|
||||
let total = wanted.iter().map(|f| f.size).sum();
|
||||
progress(Progress { done: total / 2, total });
|
||||
self.wait_while_held();
|
||||
let mut done = 0u64;
|
||||
let mut out = Vec::new();
|
||||
for file in wanted {
|
||||
let from_folder = || self.folder.as_ref().and_then(|dir| std::fs::read(dir.join(&file.path)).ok());
|
||||
let bytes =
|
||||
self.files.get(&file.path).cloned().or_else(from_folder).ok_or_else(|| StoreError(format!("{} isn't available", file.path)))?;
|
||||
done = done.saturating_add(file.size);
|
||||
progress(Progress { done, total });
|
||||
out.push(Downloaded { path: file.path.clone(), bytes });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write(&self, family: &Family, files: Vec<Downloaded>) -> Result<(), StoreError> {
|
||||
let dir = self.dir.join(&family.family);
|
||||
std::fs::create_dir_all(&dir).map_err(err)?;
|
||||
for file in files {
|
||||
let name = file.path.rsplit('/').next().unwrap_or(&file.path);
|
||||
std::fs::write(dir.join(name), &file.bytes).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn in_store(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Installer for FakeStore {
|
||||
fn downloads_allowed(&self) -> bool {
|
||||
self.allowed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn allow_downloads(&self, allowed: bool) -> Result<(), StoreError> {
|
||||
self.allowed.store(allowed, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(&self, _: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
self.record(Call::Install(family.family.clone()));
|
||||
let files = self.fetch(family, None, progress)?;
|
||||
self.write(family, files)
|
||||
}
|
||||
|
||||
fn install_file(&self, _: &Index, family: &Family, path: &str, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
self.record(Call::InstallFile(family.family.clone(), path.to_owned()));
|
||||
let files = self.fetch(family, Some(path), progress)?;
|
||||
self.write(family, files)
|
||||
}
|
||||
|
||||
fn remove(&self, request: &RemoveRequest) -> Result<(), StoreError> {
|
||||
self.record(Call::Remove(request.family.clone()));
|
||||
if let Some(outside) = request.files.iter().find(|f| !self.in_store(f)) {
|
||||
return Err(StoreError(format!("{} isn't in the font store", outside.display())));
|
||||
}
|
||||
for file in &request.files {
|
||||
std::fs::remove_file(file).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download(&self, _: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
self.record(Call::Download(family.family.clone()));
|
||||
self.fetch(family, None, progress)
|
||||
}
|
||||
|
||||
fn indexes(&self) -> Result<Index, StoreError> {
|
||||
let builtin = match &self.builtin {
|
||||
Some(index) => index.clone(),
|
||||
None => crate::index::bundled().map_err(err)?,
|
||||
};
|
||||
let fetched = self.fetched.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
let changed = self.changed.lock().map(|c| c.clone()).unwrap_or_default();
|
||||
let mut users = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for source in self.user_sources() {
|
||||
let problem = |error: String| craft_font_store::SourceProblem { source: source.url.clone(), name: source.url.clone(), error };
|
||||
if !fetched.contains(&source.url) {
|
||||
problems.push(problem("not downloaded yet".into()));
|
||||
continue;
|
||||
}
|
||||
match self.served_indexes.get(&source.url).map(|bytes| Index::from_user_bytes(bytes, &source.url)) {
|
||||
Some(Ok(mut index)) => {
|
||||
if changed.contains(&source.url) {
|
||||
index.mark_changed();
|
||||
}
|
||||
users.push(index);
|
||||
}
|
||||
Some(Err(e)) => problems.push(problem(e.to_string())),
|
||||
None => problems.push(problem("nothing at this address".into())),
|
||||
}
|
||||
}
|
||||
Ok(Index::merge(std::iter::once(builtin).chain(users)).with_problems(problems))
|
||||
}
|
||||
|
||||
fn fetch_indexes(&self) -> Result<Index, StoreError> {
|
||||
if !self.downloads_allowed() {
|
||||
return Err(StoreError("downloading fonts is turned off".into()));
|
||||
}
|
||||
if let Ok(mut fetched) = self.fetched.lock() {
|
||||
*fetched = self.user_sources().into_iter().map(|s| s.url).collect();
|
||||
}
|
||||
self.indexes()
|
||||
}
|
||||
|
||||
fn user_sources(&self) -> Vec<UserSource> {
|
||||
self.user.lock().map(|u| u.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn add_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(StoreError("an index address starts with https://".into()));
|
||||
}
|
||||
let mut user = self.user.lock().map_err(err)?;
|
||||
if !user.iter().any(|s| s.url == url) {
|
||||
user.push(UserSource { url: url.to_owned(), added_at: 0, last_sha256: None });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
self.user.lock().map_err(err)?.retain(|s| s.url != url);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! The main flows against a small index, a font folder made per test and a fake font store.
|
||||
//! `CRAFT_FONT_MANAGER_SNAPSHOTS=<dir>` also saves screenshots; run that with
|
||||
//! `-- --test-threads=1`, as rendering on several threads at once can crash older Vulkan loaders.
|
||||
//! With `CRAFT_FONT_MANAGER_GOOGLE_FONTS=<google/fonts checkout>` the fake store also "downloads"
|
||||
//! the index's files from it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_font_manager::craft_font_catalog::Os;
|
||||
use craft_font_manager::index::Index;
|
||||
use craft_font_manager::store::{Call, FakeStore, Installer};
|
||||
use craft_font_manager::{Catalog, FontEnv, Source};
|
||||
use craft_font_manager::{FontsPanel, Look, Options, Palette, Runner, Settings, Tab, install_interface_fonts};
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{By, NodeT, Queryable};
|
||||
|
||||
const HACK: &str = "ofl/hack/Hack-Regular.ttf";
|
||||
const UBUNTU: &str = "ufl/ubuntu/Ubuntu-Light.ttf";
|
||||
|
||||
fn index() -> Index {
|
||||
let file = |path: &str, size: usize| {
|
||||
format!(r#"{{"path":"{path}","size":{size},"sha256":"{}","style":"normal","weight":400,"axes":[],"postscript":[]}}"#, "0a".repeat(32))
|
||||
};
|
||||
let family = |name: &str, category: &str, languages: &str, scripts: &str, files: String| {
|
||||
format!(
|
||||
r#"{{"family":"{name}","designer":"","categories":["{category}"],"languages":[{languages}],"scripts":[{scripts}],"license":"OFL","license_path":null,"files":[{files}]}}"#
|
||||
)
|
||||
};
|
||||
let json = format!(
|
||||
r#"{{"schema":1,"version":"1.0","source":"google/fonts","commit":"45e3d5269e4a17d823f6d84470bc084dc5ae46f6","base_url":"https://cdn.jsdelivr.net/gh/google/fonts@45e3d5269e4a17d823f6d84470bc084dc5ae46f6/","fallback_base_url":"https://raw.githubusercontent.com/google/fonts/45e3d5269e4a17d823f6d84470bc084dc5ae46f6/",
|
||||
"languages":[
|
||||
{{"id":"ar_Arab","name":"Arabic","autonym":"العربية","script":"Arab","population":1,"historical":false}},
|
||||
{{"id":"en_Latn","name":"English","autonym":"English","script":"Latn","population":1,"historical":false}},
|
||||
{{"id":"ja_Jpan","name":"Japanese","autonym":"日本語","script":"Jpan","population":1,"historical":false}}],
|
||||
"scripts":[{{"id":"Arab","name":"Arabic"}},{{"id":"Jpan","name":"Japanese"}},{{"id":"Latn","name":"Latin"}}],
|
||||
"families":[{},{},{},{}]}}"#,
|
||||
family("Amiri", "SERIF", "0,1", r#""Arab","Latn""#, file("ofl/amiri/Amiri-Regular.ttf", 1000)),
|
||||
family("Hack", "MONOSPACE", "1", r#""Latn""#, file(HACK, epaint_default_fonts::HACK_REGULAR.len())),
|
||||
family("Noto Sans JP", "SANS_SERIF", "1,2", r#""Jpan","Latn""#, file("ofl/notosansjp/NotoSansJP[wght].ttf", 1000)),
|
||||
family("Ubuntu", "SANS_SERIF", "1", r#""Latn""#, file(UBUNTU, epaint_default_fonts::UBUNTU_LIGHT.len())),
|
||||
);
|
||||
Index::from_json(json.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// A system with Ubuntu Light installed, an empty font store, and a fake store that can "download"
|
||||
/// Hack and Ubuntu Light.
|
||||
fn setup(name: &str, settings: Settings) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
|
||||
setup_with(name, settings, Runner::Inline, |_| {})
|
||||
}
|
||||
|
||||
/// [`setup`] with a job runner and the context set up by `fonts` (the interface fonts and
|
||||
/// direction), at the launcher's window size less its header and footer.
|
||||
fn setup_with(
|
||||
name: &str,
|
||||
settings: Settings,
|
||||
runner: Runner,
|
||||
fonts: impl Fn(&egui::Context) + 'static,
|
||||
) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
|
||||
let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("flows").join(name);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let system = root.join("usr/share/fonts");
|
||||
std::fs::create_dir_all(&system).unwrap();
|
||||
std::fs::write(system.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap();
|
||||
let store_dir = root.join("store");
|
||||
std::fs::create_dir_all(&store_dir).unwrap();
|
||||
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(store_dir.clone()), ..FontEnv::default() };
|
||||
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
|
||||
catalog.rescan();
|
||||
let files = HashMap::from([
|
||||
(HACK.to_owned(), epaint_default_fonts::HACK_REGULAR.to_vec()),
|
||||
(UBUNTU.to_owned(), epaint_default_fonts::UBUNTU_LIGHT.to_vec()),
|
||||
]);
|
||||
let store = FakeStore::new(store_dir, files);
|
||||
let store = Arc::new(match std::env::var_os("CRAFT_FONT_MANAGER_GOOGLE_FONTS") {
|
||||
Some(checkout) => store.serving(checkout),
|
||||
None => store,
|
||||
});
|
||||
let options = Options { catalog, installer: store.clone(), index: Ok(index()), runner };
|
||||
let app = FontsPanel::new(options, settings);
|
||||
let mut harness = Harness::builder()
|
||||
.with_size([1120.0, 678.0])
|
||||
// Autonyms such as العربية need fonts beyond egui's; the tests don't depend on the host's.
|
||||
.allow_missing_glyphs()
|
||||
.with_context_setup(move |ctx| {
|
||||
install_interface_fonts(ctx, &[]);
|
||||
fonts(ctx);
|
||||
})
|
||||
.build_ui_state(|ui, app: &mut FontsPanel| app.ui(ui), app);
|
||||
harness.run_steps(4);
|
||||
(harness, store)
|
||||
}
|
||||
|
||||
fn settle(h: &mut Harness<'_, FontsPanel>) {
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, FontsPanel>, label: &str) {
|
||||
h.get_all_by_label(label).next().unwrap_or_else(|| panic!("no {label:?}")).click();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
/// Clicks the first node whose name has every one of `parts` (names with a placeholder are
|
||||
/// isolated for right-to-left text).
|
||||
fn click_parts(h: &mut Harness<'_, FontsPanel>, parts: &[&str]) {
|
||||
let wanted: Vec<String> = parts.iter().map(|p| (*p).to_owned()).collect();
|
||||
let by = By::new().predicate(move |n| n.label().is_some_and(|l| wanted.iter().all(|p| l.contains(p.as_str()))));
|
||||
h.get_all(by).next().unwrap_or_else(|| panic!("no {parts:?}")).click();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn click_role(h: &mut Harness<'_, FontsPanel>, role: Role, label: &str) {
|
||||
h.get_by_role_and_label(role, label).click();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn type_into(h: &mut Harness<'_, FontsPanel>, label: &str, text: &str) {
|
||||
h.get_by_role_and_label(Role::TextInput, label).focus();
|
||||
h.step();
|
||||
h.get_by_role_and_label(Role::TextInput, label).type_text(text);
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn allow(h: &mut Harness<'_, FontsPanel>) {
|
||||
h.state_mut().set_downloads_allowed(true).unwrap();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn installed_tab(h: &mut Harness<'_, FontsPanel>) {
|
||||
click_role(h, Role::Tab, "Installed");
|
||||
assert_eq!(h.state().tab(), Tab::Installed);
|
||||
}
|
||||
|
||||
fn has_installed(h: &Harness<'_, FontsPanel>, family: &str) -> bool {
|
||||
h.state().installed().iter().any(|f| f.name == family)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_and_filters_narrow_the_catalogue() {
|
||||
let (mut h, _) = setup("search", Settings::default());
|
||||
assert_eq!(h.state().tab(), Tab::Catalogue);
|
||||
assert_eq!(h.state().results(), ["Amiri", "Hack", "Noto Sans JP", "Ubuntu"]);
|
||||
|
||||
type_into(&mut h, "Search", "noto");
|
||||
assert_eq!(h.state().results(), ["Noto Sans JP"]);
|
||||
|
||||
type_into(&mut h, "Search", "XYZ");
|
||||
assert!(h.state().results().is_empty());
|
||||
assert!(h.query_by_label("No fonts match").is_some());
|
||||
click(&mut h, "Clear search and filters");
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
|
||||
click_role(&mut h, Role::RadioButton, "Serif");
|
||||
assert_eq!(h.state().results(), ["Amiri"]);
|
||||
click_role(&mut h, Role::RadioButton, "All categories");
|
||||
|
||||
// Ubuntu Light is installed on the system.
|
||||
click_role(&mut h, Role::RadioButton, "Installed");
|
||||
assert_eq!(h.state().results(), ["Ubuntu"]);
|
||||
click_role(&mut h, Role::RadioButton, "Not installed");
|
||||
assert_eq!(h.state().results(), ["Amiri", "Hack", "Noto Sans JP"]);
|
||||
click(&mut h, "Clear filters");
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_filter_finds_languages_by_their_own_name() {
|
||||
let (mut h, _) = setup("language", Settings::default());
|
||||
click_role(&mut h, Role::ComboBox, "Language");
|
||||
type_into(&mut h, "Find a language", "日本");
|
||||
assert!(h.query_by_label("Arabic · العربية").is_none());
|
||||
click(&mut h, "Japanese · 日本語");
|
||||
assert_eq!(h.state().results(), ["Noto Sans JP"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_a_family_then_remove_it_from_its_card() {
|
||||
let (mut h, store) = setup("install", Settings::default());
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
|
||||
click(&mut h, "Hack");
|
||||
assert_eq!(h.state().details_family().as_deref(), Some("Hack"));
|
||||
click(&mut h, "Install all");
|
||||
click(&mut h, "Download");
|
||||
assert_eq!(store.calls(), [Call::Install("Hack".into())]);
|
||||
assert!(h.state().downloads_allowed());
|
||||
let hack = h.state().installed().iter().find(|f| f.name == "Hack").cloned().expect("Hack is listed");
|
||||
assert_eq!(hack.source, Source::Store);
|
||||
assert!(h.query_by_label("Remove all").is_some());
|
||||
|
||||
h.key_press(egui::Key::Escape);
|
||||
settle(&mut h);
|
||||
assert!(h.state().details_family().is_none());
|
||||
installed_tab(&mut h);
|
||||
click(&mut h, "Remove Hack");
|
||||
assert_eq!(store.calls().last(), Some(&Call::Remove("Hack".into())));
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_and_remove_one_style() {
|
||||
let (mut h, store) = setup("style", Settings::default());
|
||||
allow(&mut h);
|
||||
click(&mut h, "Hack");
|
||||
click(&mut h, "Install Regular");
|
||||
assert_eq!(store.calls(), [Call::InstallFile("Hack".into(), HACK.into())]);
|
||||
assert!(has_installed(&h, "Hack"));
|
||||
|
||||
click(&mut h, "Remove Regular");
|
||||
assert_eq!(store.calls().last(), Some(&Call::Remove("Hack".into())));
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_fonts_cannot_be_removed() {
|
||||
let (mut h, store) = setup("system", Settings::default());
|
||||
installed_tab(&mut h);
|
||||
let system = h.state().installed().first().cloned().expect("the system font is listed");
|
||||
assert_eq!(system.source, Source::System);
|
||||
assert!(h.query_by_label(&format!("Remove {}", system.name)).is_none());
|
||||
click(&mut h, &system.name);
|
||||
let remove = h.get_by_label("Remove all");
|
||||
assert!(remove.accesskit_node().is_disabled());
|
||||
remove.click();
|
||||
settle(&mut h);
|
||||
assert!(store.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_downloads_without_consent() {
|
||||
let (mut h, store) = setup("consent", Settings::default());
|
||||
assert!(h.query_by_label("Downloads are off").is_some());
|
||||
click(&mut h, "Hack");
|
||||
|
||||
click(&mut h, "Preview");
|
||||
assert!(h.query_by_label("Download from Google Fonts?").is_some());
|
||||
click(&mut h, "Cancel");
|
||||
click(&mut h, "Install all");
|
||||
click(&mut h, "Cancel");
|
||||
assert!(store.calls().is_empty());
|
||||
assert!(!h.state().downloads_allowed());
|
||||
assert!(h.query_by_label("Download from Google Fonts?").is_none());
|
||||
h.key_press(egui::Key::Escape);
|
||||
settle(&mut h);
|
||||
|
||||
// Allowed from the note, a preview downloads without asking and installs nothing.
|
||||
click_role(&mut h, Role::Button, "Allow downloads");
|
||||
assert!(h.state().downloads_allowed());
|
||||
assert!(h.query_by_label("Downloads are off").is_none());
|
||||
click(&mut h, "Preview Hack");
|
||||
assert_eq!(store.calls(), [Call::Download("Hack".into())]);
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_install_offers_retry() {
|
||||
let (mut h, store) = setup("retry", Settings::default());
|
||||
allow(&mut h);
|
||||
store.fail_next("network unreachable");
|
||||
click(&mut h, "Install Hack");
|
||||
assert!(h.query_by_label("Couldn't install").is_some());
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
click(&mut h, "Retry");
|
||||
assert!(has_installed(&h, "Hack"));
|
||||
assert!(h.query_by_label("Couldn't install").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_between_cards_and_enter_opens_one() {
|
||||
let (mut h, _) = setup("keys", Settings::default());
|
||||
h.get_by_role_and_label(Role::Button, "Amiri").focus();
|
||||
settle(&mut h);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
settle(&mut h);
|
||||
h.key_press(egui::Key::Enter);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().details_family().as_deref(), Some("Hack"));
|
||||
}
|
||||
|
||||
const USER_INDEX: &str = "https://fonts.example.org/index.json";
|
||||
|
||||
/// A source a user could add: another "Hack", from an index of its own.
|
||||
fn user_index() -> Vec<u8> {
|
||||
format!(
|
||||
r#"{{"schema":1,"version":"1.1","source":"examplefonts","commit":"{commit}",
|
||||
"source_info":{{"id":"examplefonts","name":"Example Fonts","curated":true,"file_urls":["https://fonts.example.org/files/{{path}}"]}},
|
||||
"languages":[{{"id":"en_Latn","name":"English","script":"Latn","population":1}}],
|
||||
"scripts":[{{"id":"Latn","name":"Latin"}}],
|
||||
"families":[{{"family":"Hack","source":"examplefonts","categories":["MONOSPACE"],"license":"OFL","license_path":null,"languages":[0],"scripts":["Latn"],
|
||||
"files":[{{"path":"{HACK}","size":{size},"sha256":"{sha}","style":"normal","weight":400,"axes":[],"postscript":[]}}]}}]}}"#,
|
||||
commit = "1".repeat(40),
|
||||
size = epaint_default_fonts::HACK_REGULAR.len(),
|
||||
sha = "0a".repeat(32),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// The panel with its sources beside it, as a host's settings show them.
|
||||
fn sources_setup(name: &str) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
|
||||
let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("flows").join(name);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let store_dir = root.join("store");
|
||||
std::fs::create_dir_all(&store_dir).unwrap();
|
||||
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(store_dir.clone()), ..FontEnv::default() };
|
||||
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
|
||||
catalog.rescan();
|
||||
let store = Arc::new(FakeStore::new(store_dir, HashMap::new()).with_index(index()).serving_index(USER_INDEX, user_index()));
|
||||
store.allow_downloads(true).unwrap();
|
||||
let options = Options { catalog, installer: store.clone(), index: store.indexes().map_err(|e| e.0), runner: Runner::Inline };
|
||||
let mut harness = Harness::builder()
|
||||
.with_size([1400.0, 760.0])
|
||||
.allow_missing_glyphs()
|
||||
.with_context_setup(|ctx| install_interface_fonts(ctx, &[]))
|
||||
.build_ui_state(
|
||||
|ui, app: &mut FontsPanel| {
|
||||
egui::Panel::left("sources").exact_size(420.0).show(ui, |ui| app.sources_ui(ui));
|
||||
egui::CentralPanel::default().show(ui, |ui| app.ui(ui));
|
||||
},
|
||||
FontsPanel::new(options, Settings::default()),
|
||||
);
|
||||
harness.run_steps(4);
|
||||
(harness, store)
|
||||
}
|
||||
|
||||
/// Steps until `done`, for work on the panel's own threads.
|
||||
fn wait(h: &mut Harness<'_, FontsPanel>, done: impl Fn(&FontsPanel) -> bool) {
|
||||
for _ in 0..200 {
|
||||
if done(h.state()) {
|
||||
settle(h);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
h.step();
|
||||
}
|
||||
panic!("timed out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_source_is_not_curated_and_can_be_removed() {
|
||||
let (mut h, store) = sources_setup("sources");
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
type_into(&mut h, "Index address", USER_INDEX);
|
||||
click_role(&mut h, Role::Button, "Add");
|
||||
wait(&mut h, |p| p.results().len() == 5);
|
||||
// Two families named Hack, one from each source, each with its own card.
|
||||
assert_eq!(h.state().results().iter().filter(|f| *f == "Hack").count(), 2);
|
||||
assert!(h.query_all_by_label("Not curated").count() >= 2, "a badge on the card and one in the sources");
|
||||
assert!(h.query_by_label("Changed since added").is_none());
|
||||
|
||||
store.change_index(USER_INDEX);
|
||||
click(&mut h, "Update sources");
|
||||
wait(&mut h, |p| !p.fetching_sources());
|
||||
assert!(h.query_by_label("Changed since added").is_some());
|
||||
|
||||
click_parts(&mut h, &["Remove", USER_INDEX]);
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
assert!(store.user_sources().is_empty());
|
||||
}
|
||||
|
||||
/// The launcher's colours, for screenshots that look as the panel does there.
|
||||
fn launcher_look() -> Look {
|
||||
let hex = |rgb: u32| egui::Color32::from_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8);
|
||||
let ink = |rgb: u32, a: u8| egui::Color32::from_rgba_unmultiplied((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, a);
|
||||
let dark = Palette {
|
||||
bg: hex(0x121316),
|
||||
sunken: hex(0x060607),
|
||||
ink: hex(0xf2f1ee),
|
||||
ink_strong: hex(0xffffff),
|
||||
muted: ink(0xf2f1ee, 153),
|
||||
faint: ink(0xf2f1ee, 97),
|
||||
line: ink(0xf2f1ee, 38),
|
||||
line_strong: ink(0xf2f1ee, 102),
|
||||
accent: hex(0x2d81ff),
|
||||
accent_ink: hex(0x74aaff),
|
||||
invert_bg: hex(0xffffff),
|
||||
invert_fg: hex(0x0b0b0c),
|
||||
good: hex(0x3fd6b8),
|
||||
warn: hex(0xffc14d),
|
||||
bad: hex(0xff6259),
|
||||
};
|
||||
Look { dark: Some(dark), caps: true, corner: egui::CornerRadius::ZERO, control_height: 34.0, ..Look::default() }
|
||||
}
|
||||
|
||||
fn save(h: &mut Harness<'_, FontsPanel>, dir: &std::ffi::OsStr, name: &str) {
|
||||
h.run_steps(8);
|
||||
let image = h.render().unwrap();
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
image.save(PathBuf::from(dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
/// The interface fonts with the system's for the scripts egui's own faces lack.
|
||||
fn system_fonts(language: &'static str) -> impl Fn(&egui::Context) + 'static {
|
||||
move |ctx| {
|
||||
use craft_ui::fonts::{I18nScripts, UiFonts};
|
||||
let provider = Arc::new(egui_system_font_provider::SystemFontProvider::new().with_locale(Some(language)));
|
||||
UiFonts::new(&I18nScripts).languages(&["en", language]).ui_language(language).system_provider(provider).install(ctx);
|
||||
craft_ui::direction::apply(ctx, craft_ui::direction::resolve(language, craft_ui::direction::Setting::Language));
|
||||
}
|
||||
}
|
||||
|
||||
/// `CRAFT_FONT_MANAGER_SNAPSHOTS=<dir>`: the panel's main states.
|
||||
#[test]
|
||||
fn screenshots() {
|
||||
let Some(dir) = std::env::var_os("CRAFT_FONT_MANAGER_SNAPSHOTS") else { return };
|
||||
let shot = |name: &str, language: &'static str, f: &dyn Fn(&mut Harness<'_, FontsPanel>, &FakeStore)| {
|
||||
let (mut h, store) = setup_with(&format!("shot-{name}"), Settings::default(), Runner::Thread, system_fonts(language));
|
||||
h.state_mut().set_look(launcher_look());
|
||||
h.state_mut().set_language(language);
|
||||
settle(&mut h);
|
||||
f(&mut h, &store);
|
||||
save(&mut h, &dir, name);
|
||||
store.release();
|
||||
};
|
||||
shot("catalogue-downloads-off", "en", &|_, _| {});
|
||||
shot("catalogue", "en", &|h, _| {
|
||||
allow(h);
|
||||
click(h, "Preview Hack");
|
||||
h.run_steps(20);
|
||||
});
|
||||
shot("downloading", "en", &|h, store| {
|
||||
allow(h);
|
||||
store.hold();
|
||||
click(h, "Install Hack");
|
||||
h.run_steps(10);
|
||||
});
|
||||
shot("failed", "en", &|h, store| {
|
||||
allow(h);
|
||||
store.fail_next("couldn't reach cdn.jsdelivr.net or raw.githubusercontent.com");
|
||||
click(h, "Install Hack");
|
||||
h.run_steps(20);
|
||||
});
|
||||
shot("empty-search", "en", &|h, _| type_into(h, "Search", "Garamondish"));
|
||||
shot("installed", "en", &|h, _| {
|
||||
allow(h);
|
||||
click(h, "Install Hack");
|
||||
h.run_steps(20);
|
||||
installed_tab(h);
|
||||
});
|
||||
shot("details", "en", &|h, _| {
|
||||
allow(h);
|
||||
click(h, "Preview Hack");
|
||||
h.run_steps(20);
|
||||
click(h, "Hack");
|
||||
});
|
||||
shot("details-not-installed", "en", &|h, _| click(h, "Noto Sans JP"));
|
||||
shot("arabic", "ar", &|h, _| {
|
||||
allow(h);
|
||||
click_parts(h, &["معاينة", "Amiri"]);
|
||||
h.run_steps(20);
|
||||
click_role(h, Role::ComboBox, "اللغة");
|
||||
type_into(h, "ابحث عن لغة", "Arabic");
|
||||
click(h, "Arabic · العربية");
|
||||
});
|
||||
shot("arabic-details", "ar", &|h, _| {
|
||||
allow(h);
|
||||
click_parts(h, &["معاينة", "Amiri"]);
|
||||
h.run_steps(20);
|
||||
click(h, "Amiri");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "craft-font-store"
|
||||
description = "The Craft apps' font store: the font indexes (Google Fonts, Noto, sources the user adds), exact font lookup, and verified installs into the suite's font folder."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The pinned built-in indexes compiled in (`Index::bundled`), about 620 KB gzipped, so an app can
|
||||
# look fonts up with no request at all.
|
||||
bundled-index = []
|
||||
|
||||
[dependencies]
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
skrifa = { version = "0.47", default-features = false, features = ["std"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# The store and the network are native only; the web keeps fonts elsewhere.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-atomic = { workspace = true }
|
||||
craft-font-catalog = { workspace = true }
|
||||
# Blocking HTTPS with rustls and webpki roots: no async runtime, no OpenSSL.
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] }
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.54"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,122 @@
|
||||
# craft-font-store
|
||||
|
||||
The suite's font store: the font indexes of every source, exact lookup of a missing font, and verified installs into the one folder every app's font catalog reads ([`craft-font-catalog`](../font-catalog/README.md)'s `font_store_dir`).
|
||||
|
||||
The indexes are [aiongg/craft-font-index](https://github.com/aiongg/craft-font-index) (format 1.x), designed by @kntrttm for photocraft#1135 and #2453. Every font file and licence in an index has a size and a sha256, and only families under a licence that allows redistribution (OFL, Apache 2.0, Ubuntu Font Licence) are listed.
|
||||
|
||||
## Sources
|
||||
|
||||
| Source | ID | Curated | Fonts come from |
|
||||
|---|---|---|---|
|
||||
| Google Fonts | `google/fonts` | yes | [google/fonts](https://github.com/google/fonts) at the commit the index records |
|
||||
| Noto | `notofonts` | yes | [notofonts](https://github.com/notofonts) repositories at the commits and release tags the index records (the full CJK fonts from noto-cjk, and script families, widths and UI variants Google Fonts lacks) |
|
||||
| An index the user adds | `user:<host>/<id>` | never | wherever its URL templates point (https only) |
|
||||
|
||||
The built-in sources are the indexes `sources.json` lists. `PINNED_INDEX` names a commit of the index repository and the sha256 of its `sources.json`; each index must have the size and sha256 `sources.json` gives it.
|
||||
|
||||
A source the user adds is an index file at an https URL (`.json` or `.json.gz`). It is checked by the same rules as a built-in one, but:
|
||||
|
||||
- it is never curated, whatever its file says, so the interface can mark its families "not curated";
|
||||
- its ID is prefixed with `user:<host>/`, so it can't stand in for a built-in source;
|
||||
- the sha256 of the file first fetched is kept, and a different file later shows as `FontSource::changed`;
|
||||
- only an app's own interface can add one (`add_user_source`); an agent gets `Error::NotAllowed`.
|
||||
|
||||
## Network access
|
||||
|
||||
| Rule | How |
|
||||
|---|---|
|
||||
| Off until the user turns it on | `FontStore::network()` is `Network::Off` until `enable_network()`. The setting is `settings.json` in the store folder, so it holds for every app. |
|
||||
| Only the user turns it on | `FontStore::for_user` is for the app's own interface; call `enable_network()` from a setting or from the user's Install click. `FontStore::for_agent` (MCP, CLI, scripting) gets `Error::NotAllowed`. An agent can install while the user has the network on. |
|
||||
| No requests at startup | Nothing in the crate makes a request on its own. Only `fetch_indexes` and the installs do, and they return `Error::NetworkOff` without one while the network is off. |
|
||||
| One HTTP client | `Client`: ureq with rustls and the webpki roots, HTTPS only, no cookies, `User-Agent: craft-font-store/<version>`. Timeouts: 15 s to connect, 30 s to the response headers, 10 minutes per file. |
|
||||
|
||||
A file's URLs come from its source's URL templates, best first. Both built-in sources use `https://cdn.jsdelivr.net/gh/{repo}@{ref}/{path}`, then `https://raw.githubusercontent.com/{repo}/{ref}/{path}`. The store tries the next URL after any failure: a network error, an HTTP error (jsDelivr answers 403 for files over 20 MB, such as the Noto Serif CJK fonts), or bytes that don't verify.
|
||||
|
||||
## The index
|
||||
|
||||
`Index` holds the families of one or more sources. Families are sorted by name. Two sources can have a family of the same name (Google Fonts and Noto both have `Noto Serif Myanmar`, Noto's with more styles); lookups by name prefer the first source: the built-in ones in `sources.json` order, then the user's.
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `Index::bundled()` (feature `bundled-index`) | The pinned built-in indexes compiled in: about 620 KB gzipped, no request. |
|
||||
| `FontStore::offline_indexes()` (feature `bundled-index`) | The bundled indexes and the user's sources as last fetched. |
|
||||
| `FontStore::cached_indexes(&PINNED_INDEX)` | The indexes the last `fetch_indexes` saved, when its `sources.json` is the pin's and every built-in index matches it, with the user's sources. |
|
||||
| `FontStore::fetch_indexes(&PINNED_INDEX, &cancel)` | Downloads `sources.json`, every gzipped index it lists and every user source, checks them and saves them. An index that fails is reported in `Index::problems()`; the others load. Needs the network on. |
|
||||
| `Index::from_bytes(bytes)`, `Index::from_user_bytes(bytes, url)` | One index file, JSON or gzipped JSON. |
|
||||
| `Index::merge(indexes)` | One index of several. |
|
||||
|
||||
Reading checks every entry: the version (a major version other than 1 is refused; unknown fields are ignored), https URL templates, safe relative paths (no empty, `.` or `..` segment, no `\`), safe and unique file names, `owner/name` repositories and refs, sha256s, sizes, weights and widths. A format 1.0 index is Google Fonts, with URLs from its `base_url` and `fallback_base_url`.
|
||||
|
||||
| Call | What it gives |
|
||||
|---|---|
|
||||
| `index.sources()`, `index.source_of(family)` | Each source's name, homepage, curated flag, commit, and families left out with why. |
|
||||
| `family.source`, `family.is_curated()` | The family's source ID, and whether that source is curated. |
|
||||
| `index.file_urls(family, file)`, `index.license_urls(family)` | The URLs to try, best first. |
|
||||
|
||||
## Exact matches only
|
||||
|
||||
| Call | Matches |
|
||||
|---|---|
|
||||
| `index.family(name)` | The whole family name, ignoring case, from the first source that has it. `families_named(name)` gives every source's; `family_from(source, name)` one source's. |
|
||||
| `index.find_face(family, style)` | A style name (`Bold Italic`, `SemiBold`), a CSS weight (`700`) or a PostScript style (`BoldItalic`, `CondensedBold`). A variable file has every weight its `wght` axis reaches. A file of normal width is preferred. |
|
||||
| `index.find_postscript(name)` | The PostScript name, case included. A name that families of different names share is `Unavailable::Ambiguous`; look it up in the document's family with `Family::face_by_postscript`. |
|
||||
|
||||
A family without the style asked for is `Unavailable::NoStyle`: the store never offers a nearer weight or a similar family. Substitution stays a separate choice in each app.
|
||||
|
||||
## Installing
|
||||
|
||||
```rust
|
||||
use craft_font_catalog::{Catalog, FontEnv};
|
||||
use craft_font_store::{Cancel, FontStore, Index};
|
||||
|
||||
let store = FontStore::for_user(&FontEnv::from_process()).unwrap().with_catalog(Catalog::global().clone());
|
||||
let index = store.offline_indexes();
|
||||
let face = index.find_face("Lato", "Bold Italic")?;
|
||||
store.enable_network()?; // the user clicked Install
|
||||
let cancel = Cancel::new(); // cancel.cancel() from the UI thread stops the download
|
||||
store.install_face(&index, face, &cancel, &mut |p| println!("{} of {} bytes", p.done, p.total))?;
|
||||
```
|
||||
|
||||
`install_family` installs every file of a family; `install_face` one file and the licence. Each file is:
|
||||
|
||||
1. downloaded (at most its index size), with progress and cancellation;
|
||||
2. checked: its size, its sha256, then that every face in it parses and has outlines (`verify_font`);
|
||||
3. saved under `.staging/` as `<name>.part`, which no font scan picks up.
|
||||
|
||||
The licence file (`OFL.txt`, `LICENSE`) is checked against its size and sha256 and goes beside the fonts. For a format 1.0 index, which has no sha256 for it, only its size is checked (1 MiB at most).
|
||||
|
||||
When every file has passed, the store takes `.lock`, moves the files into the family's folder and writes `manifest.json` (written atomically through `craft-atomic`). If a move or the manifest fails, the files moved so far are taken back out and replaced files are restored. A failed or cancelled install leaves nothing behind. Files already installed with the same sha256 aren't downloaded again.
|
||||
|
||||
## The store folder
|
||||
|
||||
```text
|
||||
settings.json the network setting and the sources the user added
|
||||
manifest.json what is installed
|
||||
indexes/ the last indexes fetched: sources.json, <source>.json.gz, user-<hash>
|
||||
<family folder>/ the font files and the licence
|
||||
.staging/ downloads being installed
|
||||
.lock held while the store changes
|
||||
```
|
||||
|
||||
A Google Fonts family's folder is google/fonts' own folder name (`lato/`); another source's is `<source>/<family>` (`notofonts/noto-sans-cjk-jp/`, `user-fonts-example-org-myfonts/some-family/`).
|
||||
|
||||
`manifest.json` lists each installed family: its name, source, folder, licence and licence file, and per file its name, path in its repository, size, sha256, the index commit it came from, and its PostScript names. An entry is one family of one source, so the same name from two sources is two entries. A manifest that can't be read is moved aside to `manifest.json.bad` and counts as empty.
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `installed()`, `installed_family(source, name)`, `is_installed(family, file)` | Read the manifest. |
|
||||
| `remove_family(source, name)` | Moves the family folder out at once, updates the manifest, then deletes the files and the licence. |
|
||||
| `verify()` | Reads every installed file and reports the missing, damaged and unparseable ones. |
|
||||
| `user_sources()`, `add_user_source(url)`, `remove_user_source(url)` | The sources the user added. Removing one leaves its installed families installed. |
|
||||
|
||||
## Telling the catalog
|
||||
|
||||
The catalog scans the store folder. With `with_catalog(catalog)`, every install and removal ends with `catalog.rescan()`, which reads only new and changed files, so the app sees the fonts at once. Other running apps notice through `Catalog::rescan_if_changed` (when their window is activated) or `Catalog::watch`.
|
||||
|
||||
## Web
|
||||
|
||||
The index, the lookups, `verify_font` and `IndexPin` build for `wasm32-unknown-unknown`. The store folder and the HTTP client are native only; the web keeps fonts elsewhere.
|
||||
|
||||
## Tests
|
||||
|
||||
`cargo test -p craft-font-store --all-features`. The store tests run a small HTTP server on 127.0.0.1 that stands in for jsDelivr, raw GitHub and a user's host; no test uses the network. One ignored test fetches the pinned indexes and installs a Noto family over the network: `cargo test -p craft-font-store --all-features -- --ignored`.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
{"schema":1,"version":"1.1","indexes":[
|
||||
{"source":"google/fonts","name":"Google Fonts","curated":true,"path":"indexes/google-fonts.json","size":4724581,"sha256":"72ea418e43785d572e73350ad08e9e6601a5a12f93315805f92bd3e092618a63","gz_path":"indexes/google-fonts.json.gz","gz_size":596893,"gz_sha256":"62d1d5645417a691e4710f6d1b895f925284ea9c804de268a97432bd67e9c1f6","commit":"45e3d5269e4a17d823f6d84470bc084dc5ae46f6","families":2040,"files":3781},
|
||||
{"source":"notofonts","name":"Noto","curated":true,"path":"indexes/noto.json","size":117706,"sha256":"8243d697c407776ec4fb19fa892c089279a4a528eb4e04cd970ac6ec14ce142a","gz_path":"indexes/noto.json.gz","gz_size":18192,"gz_sha256":"0d467fb79bcd3afe10350ed3caba2ff57af02aedbc413292e06bc78c02994999","commit":"93fac8abebe6c7fed0c5b7d2456d8fa484fa146c","families":30,"files":130}
|
||||
]}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The one HTTP client: blocking HTTPS GET through ureq with rustls, timeouts, a size cap,
|
||||
//! progress and cancellation.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Cancels a download from another thread. Clones share the flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cancel(Arc<AtomicBool>);
|
||||
|
||||
impl Cancel {
|
||||
/// A flag that isn't set.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Asks the download to stop. It stops within a few tens of milliseconds, even while a
|
||||
/// server sends nothing.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether [`Cancel::cancel`] was called.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a request may take.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Timeouts {
|
||||
/// Connecting: DNS, TCP and TLS.
|
||||
pub connect: Duration,
|
||||
/// From sending the request to the response's headers.
|
||||
pub response: Duration,
|
||||
/// The whole request, body included.
|
||||
pub overall: Duration,
|
||||
}
|
||||
|
||||
impl Default for Timeouts {
|
||||
fn default() -> Self {
|
||||
// Ten minutes moves the largest Google Fonts file (about 60 MB) over a 1 Mbit/s line.
|
||||
Self { connect: Duration::from_secs(15), response: Duration::from_secs(30), overall: Duration::from_secs(600) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a request failed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum FetchError {
|
||||
/// [`Cancel::cancel`] was called.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
/// The body is larger than allowed.
|
||||
#[error("the response is larger than {max} bytes")]
|
||||
TooLarge {
|
||||
/// The limit.
|
||||
max: u64,
|
||||
},
|
||||
/// Anything else: connection, TLS, timeout, an HTTP status other than 2xx.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The User-Agent every request sends: the crate and its version, nothing about the user.
|
||||
pub const USER_AGENT: &str = concat!("craft-font-store/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The HTTP client. HTTPS only; no cookies, no credentials.
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
/// URL prefixes the tests serve from a local server.
|
||||
#[cfg(test)]
|
||||
pub(crate) rewrite: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for Client {
|
||||
fn default() -> Self {
|
||||
Self::new(Timeouts::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Client {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Client")
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// A client with these timeouts.
|
||||
pub fn new(timeouts: Timeouts) -> Self {
|
||||
Self::build(timeouts, true)
|
||||
}
|
||||
|
||||
fn build(t: Timeouts, https_only: bool) -> Self {
|
||||
let config = ureq::Agent::config_builder()
|
||||
.https_only(https_only)
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout_connect(Some(t.connect))
|
||||
.timeout_recv_response(Some(t.response))
|
||||
.timeout_global(Some(t.overall))
|
||||
.max_redirects(3)
|
||||
.http_status_as_error(true)
|
||||
.build();
|
||||
Self {
|
||||
agent: ureq::Agent::new_with_config(config),
|
||||
#[cfg(test)]
|
||||
rewrite: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain `http://` to a local test server, with `https://` prefixes mapped onto it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_tests(t: Timeouts, rewrite: Vec<(String, String)>) -> Self {
|
||||
Self { rewrite, ..Self::build(t, false) }
|
||||
}
|
||||
|
||||
fn url(&self, url: &str) -> String {
|
||||
#[cfg(test)]
|
||||
for (from, to) in &self.rewrite {
|
||||
if let Some(rest) = url.strip_prefix(from.as_str()) {
|
||||
return format!("{to}{rest}");
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// GETs `url`, at most `max` bytes, calling `on_bytes` with the bytes read so far. The
|
||||
/// request runs on a helper thread, so `cancel` stops it even while the server sends nothing.
|
||||
pub fn get(&self, url: &str, max: u64, cancel: &Cancel, on_bytes: &mut dyn FnMut(u64)) -> Result<Vec<u8>, FetchError> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(FetchError::Cancelled);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (agent, url, flag) = (self.agent.clone(), self.url(url), Arc::clone(&stop));
|
||||
std::thread::Builder::new()
|
||||
.name("font-store-fetch".into())
|
||||
.spawn(move || {
|
||||
let progress = tx.clone();
|
||||
let result = download(&agent, &url, max, &flag, &mut |n| {
|
||||
let _ = progress.send(Msg::Bytes(n));
|
||||
});
|
||||
// After a cancel nobody receives this.
|
||||
let _ = tx.send(Msg::Done(result));
|
||||
})
|
||||
.map_err(|e| FetchError::Failed(format!("can't start the download: {e}")))?;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(Msg::Bytes(n)) => on_bytes(n),
|
||||
Ok(Msg::Done(r)) => return r,
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => return Err(FetchError::Failed("the download stopped unexpectedly".into())),
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return Err(FetchError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Msg {
|
||||
Bytes(u64),
|
||||
Done(Result<Vec<u8>, FetchError>),
|
||||
}
|
||||
|
||||
fn describe(e: &ureq::Error) -> String {
|
||||
match e {
|
||||
ureq::Error::StatusCode(code) => format!("HTTP {code}"),
|
||||
ureq::Error::Timeout(_) => "timed out".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn download(agent: &ureq::Agent, url: &str, max: u64, stop: &AtomicBool, on_bytes: &mut dyn FnMut(u64)) -> Result<Vec<u8>, FetchError> {
|
||||
let mut resp = agent.get(url).call().map_err(|e| FetchError::Failed(describe(&e)))?;
|
||||
if resp.body().content_length().is_some_and(|n| n > max) {
|
||||
return Err(FetchError::TooLarge { max });
|
||||
}
|
||||
// One byte over the cap is an error, not a truncated file.
|
||||
let mut reader = resp.body_mut().with_config().limit(max.saturating_add(1)).reader();
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return Err(FetchError::Cancelled);
|
||||
}
|
||||
let n = reader.read(&mut buf).map_err(|e| FetchError::Failed(format!("reading the response failed: {e}")))?;
|
||||
if n == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
if (out.len() as u64).saturating_add(n as u64) > max {
|
||||
return Err(FetchError::TooLarge { max });
|
||||
}
|
||||
out.extend_from_slice(buf.get(..n).unwrap_or_default());
|
||||
on_bytes(out.len() as u64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
//! Font indexes (aiongg/craft-font-index, format 1.x): the families of every source in one
|
||||
//! [`Index`], and exact lookup.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::source::{Origin, fill, parse};
|
||||
|
||||
/// The largest index read.
|
||||
pub const MAX_INDEX_BYTES: usize = 32 << 20;
|
||||
/// The largest font file an index may list.
|
||||
pub const MAX_FILE_BYTES: u64 = 256 << 20;
|
||||
/// The largest licence file an index may list.
|
||||
pub const MAX_LICENSE_BYTES: u64 = 1 << 20;
|
||||
/// The index format's major version this crate reads.
|
||||
pub const MAJOR_VERSION: u32 = 1;
|
||||
/// The source ID of Google Fonts, and of every format 1.0 index.
|
||||
pub const GOOGLE_FONTS: &str = "google/fonts";
|
||||
/// The licences that allow redistribution. Families under any other are left out.
|
||||
pub const LICENSES: [&str; 3] = ["OFL", "APACHE2", "UFL"];
|
||||
|
||||
/// Why an index can't be used.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IndexError {
|
||||
/// More than [`MAX_INDEX_BYTES`].
|
||||
#[error("the font index is larger than {max} bytes")]
|
||||
TooLarge {
|
||||
/// The limit.
|
||||
max: usize,
|
||||
},
|
||||
/// Not JSON, or a field has the wrong type.
|
||||
#[error("the font index can't be read: {0}")]
|
||||
Json(String),
|
||||
/// A major version this crate doesn't know.
|
||||
#[error("the font index has version {0}; this build reads version {MAJOR_VERSION}.x")]
|
||||
Version(String),
|
||||
/// An entry breaks the format's rules.
|
||||
#[error("the font index has an invalid entry: {0}")]
|
||||
Invalid(String),
|
||||
/// A gzipped index that doesn't decompress.
|
||||
#[error("the gzipped font index can't be read: {0}")]
|
||||
Gzip(String),
|
||||
/// The bytes aren't the pinned ones.
|
||||
#[error("the font index doesn't have the sha256 this build expects")]
|
||||
Checksum,
|
||||
}
|
||||
|
||||
/// A language some family supports.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Language {
|
||||
/// gflanguages ID: `<ISO 639>_<ISO 15924>`, sometimes with `_<region>` (`vi_Latn`).
|
||||
pub id: String,
|
||||
/// English name.
|
||||
pub name: String,
|
||||
/// The name in the language itself.
|
||||
#[serde(default)]
|
||||
pub autonym: Option<String>,
|
||||
/// ISO 15924 code.
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
/// Speakers; 0 when unknown.
|
||||
#[serde(default)]
|
||||
pub population: u64,
|
||||
/// A historical language.
|
||||
#[serde(default)]
|
||||
pub historical: bool,
|
||||
}
|
||||
|
||||
/// A script some family uses.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Script {
|
||||
/// ISO 15924 code (`Zmth`, `Zsym` and `Zsye` for maths, symbols and emoji).
|
||||
pub id: String,
|
||||
/// English name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A variation axis of a font file.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Axis {
|
||||
/// The axis tag (`wght`).
|
||||
pub tag: String,
|
||||
/// The smallest value.
|
||||
pub min: f32,
|
||||
/// The largest value.
|
||||
pub max: f32,
|
||||
}
|
||||
|
||||
/// One font file of a family.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct FontFile {
|
||||
/// Path in the family's repository: the `{path}` of the source's URL templates.
|
||||
pub path: String,
|
||||
/// Bytes.
|
||||
pub size: u64,
|
||||
/// 64 lowercase hex characters.
|
||||
pub sha256: String,
|
||||
/// `normal` or `italic`.
|
||||
#[serde(default = "normal")]
|
||||
pub style: String,
|
||||
/// CSS weight; for a variable font, the default instance's.
|
||||
pub weight: u16,
|
||||
/// CSS font-stretch percentage; for a variable font, the default instance's.
|
||||
#[serde(default = "normal_width")]
|
||||
pub width: f32,
|
||||
/// Variation axes; empty for a static font.
|
||||
#[serde(default)]
|
||||
pub axes: Vec<Axis>,
|
||||
/// PostScript names: the face's and, for a variable font, each named instance's.
|
||||
#[serde(default)]
|
||||
pub postscript: Vec<String>,
|
||||
/// The repository holding the file, when not the family's.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// The commit or tag of `repo`, when not the family's.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
}
|
||||
|
||||
fn normal() -> String {
|
||||
"normal".into()
|
||||
}
|
||||
|
||||
fn normal_width() -> f32 {
|
||||
100.0
|
||||
}
|
||||
|
||||
impl FontFile {
|
||||
/// The file's name, the last part of its path.
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.path.rsplit('/').next().unwrap_or(&self.path)
|
||||
}
|
||||
|
||||
/// Whether the file's faces are italic.
|
||||
pub fn is_italic(&self) -> bool {
|
||||
self.style.eq_ignore_ascii_case("italic")
|
||||
}
|
||||
|
||||
/// Whether it is a variable font.
|
||||
pub fn is_variable(&self) -> bool {
|
||||
!self.axes.is_empty()
|
||||
}
|
||||
|
||||
fn axis(&self, tag: &str) -> Option<&Axis> {
|
||||
self.axes.iter().find(|a| a.tag == tag)
|
||||
}
|
||||
|
||||
/// Whether the file has the face of CSS `weight`, italic or not: its own weight, or a weight
|
||||
/// its `wght` axis reaches.
|
||||
pub fn has_weight(&self, weight: u16, italic: bool) -> bool {
|
||||
let italic_ok = self.is_italic() == italic || self.axis("ital").is_some_and(|a| a.min <= 0.0 && a.max >= 1.0);
|
||||
let w = f32::from(weight);
|
||||
italic_ok && (self.weight == weight || self.axis("wght").is_some_and(|a| a.min <= w && w <= a.max))
|
||||
}
|
||||
|
||||
/// Whether the file has the normal width (100%), as its own or on its `wdth` axis.
|
||||
pub fn has_normal_width(&self) -> bool {
|
||||
self.width == 100.0 || self.axis("wdth").is_some_and(|a| a.min <= 100.0 && 100.0 <= a.max)
|
||||
}
|
||||
}
|
||||
|
||||
/// A family's licence file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct LicenseFile {
|
||||
/// Path in its repository.
|
||||
pub path: String,
|
||||
/// Bytes.
|
||||
pub size: u64,
|
||||
/// 64 lowercase hex characters.
|
||||
pub sha256: String,
|
||||
/// The repository holding it, when not the family's.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// The commit or tag of `repo`, when not the family's.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl LicenseFile {
|
||||
/// The file's name (`OFL.txt`, `LICENSE`).
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.path.rsplit('/').next().unwrap_or(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// A font family of an index.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Family {
|
||||
/// The family name, unique in its source ignoring case. Two sources can have the same name.
|
||||
pub family: String,
|
||||
/// The ID of the family's source ([`FontSource::id`]).
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
/// The designer; empty when not given.
|
||||
#[serde(default)]
|
||||
pub designer: String,
|
||||
/// `SANS_SERIF`, `SERIF`, `DISPLAY`, `HANDWRITING` or `MONOSPACE`.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
/// google/fonts subsets (`latin`, `japanese`, ...); empty for other sources.
|
||||
#[serde(default)]
|
||||
pub subsets: Vec<String>,
|
||||
/// Positions in [`Index::languages`].
|
||||
#[serde(default)]
|
||||
pub languages: Vec<usize>,
|
||||
/// ISO 15924 codes.
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<String>,
|
||||
/// `OFL`, `APACHE2` or `UFL`.
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
/// Format 1.0: the licence file in google/fonts, beside the font files.
|
||||
#[serde(default)]
|
||||
pub license_path: Option<String>,
|
||||
/// The licence file, with its size and sha256.
|
||||
#[serde(default)]
|
||||
pub license_file: Option<LicenseFile>,
|
||||
/// The repository holding the files, when not the source's.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// The commit or tag of `repo`, when not the source's.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
/// The font files, sorted by path.
|
||||
pub files: Vec<FontFile>,
|
||||
#[serde(skip)]
|
||||
pub(crate) dir: String,
|
||||
#[serde(skip)]
|
||||
pub(crate) curated: bool,
|
||||
}
|
||||
|
||||
impl Family {
|
||||
/// The family's folder in the font store: google/fonts' folder name (`notosansjp`), or
|
||||
/// `<source>/<family>` (`notofonts/noto-sans-cjk-jp`) for other sources.
|
||||
pub fn dir(&self) -> &str {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Whether the family's source is curated: a built-in source, not one the user added.
|
||||
pub fn is_curated(&self) -> bool {
|
||||
self.curated
|
||||
}
|
||||
|
||||
/// The licence file's name (`OFL.txt`, `LICENSE`).
|
||||
pub fn license_file_name(&self) -> Option<&str> {
|
||||
match &self.license_file {
|
||||
Some(f) => Some(f.file_name()),
|
||||
None => self.license_path.as_deref().and_then(|p| p.rsplit('/').next()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file with exactly this style: a style name (`Bold Italic`, `SemiBold`, `Regular`), a
|
||||
/// CSS weight (`700`), or the part of a PostScript name after its `-` (`BoldItalic`,
|
||||
/// `CondensedBold`). A variable font has every weight its `wght` axis reaches. A static file
|
||||
/// of normal width is preferred. No nearer style is offered: a style the family doesn't have
|
||||
/// is [`Unavailable::NoStyle`].
|
||||
pub fn face(&self, style: &str) -> Result<&FontFile, Unavailable> {
|
||||
let no_style = || Unavailable::NoStyle { family: self.family.clone(), style: style.to_string() };
|
||||
if let Some((weight, italic)) = parse_style(style) {
|
||||
let found: Vec<&FontFile> = self.files.iter().filter(|f| f.has_weight(weight, italic) && f.has_normal_width()).collect();
|
||||
let exact = found.iter().find(|f| !f.is_variable() && f.weight == weight && f.is_italic() == italic && f.width == 100.0);
|
||||
if let Some(f) = exact.or(found.first()) {
|
||||
return Ok(f);
|
||||
}
|
||||
}
|
||||
let wanted = squash(style);
|
||||
if wanted.is_empty() {
|
||||
return Err(no_style());
|
||||
}
|
||||
self.files.iter().find(|f| f.postscript.iter().any(|p| p.split_once('-').is_some_and(|(_, s)| squash(s) == wanted))).ok_or_else(no_style)
|
||||
}
|
||||
|
||||
/// The file with this PostScript name, matched exactly.
|
||||
pub fn face_by_postscript(&self, name: &str) -> Option<&FontFile> {
|
||||
self.files.iter().find(|f| f.postscript.iter().any(|p| p == name))
|
||||
}
|
||||
|
||||
/// The family's languages.
|
||||
pub fn languages<'a>(&'a self, index: &'a Index) -> impl Iterator<Item = &'a Language> + 'a {
|
||||
self.languages.iter().filter_map(|&i| index.languages.get(i))
|
||||
}
|
||||
|
||||
/// The total size of the family's files.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.iter().map(|f| f.size).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Letters and digits only, lowercased: `Bold Italic`, `bold-italic` and `BoldItalic` are equal.
|
||||
fn squash(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// A style name's CSS weight and whether it is italic: `Bold Italic` is (700, true), `Regular`
|
||||
/// and the empty style are (400, false), `300` is (300, false). `None` for names that aren't a
|
||||
/// weight (`Condensed Bold`).
|
||||
pub fn parse_style(style: &str) -> Option<(u16, bool)> {
|
||||
let s = squash(style);
|
||||
let (base, italic) = match s.strip_suffix("italic").or_else(|| s.strip_suffix("oblique")) {
|
||||
Some(b) => (b, true),
|
||||
None => (s.as_str(), false),
|
||||
};
|
||||
let weight = match base {
|
||||
"" | "regular" | "normal" | "book" | "roman" => 400,
|
||||
"thin" | "hairline" => 100,
|
||||
"extralight" | "ultralight" => 200,
|
||||
"light" => 300,
|
||||
"medium" => 500,
|
||||
"semibold" | "demibold" => 600,
|
||||
"bold" => 700,
|
||||
"extrabold" | "ultrabold" => 800,
|
||||
"black" | "heavy" => 900,
|
||||
n => n.parse::<u16>().ok().filter(|w| (1..=1000).contains(w))?,
|
||||
};
|
||||
Some((weight, italic))
|
||||
}
|
||||
|
||||
/// Why the index has no exact match. Near matches are never offered as installable.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Unavailable {
|
||||
/// No family of this name.
|
||||
#[error("{0} is not in the font index")]
|
||||
NoFamily(String),
|
||||
/// The family exists, without this style.
|
||||
#[error("the font index has {family} but not its {style} style")]
|
||||
NoStyle {
|
||||
/// The family's name in the index.
|
||||
family: String,
|
||||
/// The style asked for.
|
||||
style: String,
|
||||
},
|
||||
/// No file has this PostScript name.
|
||||
#[error("no font in the index has the PostScript name {0}")]
|
||||
NoPostScript(String),
|
||||
/// Files of several families have this PostScript name.
|
||||
#[error("the PostScript name {name} belongs to several families: {}", families.join(", "))]
|
||||
Ambiguous {
|
||||
/// The PostScript name.
|
||||
name: String,
|
||||
/// The families that have it.
|
||||
families: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An exact match: a family and the file that has the face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Face<'a> {
|
||||
/// The family.
|
||||
pub family: &'a Family,
|
||||
/// The file.
|
||||
pub file: &'a FontFile,
|
||||
}
|
||||
|
||||
/// A source of fonts: one index file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FontSource {
|
||||
/// The ID its families carry: `google/fonts`, `notofonts`, or `user:<host>/<id>` for a
|
||||
/// source the user added.
|
||||
pub id: String,
|
||||
/// Display name (`Google Fonts`, `Noto`).
|
||||
pub name: String,
|
||||
/// The source's web page.
|
||||
pub homepage: Option<String>,
|
||||
/// A built-in source. A source the user added is never curated, whatever its file says.
|
||||
pub curated: bool,
|
||||
/// The index format version, `"MAJOR.MINOR"`.
|
||||
pub version: String,
|
||||
/// The commit the index was generated from.
|
||||
pub commit: String,
|
||||
/// The URL a user-added source was added from.
|
||||
pub user_url: Option<String>,
|
||||
/// A user-added source whose file differs from the one first fetched.
|
||||
pub changed: bool,
|
||||
/// Families left out of the index and why (a licence that doesn't allow redistribution).
|
||||
pub warnings: Vec<String>,
|
||||
/// How many families it has.
|
||||
pub families: usize,
|
||||
pub(crate) templates: Vec<String>,
|
||||
pub(crate) repo: Option<String>,
|
||||
pub(crate) git_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl FontSource {
|
||||
/// The URL templates of its files, best first.
|
||||
pub fn file_url_templates(&self) -> &[String] {
|
||||
&self.templates
|
||||
}
|
||||
}
|
||||
|
||||
/// A source that couldn't be loaded. The other sources still are.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SourceProblem {
|
||||
/// The source's ID, or its URL for a user-added source.
|
||||
pub source: String,
|
||||
/// Its display name, or its URL.
|
||||
pub name: String,
|
||||
/// What went wrong.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub(crate) type Part = (FontSource, Vec<Language>, Vec<Script>, Vec<Family>);
|
||||
|
||||
/// The checked indexes of one or more sources. Families are sorted by name; families of the same
|
||||
/// name are in source order (built-in sources in `sources.json` order, then the user's), and
|
||||
/// lookups by name prefer the first.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Index {
|
||||
sources: Vec<FontSource>,
|
||||
problems: Vec<SourceProblem>,
|
||||
languages: Vec<Language>,
|
||||
scripts: Vec<Script>,
|
||||
families: Vec<Family>,
|
||||
by_name: HashMap<String, Vec<usize>>,
|
||||
by_postscript: HashMap<String, Vec<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Reads one source's index from its JSON, checking every entry. Its `curated` flag is
|
||||
/// kept; a format 1.0 index is Google Fonts and curated.
|
||||
pub fn from_json(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
parse(bytes, Origin::Trusted)
|
||||
}
|
||||
|
||||
/// Reads an index from gzipped JSON.
|
||||
pub fn from_gzip(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
Self::from_json(&gunzip(bytes, MAX_INDEX_BYTES)?)
|
||||
}
|
||||
|
||||
/// Reads an index from JSON or gzipped JSON.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
if is_gzip(bytes) { Self::from_gzip(bytes) } else { Self::from_json(bytes) }
|
||||
}
|
||||
|
||||
/// Reads an index the user added from `url` (JSON or gzipped JSON): not curated, its source
|
||||
/// ID `user:<host>/<id>`, so it can't stand in for a built-in source.
|
||||
pub fn from_user_bytes(bytes: &[u8], url: &str) -> Result<Self, IndexError> {
|
||||
let json = if is_gzip(bytes) { gunzip(bytes, MAX_INDEX_BYTES)? } else { bytes.to_vec() };
|
||||
parse(&json, Origin::User { url })
|
||||
}
|
||||
|
||||
/// The built-in sources compiled into this build ([`crate::PINNED_INDEX`]): `sources.json`
|
||||
/// and every index it lists, each checked against its sha256.
|
||||
#[cfg(feature = "bundled-index")]
|
||||
pub fn bundled() -> Result<Self, IndexError> {
|
||||
crate::sources::bundled()
|
||||
}
|
||||
|
||||
/// One index of every source in `parts`, in order. A source whose ID came earlier is left
|
||||
/// out and reported in [`Index::problems`].
|
||||
pub fn merge(parts: impl IntoIterator<Item = Index>) -> Self {
|
||||
let mut list = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for part in parts {
|
||||
problems.extend(part.problems);
|
||||
let mut families = part.families;
|
||||
for source in part.sources {
|
||||
let (mine, rest): (Vec<Family>, Vec<Family>) = families.into_iter().partition(|f| f.source == source.id);
|
||||
families = rest;
|
||||
list.push((source, part.languages.clone(), part.scripts.clone(), mine));
|
||||
}
|
||||
}
|
||||
Self::assemble(list, problems)
|
||||
}
|
||||
|
||||
/// The index with `problems` added, for sources that couldn't be loaded.
|
||||
pub fn with_problems(mut self, problems: impl IntoIterator<Item = SourceProblem>) -> Self {
|
||||
self.problems.extend(problems);
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks every source as changed since the user added it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn mark_changed(&mut self) {
|
||||
for s in &mut self.sources {
|
||||
s.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn assemble(parts: Vec<Part>, mut problems: Vec<SourceProblem>) -> Self {
|
||||
let mut sources: Vec<FontSource> = Vec::new();
|
||||
let mut languages: BTreeMap<String, Language> = BTreeMap::new();
|
||||
let mut scripts: BTreeMap<String, Script> = BTreeMap::new();
|
||||
let mut families: Vec<(Family, Vec<String>)> = Vec::new();
|
||||
for (source, langs, scrs, fams) in parts {
|
||||
if sources.iter().any(|s| s.id == source.id) {
|
||||
problems.push(SourceProblem {
|
||||
source: source.user_url.clone().unwrap_or_else(|| source.id.clone()),
|
||||
name: source.name.clone(),
|
||||
error: format!("another source already has the ID {}", source.id),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for f in fams {
|
||||
let ids = f.languages.iter().filter_map(|&i| langs.get(i)).map(|l| l.id.clone()).collect();
|
||||
families.push((f, ids));
|
||||
}
|
||||
for l in langs {
|
||||
languages.entry(l.id.clone()).or_insert(l);
|
||||
}
|
||||
for s in scrs {
|
||||
scripts.entry(s.id.clone()).or_insert(s);
|
||||
}
|
||||
sources.push(source);
|
||||
}
|
||||
let position: HashMap<&str, usize> = languages.keys().enumerate().map(|(i, id)| (id.as_str(), i)).collect();
|
||||
let mut merged: Vec<Family> = families
|
||||
.into_iter()
|
||||
.map(|(mut f, ids)| {
|
||||
f.languages = ids.iter().filter_map(|id| position.get(id.as_str()).copied()).collect();
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
let order: HashMap<&str, usize> = sources.iter().enumerate().map(|(i, s)| (s.id.as_str(), i)).collect();
|
||||
merged.sort_by_cached_key(|f| (f.family.to_lowercase(), order.get(f.source.as_str()).copied().unwrap_or(usize::MAX)));
|
||||
let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut by_postscript: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
|
||||
for (fi, fam) in merged.iter().enumerate() {
|
||||
by_name.entry(fam.family.to_lowercase()).or_default().push(fi);
|
||||
for (xi, file) in fam.files.iter().enumerate() {
|
||||
for ps in &file.postscript {
|
||||
let hits = by_postscript.entry(ps.clone()).or_default();
|
||||
if !hits.contains(&(fi, xi)) {
|
||||
hits.push((fi, xi));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Index {
|
||||
sources,
|
||||
problems,
|
||||
languages: languages.into_values().collect(),
|
||||
scripts: scripts.into_values().collect(),
|
||||
families: merged,
|
||||
by_name,
|
||||
by_postscript,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sources, built-in ones first.
|
||||
pub fn sources(&self) -> &[FontSource] {
|
||||
&self.sources
|
||||
}
|
||||
|
||||
/// The source with this ID.
|
||||
pub fn source(&self, id: &str) -> Option<&FontSource> {
|
||||
self.sources.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// The source a family comes from.
|
||||
pub fn source_of(&self, family: &Family) -> Option<&FontSource> {
|
||||
self.source(&family.source)
|
||||
}
|
||||
|
||||
/// The sources that couldn't be loaded, and why.
|
||||
pub fn problems(&self) -> &[SourceProblem] {
|
||||
&self.problems
|
||||
}
|
||||
|
||||
/// Every family, sorted by name, then by source.
|
||||
pub fn families(&self) -> &[Family] {
|
||||
&self.families
|
||||
}
|
||||
|
||||
/// Every language a family supports, sorted by ID.
|
||||
pub fn languages(&self) -> &[Language] {
|
||||
&self.languages
|
||||
}
|
||||
|
||||
/// Every script a family uses, sorted by code.
|
||||
pub fn scripts(&self) -> &[Script] {
|
||||
&self.scripts
|
||||
}
|
||||
|
||||
/// The family of this name, ignoring case, from the first source that has it. Only the whole
|
||||
/// name matches.
|
||||
pub fn family(&self, name: &str) -> Option<&Family> {
|
||||
self.families_named(name).next()
|
||||
}
|
||||
|
||||
/// Every family of this name, ignoring case, in source order.
|
||||
pub fn families_named(&self, name: &str) -> impl Iterator<Item = &Family> + '_ {
|
||||
self.by_name.get(&name.to_lowercase()).into_iter().flatten().filter_map(|&i| self.families.get(i))
|
||||
}
|
||||
|
||||
/// The family of this name from one source.
|
||||
pub fn family_from(&self, source: &str, name: &str) -> Option<&Family> {
|
||||
self.families_named(name).find(|f| f.source == source)
|
||||
}
|
||||
|
||||
/// The exact face for a family and style ([`Family::face`]), from the first source whose
|
||||
/// family of that name has the style.
|
||||
pub fn find_face(&self, family: &str, style: &str) -> Result<Face<'_>, Unavailable> {
|
||||
let mut first_error = None;
|
||||
for fam in self.families_named(family) {
|
||||
match fam.face(style) {
|
||||
Ok(file) => return Ok(Face { family: fam, file }),
|
||||
Err(e) => {
|
||||
first_error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_error.unwrap_or_else(|| Unavailable::NoFamily(family.to_string())))
|
||||
}
|
||||
|
||||
/// The face with this PostScript name, matched exactly (case included), from the first
|
||||
/// source that has it. When families of different names have it, it is
|
||||
/// [`Unavailable::Ambiguous`]: look it up in the document's family with
|
||||
/// [`Family::face_by_postscript`].
|
||||
pub fn find_postscript(&self, name: &str) -> Result<Face<'_>, Unavailable> {
|
||||
let hits = self.by_postscript.get(name).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for &(fi, _) in hits {
|
||||
if let Some(f) = self.families.get(fi)
|
||||
&& !names.iter().any(|n| n.to_lowercase() == f.family.to_lowercase())
|
||||
{
|
||||
names.push(&f.family);
|
||||
}
|
||||
}
|
||||
match (names.len(), hits.first()) {
|
||||
(1, Some(&(fi, xi))) => {
|
||||
let family = self.families.get(fi).ok_or_else(|| Unavailable::NoPostScript(name.into()))?;
|
||||
let file = family.files.get(xi).ok_or_else(|| Unavailable::NoPostScript(name.into()))?;
|
||||
Ok(Face { family, file })
|
||||
}
|
||||
(0, _) | (_, None) => Err(Unavailable::NoPostScript(name.into())),
|
||||
_ => Err(Unavailable::Ambiguous { name: name.into(), families: names.into_iter().map(str::to_string).collect() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URLs of a font file of `family`, best first: the source's templates filled with the
|
||||
/// file's path, repository and ref.
|
||||
pub fn file_urls(&self, family: &Family, file: &FontFile) -> Vec<String> {
|
||||
self.urls(family, &file.path, file.repo.as_deref(), file.git_ref.as_deref())
|
||||
}
|
||||
|
||||
/// The URLs of `family`'s licence file, best first; empty when it has none.
|
||||
pub fn license_urls(&self, family: &Family) -> Vec<String> {
|
||||
match (&family.license_file, &family.license_path) {
|
||||
(Some(f), _) => self.urls(family, &f.path, f.repo.as_deref(), f.git_ref.as_deref()),
|
||||
(None, Some(path)) => self.urls(family, path, None, None),
|
||||
(None, None) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn urls(&self, family: &Family, path: &str, repo: Option<&str>, git_ref: Option<&str>) -> Vec<String> {
|
||||
let Some(source) = self.source_of(family) else { return Vec::new() };
|
||||
let repo = repo.or(family.repo.as_deref()).or(source.repo.as_deref());
|
||||
let git_ref = git_ref.or(family.git_ref.as_deref()).or(source.git_ref.as_deref());
|
||||
fill(&source.templates, path, repo, git_ref).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_gzip(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0x1f, 0x8b])
|
||||
}
|
||||
|
||||
/// Decompresses at most `max` bytes; more is an error, not a truncated index.
|
||||
pub(crate) fn gunzip(bytes: &[u8], max: usize) -> Result<Vec<u8>, IndexError> {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|e| IndexError::Gzip(e.to_string()))?;
|
||||
if out.len() > max {
|
||||
return Err(IndexError::TooLarge { max });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! The suite's font store: the font indexes of every source (Google Fonts, Noto, indexes the user
|
||||
//! adds), exact lookup of a missing font, and verified installs into the one folder every app's
|
||||
//! font catalog reads.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(not(target_arch = "wasm32"))] {
|
||||
//! use craft_font_store::{Cancel, FontStore, Index, PINNED_INDEX};
|
||||
//! use craft_font_catalog::{Catalog, FontEnv};
|
||||
//!
|
||||
//! let store = FontStore::for_user(&FontEnv::from_process()).unwrap().with_catalog(Catalog::global().clone());
|
||||
//! let index = store.cached_indexes(&PINNED_INDEX).unwrap();
|
||||
//! if let Ok(face) = index.find_face("Lato", "Bold Italic") {
|
||||
//! store.enable_network().unwrap(); // the user clicked Install
|
||||
//! store.install_face(&index, face, &Cancel::new(), &mut |p| println!("{}/{}", p.done, p.total)).unwrap();
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! The index and its lookups, [`verify_font`] and [`IndexPin`] build on every target. The store
|
||||
//! and the HTTP client are native only.
|
||||
|
||||
mod index;
|
||||
mod pin;
|
||||
mod source;
|
||||
mod sources;
|
||||
mod verify;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod http;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod store;
|
||||
|
||||
pub use index::{
|
||||
Axis, Face, Family, FontFile, FontSource, GOOGLE_FONTS, Index, IndexError, LICENSES, Language, LicenseFile, MAJOR_VERSION, MAX_FILE_BYTES,
|
||||
MAX_INDEX_BYTES, MAX_LICENSE_BYTES, Script, SourceProblem, Unavailable, parse_style,
|
||||
};
|
||||
pub use pin::{IndexPin, PINNED_INDEX};
|
||||
pub use sources::{SourceEntry, SourcesFile};
|
||||
pub use verify::{VerifyError, is_font, sha256_hex, verify_font};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use http::{Cancel, Client, FetchError, Timeouts, USER_AGENT};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use store::{Caller, Error, FontStore, InstalledFamily, InstalledFile, Network, Problem, Progress, UserSource};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The built-in sources this build trusts: a commit of the index repository and the sha256 of its
|
||||
//! `sources.json`.
|
||||
|
||||
use crate::source::encode_path;
|
||||
|
||||
/// `sources.json` pinned by commit and sha256. The indexes it lists are checked against the
|
||||
/// sha256s in it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct IndexPin {
|
||||
/// The GitHub repository holding the indexes (`owner/name`).
|
||||
pub repo: &'static str,
|
||||
/// The repository's commit.
|
||||
pub commit: &'static str,
|
||||
/// The sha256 of `sources.json` at that commit, lowercase hex.
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
impl IndexPin {
|
||||
/// Where `sources.json` is downloaded from: jsDelivr, then raw GitHub.
|
||||
pub fn urls(&self) -> Vec<String> {
|
||||
self.file_urls("sources.json")
|
||||
}
|
||||
|
||||
/// Where a file of the repository at the pinned commit is downloaded from (an index's
|
||||
/// `path` or `gz_path`): jsDelivr, then raw GitHub.
|
||||
pub fn file_urls(&self, path: &str) -> Vec<String> {
|
||||
let (repo, commit, path) = (self.repo, self.commit, encode_path(path));
|
||||
vec![format!("https://cdn.jsdelivr.net/gh/{repo}@{commit}/{path}"), format!("https://raw.githubusercontent.com/{repo}/{commit}/{path}")]
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in sources of this build: aiongg/craft-font-index at `5a9a7f8` (Google Fonts at
|
||||
/// google/fonts `45e3d52`, and Noto), the copy `Index::bundled` compiles in.
|
||||
pub const PINNED_INDEX: IndexPin = IndexPin {
|
||||
repo: "aiongg/craft-font-index",
|
||||
commit: "5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f",
|
||||
sha256: "4b0c2b9cdec1885027dc3316cdc013ccfbc0d63b6ed9950138d762afaff72e23",
|
||||
};
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Reading and checking one source's index file, and its URL templates.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::index::{
|
||||
Family, FontSource, GOOGLE_FONTS, Index, IndexError, LICENSES, Language, MAJOR_VERSION, MAX_FILE_BYTES, MAX_INDEX_BYTES, MAX_LICENSE_BYTES,
|
||||
Script,
|
||||
};
|
||||
|
||||
pub(crate) const MAX_STR: usize = 1024;
|
||||
const MAX_LIST: usize = 4096;
|
||||
const MAX_FAMILIES: usize = 50_000;
|
||||
const MAX_FILES: usize = 200_000;
|
||||
const MAX_SEGMENT: usize = 64;
|
||||
const MAX_FILE_NAME: usize = 200;
|
||||
const MAX_TEMPLATES: usize = 8;
|
||||
|
||||
/// Where an index file comes from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Origin<'a> {
|
||||
/// A built-in source, or any file the app itself trusts: its `curated` flag is kept.
|
||||
Trusted,
|
||||
/// A source the user added by URL: never curated, its ID prefixed with `user:<host>/`.
|
||||
User { url: &'a str },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawSourceInfo {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
curated: bool,
|
||||
#[serde(default)]
|
||||
file_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Raw {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
source: String,
|
||||
commit: String,
|
||||
#[serde(default)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
fallback_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
source_info: Option<RawSourceInfo>,
|
||||
#[serde(default)]
|
||||
repo: Option<String>,
|
||||
#[serde(default, rename = "ref")]
|
||||
git_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
languages: Vec<Language>,
|
||||
#[serde(default)]
|
||||
scripts: Vec<Script>,
|
||||
families: Vec<Family>,
|
||||
}
|
||||
|
||||
pub(crate) fn invalid<T>(msg: impl Into<String>) -> Result<T, IndexError> {
|
||||
Err(IndexError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
s.chars().take(120).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn check_version(version: &str) -> Result<(), IndexError> {
|
||||
let major = version.split_once('.').and_then(|(major, minor)| {
|
||||
let major: u32 = major.parse().ok()?;
|
||||
minor.parse::<u32>().ok()?;
|
||||
Some(major)
|
||||
});
|
||||
if major == Some(MAJOR_VERSION) { Ok(()) } else { Err(IndexError::Version(version.chars().take(40).collect())) }
|
||||
}
|
||||
|
||||
pub(crate) fn check_str(what: &str, s: &str) -> Result<(), IndexError> {
|
||||
if s.len() > MAX_STR || s.chars().any(char::is_control) {
|
||||
return invalid(format!("{what} is too long or has control characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_sha256(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// A git ref or a repository part: `[A-Za-z0-9._-]+`, not starting with `.`.
|
||||
pub(crate) fn is_ref(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 128 && !s.starts_with('.') && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
}
|
||||
|
||||
/// `owner/name` on GitHub.
|
||||
pub(crate) fn is_repo(s: &str) -> bool {
|
||||
s.split_once('/').is_some_and(|(o, n)| is_ref(o) && is_ref(n))
|
||||
}
|
||||
|
||||
fn check_repo_ref(what: &str, repo: Option<&str>, git_ref: Option<&str>) -> Result<(), IndexError> {
|
||||
if let Some(r) = repo.filter(|r| !is_repo(r)) {
|
||||
return invalid(format!("{what} has repo `{}`, not owner/name", short(r)));
|
||||
}
|
||||
if let Some(r) = git_ref.filter(|r| !is_ref(r)) {
|
||||
return invalid(format!("{what} has ref `{}`", short(r)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file name the store can write on every platform.
|
||||
pub(crate) fn safe_file_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_FILE_NAME
|
||||
&& !name.starts_with('.')
|
||||
&& !name.ends_with('.')
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-[],.+".contains(&b))
|
||||
}
|
||||
|
||||
fn safe_segment(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= MAX_SEGMENT && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
/// A family folder in the store: google/fonts' `[a-z0-9_-]+`, or `<source>/<family>` of the same
|
||||
/// characters.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn safe_dir(dir: &str) -> bool {
|
||||
let mut parts = dir.split('/');
|
||||
match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(a), None, None) => safe_segment(a),
|
||||
(Some(a), Some(b), None) => safe_segment(a) && safe_segment(b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase letters and digits, other runs as one `-`: `notofonts`, `google-fonts`,
|
||||
/// `noto-sans-cjk-jp`. A name with no ASCII letters or digits gets a hash of itself.
|
||||
pub(crate) fn slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.is_empty() && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_SEGMENT);
|
||||
let out = out.trim_end_matches('-').to_string();
|
||||
if out.is_empty() { crate::verify::sha256_hex(s.as_bytes()).chars().take(16).collect() } else { out }
|
||||
}
|
||||
|
||||
/// A relative path in a repository: `/`-separated, no empty, `.` or `..` segment, no `\`, and a
|
||||
/// file name the store can write.
|
||||
pub(crate) fn check_path(path: &str) -> Result<&str, IndexError> {
|
||||
let bad = path.is_empty()
|
||||
|| path.len() > MAX_STR
|
||||
|| path.contains('\\')
|
||||
|| path.chars().any(char::is_control)
|
||||
|| path.split('/').any(|s| s.is_empty() || s == "." || s == "..");
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
if bad || !safe_file_name(name) { invalid(format!("`{}` is not a safe relative path", short(path))) } else { Ok(name) }
|
||||
}
|
||||
|
||||
/// Percent-encodes each `/`-separated segment, keeping the unreserved characters.
|
||||
pub(crate) fn encode_path(p: &str) -> String {
|
||||
let mut out = String::with_capacity(p.len() + 8);
|
||||
for b in p.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
|
||||
out.push(char::from(b));
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_template(t: &str) -> Result<(), IndexError> {
|
||||
let rest = t.replace("{path}", "").replace("{repo}", "").replace("{ref}", "");
|
||||
let ok = t.len() <= MAX_STR
|
||||
&& t.starts_with("https://")
|
||||
&& t.len() > "https://".len()
|
||||
&& t.contains("{path}")
|
||||
&& !rest.contains(['{', '}', '\\'])
|
||||
&& !t.chars().any(|c| c.is_control() || c.is_whitespace());
|
||||
if ok { Ok(()) } else { invalid(format!("file URL template `{}` is not an https URL with {{path}}", short(t))) }
|
||||
}
|
||||
|
||||
fn check_base_url(u: &str) -> Result<(), IndexError> {
|
||||
let ok = u.len() <= MAX_STR
|
||||
&& u.len() > "https://".len()
|
||||
&& u.starts_with("https://")
|
||||
&& u.ends_with('/')
|
||||
&& !u.chars().any(|c| c.is_control() || c.is_whitespace() || matches!(c, '\\' | '{' | '}'));
|
||||
if ok { Ok(()) } else { invalid(format!("base URL `{}` is not an https folder URL", short(u))) }
|
||||
}
|
||||
|
||||
/// The URLs of `path`, filling each template in turn. `repo` and `ref` are needed only when a
|
||||
/// template names them.
|
||||
pub(crate) fn fill(templates: &[String], path: &str, repo: Option<&str>, git_ref: Option<&str>) -> Result<Vec<String>, IndexError> {
|
||||
check_path(path)?;
|
||||
let encoded = encode_path(path);
|
||||
let mut urls = Vec::with_capacity(templates.len());
|
||||
for t in templates {
|
||||
let mut url = t.replace("{path}", &encoded);
|
||||
for (key, value, ok) in [("{repo}", repo, is_repo as fn(&str) -> bool), ("{ref}", git_ref, is_ref)] {
|
||||
if url.contains(key) {
|
||||
match value {
|
||||
Some(v) if ok(v) => url = url.replace(key, v),
|
||||
_ => return invalid(format!("`{}` has no valid {key} for its URL", short(path))),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !url.starts_with("https://") || url.contains('{') {
|
||||
return invalid(format!("`{}` doesn't resolve to an https URL", short(path)));
|
||||
}
|
||||
urls.push(url);
|
||||
}
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// The host of an https URL, lowercased: `fonts.example.org`.
|
||||
pub(crate) fn https_host(url: &str) -> Option<String> {
|
||||
let rest = url.strip_prefix("https://")?;
|
||||
let host = rest.split(['/', '?', '#']).next()?.to_ascii_lowercase();
|
||||
let ok = !host.is_empty() && host.len() <= 253 && host.bytes().all(|b| b.is_ascii_alphanumeric() || b".-:[]".contains(&b));
|
||||
ok.then_some(host)
|
||||
}
|
||||
|
||||
/// Whether `url` is an https URL a user may add as a source.
|
||||
pub(crate) fn is_source_url(url: &str) -> bool {
|
||||
url.len() <= MAX_STR && https_host(url).is_some() && !url.chars().any(|c| c.is_control() || c.is_whitespace() || c == '\\')
|
||||
}
|
||||
|
||||
/// What [`check_family`] needs to know about the source.
|
||||
struct Ctx<'a> {
|
||||
/// The ID the file declares.
|
||||
declared: &'a str,
|
||||
/// The ID the app uses (prefixed for a user source).
|
||||
id: &'a str,
|
||||
slug: &'a str,
|
||||
google: bool,
|
||||
curated: bool,
|
||||
templates: &'a [String],
|
||||
repo: Option<&'a str>,
|
||||
git_ref: Option<&'a str>,
|
||||
languages: usize,
|
||||
}
|
||||
|
||||
/// Reads one source's index file.
|
||||
pub(crate) fn parse(bytes: &[u8], origin: Origin<'_>) -> Result<Index, IndexError> {
|
||||
if bytes.len() > MAX_INDEX_BYTES {
|
||||
return Err(IndexError::TooLarge { max: MAX_INDEX_BYTES });
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
// The version first: a newer major version may change any other field.
|
||||
let version = value.get("version").and_then(|v| v.as_str()).ok_or_else(|| IndexError::Version("(none)".into()))?;
|
||||
check_version(version)?;
|
||||
let raw: Raw = serde_json::from_value(value).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
check(raw, origin)
|
||||
}
|
||||
|
||||
fn check(raw: Raw, origin: Origin<'_>) -> Result<Index, IndexError> {
|
||||
check_str("commit", &raw.commit)?;
|
||||
if raw.commit.len() != 40 || !raw.commit.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return invalid(format!("commit `{}` is not 40 hex characters", short(&raw.commit)));
|
||||
}
|
||||
let info_id = raw.source_info.as_ref().map(|i| i.id.as_str()).unwrap_or_default();
|
||||
let declared = match (raw.source.as_str(), info_id) {
|
||||
("", "") => GOOGLE_FONTS,
|
||||
(s, "") | ("", s) => s,
|
||||
(s, i) if s == i => s,
|
||||
_ => return invalid("source and source_info.id differ"),
|
||||
}
|
||||
.to_string();
|
||||
if !declared.split('/').all(is_ref) || declared.len() > 128 {
|
||||
return invalid(format!("source `{}` is not a valid ID", short(&declared)));
|
||||
}
|
||||
let host = match origin {
|
||||
Origin::Trusted => None,
|
||||
Origin::User { url } => Some(https_host(url).ok_or_else(|| IndexError::Invalid("the source's URL is not https".into()))?),
|
||||
};
|
||||
let id = host.as_ref().map_or_else(|| declared.clone(), |h| format!("user:{h}/{declared}"));
|
||||
for u in raw.base_url.iter().chain(&raw.fallback_base_url) {
|
||||
check_base_url(u)?;
|
||||
}
|
||||
check_repo_ref("the index", raw.repo.as_deref(), raw.git_ref.as_deref())?;
|
||||
let (name, homepage, curated, templates) = match &raw.source_info {
|
||||
Some(info) => {
|
||||
check_str("source name", &info.name)?;
|
||||
if info.file_urls.is_empty() || info.file_urls.len() > MAX_TEMPLATES {
|
||||
return invalid("source_info.file_urls must list 1 to 8 templates");
|
||||
}
|
||||
for t in &info.file_urls {
|
||||
check_template(t)?;
|
||||
}
|
||||
let homepage = info.homepage.clone().filter(|h| is_source_url(h));
|
||||
let name = if info.name.trim().is_empty() { declared.clone() } else { info.name.clone() };
|
||||
(name, homepage, info.curated, info.file_urls.clone())
|
||||
}
|
||||
None => {
|
||||
let Some(base) = &raw.base_url else { return invalid("an index without source_info needs base_url") };
|
||||
let templates = std::iter::once(base).chain(&raw.fallback_base_url).map(|b| format!("{b}{{path}}")).collect();
|
||||
let name = host.clone().unwrap_or_else(|| "Google Fonts".into());
|
||||
(name, None, true, templates)
|
||||
}
|
||||
};
|
||||
let curated = curated && host.is_none();
|
||||
if raw.families.len() > MAX_FAMILIES || raw.languages.len() > MAX_FAMILIES * 4 || raw.scripts.len() > MAX_LIST {
|
||||
return invalid("too many families, languages or scripts");
|
||||
}
|
||||
for l in &raw.languages {
|
||||
check_str("language", &l.id)?;
|
||||
}
|
||||
for s in &raw.scripts {
|
||||
check_str("script", &s.id)?;
|
||||
}
|
||||
let slug = slug(&id);
|
||||
let ctx = Ctx {
|
||||
declared: &declared,
|
||||
id: &id,
|
||||
slug: &slug,
|
||||
google: host.is_none() && declared == GOOGLE_FONTS,
|
||||
curated,
|
||||
templates: &templates,
|
||||
repo: raw.repo.as_deref(),
|
||||
git_ref: raw.git_ref.as_deref(),
|
||||
languages: raw.languages.len(),
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
let mut families = Vec::with_capacity(raw.families.len());
|
||||
let mut names = HashSet::new();
|
||||
let mut dirs = HashSet::new();
|
||||
let mut total = 0usize;
|
||||
for mut fam in raw.families {
|
||||
check_str("license", &fam.license)?;
|
||||
if !LICENSES.contains(&fam.license.as_str()) {
|
||||
warnings.push(format!("{} is left out: its licence `{}` doesn't allow redistribution", short(&fam.family), short(&fam.license)));
|
||||
continue;
|
||||
}
|
||||
check_family(&mut fam, &ctx)?;
|
||||
total = total.saturating_add(fam.files.len());
|
||||
if total > MAX_FILES {
|
||||
return invalid("too many files");
|
||||
}
|
||||
if !names.insert(fam.family.to_lowercase()) {
|
||||
return invalid(format!("family `{}` is listed twice", fam.family));
|
||||
}
|
||||
if !dirs.insert(fam.dir.clone()) {
|
||||
return invalid(format!("folder `{}` holds more than one family", fam.dir));
|
||||
}
|
||||
families.push(fam);
|
||||
}
|
||||
let source = FontSource {
|
||||
id,
|
||||
name,
|
||||
homepage,
|
||||
curated,
|
||||
version: raw.version,
|
||||
commit: raw.commit,
|
||||
user_url: match origin {
|
||||
Origin::Trusted => None,
|
||||
Origin::User { url } => Some(url.to_string()),
|
||||
},
|
||||
changed: false,
|
||||
warnings,
|
||||
families: families.len(),
|
||||
templates,
|
||||
repo: raw.repo,
|
||||
git_ref: raw.git_ref,
|
||||
};
|
||||
Ok(Index::assemble(vec![(source, raw.languages, raw.scripts, families)], Vec::new()))
|
||||
}
|
||||
|
||||
/// The family folder in google/fonts: every file at `<ofl|apache|ufl>/<dir>/<file>`.
|
||||
fn google_dir(fam: &Family) -> Result<String, IndexError> {
|
||||
let dir_of = |path: &str| -> Option<String> {
|
||||
let mut parts = path.split('/');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(lic), Some(dir), Some(_), None) if ["ofl", "apache", "ufl"].contains(&lic) && safe_segment(dir) => Some(dir.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut dir: Option<String> = None;
|
||||
for path in fam.files.iter().map(|f| f.path.as_str()).chain(fam.license_path.as_deref()) {
|
||||
let d = dir_of(path).ok_or_else(|| IndexError::Invalid(format!("`{}` is not <ofl|apache|ufl>/<folder>/<file>", short(path))))?;
|
||||
match &dir {
|
||||
None => dir = Some(d),
|
||||
Some(x) if *x == d => {}
|
||||
Some(_) => return invalid(format!("the files of `{}` are in different folders", fam.family)),
|
||||
}
|
||||
}
|
||||
dir.ok_or_else(|| IndexError::Invalid(format!("`{}` has no files", fam.family)))
|
||||
}
|
||||
|
||||
fn check_family(fam: &mut Family, ctx: &Ctx<'_>) -> Result<(), IndexError> {
|
||||
check_str("family name", &fam.family)?;
|
||||
if fam.family.trim().is_empty() {
|
||||
return invalid("a family has no name");
|
||||
}
|
||||
let name = fam.family.clone();
|
||||
if !fam.source.is_empty() && fam.source != ctx.declared {
|
||||
return invalid(format!("`{name}` is from source `{}`, not the index's", short(&fam.source)));
|
||||
}
|
||||
fam.source = ctx.id.to_string();
|
||||
fam.curated = ctx.curated;
|
||||
check_str("designer", &fam.designer)?;
|
||||
if fam.files.is_empty() {
|
||||
return invalid(format!("`{name}` has no files"));
|
||||
}
|
||||
let lists = [fam.categories.len(), fam.subsets.len(), fam.languages.len(), fam.scripts.len(), fam.files.len()];
|
||||
if lists.iter().any(|&n| n > MAX_LIST) {
|
||||
return invalid(format!("`{name}` has too many entries"));
|
||||
}
|
||||
for s in fam.categories.iter().chain(&fam.subsets).chain(&fam.scripts) {
|
||||
check_str("category, subset or script", s)?;
|
||||
}
|
||||
if fam.languages.iter().any(|&i| i >= ctx.languages) {
|
||||
return invalid(format!("`{name}` names a language the index doesn't have"));
|
||||
}
|
||||
check_repo_ref(&name, fam.repo.as_deref(), fam.git_ref.as_deref())?;
|
||||
let (fam_repo, fam_ref) = (fam.repo.clone(), fam.git_ref.clone());
|
||||
let repo = |own: Option<String>| own.or_else(|| fam_repo.clone()).or_else(|| ctx.repo.map(str::to_string));
|
||||
let git_ref = |own: Option<String>| own.or_else(|| fam_ref.clone()).or_else(|| ctx.git_ref.map(str::to_string));
|
||||
let mut names = HashSet::new();
|
||||
for f in &fam.files {
|
||||
let file = check_path(&f.path)?.to_ascii_lowercase();
|
||||
if ![".ttf", ".otf", ".ttc", ".otc"].iter().any(|ext| file.ends_with(ext)) {
|
||||
return invalid(format!("`{}` is not a font file", short(&f.path)));
|
||||
}
|
||||
if !names.insert(file) {
|
||||
return invalid(format!("`{}` is listed twice", short(&f.path)));
|
||||
}
|
||||
if !is_sha256(&f.sha256) {
|
||||
return invalid(format!("`{}` has a bad sha256", short(&f.path)));
|
||||
}
|
||||
if f.size == 0 || f.size > MAX_FILE_BYTES {
|
||||
return invalid(format!("`{}` has size {}", short(&f.path), f.size));
|
||||
}
|
||||
if !(1..=1000).contains(&f.weight) {
|
||||
return invalid(format!("`{}` has weight {}", short(&f.path), f.weight));
|
||||
}
|
||||
if !(f.width.is_finite() && f.width > 0.0 && f.width <= 1000.0) {
|
||||
return invalid(format!("`{}` has width {}", short(&f.path), f.width));
|
||||
}
|
||||
check_str("style", &f.style)?;
|
||||
if f.axes.len() > 64 || f.postscript.len() > MAX_LIST {
|
||||
return invalid(format!("`{}` has too many axes or PostScript names", short(&f.path)));
|
||||
}
|
||||
for a in &f.axes {
|
||||
if a.tag.len() != 4 || !a.tag.is_ascii() || !a.min.is_finite() || !a.max.is_finite() || a.min > a.max {
|
||||
return invalid(format!("`{}` has a bad axis", short(&f.path)));
|
||||
}
|
||||
}
|
||||
for ps in &f.postscript {
|
||||
check_str("PostScript name", ps)?;
|
||||
if ps.is_empty() {
|
||||
return invalid(format!("`{}` has an empty PostScript name", short(&f.path)));
|
||||
}
|
||||
}
|
||||
check_repo_ref(&f.path, f.repo.as_deref(), f.git_ref.as_deref())?;
|
||||
fill(ctx.templates, &f.path, repo(f.repo.clone()).as_deref(), git_ref(f.git_ref.clone()).as_deref())?;
|
||||
}
|
||||
let beside = |path: &str| -> Result<(), IndexError> {
|
||||
if names.contains(&check_path(path)?.to_ascii_lowercase()) {
|
||||
return invalid(format!("the licence of `{name}` has a font file's name"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Some(lf) = &fam.license_file {
|
||||
beside(&lf.path)?;
|
||||
if !is_sha256(&lf.sha256) || lf.size == 0 || lf.size > MAX_LICENSE_BYTES {
|
||||
return invalid(format!("the licence of `{name}` has a bad sha256 or size"));
|
||||
}
|
||||
check_repo_ref(&lf.path, lf.repo.as_deref(), lf.git_ref.as_deref())?;
|
||||
fill(ctx.templates, &lf.path, repo(lf.repo.clone()).as_deref(), git_ref(lf.git_ref.clone()).as_deref())?;
|
||||
}
|
||||
if let Some(lp) = &fam.license_path {
|
||||
beside(lp)?;
|
||||
if fam.license_file.is_none() {
|
||||
fill(ctx.templates, lp, repo(None).as_deref(), git_ref(None).as_deref())?;
|
||||
}
|
||||
}
|
||||
fam.dir = if ctx.google { google_dir(fam)? } else { format!("{}/{}", ctx.slug, slug(&fam.family)) };
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `sources.json`: the built-in indexes, each with its size and sha256.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::index::{Index, IndexError, MAX_INDEX_BYTES, gunzip};
|
||||
use crate::source::{MAX_STR, check_path, check_str, check_version, invalid, is_sha256};
|
||||
use crate::verify::sha256_hex;
|
||||
|
||||
/// The largest `sources.json` read.
|
||||
pub(crate) const MAX_SOURCES_BYTES: usize = 1 << 20;
|
||||
const MAX_SOURCES: usize = 64;
|
||||
|
||||
/// One built-in index of `sources.json`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct SourceEntry {
|
||||
/// The index's source ID.
|
||||
pub source: String,
|
||||
/// The source's display name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Whether the source is curated.
|
||||
#[serde(default)]
|
||||
pub curated: bool,
|
||||
/// The index, relative to `sources.json`.
|
||||
pub path: String,
|
||||
/// Bytes of the index.
|
||||
pub size: u64,
|
||||
/// sha256 of the index.
|
||||
pub sha256: String,
|
||||
/// The gzipped index, relative to `sources.json`.
|
||||
#[serde(default)]
|
||||
pub gz_path: Option<String>,
|
||||
/// Bytes of the gzipped index.
|
||||
#[serde(default)]
|
||||
pub gz_size: Option<u64>,
|
||||
/// sha256 of the gzipped index.
|
||||
#[serde(default)]
|
||||
pub gz_sha256: Option<String>,
|
||||
/// The commit the index was generated from.
|
||||
#[serde(default)]
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
impl SourceEntry {
|
||||
/// The gzipped copy's path, size and sha256, when `sources.json` lists one.
|
||||
pub fn gz(&self) -> Option<(&str, u64, &str)> {
|
||||
match (&self.gz_path, self.gz_size, &self.gz_sha256) {
|
||||
(Some(p), Some(n), Some(h)) => Some((p, n, h)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The index from the bytes of its gzipped copy (when `gz`) or of the index itself, checked
|
||||
/// against the sizes and sha256s listed here, and that it is this entry's source.
|
||||
pub fn read(&self, bytes: &[u8], gz: bool) -> Result<Index, IndexError> {
|
||||
let json = if gz {
|
||||
let (_, size, sha256) = self.gz().ok_or(IndexError::Checksum)?;
|
||||
if bytes.len() as u64 != size || sha256_hex(bytes) != sha256 {
|
||||
return Err(IndexError::Checksum);
|
||||
}
|
||||
gunzip(bytes, MAX_INDEX_BYTES)?
|
||||
} else {
|
||||
bytes.to_vec()
|
||||
};
|
||||
if json.len() as u64 != self.size || sha256_hex(&json) != self.sha256 {
|
||||
return Err(IndexError::Checksum);
|
||||
}
|
||||
let index = Index::from_json(&json)?;
|
||||
if index.sources().iter().any(|s| s.id != self.source) {
|
||||
return invalid(format!("the index at {} is not of source {}", self.path, self.source));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
/// `sources.json`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct SourcesFile {
|
||||
/// `"MAJOR.MINOR"`.
|
||||
pub version: String,
|
||||
/// The built-in indexes, in the order a client lists them.
|
||||
pub indexes: Vec<SourceEntry>,
|
||||
}
|
||||
|
||||
impl SourcesFile {
|
||||
/// Reads and checks `sources.json`.
|
||||
pub fn from_json(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
if bytes.len() > MAX_SOURCES_BYTES {
|
||||
return Err(IndexError::TooLarge { max: MAX_SOURCES_BYTES });
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
let version = value.get("version").and_then(|v| v.as_str()).ok_or_else(|| IndexError::Version("(none)".into()))?;
|
||||
check_version(version)?;
|
||||
let file: SourcesFile = serde_json::from_value(value).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
if file.indexes.len() > MAX_SOURCES {
|
||||
return invalid("sources.json lists too many indexes");
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for e in &file.indexes {
|
||||
check_str("source", &e.source)?;
|
||||
check_str("source name", &e.name)?;
|
||||
check_str("commit", &e.commit)?;
|
||||
if e.source.is_empty() || e.source.len() > MAX_STR || !ids.insert(e.source.as_str()) {
|
||||
return invalid(format!("source `{}` is empty or listed twice", e.source));
|
||||
}
|
||||
check_path(&e.path)?;
|
||||
if !is_sha256(&e.sha256) || e.size == 0 || e.size > MAX_INDEX_BYTES as u64 {
|
||||
return invalid(format!("the index of `{}` has a bad sha256 or size", e.source));
|
||||
}
|
||||
if e.gz_path.is_some() || e.gz_size.is_some() || e.gz_sha256.is_some() {
|
||||
let Some((p, n, h)) = e.gz() else { return invalid(format!("the gzipped index of `{}` is incomplete", e.source)) };
|
||||
check_path(p)?;
|
||||
if !is_sha256(h) || n == 0 || n > MAX_INDEX_BYTES as u64 {
|
||||
return invalid(format!("the gzipped index of `{}` has a bad sha256 or size", e.source));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-index")]
|
||||
const BUNDLED_SOURCES: &[u8] = include_bytes!("../index/sources.json");
|
||||
|
||||
/// The gzipped indexes compiled in, by their path in `sources.json`.
|
||||
#[cfg(feature = "bundled-index")]
|
||||
const BUNDLED: [(&str, &[u8]); 2] = [
|
||||
("indexes/google-fonts.json.gz", include_bytes!("../index/indexes/google-fonts.json.gz")),
|
||||
("indexes/noto.json.gz", include_bytes!("../index/indexes/noto.json.gz")),
|
||||
];
|
||||
|
||||
#[cfg(feature = "bundled-index")]
|
||||
pub(crate) fn bundled() -> Result<Index, IndexError> {
|
||||
if sha256_hex(BUNDLED_SOURCES) != crate::PINNED_INDEX.sha256 {
|
||||
return Err(IndexError::Checksum);
|
||||
}
|
||||
let sources = SourcesFile::from_json(BUNDLED_SOURCES)?;
|
||||
let mut parts = Vec::new();
|
||||
for entry in &sources.indexes {
|
||||
let gz_path = entry.gz().map(|(p, _, _)| p);
|
||||
let bytes = BUNDLED.iter().find(|(p, _)| Some(*p) == gz_path).map(|(_, b)| *b);
|
||||
let bytes = bytes.ok_or_else(|| IndexError::Invalid(format!("{} is not compiled in", entry.source)))?;
|
||||
parts.push(entry.read(bytes, true)?);
|
||||
}
|
||||
Ok(Index::merge(parts))
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
//! The font store folder: consent, installs, the manifest, removal and checks.
|
||||
//!
|
||||
//! Layout of the folder ([`craft_font_catalog::font_store_dir`]):
|
||||
//!
|
||||
//! ```text
|
||||
//! settings.json the consent setting and the sources the user added
|
||||
//! manifest.json what is installed
|
||||
//! indexes/ the last indexes fetched: sources.json, one file per source
|
||||
//! <family dir>/ google/fonts' folder name, or <source>/<family>: the fonts and the licence
|
||||
//! .staging/ downloads being installed; nothing a font scan picks up
|
||||
//! .lock held while the store changes
|
||||
//! ```
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use craft_font_catalog::{Catalog, FontEnv, font_store_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::http::{Cancel, Client, FetchError};
|
||||
use crate::index::{Face, Family, FontFile, GOOGLE_FONTS, Index, IndexError, MAX_INDEX_BYTES, MAX_LICENSE_BYTES, SourceProblem, Unavailable};
|
||||
use crate::pin::IndexPin;
|
||||
use crate::source::{is_source_url, safe_dir, safe_file_name, slug};
|
||||
use crate::sources::{MAX_SOURCES_BYTES, SourceEntry, SourcesFile};
|
||||
use crate::verify::{VerifyError, is_font, sha256_hex, verify_font};
|
||||
|
||||
const SETTINGS: &str = "settings.json";
|
||||
const MANIFEST: &str = "manifest.json";
|
||||
const INDEX_CACHE: &str = "indexes";
|
||||
const SOURCES_CACHE: &str = "sources.json";
|
||||
const STAGING: &str = ".staging";
|
||||
const LOCK: &str = ".lock";
|
||||
const MAX_MANIFEST_BYTES: u64 = 16 << 20;
|
||||
const MAX_SETTINGS_BYTES: u64 = 1 << 20;
|
||||
const MAX_USER_SOURCES: usize = 64;
|
||||
/// Staging folders older than this belong to an install that crashed.
|
||||
const STALE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Whether the store may download.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Network {
|
||||
/// No requests. The default.
|
||||
#[default]
|
||||
Off,
|
||||
/// The user allowed downloads from jsDelivr and GitHub.
|
||||
On,
|
||||
}
|
||||
|
||||
/// Who drives the store. Only a person in the app can turn the network on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Caller {
|
||||
/// The app's own interface, acting on the user's click.
|
||||
User,
|
||||
/// An agent (MCP, the CLI, scripting): it can install when the user has turned the network
|
||||
/// on, and can never turn it on.
|
||||
Agent,
|
||||
}
|
||||
|
||||
/// Why a store operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Downloading is off; the user turns it on in the app.
|
||||
#[error("downloading fonts is turned off")]
|
||||
NetworkOff,
|
||||
/// An agent tried to turn the network on.
|
||||
#[error("only the user can turn on downloading fonts")]
|
||||
NotAllowed,
|
||||
/// Not an https URL a source can be added from.
|
||||
#[error("{0} is not an https URL")]
|
||||
BadUrl(String),
|
||||
/// No exact match in the index.
|
||||
#[error(transparent)]
|
||||
Unavailable(#[from] Unavailable),
|
||||
/// The index can't be used.
|
||||
#[error(transparent)]
|
||||
Index(#[from] IndexError),
|
||||
/// Every URL failed.
|
||||
#[error("downloading {what} failed: {}", reasons.join("; "))]
|
||||
Download {
|
||||
/// The file.
|
||||
what: String,
|
||||
/// Each URL's failure.
|
||||
reasons: Vec<String>,
|
||||
},
|
||||
/// The downloaded file isn't the one the index describes.
|
||||
#[error("{file} was rejected: {error}")]
|
||||
Verify {
|
||||
/// The file.
|
||||
file: String,
|
||||
/// What was wrong.
|
||||
error: VerifyError,
|
||||
},
|
||||
/// The family isn't installed.
|
||||
#[error("{0} is not installed")]
|
||||
NotInstalled(String),
|
||||
/// The operation was cancelled.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
/// Reading or writing the store failed.
|
||||
#[error("{what}: {source}")]
|
||||
Io {
|
||||
/// What was being done.
|
||||
what: String,
|
||||
/// The error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
fn io(what: impl Into<String>) -> impl FnOnce(std::io::Error) -> Error {
|
||||
let what = what.into();
|
||||
move |source| Error::Io { what, source }
|
||||
}
|
||||
|
||||
/// Download progress of an install.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
/// The file being downloaded, from 0.
|
||||
pub file: usize,
|
||||
/// Files to download.
|
||||
pub files: usize,
|
||||
/// Bytes downloaded, of all files.
|
||||
pub done: u64,
|
||||
/// Bytes of all files.
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// An installed font file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstalledFile {
|
||||
/// The file's name in the family folder.
|
||||
pub name: String,
|
||||
/// Its path in its source's repository.
|
||||
pub path: String,
|
||||
/// Bytes.
|
||||
pub size: u64,
|
||||
/// sha256, lowercase hex.
|
||||
pub sha256: String,
|
||||
/// The commit of the index it was installed from.
|
||||
pub commit: String,
|
||||
/// Its PostScript names.
|
||||
#[serde(default)]
|
||||
pub postscript: Vec<String>,
|
||||
}
|
||||
|
||||
/// An installed family.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstalledFamily {
|
||||
/// The family name.
|
||||
pub family: String,
|
||||
/// The ID of the source it was installed from.
|
||||
#[serde(default = "google_fonts")]
|
||||
pub source: String,
|
||||
/// The folder in the store holding its files.
|
||||
pub dir: String,
|
||||
/// `OFL`, `APACHE2` or `UFL`.
|
||||
pub license: String,
|
||||
/// The licence file's name in the folder.
|
||||
pub license_file: Option<String>,
|
||||
/// The installed files: all of the family's, or some faces.
|
||||
pub files: Vec<InstalledFile>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Manifest {
|
||||
version: u32,
|
||||
families: Vec<InstalledFamily>,
|
||||
}
|
||||
|
||||
fn google_fonts() -> String {
|
||||
GOOGLE_FONTS.into()
|
||||
}
|
||||
|
||||
/// A source the user added: an index file at an https URL, never curated.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserSource {
|
||||
/// The index file (format 1.x, JSON or gzipped JSON).
|
||||
pub url: String,
|
||||
/// When it was added, in seconds since 1970.
|
||||
pub added_at: u64,
|
||||
/// The sha256 of the file first fetched from it. A different file later shows as
|
||||
/// [`crate::FontSource::changed`].
|
||||
#[serde(default)]
|
||||
pub last_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct Settings {
|
||||
#[serde(default)]
|
||||
network: Network,
|
||||
#[serde(default)]
|
||||
user_sources: Vec<UserSource>,
|
||||
}
|
||||
|
||||
/// Something [`FontStore::verify`] found wrong with an installed file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Problem {
|
||||
/// The family.
|
||||
pub family: String,
|
||||
/// The file.
|
||||
pub file: String,
|
||||
/// What is wrong; `None` when the file is missing.
|
||||
pub error: Option<VerifyError>,
|
||||
}
|
||||
|
||||
/// The suite's font store.
|
||||
///
|
||||
/// Nothing here touches the network on its own: only [`FontStore::fetch_index`] and the installs
|
||||
/// do, and only while the user has turned the network on.
|
||||
pub struct FontStore {
|
||||
dir: PathBuf,
|
||||
caller: Caller,
|
||||
client: Client,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FontStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FontStore").field("dir", &self.dir).field("caller", &self.caller).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl FontStore {
|
||||
/// The store for the app's own interface, in the folder every app reads.
|
||||
/// `None` where there is no per-user folder.
|
||||
pub fn for_user(env: &FontEnv) -> Option<Self> {
|
||||
Some(Self::new(font_store_dir(env)?, Caller::User))
|
||||
}
|
||||
|
||||
/// The store for an agent (MCP, CLI): it can never turn the network on.
|
||||
pub fn for_agent(env: &FontEnv) -> Option<Self> {
|
||||
Some(Self::new(font_store_dir(env)?, Caller::Agent))
|
||||
}
|
||||
|
||||
/// A store in `dir`.
|
||||
pub fn new(dir: PathBuf, caller: Caller) -> Self {
|
||||
Self { dir, caller, client: Client::default(), catalog: None }
|
||||
}
|
||||
|
||||
/// Uses `client` for downloads (other timeouts).
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// Rescans `catalog` after each install and removal, so the app sees the change at once.
|
||||
/// Apps pass `Catalog::global().clone()`.
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
|
||||
/// The store's folder.
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Who drives this store.
|
||||
pub fn caller(&self) -> Caller {
|
||||
self.caller
|
||||
}
|
||||
|
||||
// ----- consent
|
||||
|
||||
/// The user's setting; [`Network::Off`] when it was never set or can't be read.
|
||||
pub fn network(&self) -> Network {
|
||||
self.settings().network
|
||||
}
|
||||
|
||||
fn settings(&self) -> Settings {
|
||||
read_limited(&self.dir.join(SETTINGS), MAX_SETTINGS_BYTES).and_then(|b| serde_json::from_slice::<Settings>(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Turns downloading on: call it only from the user's own action (a setting, an Install
|
||||
/// click). [`Error::NotAllowed`] for an agent.
|
||||
pub fn enable_network(&self) -> Result<(), Error> {
|
||||
if self.caller != Caller::User {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
self.change_settings(|s| s.network = Network::On)
|
||||
}
|
||||
|
||||
/// Turns downloading off. Anyone may.
|
||||
pub fn disable_network(&self) -> Result<(), Error> {
|
||||
self.change_settings(|s| s.network = Network::Off)
|
||||
}
|
||||
|
||||
fn change_settings(&self, change: impl FnOnce(&mut Settings)) -> Result<(), Error> {
|
||||
let _lock = self.lock()?;
|
||||
let mut settings = self.settings();
|
||||
change(&mut settings);
|
||||
let bytes = serde_json::to_vec_pretty(&settings).map_err(|e| io("saving the setting")(std::io::Error::other(e)))?;
|
||||
self.write_file(SETTINGS, &bytes)
|
||||
}
|
||||
|
||||
// ----- sources the user added
|
||||
|
||||
/// The sources the user added, in the order added.
|
||||
pub fn user_sources(&self) -> Vec<UserSource> {
|
||||
self.settings().user_sources
|
||||
}
|
||||
|
||||
/// Adds an index file at `url` as a source, from the user's own action; the next
|
||||
/// [`FontStore::fetch_indexes`] downloads it. Its families are never curated.
|
||||
/// [`Error::NotAllowed`] for an agent.
|
||||
pub fn add_user_source(&self, url: &str) -> Result<(), Error> {
|
||||
if self.caller != Caller::User {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
let url = url.trim();
|
||||
if !is_source_url(url) {
|
||||
return Err(Error::BadUrl(url.chars().take(200).collect()));
|
||||
}
|
||||
let added_at = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
self.change_settings(|s| {
|
||||
if !s.user_sources.iter().any(|u| u.url == url) && s.user_sources.len() < MAX_USER_SOURCES {
|
||||
s.user_sources.push(UserSource { url: url.to_string(), added_at, last_sha256: None });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a source the user added, and its cached index. Families installed from it stay
|
||||
/// installed.
|
||||
pub fn remove_user_source(&self, url: &str) -> Result<(), Error> {
|
||||
self.change_settings(|s| s.user_sources.retain(|u| u.url != url))?;
|
||||
let _ = std::fs::remove_file(self.user_cache(url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn user_cache(&self, url: &str) -> PathBuf {
|
||||
let key: String = sha256_hex(url.as_bytes()).chars().take(16).collect();
|
||||
self.dir.join(INDEX_CACHE).join(format!("user-{key}"))
|
||||
}
|
||||
|
||||
fn require_network(&self) -> Result<(), Error> {
|
||||
if self.network() == Network::On { Ok(()) } else { Err(Error::NetworkOff) }
|
||||
}
|
||||
|
||||
// ----- index
|
||||
|
||||
/// The indexes saved by the last [`FontStore::fetch_indexes`], when its `sources.json` is
|
||||
/// `pin`'s and every built-in index matches it, with the sources the user added.
|
||||
pub fn cached_indexes(&self, pin: &IndexPin) -> Option<Index> {
|
||||
let cache = self.dir.join(INDEX_CACHE);
|
||||
let bytes = read_limited(&cache.join(SOURCES_CACHE), MAX_SOURCES_BYTES as u64)?;
|
||||
if sha256_hex(&bytes) != pin.sha256 {
|
||||
return None;
|
||||
}
|
||||
let sources = SourcesFile::from_json(&bytes).ok()?;
|
||||
let mut parts = Vec::new();
|
||||
for entry in &sources.indexes {
|
||||
let bytes = read_limited(&cache.join(builtin_cache_name(entry)), MAX_INDEX_BYTES as u64)?;
|
||||
parts.push(entry.read(&bytes, entry.gz().is_some()).ok()?);
|
||||
}
|
||||
let (users, problems) = self.cached_user_indexes();
|
||||
Some(Index::merge(parts.into_iter().chain(users)).with_problems(problems))
|
||||
}
|
||||
|
||||
/// The indexes without a request: the built-in ones compiled in, with the sources the user
|
||||
/// added as last fetched.
|
||||
#[cfg(feature = "bundled-index")]
|
||||
pub fn offline_indexes(&self) -> Index {
|
||||
let (users, mut problems) = self.cached_user_indexes();
|
||||
let builtin = Index::bundled().unwrap_or_else(|e| {
|
||||
problems.insert(0, SourceProblem { source: "sources.json".into(), name: "Built-in sources".into(), error: e.to_string() });
|
||||
Index::default()
|
||||
});
|
||||
Index::merge(std::iter::once(builtin).chain(users)).with_problems(problems)
|
||||
}
|
||||
|
||||
fn cached_user_indexes(&self) -> (Vec<Index>, Vec<SourceProblem>) {
|
||||
let mut indexes = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for user in self.user_sources() {
|
||||
let problem = |error: String| SourceProblem { source: user.url.clone(), name: user.url.clone(), error };
|
||||
let Some(bytes) = read_limited(&self.user_cache(&user.url), MAX_INDEX_BYTES as u64) else {
|
||||
problems.push(problem("not downloaded yet".into()));
|
||||
continue;
|
||||
};
|
||||
match user_index(&bytes, &user) {
|
||||
Ok(index) => indexes.push(index),
|
||||
Err(e) => problems.push(problem(e.to_string())),
|
||||
}
|
||||
}
|
||||
(indexes, problems)
|
||||
}
|
||||
|
||||
/// Downloads `sources.json` as `pin` names it, every index it lists and every source the user
|
||||
/// added, checks them and saves them for [`FontStore::cached_indexes`]. Needs the network on.
|
||||
///
|
||||
/// An index that fails leaves the others usable: it is reported in [`Index::problems`]. Only
|
||||
/// a `sources.json` that can't be had fails the whole call.
|
||||
pub fn fetch_indexes(&self, pin: &IndexPin, cancel: &Cancel) -> Result<Index, Error> {
|
||||
self.require_network()?;
|
||||
let check = |bytes: &[u8]| if sha256_hex(bytes) == pin.sha256 { Ok(()) } else { Err(VerifyError::Sha256) };
|
||||
let bytes = self.fetch(&pin.urls(), MAX_SOURCES_BYTES as u64, "sources.json", cancel, &mut |_| {}, &check)?;
|
||||
let sources = SourcesFile::from_json(&bytes)?;
|
||||
let cache = self.dir.join(INDEX_CACHE);
|
||||
std::fs::create_dir_all(&cache).map_err(io("creating the index folder"))?;
|
||||
let mut parts = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for entry in &sources.indexes {
|
||||
match self.fetch_builtin(pin, entry, cancel) {
|
||||
Ok(index) => parts.push(index),
|
||||
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||
Err(e) => problems.push(SourceProblem { source: entry.source.clone(), name: entry.name.clone(), error: e.to_string() }),
|
||||
}
|
||||
}
|
||||
self.write_file(&format!("{INDEX_CACHE}/{SOURCES_CACHE}"), &bytes)?;
|
||||
for user in self.user_sources() {
|
||||
match self.fetch_user(&user, cancel) {
|
||||
Ok(index) => parts.push(index),
|
||||
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||
Err(e) => problems.push(SourceProblem { source: user.url.clone(), name: user.url.clone(), error: e.to_string() }),
|
||||
}
|
||||
}
|
||||
Ok(Index::merge(parts).with_problems(problems))
|
||||
}
|
||||
|
||||
fn fetch_builtin(&self, pin: &IndexPin, entry: &SourceEntry, cancel: &Cancel) -> Result<Index, Error> {
|
||||
let (path, size, sha256) = entry.gz().unwrap_or((&entry.path, entry.size, &entry.sha256));
|
||||
let check = |bytes: &[u8]| {
|
||||
if bytes.len() as u64 != size {
|
||||
Err(VerifyError::Size { expected: size, actual: bytes.len() as u64 })
|
||||
} else if sha256_hex(bytes) != sha256 {
|
||||
Err(VerifyError::Sha256)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let bytes = self.fetch(&pin.file_urls(path), size, &entry.name, cancel, &mut |_| {}, &check)?;
|
||||
let index = entry.read(&bytes, entry.gz().is_some())?;
|
||||
self.write_file(&format!("{INDEX_CACHE}/{}", builtin_cache_name(entry)), &bytes)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn fetch_user(&self, user: &UserSource, cancel: &Cancel) -> Result<Index, Error> {
|
||||
let bytes = self.fetch(std::slice::from_ref(&user.url), MAX_INDEX_BYTES as u64, &user.url, cancel, &mut |_| {}, &|_| Ok(()))?;
|
||||
let index = user_index(&bytes, user)?;
|
||||
if let Some(name) = self.user_cache(&user.url).file_name().and_then(|n| n.to_str()) {
|
||||
self.write_file(&format!("{INDEX_CACHE}/{name}"), &bytes)?;
|
||||
}
|
||||
if user.last_sha256.is_none() {
|
||||
let sha = sha256_hex(&bytes);
|
||||
self.change_settings(|s| {
|
||||
if let Some(u) = s.user_sources.iter_mut().find(|u| u.url == user.url && u.last_sha256.is_none()) {
|
||||
u.last_sha256 = Some(sha);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
// ----- install
|
||||
|
||||
/// Installs every file of `family`. Files already installed with the same sha256 aren't
|
||||
/// downloaded again.
|
||||
pub fn install_family(
|
||||
&self,
|
||||
index: &Index,
|
||||
family: &Family,
|
||||
cancel: &Cancel,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<InstalledFamily, Error> {
|
||||
let files: Vec<&FontFile> = family.files.iter().collect();
|
||||
self.install(index, family, &files, cancel, progress)
|
||||
}
|
||||
|
||||
/// Installs the file of one face (from [`Index::find_face`] or [`Index::find_postscript`]),
|
||||
/// with the family's licence.
|
||||
pub fn install_face(&self, index: &Index, face: Face<'_>, cancel: &Cancel, progress: &mut dyn FnMut(Progress)) -> Result<InstalledFamily, Error> {
|
||||
self.install(index, face.family, &[face.file], cancel, progress)
|
||||
}
|
||||
|
||||
fn install(
|
||||
&self,
|
||||
index: &Index,
|
||||
family: &Family,
|
||||
files: &[&FontFile],
|
||||
cancel: &Cancel,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<InstalledFamily, Error> {
|
||||
let installed = self.installed_family(&family.source, &family.family);
|
||||
let folder = self.dir.join(family.dir());
|
||||
let have = |f: &FontFile| installed.as_ref().is_some_and(|i| i.files.iter().any(|x| x.name == f.file_name() && x.sha256 == f.sha256));
|
||||
let wanted: Vec<&FontFile> = files.iter().copied().filter(|f| !have(f) || !folder.join(f.file_name()).is_file()).collect();
|
||||
let license_missing = family.license_file_name().is_some_and(|name| !folder.join(name).is_file());
|
||||
if wanted.is_empty() && !license_missing {
|
||||
return installed.ok_or_else(|| Error::NotInstalled(family.family.clone()));
|
||||
}
|
||||
self.require_network()?;
|
||||
std::fs::create_dir_all(&self.dir).map_err(io("creating the font store folder"))?;
|
||||
self.sweep_staging();
|
||||
let stage = Stage::new(&self.dir)?;
|
||||
|
||||
let total: u64 = wanted.iter().map(|f| f.size).sum();
|
||||
let mut done_before = 0u64;
|
||||
let mut staged: Vec<(String, PathBuf)> = Vec::new();
|
||||
for (i, file) in wanted.iter().enumerate() {
|
||||
let mut on_bytes = |n: u64| progress(Progress { file: i, files: wanted.len(), done: done_before + n, total });
|
||||
let check = |bytes: &[u8]| verify_font(file, bytes);
|
||||
let bytes = self.fetch(&index.file_urls(family, file), file.size, file.file_name(), cancel, &mut on_bytes, &check)?;
|
||||
done_before += file.size;
|
||||
staged.push((file.file_name().to_string(), stage.write(file.file_name(), &bytes)?));
|
||||
}
|
||||
if license_missing && let Some(name) = family.license_file_name() {
|
||||
// A format 1.0 index has no size or sha256 for the licence: the size cap is the only
|
||||
// check.
|
||||
let max = family.license_file.as_ref().map_or(MAX_LICENSE_BYTES, |f| f.size);
|
||||
let check = |bytes: &[u8]| match &family.license_file {
|
||||
Some(f) if bytes.len() as u64 != f.size => Err(VerifyError::Size { expected: f.size, actual: bytes.len() as u64 }),
|
||||
Some(f) if sha256_hex(bytes) != f.sha256 => Err(VerifyError::Sha256),
|
||||
_ => Ok(()),
|
||||
};
|
||||
let bytes = self.fetch(&index.license_urls(family), max, name, cancel, &mut |_| {}, &check)?;
|
||||
staged.push((name.to_string(), stage.write(name, &bytes)?));
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
let result = {
|
||||
let _lock = self.lock()?;
|
||||
let mut manifest = self.read_manifest();
|
||||
let commit = Commit::apply(&folder, &stage.dir, &staged)?;
|
||||
let entry = merge_entry(&mut manifest, index, family, &wanted);
|
||||
match self.write_manifest(&manifest) {
|
||||
Ok(()) => {
|
||||
commit.keep();
|
||||
Ok(entry)
|
||||
}
|
||||
Err(e) => {
|
||||
commit.undo();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(stage);
|
||||
self.rescan();
|
||||
result
|
||||
}
|
||||
|
||||
/// Downloads from each URL in turn until one gives bytes that pass `check`.
|
||||
fn fetch(
|
||||
&self,
|
||||
urls: &[String],
|
||||
max: u64,
|
||||
what: &str,
|
||||
cancel: &Cancel,
|
||||
on_bytes: &mut dyn FnMut(u64),
|
||||
check: &dyn Fn(&[u8]) -> Result<(), VerifyError>,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let mut reasons = Vec::new();
|
||||
let mut rejected = None;
|
||||
for url in urls {
|
||||
match self.client.get(url, max, cancel, on_bytes) {
|
||||
Ok(bytes) => match check(&bytes) {
|
||||
Ok(()) => return Ok(bytes),
|
||||
Err(e) => {
|
||||
reasons.push(format!("{}: {e}", host(url)));
|
||||
rejected.get_or_insert(e);
|
||||
}
|
||||
},
|
||||
Err(FetchError::Cancelled) => return Err(Error::Cancelled),
|
||||
Err(e) => reasons.push(format!("{}: {e}", host(url))),
|
||||
}
|
||||
}
|
||||
match rejected {
|
||||
Some(error) => Err(Error::Verify { file: what.to_string(), error }),
|
||||
None => Err(Error::Download { what: what.to_string(), reasons }),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- installed fonts
|
||||
|
||||
/// The installed families, sorted by name.
|
||||
pub fn installed(&self) -> Vec<InstalledFamily> {
|
||||
self.read_manifest().families
|
||||
}
|
||||
|
||||
/// The family of this name installed from `source`, ignoring case.
|
||||
pub fn installed_family(&self, source: &str, name: &str) -> Option<InstalledFamily> {
|
||||
self.installed().into_iter().find(|f| f.is(source, name))
|
||||
}
|
||||
|
||||
/// Whether `file` is installed, with the index's sha256.
|
||||
pub fn is_installed(&self, family: &Family, file: &FontFile) -> bool {
|
||||
self.installed_family(&family.source, &family.family)
|
||||
.is_some_and(|i| i.files.iter().any(|x| x.name == file.file_name() && x.sha256 == file.sha256))
|
||||
}
|
||||
|
||||
/// Removes the family of this name installed from `source`: its files, its licence and its
|
||||
/// manifest entry. Its folder disappears at once and is deleted after.
|
||||
pub fn remove_family(&self, source: &str, name: &str) -> Result<(), Error> {
|
||||
let result = {
|
||||
let _lock = self.lock()?;
|
||||
let mut manifest = self.read_manifest();
|
||||
let pos = manifest.families.iter().position(|f| f.is(source, name)).ok_or_else(|| Error::NotInstalled(name.to_string()))?;
|
||||
let entry = manifest.families.remove(pos);
|
||||
let folder = self.dir.join(&entry.dir);
|
||||
let stage = Stage::new(&self.dir)?;
|
||||
let moved = stage.dir.join("removed");
|
||||
let had_folder = folder.exists();
|
||||
if had_folder {
|
||||
std::fs::rename(&folder, &moved).map_err(io(format!("removing {}", entry.family)))?;
|
||||
}
|
||||
match self.write_manifest(&manifest) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if had_folder {
|
||||
let _ = std::fs::rename(&moved, &folder);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.rescan();
|
||||
result
|
||||
}
|
||||
|
||||
/// Removes some files of an installed family (one style, say), by their names in its folder.
|
||||
/// Taking out the last one removes the family.
|
||||
pub fn remove_files(&self, source: &str, name: &str, files: &[String]) -> Result<(), Error> {
|
||||
let result = {
|
||||
let _lock = self.lock()?;
|
||||
let mut manifest = self.read_manifest();
|
||||
let pos = manifest.families.iter().position(|f| f.is(source, name)).ok_or_else(|| Error::NotInstalled(name.to_string()))?;
|
||||
let Some(entry) = manifest.families.get_mut(pos) else { return Err(Error::NotInstalled(name.to_string())) };
|
||||
entry.files.retain(|f| !files.contains(&f.name));
|
||||
let folder = self.dir.join(&entry.dir);
|
||||
let stage = Stage::new(&self.dir)?;
|
||||
let moving: Vec<(PathBuf, PathBuf)> = if entry.files.is_empty() {
|
||||
manifest.families.remove(pos);
|
||||
vec![(folder, stage.dir.join("removed"))]
|
||||
} else {
|
||||
files.iter().map(|f| (folder.join(f), stage.dir.join(f))).collect()
|
||||
};
|
||||
let mut moved = Vec::new();
|
||||
for (from, to) in &moving {
|
||||
if from.exists() {
|
||||
std::fs::rename(from, to).map_err(io(format!("removing {}", from.display())))?;
|
||||
moved.push((from, to));
|
||||
}
|
||||
}
|
||||
match self.write_manifest(&manifest) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
for (from, to) in moved {
|
||||
let _ = std::fs::rename(to, from);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.rescan();
|
||||
result
|
||||
}
|
||||
|
||||
/// Checks every installed file against the manifest: present, the right size and sha256,
|
||||
/// and a font.
|
||||
pub fn verify(&self) -> Vec<Problem> {
|
||||
let mut problems = Vec::new();
|
||||
for fam in self.installed() {
|
||||
for f in &fam.files {
|
||||
let path = self.dir.join(&fam.dir).join(&f.name);
|
||||
let problem = |error| Problem { family: fam.family.clone(), file: f.name.clone(), error };
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
problems.push(problem(None));
|
||||
continue;
|
||||
};
|
||||
let error = if bytes.len() as u64 != f.size {
|
||||
Some(VerifyError::Size { expected: f.size, actual: bytes.len() as u64 })
|
||||
} else if sha256_hex(&bytes) != f.sha256 {
|
||||
Some(VerifyError::Sha256)
|
||||
} else if !is_font(&bytes) {
|
||||
Some(VerifyError::NotAFont)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if error.is_some() {
|
||||
problems.push(problem(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
// ----- folder
|
||||
|
||||
fn rescan(&self) {
|
||||
if let Some(catalog) = &self.catalog {
|
||||
catalog.rescan();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<File, Error> {
|
||||
std::fs::create_dir_all(&self.dir).map_err(io("creating the font store folder"))?;
|
||||
let file = File::options().create(true).truncate(false).write(true).open(self.dir.join(LOCK)).map_err(io("locking the font store"))?;
|
||||
file.lock().map_err(io("locking the font store"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The manifest, without entries that aren't safe to act on. A manifest that can't be read
|
||||
/// is kept aside as `manifest.json.bad` and counts as empty.
|
||||
fn read_manifest(&self) -> Manifest {
|
||||
let path = self.dir.join(MANIFEST);
|
||||
let Some(bytes) = read_limited(&path, MAX_MANIFEST_BYTES) else { return Manifest::default() };
|
||||
let Ok(mut manifest) = serde_json::from_slice::<Manifest>(&bytes) else {
|
||||
let _ = std::fs::rename(&path, self.dir.join("manifest.json.bad"));
|
||||
return Manifest::default();
|
||||
};
|
||||
manifest
|
||||
.families
|
||||
.retain(|f| safe_dir(&f.dir) && f.files.iter().all(|x| safe_file_name(&x.name)) && f.license_file.as_deref().is_none_or(safe_file_name));
|
||||
manifest
|
||||
}
|
||||
|
||||
fn write_manifest(&self, manifest: &Manifest) -> Result<(), Error> {
|
||||
let manifest = Manifest { version: 1, families: manifest.families.clone() };
|
||||
let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| io("saving the manifest")(std::io::Error::other(e)))?;
|
||||
self.write_file(MANIFEST, &bytes)
|
||||
}
|
||||
|
||||
fn write_file(&self, name: &str, bytes: &[u8]) -> Result<(), Error> {
|
||||
std::fs::create_dir_all(&self.dir).map_err(io("creating the font store folder"))?;
|
||||
craft_atomic::atomic_write(&self.dir.join(name), bytes).map_err(io(format!("saving {name}")))
|
||||
}
|
||||
|
||||
fn sweep_staging(&self) {
|
||||
for e in std::fs::read_dir(self.dir.join(STAGING)).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).is_ok_and(|t| SystemTime::now().duration_since(t).is_ok_and(|d| d > STALE));
|
||||
if old {
|
||||
let _ = std::fs::remove_dir_all(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The family's manifest entry after installing `files`, merged into `manifest`.
|
||||
fn merge_entry(manifest: &mut Manifest, index: &Index, family: &Family, files: &[&FontFile]) -> InstalledFamily {
|
||||
let pos = match manifest.families.iter().position(|f| f.is(&family.source, &family.family)) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
manifest.families.push(InstalledFamily {
|
||||
family: family.family.clone(),
|
||||
source: family.source.clone(),
|
||||
dir: family.dir().to_string(),
|
||||
license: family.license.clone(),
|
||||
license_file: None,
|
||||
files: Vec::new(),
|
||||
});
|
||||
manifest.families.len() - 1
|
||||
}
|
||||
};
|
||||
let Some(entry) = manifest.families.get_mut(pos) else { return InstalledFamily::empty() };
|
||||
entry.license.clone_from(&family.license);
|
||||
let commit = index.source_of(family).map(|s| s.commit.clone()).unwrap_or_default();
|
||||
if let Some(name) = family.license_file_name() {
|
||||
entry.license_file = Some(name.to_string());
|
||||
}
|
||||
for f in files {
|
||||
entry.files.retain(|x| x.name != f.file_name());
|
||||
entry.files.push(InstalledFile {
|
||||
name: f.file_name().to_string(),
|
||||
path: f.path.clone(),
|
||||
size: f.size,
|
||||
sha256: f.sha256.clone(),
|
||||
commit: commit.clone(),
|
||||
postscript: f.postscript.clone(),
|
||||
});
|
||||
}
|
||||
entry.files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let entry = entry.clone();
|
||||
manifest.families.sort_by_key(|f| (f.family.to_lowercase(), f.source.clone()));
|
||||
entry
|
||||
}
|
||||
|
||||
impl InstalledFamily {
|
||||
fn empty() -> Self {
|
||||
Self { family: String::new(), source: String::new(), dir: String::new(), license: String::new(), license_file: None, files: Vec::new() }
|
||||
}
|
||||
|
||||
/// Whether this is the family `name` (ignoring case) from `source`.
|
||||
pub fn is(&self, source: &str, name: &str) -> bool {
|
||||
self.source == source && self.family.to_lowercase() == name.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_cache_name(entry: &SourceEntry) -> String {
|
||||
format!("{}{}", slug(&entry.source), if entry.gz().is_some() { ".json.gz" } else { ".json" })
|
||||
}
|
||||
|
||||
/// A user-added source's index from the bytes fetched from it.
|
||||
fn user_index(bytes: &[u8], user: &UserSource) -> Result<Index, IndexError> {
|
||||
let mut index = Index::from_user_bytes(bytes, &user.url)?;
|
||||
if user.last_sha256.as_ref().is_some_and(|h| *h != sha256_hex(bytes)) {
|
||||
index.mark_changed();
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn read_limited(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
if std::fs::metadata(path).ok()?.len() > max {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
fn host(url: &str) -> &str {
|
||||
url.split("://").nth(1).and_then(|r| r.split('/').next()).unwrap_or(url)
|
||||
}
|
||||
|
||||
/// A folder of downloads under `.staging`, deleted when dropped.
|
||||
struct Stage {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
fn new(store: &Path) -> Result<Self, Error> {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = store.join(STAGING).join(format!("{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).map_err(io("creating a download folder"))?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
/// Writes a verified download as `<name>.part`, which no font scan picks up.
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<PathBuf, Error> {
|
||||
let path = self.dir.join(format!("{name}.part"));
|
||||
let write = || -> std::io::Result<()> {
|
||||
let mut f = File::options().write(true).create_new(true).open(&path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
};
|
||||
write().map_err(io(format!("saving {name}")))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stage {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged files moved into a family folder, undone unless kept.
|
||||
struct Commit {
|
||||
folder: PathBuf,
|
||||
created_folder: bool,
|
||||
/// Each file moved in, and where the file it replaced went.
|
||||
moved: Vec<(PathBuf, Option<PathBuf>)>,
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
impl Commit {
|
||||
fn apply(folder: &Path, stage: &Path, staged: &[(String, PathBuf)]) -> Result<Self, Error> {
|
||||
let created_folder = !folder.is_dir();
|
||||
let mut commit = Self { folder: folder.to_path_buf(), created_folder, moved: Vec::new(), kept: false };
|
||||
std::fs::create_dir_all(folder).map_err(io("creating the family folder"))?;
|
||||
for (name, from) in staged {
|
||||
let dest = folder.join(name);
|
||||
let backup = if dest.exists() {
|
||||
let b = stage.join(format!("{name}.old"));
|
||||
std::fs::rename(&dest, &b).map_err(io(format!("replacing {name}")))?;
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = std::fs::rename(from, &dest) {
|
||||
if let Some(b) = &backup {
|
||||
let _ = std::fs::rename(b, &dest);
|
||||
}
|
||||
return Err(io(format!("installing {name}"))(e));
|
||||
}
|
||||
commit.moved.push((dest, backup));
|
||||
}
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
fn keep(mut self) {
|
||||
self.kept = true;
|
||||
}
|
||||
|
||||
fn undo(self) {}
|
||||
}
|
||||
|
||||
impl Drop for Commit {
|
||||
fn drop(&mut self) {
|
||||
if self.kept {
|
||||
return;
|
||||
}
|
||||
for (dest, backup) in self.moved.iter().rev() {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
if let Some(b) = backup {
|
||||
let _ = std::fs::rename(b, dest);
|
||||
}
|
||||
}
|
||||
if self.created_folder {
|
||||
let _ = std::fs::remove_dir(&self.folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const COMMIT: &str = "45e3d5269e4a17d823f6d84470bc084dc5ae46f6";
|
||||
|
||||
/// A font with the tables a scan and [`is_font`] need: `name`, `OS/2` and an empty `glyf`.
|
||||
fn font(family: &str, style: &str, weight: u16) -> Vec<u8> {
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::tables::name::{Name, NameRecord};
|
||||
use write_fonts::tables::os2::Os2;
|
||||
use write_fonts::types::{NameId, Tag};
|
||||
let ps = format!("{}-{}", family.replace(' ', ""), style.replace(' ', ""));
|
||||
let mut records: Vec<NameRecord> = [(1, family.to_string()), (2, style.to_string()), (4, format!("{family} {style}")), (6, ps)]
|
||||
.into_iter()
|
||||
.map(|(id, text)| NameRecord::new(3, 1, 0x409, NameId::new(id), text.into()))
|
||||
.collect();
|
||||
records.sort_by_key(|r| r.name_id);
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_table(&Name::new(records)).unwrap();
|
||||
builder.add_table(&Os2 { us_weight_class: weight, ul_code_page_range_1: Some(1), ul_code_page_range_2: Some(0), ..Default::default() }).unwrap();
|
||||
builder.add_raw(Tag::new(b"glyf"), vec![0u8; 4]);
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// An index and the files it lists, by their URL-encoded google/fonts paths.
|
||||
struct Fixture {
|
||||
files: Vec<(String, Vec<u8>)>,
|
||||
index: serde_json::Value,
|
||||
}
|
||||
|
||||
fn file_entry(path: &str, bytes: &[u8], style: &str, weight: u16, axes: serde_json::Value, ps: &[&str]) -> serde_json::Value {
|
||||
json!({"path": path, "size": bytes.len(), "sha256": sha256_hex(bytes), "style": style, "weight": weight, "axes": axes, "postscript": ps})
|
||||
}
|
||||
|
||||
/// An index of four families, and the bytes of their files.
|
||||
fn fixture() -> Fixture {
|
||||
let regular = font("Test Sans", "Regular", 400);
|
||||
let bold = font("Test Sans", "Bold", 700);
|
||||
let italic = font("Test Sans", "Italic", 400);
|
||||
let var = font("Test Var", "Regular", 400);
|
||||
let one = font("Shared One", "Regular", 400);
|
||||
let two = font("Shared Two", "Regular", 400);
|
||||
let index = json!({
|
||||
"schema": 1, "version": "1.0", "source": "google/fonts", "commit": COMMIT,
|
||||
"base_url": format!("https://cdn.jsdelivr.net/gh/google/fonts@{COMMIT}/"),
|
||||
"fallback_base_url": format!("https://raw.githubusercontent.com/google/fonts/{COMMIT}/"),
|
||||
"a_field_from_a_later_minor_version": true,
|
||||
"languages": [{"id": "en_Latn", "name": "English", "autonym": "English", "script": "Latn", "population": 1, "historical": false}],
|
||||
"scripts": [{"id": "Latn", "name": "Latin"}],
|
||||
"families": [
|
||||
{"family": "Shared One", "license": "OFL", "license_path": null, "languages": [0], "scripts": ["Latn"],
|
||||
"files": [file_entry("ofl/sharedone/SharedOne-Regular.ttf", &one, "normal", 400, json!([]), &["Shared-Regular"])]},
|
||||
{"family": "Shared Two", "license": "OFL", "license_path": null,
|
||||
"files": [file_entry("ofl/sharedtwo/SharedTwo-Regular.ttf", &two, "normal", 400, json!([]), &["Shared-Regular"])]},
|
||||
{"family": "Test Sans", "designer": "Someone", "categories": ["SANS_SERIF"], "license": "OFL", "license_path": "ofl/testsans/OFL.txt",
|
||||
"languages": [0], "scripts": ["Latn"], "files": [
|
||||
file_entry("ofl/testsans/TestSans-Bold.ttf", &bold, "normal", 700, json!([]), &["TestSans-Bold"]),
|
||||
file_entry("ofl/testsans/TestSans-Italic.ttf", &italic, "italic", 400, json!([]), &["TestSans-Italic"]),
|
||||
file_entry("ofl/testsans/TestSans-Regular.ttf", ®ular, "normal", 400, json!([]), &["TestSans-Regular"])]},
|
||||
{"family": "Test Var", "license": "APACHE2", "license_path": "apache/testvar/LICENSE.txt", "files": [
|
||||
file_entry("apache/testvar/TestVar[wght].ttf", &var, "normal", 400, json!([{"tag": "wght", "min": 300.0, "max": 700.0}]),
|
||||
&["TestVar-Light", "TestVar-Regular", "TestVar-SemiBold", "TestVar-Bold"])]},
|
||||
]
|
||||
});
|
||||
let files = vec![
|
||||
("ofl/sharedone/SharedOne-Regular.ttf".into(), one),
|
||||
("ofl/sharedtwo/SharedTwo-Regular.ttf".into(), two),
|
||||
("ofl/testsans/TestSans-Bold.ttf".into(), bold),
|
||||
("ofl/testsans/TestSans-Italic.ttf".into(), italic),
|
||||
("ofl/testsans/TestSans-Regular.ttf".into(), regular),
|
||||
("ofl/testsans/OFL.txt".into(), b"SIL Open Font License".to_vec()),
|
||||
("apache/testvar/TestVar%5Bwght%5D.ttf".into(), var),
|
||||
("apache/testvar/LICENSE.txt".into(), b"Apache License".to_vec()),
|
||||
];
|
||||
Fixture { files, index }
|
||||
}
|
||||
|
||||
fn index_of(v: &serde_json::Value) -> Result<Index, IndexError> {
|
||||
Index::from_json(v.to_string().as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_is_read_and_checked() {
|
||||
let f = fixture();
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let source = &index.sources()[0];
|
||||
assert_eq!((source.version.as_str(), source.commit.as_str(), index.families().len()), ("1.0", COMMIT, 4));
|
||||
// A format 1.0 index is Google Fonts, curated, with URLs from its base URLs.
|
||||
assert_eq!((source.id.as_str(), source.name.as_str(), source.curated), (GOOGLE_FONTS, "Google Fonts", true));
|
||||
let sans = index.family("test sans").unwrap();
|
||||
assert_eq!((sans.dir(), sans.license_file_name(), sans.designer.as_str()), ("testsans", Some("OFL.txt"), "Someone"));
|
||||
assert_eq!((sans.source.as_str(), sans.is_curated()), (GOOGLE_FONTS, true));
|
||||
assert_eq!(sans.languages(&index).map(|l| l.id.as_str()).collect::<Vec<_>>(), ["en_Latn"]);
|
||||
|
||||
let with = |edit: &dyn Fn(&mut serde_json::Value)| {
|
||||
let mut v = f.index.clone();
|
||||
edit(&mut v);
|
||||
index_of(&v)
|
||||
};
|
||||
assert!(with(&|v| v["version"] = json!("1.7")).is_ok(), "a later minor version only adds fields");
|
||||
assert!(matches!(with(&|v| v["version"] = json!("2.0")), Err(IndexError::Version(_))));
|
||||
assert!(matches!(with(&|v| v["version"] = json!("1")), Err(IndexError::Version(_))));
|
||||
assert!(matches!(
|
||||
with(&|v| {
|
||||
v.as_object_mut().unwrap().remove("version");
|
||||
}),
|
||||
Err(IndexError::Version(_))
|
||||
));
|
||||
// A version 2 index may change any other field: the version is checked first.
|
||||
assert!(matches!(
|
||||
with(&|v| {
|
||||
v["version"] = json!("2.0");
|
||||
v["families"] = json!("other");
|
||||
}),
|
||||
Err(IndexError::Version(_))
|
||||
));
|
||||
let invalid = [
|
||||
with(&|v| v["base_url"] = json!("http://cdn.jsdelivr.net/gh/google/fonts/")),
|
||||
with(&|v| v["commit"] = json!("main")),
|
||||
with(&|v| v["families"][2]["files"][0]["path"] = json!("ofl/../TestSans-Bold.ttf")),
|
||||
with(&|v| v["families"][2]["files"][0]["path"] = json!("ofl/testsans/.hidden.ttf")),
|
||||
with(&|v| v["families"][2]["files"][0]["path"] = json!("ofl/other/TestSans-Bold.ttf")),
|
||||
with(&|v| v["families"][2]["files"][0]["sha256"] = json!("00")),
|
||||
with(&|v| v["families"][2]["files"][0]["weight"] = json!(0)),
|
||||
with(&|v| v["families"][2]["license_path"] = json!("ofl/elsewhere/OFL.txt")),
|
||||
with(&|v| v["families"][2]["license_path"] = json!("ofl/testsans/TestSans-Bold.ttf")),
|
||||
with(&|v| v["families"][2]["source"] = json!("notofonts")),
|
||||
with(&|v| v["base_url"] = json!("https://cdn.jsdelivr.net/gh/{repo}/")),
|
||||
with(&|v| v["families"][2]["languages"] = json!([5])),
|
||||
with(&|v| v["families"][3]["family"] = json!("TEST SANS")),
|
||||
];
|
||||
for (i, r) in invalid.into_iter().enumerate() {
|
||||
assert!(matches!(r, Err(IndexError::Invalid(_))), "case {i}: {r:?}");
|
||||
}
|
||||
assert!(matches!(Index::from_json(b"{"), Err(IndexError::Json(_))));
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-index")]
|
||||
#[test]
|
||||
fn the_bundled_indexes_are_the_pinned_ones() {
|
||||
assert_eq!(sha256_hex(include_bytes!("../index/sources.json")), PINNED_INDEX.sha256);
|
||||
let index = Index::bundled().unwrap();
|
||||
let ids: Vec<&str> = index.sources().iter().map(|s| s.id.as_str()).collect();
|
||||
assert_eq!(ids, [GOOGLE_FONTS, "notofonts"]);
|
||||
assert!(index.sources().iter().all(|s| s.curated && s.warnings.is_empty()));
|
||||
assert!(index.problems().is_empty());
|
||||
assert!(index.families().len() > 2000);
|
||||
let face = index.find_face("Lato", "Bold Italic").unwrap();
|
||||
assert_eq!((face.file.file_name(), face.family.dir()), ("Lato-BoldItalic.ttf", "lato"));
|
||||
|
||||
// Noto's own families, in folders of their own.
|
||||
let cjk = index.family("Noto Sans CJK JP").unwrap();
|
||||
assert_eq!((cjk.source.as_str(), cjk.dir(), cjk.license_file_name()), ("notofonts", "notofonts/noto-sans-cjk-jp", Some("LICENSE")));
|
||||
assert!(index.file_urls(cjk, &cjk.files[0])[0].starts_with("https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@Sans2.004/Sans/OTF/Japanese/"));
|
||||
// A family in both sources: Google Fonts first, Noto's with more styles after it.
|
||||
let both: Vec<&str> = index.families_named("Noto Serif Myanmar").map(|f| f.source.as_str()).collect();
|
||||
assert_eq!(both, [GOOGLE_FONTS, "notofonts"]);
|
||||
let noto = index.family_from("notofonts", "Noto Serif Myanmar").unwrap();
|
||||
assert_eq!(
|
||||
index.license_urls(noto),
|
||||
[
|
||||
"https://cdn.jsdelivr.net/gh/notofonts/myanmar@NotoSerifMyanmar-v2.106/OFL.txt",
|
||||
"https://raw.githubusercontent.com/notofonts/myanmar/NotoSerifMyanmar-v2.106/OFL.txt"
|
||||
]
|
||||
);
|
||||
// Languages are merged: every family's positions name a language of the merged list.
|
||||
let ja = index.languages().iter().position(|l| l.id == "ja_Jpan").unwrap();
|
||||
assert!(cjk.languages.contains(&ja));
|
||||
let gujarati_ui = index.family_from("notofonts", "Noto Sans Gujarati UI").unwrap();
|
||||
assert!(gujarati_ui.files.iter().any(|f| f.width != 100.0));
|
||||
assert!(gujarati_ui.face("Bold").unwrap().has_normal_width());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookups_match_exactly_and_refuse_near_matches() {
|
||||
let index = index_of(&fixture().index).unwrap();
|
||||
let name = |family: &str, style: &str| index.find_face(family, style).map(|f| f.file.file_name().to_string());
|
||||
assert_eq!(name("Test Sans", "Bold").unwrap(), "TestSans-Bold.ttf");
|
||||
assert_eq!(name("test sans", "700").unwrap(), "TestSans-Bold.ttf");
|
||||
assert_eq!(name("Test Sans", "Regular").unwrap(), "TestSans-Regular.ttf");
|
||||
assert_eq!(name("Test Sans", "").unwrap(), "TestSans-Regular.ttf");
|
||||
assert_eq!(name("Test Sans", "Italic").unwrap(), "TestSans-Italic.ttf");
|
||||
// Near matches are not offered.
|
||||
for style in ["Medium", "Bold Italic", "Light", "Condensed"] {
|
||||
assert!(matches!(name("Test Sans", style), Err(Unavailable::NoStyle { .. })), "{style}");
|
||||
}
|
||||
assert!(matches!(name("Test San", "Bold"), Err(Unavailable::NoFamily(_))));
|
||||
assert!(matches!(name("TestSans", "Bold"), Err(Unavailable::NoFamily(_))));
|
||||
// A variable font has the weights its axis reaches, and no others.
|
||||
assert_eq!(name("Test Var", "SemiBold").unwrap(), "TestVar[wght].ttf");
|
||||
assert_eq!(name("Test Var", "Light").unwrap(), "TestVar[wght].ttf");
|
||||
assert!(matches!(name("Test Var", "Thin"), Err(Unavailable::NoStyle { .. })));
|
||||
assert!(matches!(name("Test Var", "Bold Italic"), Err(Unavailable::NoStyle { .. })));
|
||||
|
||||
assert_eq!(index.find_postscript("TestSans-Bold").unwrap().family.family, "Test Sans");
|
||||
assert_eq!(index.find_postscript("TestVar-SemiBold").unwrap().file.file_name(), "TestVar[wght].ttf");
|
||||
assert!(matches!(index.find_postscript("testsans-bold"), Err(Unavailable::NoPostScript(_))));
|
||||
assert!(matches!(index.find_postscript("TestSans-Medium"), Err(Unavailable::NoPostScript(_))));
|
||||
let Err(Unavailable::Ambiguous { families, .. }) = index.find_postscript("Shared-Regular") else { panic!("not ambiguous") };
|
||||
assert_eq!(families, ["Shared One", "Shared Two"]);
|
||||
assert!(index.family("Shared Two").unwrap().face_by_postscript("Shared-Regular").is_some());
|
||||
|
||||
assert_eq!(parse_style("Extra Bold Italic"), Some((800, true)));
|
||||
assert_eq!(parse_style("semi-bold"), Some((600, false)));
|
||||
assert_eq!(parse_style("Condensed Bold"), None);
|
||||
let var = index.family("Test Var").unwrap();
|
||||
assert_eq!(
|
||||
index.file_urls(var, &var.files[0]),
|
||||
[
|
||||
format!("https://cdn.jsdelivr.net/gh/google/fonts@{COMMIT}/apache/testvar/TestVar%5Bwght%5D.ttf"),
|
||||
format!("https://raw.githubusercontent.com/google/fonts/{COMMIT}/apache/testvar/TestVar%5Bwght%5D.ttf"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_checks_size_hash_and_font() {
|
||||
let index = index_of(&fixture().index).unwrap();
|
||||
let file = index.find_face("Test Sans", "Bold").unwrap().file;
|
||||
let bytes = font("Test Sans", "Bold", 700);
|
||||
assert_eq!(verify_font(file, &bytes), Ok(()));
|
||||
let mut changed = bytes.clone();
|
||||
*changed.last_mut().unwrap() ^= 1;
|
||||
assert_eq!(verify_font(file, &changed), Err(VerifyError::Sha256));
|
||||
assert!(matches!(verify_font(file, &bytes[1..]), Err(VerifyError::Size { .. })));
|
||||
assert!(!is_font(b"not a font at all"));
|
||||
assert!(is_font(&bytes));
|
||||
}
|
||||
|
||||
const NOTO_COMMIT: &str = "93fac8abebe6c7fed0c5b7d2456d8fa484fa146c";
|
||||
const VAR_PATH: &str = "fonts/NotoSerifTest/full/variable-ttf/NotoSerifTest[wdth,wght].ttf";
|
||||
const CJK_PATH: &str = "Sans/OTF/Japanese/NotoSansCJKtest-Regular.otf";
|
||||
const CONDENSED_PATH: &str = "fonts/TestSans/full/ttf/TestSans-CondensedBold.ttf";
|
||||
|
||||
/// A format 1.1 index of another source, as craft-font-index's Noto index has it: URL templates
|
||||
/// with `{repo}` and `{ref}`, a family in another repository at a tag, licences with a sha256
|
||||
/// (one in a third repository), and a family Google Fonts also has, in a width it lacks.
|
||||
fn noto_fixture() -> Fixture {
|
||||
let var = font("Noto Serif Test", "Regular", 400);
|
||||
let cjk = font("Noto Sans CJK Test", "Regular", 400);
|
||||
let condensed = font("Test Sans", "Condensed Bold", 700);
|
||||
let ofl = b"Noto OFL".to_vec();
|
||||
let cjk_licence = b"noto-cjk LICENSE".to_vec();
|
||||
let lic = |path: &str, bytes: &[u8]| json!({"path": path, "size": bytes.len(), "sha256": sha256_hex(bytes)});
|
||||
let mut var_entry = file_entry(
|
||||
VAR_PATH,
|
||||
&var,
|
||||
"normal",
|
||||
400,
|
||||
json!([{"tag": "wght", "min": 100.0, "max": 900.0}, {"tag": "wdth", "min": 62.5, "max": 100.0}]),
|
||||
&["NotoSerifTest-Regular", "NotoSerifTest-Bold"],
|
||||
);
|
||||
var_entry["a_later_field"] = json!(1);
|
||||
let mut condensed_entry = file_entry(CONDENSED_PATH, &condensed, "normal", 700, json!([]), &["TestSans-CondensedBold"]);
|
||||
condensed_entry["width"] = json!(75.0);
|
||||
let mut serif_licence = lic("OFL.txt", &ofl);
|
||||
serif_licence["repo"] = json!("notofonts/serif-test");
|
||||
serif_licence["ref"] = json!("NotoSerifTest-v2.106");
|
||||
let index = json!({
|
||||
"schema": 1, "version": "1.1", "source": "notofonts", "commit": NOTO_COMMIT,
|
||||
"source_info": {"id": "notofonts", "name": "Noto", "homepage": "https://notofonts.github.io", "curated": true,
|
||||
"file_urls": ["https://cdn.jsdelivr.net/gh/{repo}@{ref}/{path}", "https://raw.githubusercontent.com/{repo}/{ref}/{path}"]},
|
||||
"repo": "notofonts/notofonts.github.io", "ref": NOTO_COMMIT,
|
||||
"languages": [
|
||||
{"id": "en_Latn", "name": "English", "script": "Latn", "population": 1},
|
||||
{"id": "ja_Jpan", "name": "Japanese", "autonym": "日本語", "script": "Jpan", "population": 1}],
|
||||
"scripts": [{"id": "Jpan", "name": "Japanese"}, {"id": "Latn", "name": "Latin"}],
|
||||
"families": [
|
||||
{"family": "Noto Sans CJK Test", "source": "notofonts", "license": "OFL", "license_path": null,
|
||||
"license_file": lic("LICENSE", &cjk_licence), "repo": "notofonts/noto-cjk", "ref": "Sans2.004",
|
||||
"languages": [0, 1], "scripts": ["Jpan", "Latn"],
|
||||
"files": [file_entry(CJK_PATH, &cjk, "normal", 400, json!([]), &["NotoSansCJKtest-Regular"])]},
|
||||
{"family": "Noto Serif Test", "source": "notofonts", "license": "OFL", "license_path": null,
|
||||
"license_file": serif_licence, "languages": [0], "files": [var_entry]},
|
||||
{"family": "Test Sans", "source": "notofonts", "license": "OFL", "license_path": null,
|
||||
"license_file": lic("OFL.txt", &ofl), "files": [condensed_entry]},
|
||||
]
|
||||
});
|
||||
let files = vec![
|
||||
(format!("notofonts/noto-cjk/Sans2.004/{CJK_PATH}"), cjk),
|
||||
("notofonts/noto-cjk/Sans2.004/LICENSE".into(), cjk_licence),
|
||||
(format!("notofonts/notofonts.github.io/{NOTO_COMMIT}/fonts/NotoSerifTest/full/variable-ttf/NotoSerifTest%5Bwdth%2Cwght%5D.ttf"), var),
|
||||
("notofonts/serif-test/NotoSerifTest-v2.106/OFL.txt".into(), ofl.clone()),
|
||||
(format!("notofonts/notofonts.github.io/{NOTO_COMMIT}/{CONDENSED_PATH}"), condensed),
|
||||
(format!("notofonts/notofonts.github.io/{NOTO_COMMIT}/OFL.txt"), ofl),
|
||||
];
|
||||
Fixture { files, index }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_noto_index_resolves_urls_per_family_and_entry() {
|
||||
let index = index_of(¬o_fixture().index).unwrap();
|
||||
let source = &index.sources()[0];
|
||||
assert_eq!((source.id.as_str(), source.name.as_str(), source.curated, source.commit.as_str()), ("notofonts", "Noto", true, NOTO_COMMIT));
|
||||
// The top-level repo and ref, with each path segment percent-encoded.
|
||||
let var = index.family("Noto Serif Test").unwrap();
|
||||
assert_eq!(
|
||||
index.file_urls(var, &var.files[0]),
|
||||
[
|
||||
format!(
|
||||
"https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io@{NOTO_COMMIT}/fonts/NotoSerifTest/full/variable-ttf/NotoSerifTest%5Bwdth%2Cwght%5D.ttf"
|
||||
),
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/notofonts/notofonts.github.io/{NOTO_COMMIT}/fonts/NotoSerifTest/full/variable-ttf/NotoSerifTest%5Bwdth%2Cwght%5D.ttf"
|
||||
),
|
||||
]
|
||||
);
|
||||
// The licence's own repository and ref.
|
||||
assert_eq!(index.license_urls(var)[1], "https://raw.githubusercontent.com/notofonts/serif-test/NotoSerifTest-v2.106/OFL.txt");
|
||||
// The family's repository and ref, for its files and its licence.
|
||||
let cjk = index.family("Noto Sans CJK Test").unwrap();
|
||||
assert_eq!(index.file_urls(cjk, &cjk.files[0])[0], format!("https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@Sans2.004/{CJK_PATH}"));
|
||||
assert_eq!(index.license_urls(cjk)[0], "https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@Sans2.004/LICENSE");
|
||||
assert_eq!((cjk.dir(), cjk.license_file_name(), cjk.license_path.as_deref()), ("notofonts/noto-sans-cjk-test", Some("LICENSE"), None));
|
||||
assert_eq!(var.files[0].width, 100.0);
|
||||
|
||||
let with = |edit: &dyn Fn(&mut serde_json::Value)| {
|
||||
let mut v = noto_fixture().index;
|
||||
edit(&mut v);
|
||||
index_of(&v)
|
||||
};
|
||||
let invalid = [
|
||||
with(&|v| v["families"][1]["files"][0]["path"] = json!("../x.ttf")),
|
||||
with(&|v| v["families"][1]["files"][0]["path"] = json!("/x.ttf")),
|
||||
with(&|v| v["families"][1]["files"][0]["path"] = json!("a//b.ttf")),
|
||||
with(&|v| v["families"][1]["files"][0]["path"] = json!("a/./b.ttf")),
|
||||
with(&|v| v["families"][1]["files"][0]["path"] = json!("a\\b.ttf")),
|
||||
with(&|v| v["families"][1]["ref"] = json!("a/../b")),
|
||||
with(&|v| v["families"][1]["repo"] = json!("noslash")),
|
||||
with(&|v| v["families"][0]["license_file"]["path"] = json!("Sans/OTF/Japanese/NotoSansCJKtest-Regular.otf")),
|
||||
with(&|v| v["families"][0]["license_file"]["sha256"] = json!("00")),
|
||||
with(&|v| v["families"][1]["files"][0]["width"] = json!(0.0)),
|
||||
with(&|v| {
|
||||
v.as_object_mut().unwrap().remove("repo");
|
||||
}),
|
||||
with(&|v| v["source_info"]["file_urls"] = json!(["http://cdn.jsdelivr.net/gh/{repo}@{ref}/{path}"])),
|
||||
with(&|v| v["source_info"]["file_urls"] = json!(["https://cdn.jsdelivr.net/gh/{owner}/{path}"])),
|
||||
with(&|v| v["source_info"]["file_urls"] = json!(["https://cdn.jsdelivr.net/gh/{repo}@{ref}/"])),
|
||||
with(&|v| v["source_info"]["id"] = json!("other")),
|
||||
];
|
||||
for (i, r) in invalid.into_iter().enumerate() {
|
||||
assert!(matches!(r, Err(IndexError::Invalid(_))), "case {i}: {r:?}");
|
||||
}
|
||||
// Only licences that allow redistribution: other families are left out, with a warning.
|
||||
let index = with(&|v| v["families"][1]["license"] = json!("Proprietary")).unwrap();
|
||||
assert!(index.family("Noto Serif Test").is_none());
|
||||
assert_eq!((index.families().len(), index.sources()[0].warnings.len()), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_merge_with_the_first_preferred() {
|
||||
let index = Index::merge([index_of(&fixture().index).unwrap(), index_of(¬o_fixture().index).unwrap()]);
|
||||
assert_eq!(index.sources().iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), [GOOGLE_FONTS, "notofonts"]);
|
||||
let names: Vec<(&str, &str)> = index.families().iter().map(|f| (f.family.as_str(), f.source.as_str())).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
("Noto Sans CJK Test", "notofonts"),
|
||||
("Noto Serif Test", "notofonts"),
|
||||
("Shared One", GOOGLE_FONTS),
|
||||
("Shared Two", GOOGLE_FONTS),
|
||||
("Test Sans", GOOGLE_FONTS),
|
||||
("Test Sans", "notofonts"),
|
||||
("Test Var", GOOGLE_FONTS),
|
||||
]
|
||||
);
|
||||
// The first source's family for a name; a style only another source has comes from it.
|
||||
assert_eq!(index.family("test sans").unwrap().source, GOOGLE_FONTS);
|
||||
assert_eq!(index.find_face("Test Sans", "Bold").unwrap().family.source, GOOGLE_FONTS);
|
||||
let condensed = index.find_face("Test Sans", "Condensed Bold").unwrap();
|
||||
assert_eq!((condensed.family.source.as_str(), condensed.family.dir()), ("notofonts", "notofonts/test-sans"));
|
||||
assert_eq!(index.find_postscript("TestSans-CondensedBold").unwrap().family.source, "notofonts");
|
||||
assert!(matches!(index.find_face("Test Sans", "Light"), Err(Unavailable::NoStyle { .. })));
|
||||
// Languages of both, by ID, and every family's positions point into them.
|
||||
assert_eq!(index.languages().iter().map(|l| l.id.as_str()).collect::<Vec<_>>(), ["en_Latn", "ja_Jpan"]);
|
||||
let cjk = index.family("Noto Sans CJK Test").unwrap();
|
||||
assert_eq!(cjk.languages(&index).map(|l| l.id.as_str()).collect::<Vec<_>>(), ["en_Latn", "ja_Jpan"]);
|
||||
assert_eq!(index.family("Shared One").unwrap().languages(&index).map(|l| l.id.as_str()).collect::<Vec<_>>(), ["en_Latn"]);
|
||||
// URLs stay per source.
|
||||
assert!(index.file_urls(cjk, &cjk.files[0])[0].contains("noto-cjk@Sans2.004"));
|
||||
// A second source with the same ID is left out and reported.
|
||||
let twice = Index::merge([index_of(¬o_fixture().index).unwrap(), index_of(¬o_fixture().index).unwrap()]);
|
||||
assert_eq!((twice.sources().len(), twice.families().len(), twice.problems().len()), (1, 3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_index_is_never_curated_and_cant_shadow_a_built_in_source() {
|
||||
let mut v = noto_fixture().index;
|
||||
v["source_info"]["file_urls"] = json!(["https://fonts.example.org/files/{path}"]);
|
||||
v["families"][1]["license"] = json!("Proprietary");
|
||||
let url = "https://fonts.example.org/index.json";
|
||||
let index = Index::from_user_bytes(v.to_string().as_bytes(), url).unwrap();
|
||||
let source = &index.sources()[0];
|
||||
assert_eq!((source.id.as_str(), source.curated, source.user_url.as_deref()), ("user:fonts.example.org/notofonts", false, Some(url)));
|
||||
assert_eq!(source.warnings.len(), 1);
|
||||
let cjk = index.family("Noto Sans CJK Test").unwrap();
|
||||
assert!(!cjk.is_curated());
|
||||
assert_eq!(cjk.dir(), "user-fonts-example-org-notofonts/noto-sans-cjk-test");
|
||||
assert_eq!(index.file_urls(cjk, &cjk.files[0]), [format!("https://fonts.example.org/files/{CJK_PATH}")]);
|
||||
// Merged after a built-in source of the ID it claims, it is a separate source.
|
||||
let merged = Index::merge([index_of(¬o_fixture().index).unwrap(), index]);
|
||||
assert_eq!(merged.sources().len(), 2);
|
||||
assert!(merged.family("Noto Sans CJK Test").unwrap().is_curated());
|
||||
assert!(Index::from_user_bytes(v.to_string().as_bytes(), "http://fonts.example.org/index.json").is_err());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod store {
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use craft_font_catalog::{Catalog, FontEnv, Os};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// What the server answers for a path.
|
||||
#[derive(Clone)]
|
||||
enum Reply {
|
||||
Ok(Vec<u8>),
|
||||
Status(u16),
|
||||
/// Sends headers and a few bytes, then nothing.
|
||||
Stall,
|
||||
}
|
||||
|
||||
/// A local HTTP server for `/cdn/` (jsDelivr) and `/raw/` (raw GitHub).
|
||||
struct Server {
|
||||
base: String,
|
||||
routes: Arc<Mutex<HashMap<String, Reply>>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}/", listener.local_addr().unwrap());
|
||||
let routes: Arc<Mutex<HashMap<String, Reply>>> = Arc::default();
|
||||
let log: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let (r, l) = (routes.clone(), log.clone());
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { continue };
|
||||
let (r, l) = (r.clone(), l.clone());
|
||||
std::thread::spawn(move || answer(stream, &r, &l));
|
||||
}
|
||||
});
|
||||
Self { base, routes, log }
|
||||
}
|
||||
|
||||
fn serve(&self, prefix: &str, path: &str, reply: Reply) {
|
||||
self.routes.lock().unwrap().insert(format!("/{prefix}/{path}"), reply);
|
||||
}
|
||||
|
||||
/// The fixture's files on both hosts.
|
||||
fn serve_fixture(&self, f: &Fixture) {
|
||||
for (path, bytes) in &f.files {
|
||||
self.serve("cdn", &format!("gh/google/fonts@{COMMIT}/{path}"), Reply::Ok(bytes.clone()));
|
||||
self.serve("raw", &format!("google/fonts/{COMMIT}/{path}"), Reply::Ok(bytes.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixture whose files are keyed `<owner>/<name>/<ref>/<path>`, on both hosts.
|
||||
fn serve_repos(&self, f: &Fixture) {
|
||||
for (key, bytes) in &f.files {
|
||||
let mut parts = key.splitn(4, '/');
|
||||
let (owner, name, git_ref, path) = (parts.next().unwrap(), parts.next().unwrap(), parts.next().unwrap(), parts.next().unwrap());
|
||||
self.serve("cdn", &format!("gh/{owner}/{name}@{git_ref}/{path}"), Reply::Ok(bytes.clone()));
|
||||
self.serve("raw", &format!("{owner}/{name}/{git_ref}/{path}"), Reply::Ok(bytes.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn client(&self) -> Client {
|
||||
let t = Timeouts { connect: Duration::from_secs(2), response: Duration::from_secs(5), overall: Duration::from_secs(10) };
|
||||
Client::for_tests(
|
||||
t,
|
||||
vec![
|
||||
("https://cdn.jsdelivr.net/".into(), format!("{}cdn/", self.base)),
|
||||
("https://raw.githubusercontent.com/".into(), format!("{}raw/", self.base)),
|
||||
("https://fonts.example.org/".into(), format!("{}user/", self.base)),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(mut s: TcpStream, routes: &Mutex<HashMap<String, Reply>>, log: &Mutex<Vec<String>>) {
|
||||
let mut head = Vec::new();
|
||||
let mut b = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
match s.read(&mut b) {
|
||||
Ok(1) => head.push(b[0]),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&head).into_owned();
|
||||
let path = text.split_whitespace().nth(1).unwrap_or("/").to_string();
|
||||
log.lock().unwrap().push(path.clone());
|
||||
let reply = routes.lock().unwrap().get(&path).cloned().unwrap_or(Reply::Status(404));
|
||||
match reply {
|
||||
Reply::Ok(body) => {
|
||||
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
|
||||
let _ = s.write_all(&body);
|
||||
}
|
||||
Reply::Status(code) => {
|
||||
let _ = write!(s, "HTTP/1.1 {code} Nope\r\nContent-Length: 4\r\nConnection: close\r\n\r\nnope");
|
||||
}
|
||||
Reply::Stall => {
|
||||
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nabc");
|
||||
let _ = s.flush();
|
||||
std::thread::sleep(Duration::from_secs(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("craft-font-store-{name}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn entries(dir: &Path) -> Vec<String> {
|
||||
let mut v: Vec<String> =
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// No font files, staged downloads or family folders anywhere in the store.
|
||||
fn assert_no_family_files(dir: &Path) {
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
assert!(["settings.json", ".lock"].contains(&name.as_str()), "left behind: {}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!dir.join("testsans").exists());
|
||||
assert!(entries(&dir.join(".staging")).is_empty(), "{:?}", entries(&dir.join(".staging")));
|
||||
}
|
||||
|
||||
fn user_store(server: &Server, dir: &Path) -> FontStore {
|
||||
let store = FontStore::new(dir.to_path_buf(), Caller::User).with_client(server.client());
|
||||
store.enable_network().unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
fn none(_: Progress) {}
|
||||
|
||||
#[test]
|
||||
fn consent_is_off_by_default_and_only_the_user_turns_it_on() {
|
||||
let server = Server::start();
|
||||
let f = fixture();
|
||||
server.serve_fixture(&f);
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("consent");
|
||||
let user = FontStore::new(dir.clone(), Caller::User).with_client(server.client());
|
||||
let agent = FontStore::new(dir.clone(), Caller::Agent).with_client(server.client());
|
||||
assert_eq!((user.network(), agent.network()), (Network::Off, Network::Off));
|
||||
|
||||
let face = index.find_face("Test Sans", "Bold").unwrap();
|
||||
assert!(matches!(user.install_face(&index, face, &Cancel::new(), &mut none), Err(Error::NetworkOff)));
|
||||
assert!(matches!(agent.enable_network(), Err(Error::NotAllowed)));
|
||||
assert_eq!(agent.network(), Network::Off);
|
||||
assert!(matches!(agent.install_face(&index, face, &Cancel::new(), &mut none), Err(Error::NetworkOff)));
|
||||
assert!(server.requests().is_empty(), "no request while the network is off");
|
||||
|
||||
// The user's setting is persisted in the store folder; an agent then may install.
|
||||
user.enable_network().unwrap();
|
||||
assert_eq!(FontStore::new(dir.clone(), Caller::Agent).network(), Network::On);
|
||||
agent.install_face(&index, face, &Cancel::new(), &mut none).unwrap();
|
||||
agent.disable_network().unwrap();
|
||||
assert_eq!(user.network(), Network::Off);
|
||||
|
||||
// The agent constructor opens the same folder as the user's.
|
||||
let env = FontEnv { os: Os::Unix, font_store: Some(dir.clone()), ..Default::default() };
|
||||
assert_eq!(FontStore::for_agent(&env).unwrap().caller(), Caller::Agent);
|
||||
assert!(matches!(FontStore::for_agent(&env).unwrap().enable_network(), Err(Error::NotAllowed)));
|
||||
assert_eq!(FontStore::for_user(&env).unwrap().dir(), dir);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_verify_and_remove_a_family() {
|
||||
let server = Server::start();
|
||||
let f = fixture();
|
||||
server.serve_fixture(&f);
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("install");
|
||||
let root = temp("install-root");
|
||||
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(dir.clone()), ..Default::default() };
|
||||
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
|
||||
catalog.rescan();
|
||||
assert!(catalog.by_postscript_name("TestSans-Bold").is_none());
|
||||
let store = user_store(&server, &dir).with_catalog(catalog.clone());
|
||||
|
||||
let mut seen = Vec::new();
|
||||
let family = index.family("Test Sans").unwrap();
|
||||
let installed = store.install_family(&index, family, &Cancel::new(), &mut |p| seen.push(p)).unwrap();
|
||||
assert_eq!(entries(&dir.join("testsans")), ["OFL.txt", "TestSans-Bold.ttf", "TestSans-Italic.ttf", "TestSans-Regular.ttf"]);
|
||||
assert_eq!(installed.files.len(), 3);
|
||||
assert_eq!((installed.dir.as_str(), installed.license_file.as_deref()), ("testsans", Some("OFL.txt")));
|
||||
assert!(installed.files.iter().all(|x| x.commit == COMMIT && x.sha256.len() == 64));
|
||||
assert_eq!(store.installed(), std::slice::from_ref(&installed));
|
||||
let last = seen.last().unwrap();
|
||||
assert_eq!((last.done, last.total, last.files), (family.size(), family.size(), 3));
|
||||
assert!(seen.windows(2).all(|w| w[0].done <= w[1].done));
|
||||
// The catalog was rescanned.
|
||||
assert_eq!(catalog.by_postscript_name("TestSans-Bold").unwrap().family, "Test Sans");
|
||||
assert!(store.verify().is_empty());
|
||||
assert!(store.is_installed(family, index.find_face("Test Sans", "Italic").unwrap().file));
|
||||
|
||||
// Installed files aren't downloaded again.
|
||||
let before = server.requests().len();
|
||||
store.install_face(&index, index.find_face("Test Sans", "Bold").unwrap(), &Cancel::new(), &mut none).unwrap();
|
||||
assert_eq!(server.requests().len(), before);
|
||||
|
||||
// Damaged and missing files are reported.
|
||||
std::fs::write(dir.join("testsans/TestSans-Italic.ttf"), b"damaged").unwrap();
|
||||
std::fs::remove_file(dir.join("testsans/TestSans-Regular.ttf")).unwrap();
|
||||
let problems = store.verify();
|
||||
assert_eq!(problems.len(), 2);
|
||||
assert!(problems.iter().any(|p| p.file == "TestSans-Italic.ttf" && matches!(p.error, Some(VerifyError::Size { .. }))));
|
||||
assert!(problems.iter().any(|p| p.file == "TestSans-Regular.ttf" && p.error.is_none()));
|
||||
|
||||
// A single face of another family, with its licence.
|
||||
let face = index.find_face("Test Var", "Bold").unwrap();
|
||||
store.install_face(&index, face, &Cancel::new(), &mut none).unwrap();
|
||||
assert_eq!(entries(&dir.join("testvar")), ["LICENSE.txt", "TestVar[wght].ttf"]);
|
||||
assert_eq!(store.installed().iter().map(|f| f.family.as_str()).collect::<Vec<_>>(), ["Test Sans", "Test Var"]);
|
||||
|
||||
// One style out, then the last one takes its family with it.
|
||||
store.remove_files(GOOGLE_FONTS, "Test Sans", &["TestSans-Bold.ttf".into()]).unwrap();
|
||||
assert!(!dir.join("testsans/TestSans-Bold.ttf").exists());
|
||||
assert_eq!(store.installed_family(GOOGLE_FONTS, "Test Sans").unwrap().files.len(), 2);
|
||||
store.remove_files(GOOGLE_FONTS, "Test Var", &["TestVar[wght].ttf".into()]).unwrap();
|
||||
assert!(!dir.join("testvar").exists());
|
||||
assert!(store.installed_family(GOOGLE_FONTS, "Test Var").is_none());
|
||||
|
||||
store.remove_family(GOOGLE_FONTS, "test sans").unwrap();
|
||||
assert!(!dir.join("testsans").exists());
|
||||
assert!(store.installed().is_empty());
|
||||
assert!(catalog.by_postscript_name("TestSans-Bold").is_none());
|
||||
assert!(matches!(store.remove_family(GOOGLE_FONTS, "Test Sans"), Err(Error::NotInstalled(_))));
|
||||
assert!(entries(&dir.join(".staging")).is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsdelivr_failures_fall_back_to_raw_github() {
|
||||
let server = Server::start();
|
||||
let f = fixture();
|
||||
server.serve_fixture(&f);
|
||||
let bold = format!("gh/google/fonts@{COMMIT}/ofl/testsans/TestSans-Bold.ttf");
|
||||
server.serve("cdn", &bold, Reply::Status(503));
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("fallback");
|
||||
let store = user_store(&server, &dir);
|
||||
store.install_face(&index, index.find_face("Test Sans", "Bold").unwrap(), &Cancel::new(), &mut none).unwrap();
|
||||
let requests = server.requests();
|
||||
assert_eq!(requests.first().map(String::as_str), Some(format!("/cdn/{bold}").as_str()));
|
||||
assert_eq!(requests.get(1).map(String::as_str), Some(format!("/raw/google/fonts/{COMMIT}/ofl/testsans/TestSans-Bold.ttf").as_str()));
|
||||
assert!(store.verify().is_empty());
|
||||
|
||||
// A corrupted copy on jsDelivr also falls back.
|
||||
let italic = format!("gh/google/fonts@{COMMIT}/ofl/testsans/TestSans-Italic.ttf");
|
||||
server.serve("cdn", &italic, Reply::Ok(font("Test Sans", "Bold", 700)));
|
||||
store.install_face(&index, index.find_face("Test Sans", "Italic").unwrap(), &Cancel::new(), &mut none).unwrap();
|
||||
assert!(store.verify().is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_downloads_are_rejected_and_leave_nothing() {
|
||||
let server = Server::start();
|
||||
let f = fixture();
|
||||
server.serve_fixture(&f);
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("corrupt");
|
||||
let store = user_store(&server, &dir);
|
||||
|
||||
// Wrong bytes of the right size on both hosts: rejected on the sha256.
|
||||
let mut other = font("Test Sans", "Italic", 400);
|
||||
*other.last_mut().unwrap() ^= 1;
|
||||
for (host, path) in [("cdn", format!("gh/google/fonts@{COMMIT}")), ("raw", format!("google/fonts/{COMMIT}"))] {
|
||||
server.serve(host, &format!("{path}/ofl/testsans/TestSans-Italic.ttf"), Reply::Ok(other.clone()));
|
||||
}
|
||||
let family = index.family("Test Sans").unwrap();
|
||||
let r = store.install_family(&index, family, &Cancel::new(), &mut none);
|
||||
assert!(matches!(r, Err(Error::Verify { error: VerifyError::Sha256, .. })), "{r:?}");
|
||||
assert_no_family_files(&dir);
|
||||
assert!(store.installed().is_empty());
|
||||
|
||||
// Bytes that match the index but aren't a font.
|
||||
let junk = b"this is not a font file".to_vec();
|
||||
let mut v = f.index.clone();
|
||||
v["families"][2]["files"][0]["sha256"] = json!(sha256_hex(&junk));
|
||||
v["families"][2]["files"][0]["size"] = json!(junk.len());
|
||||
server.serve("cdn", &format!("gh/google/fonts@{COMMIT}/ofl/testsans/TestSans-Bold.ttf"), Reply::Ok(junk.clone()));
|
||||
server.serve("raw", &format!("google/fonts/{COMMIT}/ofl/testsans/TestSans-Bold.ttf"), Reply::Ok(junk));
|
||||
let junk_index = index_of(&v).unwrap();
|
||||
let r = store.install_face(&junk_index, junk_index.find_face("Test Sans", "Bold").unwrap(), &Cancel::new(), &mut none);
|
||||
assert!(matches!(r, Err(Error::Verify { error: VerifyError::NotAFont, .. })), "{r:?}");
|
||||
assert_no_family_files(&dir);
|
||||
|
||||
// A file missing on both hosts, after others downloaded: nothing of the family is written.
|
||||
server.serve_fixture(&f);
|
||||
for (host, path) in [("cdn", format!("gh/google/fonts@{COMMIT}")), ("raw", format!("google/fonts/{COMMIT}"))] {
|
||||
server.serve(host, &format!("{path}/ofl/testsans/TestSans-Regular.ttf"), Reply::Status(404));
|
||||
}
|
||||
let r = store.install_family(&index, family, &Cancel::new(), &mut none);
|
||||
assert!(matches!(&r, Err(Error::Download { reasons, .. }) if reasons.len() == 2), "{r:?}");
|
||||
assert_no_family_files(&dir);
|
||||
assert!(!dir.join("manifest.json").exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failed_commit_keeps_the_installed_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let server = Server::start();
|
||||
let f = fixture();
|
||||
server.serve_fixture(&f);
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("commit");
|
||||
let store = user_store(&server, &dir);
|
||||
store.install_face(&index, index.find_face("Test Sans", "Regular").unwrap(), &Cancel::new(), &mut none).unwrap();
|
||||
let folder = dir.join("testsans");
|
||||
let before = (store.installed(), entries(&folder));
|
||||
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let writable = std::fs::File::create(folder.join("probe")).is_ok();
|
||||
let r = store.install_family(&index, index.family("Test Sans").unwrap(), &Cancel::new(), &mut none);
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
if writable {
|
||||
return; // root ignores permissions
|
||||
}
|
||||
assert!(matches!(r, Err(Error::Io { .. })), "{r:?}");
|
||||
assert_eq!((store.installed(), entries(&folder)), before);
|
||||
assert!(entries(&dir.join(".staging")).is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_a_stalled_install() {
|
||||
let server = Server::start();
|
||||
let f = fixture();
|
||||
server.serve_fixture(&f);
|
||||
server.serve("cdn", &format!("gh/google/fonts@{COMMIT}/ofl/testsans/TestSans-Bold.ttf"), Reply::Stall);
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("cancel");
|
||||
let store = user_store(&server, &dir);
|
||||
let cancel = Cancel::new();
|
||||
let c = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
c.cancel();
|
||||
});
|
||||
let t = Instant::now();
|
||||
let r = store.install_family(&index, index.family("Test Sans").unwrap(), &cancel, &mut none);
|
||||
assert!(matches!(r, Err(Error::Cancelled)), "{r:?}");
|
||||
assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
|
||||
assert_no_family_files(&dir);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
use flate2::write::GzEncoder;
|
||||
let mut e = GzEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
e.write_all(bytes).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
/// `sources.json` listing the Google Fonts fixture (gzipped) and the Noto fixture (plain
|
||||
/// JSON), with the index bytes each path serves.
|
||||
fn sources_fixture(noto_sha256: Option<&str>) -> (Vec<u8>, Vec<(String, Vec<u8>)>) {
|
||||
let google = fixture().index.to_string().into_bytes();
|
||||
let google_gz = gzip(&google);
|
||||
let noto = noto_fixture().index.to_string().into_bytes();
|
||||
let sources = json!({"schema": 1, "version": "1.1", "indexes": [
|
||||
{"source": GOOGLE_FONTS, "name": "Google Fonts", "curated": true, "path": "indexes/google-fonts.json",
|
||||
"size": google.len(), "sha256": sha256_hex(&google), "gz_path": "indexes/google-fonts.json.gz",
|
||||
"gz_size": google_gz.len(), "gz_sha256": sha256_hex(&google_gz), "commit": COMMIT, "families": 4, "files": 6},
|
||||
{"source": "notofonts", "name": "Noto", "curated": true, "path": "indexes/noto.json",
|
||||
"size": noto.len(), "sha256": noto_sha256.map_or_else(|| sha256_hex(¬o), str::to_string), "commit": NOTO_COMMIT}
|
||||
]});
|
||||
let files = vec![("indexes/google-fonts.json.gz".into(), google_gz), ("indexes/noto.json".into(), noto)];
|
||||
(sources.to_string().into_bytes(), files)
|
||||
}
|
||||
|
||||
fn pin_of(sources: &[u8]) -> IndexPin {
|
||||
IndexPin { repo: "owner/index", commit: "abc", sha256: Box::leak(sha256_hex(sources).into_boxed_str()) }
|
||||
}
|
||||
|
||||
fn serve_sources(server: &Server, sources: &[u8], files: &[(String, Vec<u8>)]) {
|
||||
server.serve("cdn", "gh/owner/index@abc/sources.json", Reply::Status(500));
|
||||
server.serve("raw", "owner/index/abc/sources.json", Reply::Ok(sources.to_vec()));
|
||||
for (path, bytes) in files {
|
||||
server.serve("cdn", &format!("gh/owner/index@abc/{path}"), Reply::Ok(bytes.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_indexes_are_fetched_only_with_consent_and_checked() {
|
||||
let server = Server::start();
|
||||
let (sources, files) = sources_fixture(None);
|
||||
let pin = pin_of(&sources);
|
||||
serve_sources(&server, &sources, &files);
|
||||
let dir = temp("index");
|
||||
let store = FontStore::new(dir.clone(), Caller::User).with_client(server.client());
|
||||
assert!(store.cached_indexes(&pin).is_none());
|
||||
assert!(matches!(store.fetch_indexes(&pin, &Cancel::new()), Err(Error::NetworkOff)));
|
||||
assert!(server.requests().is_empty());
|
||||
|
||||
store.enable_network().unwrap();
|
||||
let index = store.fetch_indexes(&pin, &Cancel::new()).unwrap();
|
||||
assert!(index.problems().is_empty(), "{:?}", index.problems());
|
||||
assert_eq!(index.sources().iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), [GOOGLE_FONTS, "notofonts"]);
|
||||
assert_eq!(index.families().len(), 7);
|
||||
assert_eq!(entries(&dir.join("indexes")), ["google-fonts.json.gz", "notofonts.json", "sources.json"]);
|
||||
let cached = store.cached_indexes(&pin).unwrap();
|
||||
assert_eq!((cached.families().len(), cached.sources().len()), (7, 2));
|
||||
|
||||
let wrong = IndexPin { sha256: "0000000000000000000000000000000000000000000000000000000000000000", ..pin };
|
||||
assert!(store.cached_indexes(&wrong).is_none());
|
||||
assert!(matches!(store.fetch_indexes(&wrong, &Cancel::new()), Err(Error::Verify { error: VerifyError::Sha256, .. })));
|
||||
|
||||
// A cached index that was changed is not used.
|
||||
std::fs::write(dir.join("indexes/notofonts.json"), b"{}").unwrap();
|
||||
assert!(store.cached_indexes(&pin).is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_with_the_wrong_sha256_fails_alone() {
|
||||
let server = Server::start();
|
||||
let (sources, files) = sources_fixture(Some("1111111111111111111111111111111111111111111111111111111111111111"));
|
||||
let pin = pin_of(&sources);
|
||||
serve_sources(&server, &sources, &files);
|
||||
let dir = temp("one-bad-index");
|
||||
let store = user_store(&server, &dir);
|
||||
let index = store.fetch_indexes(&pin, &Cancel::new()).unwrap();
|
||||
assert_eq!(index.sources().iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), [GOOGLE_FONTS]);
|
||||
assert_eq!(index.problems().iter().map(|p| p.source.as_str()).collect::<Vec<_>>(), ["notofonts"]);
|
||||
assert!(index.family("Test Var").is_some() && index.family("Noto Sans CJK Test").is_none());
|
||||
// Both hosts were tried for it.
|
||||
assert_eq!(server.requests().iter().filter(|r| r.ends_with("indexes/noto.json")).count(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_noto_family_installs_into_its_own_folder_after_a_jsdelivr_403() {
|
||||
let server = Server::start();
|
||||
let f = noto_fixture();
|
||||
server.serve_repos(&f);
|
||||
// jsDelivr refuses files over 20 MB with 403.
|
||||
let cjk = format!("gh/notofonts/noto-cjk@Sans2.004/{CJK_PATH}");
|
||||
server.serve("cdn", &cjk, Reply::Status(403));
|
||||
let index = index_of(&f.index).unwrap();
|
||||
let dir = temp("noto");
|
||||
let store = user_store(&server, &dir);
|
||||
let family = index.family("Noto Sans CJK Test").unwrap();
|
||||
let installed = store.install_family(&index, family, &Cancel::new(), &mut none).unwrap();
|
||||
assert_eq!(entries(&dir.join("notofonts/noto-sans-cjk-test")), ["LICENSE", "NotoSansCJKtest-Regular.otf"]);
|
||||
assert_eq!((installed.source.as_str(), installed.dir.as_str()), ("notofonts", "notofonts/noto-sans-cjk-test"));
|
||||
assert!(installed.files.iter().all(|x| x.commit == NOTO_COMMIT));
|
||||
let requests = server.requests();
|
||||
let at = requests.iter().position(|r| *r == format!("/cdn/{cjk}")).unwrap();
|
||||
assert_eq!(requests[at + 1], format!("/raw/notofonts/noto-cjk/Sans2.004/{CJK_PATH}"));
|
||||
assert!(store.verify().is_empty());
|
||||
|
||||
// The licence is checked against its sha256: a changed one fails the install.
|
||||
let serif = index.family("Noto Serif Test").unwrap();
|
||||
for (host, path) in
|
||||
[("cdn", "gh/notofonts/serif-test@NotoSerifTest-v2.106/OFL.txt"), ("raw", "notofonts/serif-test/NotoSerifTest-v2.106/OFL.txt")]
|
||||
{
|
||||
server.serve(host, path, Reply::Ok(b"Noto OFX".to_vec()));
|
||||
}
|
||||
let r = store.install_family(&index, serif, &Cancel::new(), &mut none);
|
||||
assert!(matches!(r, Err(Error::Verify { error: VerifyError::Sha256, .. })), "{r:?}");
|
||||
assert!(!dir.join("notofonts/noto-serif-test").exists());
|
||||
server.serve_repos(&f);
|
||||
store.install_family(&index, serif, &Cancel::new(), &mut none).unwrap();
|
||||
assert_eq!(entries(&dir.join("notofonts/noto-serif-test")), ["NotoSerifTest[wdth,wght].ttf", "OFL.txt"]);
|
||||
|
||||
// A family of the same name from Google Fonts is installed and removed separately.
|
||||
let google = index_of(&fixture().index).unwrap();
|
||||
let both = Index::merge([google, index]);
|
||||
server.serve_fixture(&fixture());
|
||||
store.install_family(&both, both.family_from(GOOGLE_FONTS, "Test Sans").unwrap(), &Cancel::new(), &mut none).unwrap();
|
||||
store.install_family(&both, both.family_from("notofonts", "Test Sans").unwrap(), &Cancel::new(), &mut none).unwrap();
|
||||
assert_eq!(entries(&dir.join("notofonts/test-sans")), ["OFL.txt", "TestSans-CondensedBold.ttf"]);
|
||||
assert_eq!(store.installed().iter().filter(|f| f.family == "Test Sans").count(), 2);
|
||||
store.remove_family("notofonts", "Test Sans").unwrap();
|
||||
assert!(!dir.join("notofonts/test-sans").exists());
|
||||
assert!(store.installed_family(GOOGLE_FONTS, "Test Sans").is_some() && dir.join("testsans").is_dir());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_sources_are_added_by_the_user_and_never_curated() {
|
||||
let server = Server::start();
|
||||
let (sources, files) = sources_fixture(None);
|
||||
let pin = pin_of(&sources);
|
||||
serve_sources(&server, &sources, &files);
|
||||
let mut v = noto_fixture().index;
|
||||
v["source_info"]["file_urls"] = json!(["https://fonts.example.org/files/{path}"]);
|
||||
let url = "https://fonts.example.org/index.json.gz";
|
||||
server.serve("user", "index.json.gz", Reply::Ok(gzip(v.to_string().as_bytes())));
|
||||
let f = noto_fixture();
|
||||
let cjk_bytes = f.files.iter().find(|(k, _)| k.ends_with(CJK_PATH)).unwrap().1.clone();
|
||||
server.serve("user", &format!("files/{CJK_PATH}"), Reply::Ok(cjk_bytes));
|
||||
server.serve("user", "files/LICENSE", Reply::Ok(b"noto-cjk LICENSE".to_vec()));
|
||||
|
||||
let dir = temp("user-source");
|
||||
let store = user_store(&server, &dir);
|
||||
let agent = FontStore::new(dir.clone(), Caller::Agent).with_client(server.client());
|
||||
assert!(matches!(agent.add_user_source(url), Err(Error::NotAllowed)));
|
||||
assert!(matches!(store.add_user_source("http://fonts.example.org/index.json"), Err(Error::BadUrl(_))));
|
||||
store.add_user_source(url).unwrap();
|
||||
store.add_user_source(url).unwrap();
|
||||
assert_eq!(store.user_sources().len(), 1);
|
||||
assert_eq!(store.network(), Network::On, "the setting is kept");
|
||||
|
||||
let index = store.fetch_indexes(&pin, &Cancel::new()).unwrap();
|
||||
assert!(index.problems().is_empty(), "{:?}", index.problems());
|
||||
let user = index.sources().last().unwrap();
|
||||
assert_eq!((user.id.as_str(), user.curated, user.changed), ("user:fonts.example.org/notofonts", false, false));
|
||||
let theirs: Vec<&Family> = index.families_named("Noto Sans CJK Test").collect();
|
||||
assert_eq!(theirs.iter().map(|f| f.is_curated()).collect::<Vec<_>>(), [true, false]);
|
||||
assert!(store.user_sources()[0].last_sha256.is_some());
|
||||
|
||||
// Installs from it are verified like any other.
|
||||
let family = theirs[1];
|
||||
let installed = store.install_family(&index, family, &Cancel::new(), &mut none).unwrap();
|
||||
assert_eq!(installed.source, "user:fonts.example.org/notofonts");
|
||||
assert!(dir.join("user-fonts-example-org-notofonts/noto-sans-cjk-test/LICENSE").is_file());
|
||||
|
||||
// A changed file shows as changed, from the network and from the cache.
|
||||
v["families"][1]["designer"] = json!("Someone else");
|
||||
server.serve("user", "index.json.gz", Reply::Ok(gzip(v.to_string().as_bytes())));
|
||||
assert!(store.fetch_indexes(&pin, &Cancel::new()).unwrap().sources().last().unwrap().changed);
|
||||
assert!(store.cached_indexes(&pin).unwrap().sources().last().unwrap().changed);
|
||||
|
||||
// Removing the source leaves its families installed.
|
||||
store.remove_user_source(url).unwrap();
|
||||
assert!(store.user_sources().is_empty());
|
||||
assert_eq!(store.cached_indexes(&pin).unwrap().sources().len(), 2);
|
||||
assert!(store.installed_family("user:fonts.example.org/notofonts", "Noto Sans CJK Test").is_some());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Fetches the pinned indexes from jsDelivr and GitHub and installs a Noto family whose
|
||||
/// licence is in another repository. `cargo test -p craft-font-store -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "uses the network"]
|
||||
fn the_pinned_indexes_and_a_noto_family_download() {
|
||||
let dir = temp("network");
|
||||
let store = FontStore::new(dir.clone(), Caller::User);
|
||||
store.enable_network().unwrap();
|
||||
let index = store.fetch_indexes(&PINNED_INDEX, &Cancel::new()).unwrap();
|
||||
assert!(index.problems().is_empty(), "{:?}", index.problems());
|
||||
assert_eq!(index.sources().len(), 2);
|
||||
let family = index.family_from("notofonts", "Noto Serif Myanmar").unwrap();
|
||||
store.install_family(&index, family, &Cancel::new(), &mut none).unwrap();
|
||||
assert!(dir.join("notofonts/noto-serif-myanmar/OFL.txt").is_file());
|
||||
assert!(store.verify().is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Checks a downloaded or installed file against its index entry.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use skrifa::raw::FileRef;
|
||||
|
||||
use crate::index::FontFile;
|
||||
|
||||
/// Tables of which a drawable face has at least one.
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
/// Why a file doesn't match its index entry.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerifyError {
|
||||
/// The size differs.
|
||||
#[error("the file is {actual} bytes, not {expected}")]
|
||||
Size {
|
||||
/// The index's size.
|
||||
expected: u64,
|
||||
/// The file's.
|
||||
actual: u64,
|
||||
},
|
||||
/// The sha256 differs.
|
||||
#[error("the file's sha256 doesn't match the font index")]
|
||||
Sha256,
|
||||
/// It doesn't parse as a font with outlines.
|
||||
#[error("the file is not a font, or a damaged one")]
|
||||
NotAFont,
|
||||
}
|
||||
|
||||
/// The sha256 of `bytes`, in lowercase hex.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Checks `bytes` against `file`: its size, then its sha256, then that every face in it parses
|
||||
/// and has outlines.
|
||||
pub fn verify_font(file: &FontFile, bytes: &[u8]) -> Result<(), VerifyError> {
|
||||
let actual = bytes.len() as u64;
|
||||
if actual != file.size {
|
||||
return Err(VerifyError::Size { expected: file.size, actual });
|
||||
}
|
||||
if sha256_hex(bytes) != file.sha256 {
|
||||
return Err(VerifyError::Sha256);
|
||||
}
|
||||
if !is_font(bytes) {
|
||||
return Err(VerifyError::NotAFont);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `bytes` is a font file or collection whose every face has a `name` table and outlines.
|
||||
pub fn is_font(bytes: &[u8]) -> bool {
|
||||
let Ok(file) = FileRef::new(bytes) else { return false };
|
||||
let mut faces = 0usize;
|
||||
for font in file.fonts() {
|
||||
let Ok(font) = font else { return false };
|
||||
let named = font.table_data(skrifa::Tag::new(b"name")).is_some();
|
||||
let drawable = OUTLINE_TABLES.iter().any(|t| font.table_data(skrifa::Tag::new(t)).is_some());
|
||||
if !named || !drawable {
|
||||
return false;
|
||||
}
|
||||
faces += 1;
|
||||
}
|
||||
faces > 0
|
||||
}
|
||||
Reference in new issue
Block a user