mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
Add craft-fonts-build: embed craft-fonts faces from an app's build.rs
This commit is contained in:
1 parent
e36f189018
commit
b37040cb93
23 files changed
+759
No files matched your search
Generated
+14
@@ -652,6 +652,20 @@ dependencies = [
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-fonts-build-consumer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build-consumer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -23,6 +23,7 @@ thiserror = "2"
|
||||
craft-atomic = { path = "crates/atomic" }
|
||||
craft-cms = { path = "crates/cms" }
|
||||
craft-docpath = { path = "crates/docpath" }
|
||||
craft-fonts-build = { path = "crates/fonts-build" }
|
||||
craft-i18n = { path = "crates/i18n" }
|
||||
craft-media = { path = "crates/media" }
|
||||
craft-print = { path = "crates/print" }
|
||||
|
||||
@@ -33,6 +33,9 @@ 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-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.
|
||||
- [`craft-i18n`](crates/i18n/README.md): translations over the embedded craft-i18n catalogs:
|
||||
lookup with fallback (`pt-br` → `pt` → English), plurals, language names, the system locale and
|
||||
a pseudo locale. The catalogs stay a fully optional build input; apps keep `tl!` and their
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "craft-fonts-build"
|
||||
description = "Embeds the optional craft-fonts faces from an app's build.rs, plus the EmbeddedFace type the generated table uses."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
craft-fonts-build-consumer = { path = "tests/consumer" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,73 @@
|
||||
# craft-fonts-build
|
||||
|
||||
Embeds the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's
|
||||
`build.rs`, and defines `EmbeddedFace`, the plain data type of the generated table. No
|
||||
dependencies and no egui, so crates below the UI layer (text engines, exporters) can use it.
|
||||
|
||||
Apps never list craft-fonts in `Cargo.toml`: Cargo would fetch the font repository for every
|
||||
build. The build reads a local checkout named by `CRAFT_FONTS_DIR` instead.
|
||||
|
||||
| Environment | Result |
|
||||
|---|---|
|
||||
| `CRAFT_FONTS_DIR` unset or empty | Empty table. |
|
||||
| `CRAFT_FONTS_DIR` = a craft-fonts checkout | Every selected face of `fonts/manifest.txt`, embedded with `include_bytes!`. |
|
||||
| `CRAFT_FONTS_DIR` set, but no manifest or a missing font file | `cargo::warning`, empty table. |
|
||||
| `CRAFT_FONTS_REQUIRED=1` (any value but empty or `0`) | Error instead when the directory is unset, unreadable, has no selected face, or has no face for a script passed to `scripts`. |
|
||||
|
||||
Give `CRAFT_FONTS_DIR` as an absolute path: Cargo runs build scripts in the package's directory.
|
||||
|
||||
## Using it
|
||||
|
||||
`Cargo.toml` (same `rev` as the app's other craft-libs crates):
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
craft-fonts-build = { git = "https://github.com/storytold/craft-libs", rev = "…" }
|
||||
|
||||
[build-dependencies]
|
||||
craft-fonts-build = { git = "https://github.com/storytold/craft-libs", rev = "…" }
|
||||
```
|
||||
|
||||
`build.rs` (FilmCraft's `crates/text/build.rs`, in full):
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=build.rs");
|
||||
let wasm = craft_fonts_build::target_is_wasm();
|
||||
let embedded = craft_fonts_build::Embed::new()
|
||||
// Web builds have a size budget: embed only the Japanese UI face there.
|
||||
.filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular"))
|
||||
.write("craft_fonts.rs");
|
||||
if let Err(e) = embedded {
|
||||
println!("cargo::error={e}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The crate:
|
||||
|
||||
```rust
|
||||
use craft_fonts_build::EmbeddedFace;
|
||||
|
||||
/// craft-fonts faces; empty unless built with `CRAFT_FONTS_DIR`.
|
||||
pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
```
|
||||
|
||||
`craft_fonts_build::embed("craft_fonts.rs")` embeds every face. `Embed` narrows the set:
|
||||
|
||||
- `scripts(&["Jpan", "Arab", "Hant"])` keeps faces with any of these script tags. A tag matches
|
||||
itself and its region forms (`Hant` matches `Hant-HK`). With `CRAFT_FONTS_REQUIRED`, each tag
|
||||
must match at least one face.
|
||||
- `filter(|entry| …)` keeps faces by any manifest field (family, style, licence, index).
|
||||
- `dir(path)` and `required(bool)` override the two environment variables.
|
||||
|
||||
`write` returns the embedded `ManifestEntry`s, for build-time checks such as "the UI face is
|
||||
present". The generated file is a slice expression of `EmbeddedFace { family, style, scripts,
|
||||
index, bytes }` literals; any struct with those fields and that name in scope works too.
|
||||
|
||||
## The manifest
|
||||
|
||||
One face per line, eight fields separated by `" | "`; craft-fonts' `docs/integration.md` defines
|
||||
them. Lines starting with `#` are comments. A file field ending in `#<n>` names face `n` of a font
|
||||
collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. Fields after the
|
||||
eighth are ignored.
|
||||
@@ -0,0 +1,228 @@
|
||||
//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::{self, Write as _};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches};
|
||||
|
||||
const DIR_VAR: &str = "CRAFT_FONTS_DIR";
|
||||
const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED";
|
||||
const EMPTY_TABLE: &str = "&[]\n";
|
||||
|
||||
/// Embed every face in `CRAFT_FONTS_DIR`'s manifest into `out_file` (relative paths are under
|
||||
/// `OUT_DIR`). Shorthand for `Embed::new().write(out_file)`.
|
||||
pub fn embed(out_file: impl AsRef<Path>) -> Result<Vec<ManifestEntry>, Error> {
|
||||
Embed::new().write(out_file)
|
||||
}
|
||||
|
||||
/// Whether the build script is building for wasm32 (web builds usually embed fewer faces).
|
||||
pub fn target_is_wasm() -> bool {
|
||||
std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32")
|
||||
}
|
||||
|
||||
type Filter = Box<dyn Fn(&ManifestEntry) -> bool>;
|
||||
|
||||
/// Which faces to embed, and from where. [`Embed::write`] does the work.
|
||||
#[derive(Default)]
|
||||
pub struct Embed {
|
||||
scripts: Option<Vec<String>>,
|
||||
filter: Option<Filter>,
|
||||
dir: Option<PathBuf>,
|
||||
required: Option<bool>,
|
||||
}
|
||||
|
||||
impl Embed {
|
||||
/// Every face, from `CRAFT_FONTS_DIR`, required when `CRAFT_FONTS_REQUIRED` is set.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Embed only faces with at least one of these script tags (see
|
||||
/// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED`, each tag must match a face.
|
||||
pub fn scripts(mut self, tags: &[&str]) -> Self {
|
||||
self.scripts = Some(tags.iter().map(|t| (*t).to_owned()).collect());
|
||||
self
|
||||
}
|
||||
|
||||
/// Embed only faces for which `keep` returns true (applied after [`Embed::scripts`]).
|
||||
pub fn filter(mut self, keep: impl Fn(&ManifestEntry) -> bool + 'static) -> Self {
|
||||
self.filter = Some(Box::new(keep));
|
||||
self
|
||||
}
|
||||
|
||||
/// Read this checkout instead of `CRAFT_FONTS_DIR`.
|
||||
pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `CRAFT_FONTS_REQUIRED`.
|
||||
pub fn required(mut self, required: bool) -> Self {
|
||||
self.required = Some(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Write the face table to `out_file` and print the `cargo::` lines; returns the faces
|
||||
/// embedded.
|
||||
///
|
||||
/// Without a checkout the table is empty. A checkout that can't be read is a
|
||||
/// `cargo::warning` and an empty table, or an [`Error`] when required. The table is written
|
||||
/// in every case, so on an error the build fails only on the message the caller prints.
|
||||
pub fn write(&self, out_file: impl AsRef<Path>) -> Result<Vec<ManifestEntry>, Error> {
|
||||
let env = Env { dir: std::env::var_os(DIR_VAR), required: std::env::var_os(REQUIRED_VAR), out_dir: std::env::var_os("OUT_DIR") };
|
||||
let mut cargo = Vec::new();
|
||||
let result = self.run(&env, out_file.as_ref(), &mut cargo);
|
||||
for line in cargo {
|
||||
println!("{line}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn run(&self, env: &Env, out_file: &Path, cargo: &mut Vec<String>) -> Result<Vec<ManifestEntry>, Error> {
|
||||
cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}"));
|
||||
cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}"));
|
||||
let out = match (out_file.is_absolute(), &env.out_dir) {
|
||||
(true, _) => out_file.to_path_buf(),
|
||||
(false, Some(out_dir)) => Path::new(out_dir).join(out_file),
|
||||
(false, None) => return Err(Error::NoOutDir),
|
||||
};
|
||||
let required = self.required.unwrap_or_else(|| env.required.as_ref().is_some_and(|v| !v.is_empty() && v != "0"));
|
||||
let dir = self.dir.clone().or_else(|| env.dir.clone().filter(|d| !d.is_empty()).map(PathBuf::from));
|
||||
|
||||
let selected = match &dir {
|
||||
None => Err(Error::NotSet),
|
||||
Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }),
|
||||
};
|
||||
let (table, faces) = match selected {
|
||||
Ok(faces) => (table(&faces), faces.into_iter().map(|(entry, _)| entry).collect()),
|
||||
Err(e) if required => {
|
||||
write(&out, EMPTY_TABLE)?;
|
||||
return Err(e);
|
||||
}
|
||||
Err(Error::NotSet) => (EMPTY_TABLE.to_owned(), Vec::new()),
|
||||
Err(e) => {
|
||||
cargo.push(format!("cargo::warning=building without craft-fonts: {e}"));
|
||||
(EMPTY_TABLE.to_owned(), Vec::new())
|
||||
}
|
||||
};
|
||||
write(&out, &table)?;
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// The wanted manifest entries with their canonical file paths.
|
||||
fn select(&self, dir: &Path, cargo: &mut Vec<String>) -> Result<Vec<(ManifestEntry, PathBuf)>, Error> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", manifest.display()));
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?;
|
||||
let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?;
|
||||
let mut faces = Vec::new();
|
||||
for entry in entries {
|
||||
let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t)));
|
||||
if !wanted || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) {
|
||||
continue;
|
||||
}
|
||||
let file = dir.join(&entry.file);
|
||||
// `include_bytes!` resolves relative paths from the including source file.
|
||||
let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?;
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", path.display()));
|
||||
faces.push((entry, path));
|
||||
}
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
fn check_required(&self, dir: &Path, faces: Vec<(ManifestEntry, PathBuf)>) -> Result<Vec<(ManifestEntry, PathBuf)>, Error> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
if faces.is_empty() {
|
||||
return Err(Error::NoFaces { manifest });
|
||||
}
|
||||
let missing: Vec<String> = self
|
||||
.scripts
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|tag| !faces.iter().any(|(entry, _)| entry.scripts.iter().any(|s| script_matches(s, tag))))
|
||||
.cloned()
|
||||
.collect();
|
||||
if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment a build script sees, separated out for tests.
|
||||
pub(crate) struct Env {
|
||||
pub dir: Option<OsString>,
|
||||
pub required: Option<OsString>,
|
||||
pub out_dir: Option<OsString>,
|
||||
}
|
||||
|
||||
fn table(faces: &[(ManifestEntry, PathBuf)]) -> String {
|
||||
let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n");
|
||||
for (entry, path) in faces {
|
||||
let scripts: Vec<String> = entry.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let _ = writeln!(
|
||||
src,
|
||||
" EmbeddedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, bytes: include_bytes!({:?}) }},",
|
||||
entry.family,
|
||||
entry.style,
|
||||
scripts.join(", "),
|
||||
entry.index,
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
src.push_str("]\n");
|
||||
src
|
||||
}
|
||||
|
||||
fn write(path: &Path, contents: &str) -> Result<(), Error> {
|
||||
std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source })
|
||||
}
|
||||
|
||||
/// Why the faces could not be embedded. Print it as `cargo::error={error}`.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// `CRAFT_FONTS_REQUIRED` is set but `CRAFT_FONTS_DIR` is unset or empty.
|
||||
NotSet,
|
||||
/// The manifest or a font file it lists can't be read.
|
||||
Read { path: PathBuf, source: io::Error },
|
||||
/// The manifest has a malformed line.
|
||||
Parse { manifest: PathBuf, error: ParseError },
|
||||
/// Required, but no face in the manifest was selected.
|
||||
NoFaces { manifest: PathBuf },
|
||||
/// Required, but no face in the manifest has these requested script tags.
|
||||
MissingScripts { manifest: PathBuf, scripts: Vec<String> },
|
||||
/// The generated table can't be written.
|
||||
Write { path: PathBuf, source: io::Error },
|
||||
/// `out_file` is relative and `OUT_DIR` is unset (not running as a build script).
|
||||
NoOutDir,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotSet => {
|
||||
write!(f, "{REQUIRED_VAR} is set but {DIR_VAR} is not: set {DIR_VAR} to the absolute path of a storytold/craft-fonts checkout")
|
||||
}
|
||||
Self::Read { path, source } => {
|
||||
write!(f, "cannot read {} ({DIR_VAR} must be the absolute path of a storytold/craft-fonts checkout): {source}", path.display())
|
||||
}
|
||||
Self::Parse { manifest, error } => write!(f, "{}: {error}", manifest.display()),
|
||||
Self::NoFaces { manifest } => write!(f, "{REQUIRED_VAR} is set but no face in {} was selected", manifest.display()),
|
||||
Self::MissingScripts { manifest, scripts } => {
|
||||
write!(f, "{REQUIRED_VAR} is set but {} has no face for {}", manifest.display(), scripts.join(", "))
|
||||
}
|
||||
Self::Write { path, source } => write!(f, "cannot write {}: {source}", path.display()),
|
||||
Self::NoOutDir => write!(f, "OUT_DIR is not set: call craft-fonts-build from a build script, or pass an absolute out_file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Read { source, .. } | Self::Write { source, .. } => Some(source),
|
||||
Self::Parse { error, .. } => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! Embed the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's
|
||||
//! `build.rs`.
|
||||
//!
|
||||
//! Apps never depend on craft-fonts in `Cargo.toml` (Cargo would fetch the font repository for
|
||||
//! every build). The build reads a local checkout named by `CRAFT_FONTS_DIR` instead:
|
||||
//!
|
||||
//! - **Unset or empty:** the table is empty and the app uses its own and system fonts.
|
||||
//! - **Set:** every selected face in `fonts/manifest.txt` is embedded with `include_bytes!`.
|
||||
//! - **Set, but not readable as a checkout:** a `cargo::warning` and an empty table.
|
||||
//! - **`CRAFT_FONTS_REQUIRED=1`** (release builds): a missing or unreadable checkout, no selected
|
||||
//! face, or a requested script without a face is an [`Error`].
|
||||
//!
|
||||
//! Add this crate both as a build-dependency (for [`embed`]) and as a dependency (for
|
||||
//! [`EmbeddedFace`]). In `build.rs`'s `main`:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! println!("cargo::rerun-if-changed=build.rs");
|
||||
//! let wasm = craft_fonts_build::target_is_wasm();
|
||||
//! let embedded = craft_fonts_build::Embed::new()
|
||||
//! // Web builds have a size budget: embed only the Japanese UI face there.
|
||||
//! .filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular"))
|
||||
//! .write("craft_fonts.rs");
|
||||
//! if let Err(e) = embedded {
|
||||
//! println!("cargo::error={e}");
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! and in the crate:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use craft_fonts_build::EmbeddedFace;
|
||||
//!
|
||||
//! pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
//! ```
|
||||
//!
|
||||
//! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with
|
||||
//! the same fields and that name in scope works too.
|
||||
|
||||
mod embed;
|
||||
mod manifest;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use embed::{Embed, Error, embed, target_is_wasm};
|
||||
pub use manifest::{ManifestEntry, ParseError, parse_manifest};
|
||||
|
||||
/// A craft-fonts face embedded by [`embed`]: one line of `fonts/manifest.txt`.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct EmbeddedFace {
|
||||
/// Family name, e.g. `"Noto Sans Arabic"`.
|
||||
pub family: &'static str,
|
||||
/// Style, e.g. `"Regular"` or `"Bold"`.
|
||||
pub style: &'static str,
|
||||
/// Script tags: ISO 15924 codes such as `Arab`, `Jpan`, `Hans`, `Hant`, optionally with a
|
||||
/// region subtag (`Hant-HK` for Hong Kong forms, which have no ISO 15924 code).
|
||||
pub scripts: &'static [&'static str],
|
||||
/// Face index in a font collection (`.ttc`); `0` for a single font.
|
||||
pub index: u32,
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
impl EmbeddedFace {
|
||||
/// `"<family> <style>"`, e.g. `"Noto Sans Arabic Regular"`.
|
||||
pub fn name(&self) -> String {
|
||||
format!("{} {}", self.family, self.style)
|
||||
}
|
||||
|
||||
/// Whether any script tag matches `tag`: `Hant` matches `Hant` and `Hant-HK`.
|
||||
pub fn has_script(&self, tag: &str) -> bool {
|
||||
self.scripts.iter().any(|s| manifest::script_matches(s, tag))
|
||||
}
|
||||
|
||||
/// CSS/OpenType weight implied by the style name (`Bold` → 700); 400 when it names none.
|
||||
pub fn weight(&self) -> u16 {
|
||||
let style = self.style.to_ascii_lowercase().replace([' ', '-'], "");
|
||||
const WEIGHTS: [(&str, u16); 14] = [
|
||||
("extralight", 200),
|
||||
("ultralight", 200),
|
||||
("semibold", 600),
|
||||
("demibold", 600),
|
||||
("extrabold", 800),
|
||||
("ultrabold", 800),
|
||||
("thin", 100),
|
||||
("light", 300),
|
||||
("medium", 500),
|
||||
("bold", 700),
|
||||
("black", 900),
|
||||
("heavy", 900),
|
||||
("regular", 400),
|
||||
("book", 400),
|
||||
];
|
||||
WEIGHTS.iter().find(|(name, _)| style.contains(name)).map_or(400, |(_, weight)| *weight)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EmbeddedFace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EmbeddedFace")
|
||||
.field("family", &self.family)
|
||||
.field("style", &self.style)
|
||||
.field("scripts", &self.scripts)
|
||||
.field("index", &self.index)
|
||||
.field("bytes", &format_args!("[{} bytes]", self.bytes.len()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! craft-fonts' `fonts/manifest.txt`: one font face per line, eight fields separated by `" | "`:
|
||||
//!
|
||||
//! ```text
|
||||
//! family | style | file | scripts | licence | licence file | sha256 | source
|
||||
//! ```
|
||||
//!
|
||||
//! Blank lines and lines starting with `#` are skipped. `file` is relative to the checkout and may
|
||||
//! end in `#<index>` to pick one face of a font collection (`.ttc`). Fields after the eighth are
|
||||
//! ignored, so a manifest that gains a field still embeds.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// One face listed in the manifest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ManifestEntry {
|
||||
/// Family name, as the font's `name` table spells it (e.g. `Noto Sans Arabic`).
|
||||
pub family: String,
|
||||
/// Style within the family (e.g. `Regular`, `Bold`).
|
||||
pub style: String,
|
||||
/// Font file, relative to the checkout, without the `#<index>` suffix.
|
||||
pub file: String,
|
||||
/// Face index in a font collection; `0` when the file field has no `#<index>`.
|
||||
pub index: u32,
|
||||
/// Script tags: ISO 15924 codes (`Jpan`, `Arab`), optionally with a region (`Hant-HK`).
|
||||
pub scripts: Vec<String>,
|
||||
/// SPDX licence identifier.
|
||||
pub licence: String,
|
||||
/// Licence text, relative to the checkout.
|
||||
pub licence_file: String,
|
||||
/// Hex SHA-256 of the font file.
|
||||
pub sha256: String,
|
||||
/// Where the file came from.
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
/// Whether any of the face's script tags matches `tag`. `Hant` matches `Hant` and `Hant-HK`;
|
||||
/// `Hant-HK` matches only `Hant-HK`.
|
||||
pub fn has_script(&self, tag: &str) -> bool {
|
||||
self.scripts.iter().any(|s| script_matches(s, tag))
|
||||
}
|
||||
}
|
||||
|
||||
/// `face_tag` is `wanted` or `wanted` plus a `-` subtag.
|
||||
pub(crate) fn script_matches(face_tag: &str, wanted: &str) -> bool {
|
||||
face_tag.strip_prefix(wanted).is_some_and(|rest| rest.is_empty() || rest.starts_with('-'))
|
||||
}
|
||||
|
||||
/// A manifest line that could not be read.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ParseError {
|
||||
/// 1-based line number.
|
||||
pub line: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "manifest line {}: {}", self.line, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Parse a whole manifest.
|
||||
pub fn parse_manifest(text: &str) -> Result<Vec<ManifestEntry>, ParseError> {
|
||||
let mut entries = Vec::new();
|
||||
for (i, raw) in text.lines().enumerate() {
|
||||
let line = i + 1;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = trimmed.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, licence, licence_file, sha256, source, ..] = fields.as_slice() else {
|
||||
return Err(ParseError { line, reason: format!("expected 8 fields separated by \" | \", found {}", fields.len()) });
|
||||
};
|
||||
for (name, value) in [("family", family), ("style", style), ("file", file), ("scripts", scripts)] {
|
||||
if value.is_empty() {
|
||||
return Err(ParseError { line, reason: format!("the {name} field is empty") });
|
||||
}
|
||||
}
|
||||
let (file, index) = match file.rsplit_once('#') {
|
||||
Some((path, index)) => {
|
||||
let index = index.parse().map_err(|_| ParseError { line, reason: format!("bad face index in {file:?}") })?;
|
||||
(path, index)
|
||||
}
|
||||
None => (*file, 0),
|
||||
};
|
||||
entries.push(ManifestEntry {
|
||||
family: (*family).to_owned(),
|
||||
style: (*style).to_owned(),
|
||||
file: file.to_owned(),
|
||||
index,
|
||||
scripts: scripts.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect(),
|
||||
licence: (*licence).to_owned(),
|
||||
licence_file: (*licence_file).to_owned(),
|
||||
sha256: (*sha256).to_owned(),
|
||||
source: (*source).to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::embed::Env;
|
||||
use crate::{Embed, EmbeddedFace, Error, parse_manifest};
|
||||
|
||||
fn fixture() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/craft-fonts")
|
||||
}
|
||||
|
||||
/// A fresh scratch directory under the system temp dir.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("craft-fonts-build-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
result: Result<Vec<String>, Error>,
|
||||
cargo: Vec<String>,
|
||||
table: String,
|
||||
}
|
||||
|
||||
/// Run `embed` with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED` as given; returns the embedded
|
||||
/// face names, the cargo lines and the generated table.
|
||||
fn run(embed: Embed, dir: Option<&Path>, required: Option<&str>) -> Run {
|
||||
static RUNS: AtomicUsize = AtomicUsize::new(0);
|
||||
let scratch = Scratch::new(&format!("run-{}", RUNS.fetch_add(1, Ordering::Relaxed)));
|
||||
let env = Env { dir: dir.map(OsString::from), required: required.map(OsString::from), out_dir: Some(scratch.0.clone().into()) };
|
||||
let mut cargo = Vec::new();
|
||||
let result = embed.run(&env, Path::new("craft_fonts.rs"), &mut cargo);
|
||||
let table = std::fs::read_to_string(scratch.0.join("craft_fonts.rs")).unwrap();
|
||||
Run { result: result.map(|faces| faces.iter().map(|f| format!("{} {}", f.family, f.style)).collect()), cargo, table }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_manifest_with_collection_indices() {
|
||||
let text = std::fs::read_to_string(fixture().join("fonts/manifest.txt")).unwrap();
|
||||
let entries = parse_manifest(&text).unwrap();
|
||||
assert_eq!(entries.len(), 6);
|
||||
assert_eq!((entries[0].family.as_str(), entries[0].style.as_str()), ("Sans JP", "Regular"));
|
||||
assert_eq!(entries[0].scripts, ["Jpan", "Latn"]);
|
||||
assert_eq!((entries[0].file.as_str(), entries[0].index), ("fonts/sans-jp/SansJP-Regular.ttf", 0));
|
||||
assert_eq!((entries[4].file.as_str(), entries[4].index), ("fonts/sans-cjk/SansCJK.ttc", 3));
|
||||
assert_eq!(entries[4].scripts, ["Hant-HK", "Latn"]);
|
||||
assert_eq!(entries[4].licence, "OFL-1.1");
|
||||
|
||||
let short = "# comment\n\nA | Regular | a.ttf | Latn\n";
|
||||
let err = parse_manifest(short).unwrap_err();
|
||||
assert_eq!(err.line, 3);
|
||||
assert!(err.to_string().contains("expected 8 fields"), "{err}");
|
||||
let bad_index = "A | Regular | a.ttc#x | Latn | OFL-1.1 | OFL.txt | 00 | src\n";
|
||||
assert!(parse_manifest(bad_index).unwrap_err().to_string().contains("face index"));
|
||||
let extra = "A | Regular | a.ttf | Latn | OFL-1.1 | OFL.txt | 00 | src | future field\n";
|
||||
assert_eq!(parse_manifest(extra).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_dir_writes_an_empty_table() {
|
||||
let r = run(Embed::new(), None, None);
|
||||
assert_eq!(r.result.unwrap(), Vec::<String>::new());
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
assert!(r.cargo.contains(&"cargo::rerun-if-env-changed=CRAFT_FONTS_DIR".to_owned()));
|
||||
assert!(r.cargo.contains(&"cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED".to_owned()));
|
||||
assert!(!r.cargo.iter().any(|l| l.starts_with("cargo::warning")));
|
||||
// `0` and empty are not "required".
|
||||
assert!(run(Embed::new(), None, Some("0")).result.is_ok());
|
||||
assert!(run(Embed::new(), Some(Path::new("")), Some("")).result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fails_without_a_usable_checkout() {
|
||||
for dir in [None, Some(Path::new(""))] {
|
||||
let r = run(Embed::new(), dir, Some("1"));
|
||||
assert!(matches!(r.result, Err(Error::NotSet)), "{dir:?}");
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
}
|
||||
let err = run(Embed::new(), None, Some("1")).result.unwrap_err().to_string();
|
||||
assert!(err.contains("CRAFT_FONTS_DIR") && err.contains("CRAFT_FONTS_REQUIRED"), "{err}");
|
||||
|
||||
let missing = Path::new("/nonexistent/craft-fonts");
|
||||
assert!(matches!(run(Embed::new(), Some(missing), Some("1")).result, Err(Error::Read { .. })));
|
||||
let r = run(Embed::new(), Some(missing), None);
|
||||
assert!(r.result.unwrap().is_empty());
|
||||
assert!(r.cargo.iter().any(|l| l.starts_with("cargo::warning=building without craft-fonts")), "{:?}", r.cargo);
|
||||
|
||||
let scratch = Scratch::new("missing-file");
|
||||
std::fs::create_dir_all(scratch.0.join("fonts")).unwrap();
|
||||
std::fs::write(scratch.0.join("fonts/manifest.txt"), "A | Regular | fonts/a.ttf | Latn | OFL-1.1 | OFL.txt | 00 | src\n").unwrap();
|
||||
assert!(matches!(run(Embed::new(), Some(&scratch.0), Some("1")).result, Err(Error::Read { .. })));
|
||||
std::fs::write(scratch.0.join("fonts/manifest.txt"), "# no faces\n").unwrap();
|
||||
assert!(matches!(run(Embed::new(), Some(&scratch.0), Some("1")).result, Err(Error::NoFaces { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeds_the_selected_scripts() {
|
||||
let r = run(Embed::new(), Some(&fixture()), Some("1"));
|
||||
assert_eq!(r.result.unwrap().len(), 6);
|
||||
|
||||
let r = run(Embed::new().scripts(&["Jpan", "Hant"]), Some(&fixture()), Some("1"));
|
||||
assert_eq!(r.result.unwrap(), ["Sans JP Regular", "Sans JP Bold", "Sans CJK TC Regular", "Sans CJK HK Regular"]);
|
||||
let manifest = fixture().join("fonts/manifest.txt");
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", manifest.display())));
|
||||
let jp = fixture().join("fonts/sans-jp/SansJP-Regular.ttf").canonicalize().unwrap();
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", jp.display())));
|
||||
assert!(r.table.contains(&format!("include_bytes!({:?})", jp.display().to_string())), "{}", r.table);
|
||||
assert!(r.table.contains("family: \"Sans CJK HK\", style: \"Regular\", scripts: &[\"Hant-HK\", \"Latn\"], index: 3"), "{}", r.table);
|
||||
|
||||
let r = run(Embed::new().scripts(&["Hant-HK"]).filter(|f| f.index != 0), Some(&fixture()), None);
|
||||
assert_eq!(r.result.unwrap(), ["Sans CJK HK Regular"]);
|
||||
|
||||
let r = run(Embed::new().scripts(&["Arab", "Kore", "Geor"]), Some(&fixture()), Some("1"));
|
||||
let Err(Error::MissingScripts { scripts, .. }) = r.result else { panic!("{:?}", r.result) };
|
||||
assert_eq!(scripts, ["Kore", "Geor"]);
|
||||
// Not required: whatever matched is embedded.
|
||||
let r = run(Embed::new().scripts(&["Arab", "Kore"]), Some(&fixture()), None);
|
||||
assert_eq!(r.result.unwrap(), ["Sans Arabic Regular"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_face_helpers() {
|
||||
let face = |style: &'static str| EmbeddedFace { family: "Sans", style, scripts: &["Hant-HK", "Latn"], index: 0, bytes: b"" };
|
||||
assert_eq!(face("Regular").name(), "Sans Regular");
|
||||
assert!(face("Regular").has_script("Hant") && face("Regular").has_script("Hant-HK") && !face("Regular").has_script("Han"));
|
||||
let weights: Vec<u16> = ["Regular", "Bold", "SemiBold", "Extra Light", "Black Italic", "Italic"].map(|s| face(s).weight()).into();
|
||||
assert_eq!(weights, [400, 700, 600, 200, 900, 400]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "craft-fonts-build-consumer"
|
||||
description = "Test crate: a build.rs that embeds the craft-fonts-build fixture the way an app does."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
craft-fonts-build = { path = "../.." }
|
||||
|
||||
[build-dependencies]
|
||||
craft-fonts-build = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Embeds the fixture checkout in `../fixture/craft-fonts` the way an app's build.rs embeds
|
||||
//! `CRAFT_FONTS_DIR`, so the generated table is compiled by a real `include!`.
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
let embedded = craft_fonts_build::Embed::new()
|
||||
.dir(root.join("../fixture/craft-fonts"))
|
||||
.required(true)
|
||||
.scripts(&["Jpan", "Hant"])
|
||||
.filter(|face| face.style == "Regular")
|
||||
.write("craft_fonts.rs");
|
||||
if let Err(e) = embedded {
|
||||
println!("cargo::error={e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! The fixture faces as an app's crate sees them; read by `craft-fonts-build`'s tests.
|
||||
|
||||
use craft_fonts_build::EmbeddedFace;
|
||||
|
||||
pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
@@ -0,0 +1,10 @@
|
||||
# Test fixture in the craft-fonts manifest format. The "font files" are short text files:
|
||||
# embedding copies bytes and never parses them.
|
||||
# family | style | file | scripts | licence | licence file | sha256 | source
|
||||
|
||||
Sans JP | Regular | fonts/sans-jp/SansJP-Regular.ttf | Jpan,Latn | OFL-1.1 | fonts/sans-jp/OFL.txt | c9d08ef1fc69f15e87ce52bda8a9c24714e4125daf7e50a40edb1aa2be10dff1 | https://example.invalid/sans-jp
|
||||
Sans JP | Bold | fonts/sans-jp/SansJP-Bold.ttf | Jpan,Latn | OFL-1.1 | fonts/sans-jp/OFL.txt | a135adabc1facd7a49fa4ef04771f74ffe69e67418e0e0f0b81a4cebba776e48 | https://example.invalid/sans-jp
|
||||
Sans Arabic | Regular | fonts/sans-arabic/SansArabic.ttf | Arab | OFL-1.1 | fonts/sans-arabic/OFL.txt | 318d7469dd372ff3ad458d46ea714c0ea969696675794d6100396f8a11103514 | https://example.invalid/sans-arabic
|
||||
Sans CJK TC | Regular | fonts/sans-cjk/SansCJK.ttc#2 | Hant,Latn | OFL-1.1 | fonts/sans-cjk/OFL.txt | 2fecde232d5dcda26a2299664dae1a2c50ad92b694575c5cfa70207010b339be | https://example.invalid/sans-cjk
|
||||
Sans CJK HK | Regular | fonts/sans-cjk/SansCJK.ttc#3 | Hant-HK,Latn | OFL-1.1 | fonts/sans-cjk/OFL.txt | 2fecde232d5dcda26a2299664dae1a2c50ad92b694575c5cfa70207010b339be | https://example.invalid/sans-cjk
|
||||
Sans Thai | Regular | fonts/sans-thai/SansThai.ttf | Thai | OFL-1.1 | fonts/sans-thai/OFL.txt | 5960276bcbad8e7949804d35651ebf42e17648f4d128c71ccdb76167a5337944 | https://example.invalid/sans-thai
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans Arabic
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans CJK collection
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans JP Bold
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans JP Regular
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans Thai
|
||||
@@ -0,0 +1,16 @@
|
||||
//! The table `craft-fonts-build-consumer`'s build.rs generated from the fixture compiles and holds
|
||||
//! the selected faces' bytes.
|
||||
|
||||
use craft_fonts_build_consumer::CRAFT_FONTS;
|
||||
|
||||
#[test]
|
||||
fn generated_table_embeds_the_selected_faces() {
|
||||
let names: Vec<String> = CRAFT_FONTS.iter().map(|f| f.name()).collect();
|
||||
assert_eq!(names, ["Sans JP Regular", "Sans CJK TC Regular", "Sans CJK HK Regular"]);
|
||||
assert_eq!(CRAFT_FONTS[0].bytes, include_bytes!("fixture/craft-fonts/fonts/sans-jp/SansJP-Regular.ttf"));
|
||||
assert_eq!(CRAFT_FONTS[0].scripts, ["Jpan", "Latn"]);
|
||||
let indices: Vec<u32> = CRAFT_FONTS.iter().map(|f| f.index).collect();
|
||||
assert_eq!(indices, [0, 2, 3]);
|
||||
assert_eq!(CRAFT_FONTS[2].bytes, include_bytes!("fixture/craft-fonts/fonts/sans-cjk/SansCJK.ttc"));
|
||||
assert!(CRAFT_FONTS[2].has_script("Hant"));
|
||||
}
|
||||
Reference in new issue
Block a user