Add craft-fonts-build: embed craft-fonts faces from an app's build.rs

This commit is contained in:
aiongg committed 2026-10-21 06:16:08 +00:00
1 parent e36f189018
commit b37040cb93
23 files changed
+759

No files matched your search

Generated
+14
View File
@@ -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"
+1
View File
@@ -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" }
+3
View File
@@ -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
+14
View File
@@ -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
+73
View File
@@ -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.
+228
View File
@@ -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,
}
}
}
+108
View File
@@ -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()
}
}
+103
View File
@@ -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)
}
+139
View File
@@ -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
+16
View File
@@ -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"));
}