mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 12:22:47 +00:00
203 lines
11 KiB
Markdown
203 lines
11 KiB
Markdown
# 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 without the `subset` feature, 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` (exactly `1`; other values are ignored) | 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). The `subset` feature goes on the
|
||
build-dependency only: it brings in the subsetter ([skera](https://crates.io/crates/skera) and the
|
||
fontations crates), which the app itself never needs. Cargo's resolver 2 and later (edition 2021
|
||
and later) keep build-dependency features apart from the app's, so the dependency stays without
|
||
it.
|
||
|
||
```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 = "…", features = ["subset"] }
|
||
```
|
||
|
||
`build.rs` (FilmCraft's `crates/text/build.rs`, in full):
|
||
|
||
```rust
|
||
use std::path::PathBuf;
|
||
|
||
fn main() {
|
||
println!("cargo::rerun-if-changed=build.rs");
|
||
let wasm = craft_fonts_build::target_is_wasm();
|
||
let embedded = craft_fonts_build::Embed::new()
|
||
// CJK faces only as the interface faces, cut to the characters FilmCraft's catalogs use.
|
||
.subset_to(catalogs("filmcraft"))
|
||
// Web builds have a size budget: embed only the Japanese interface face there.
|
||
.filter(move |face| !wasm || face.has_script("Jpan"))
|
||
.write("craft_fonts.rs");
|
||
if let Err(e) = embedded {
|
||
println!("cargo::error={e}");
|
||
}
|
||
}
|
||
|
||
/// Every locale's shared and app catalog in the craft-i18n checkout `CRAFT_I18N_DIR` names, the
|
||
/// files the app's translations embed; none when it is unset.
|
||
fn catalogs(app: &str) -> Vec<PathBuf> {
|
||
println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR");
|
||
let Some(dir) = std::env::var_os("CRAFT_I18N_DIR").filter(|dir| !dir.is_empty()) else { return Vec::new() };
|
||
let locales = PathBuf::from(dir).join("locales");
|
||
println!("cargo::rerun-if-changed={}", locales.display());
|
||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&locales)
|
||
.into_iter()
|
||
.flatten()
|
||
.flatten()
|
||
.flat_map(|locale| ["common.tsv".to_owned(), format!("{app}.tsv")].map(|file| locale.path().join(file)))
|
||
.filter(|path| path.is_file())
|
||
.collect();
|
||
paths.sort();
|
||
paths
|
||
}
|
||
```
|
||
|
||
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"));
|
||
```
|
||
|
||
`Embed::new()` selects every face except the sources of the interface faces (`role` `ui`, see
|
||
below), and `Embed::documents(&["Noto Sans", …])` only the families it names (see
|
||
[Document fonts](#document-fonts)). The builder 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=1`, each tag
|
||
must match at least one face.
|
||
- `filter(|entry| …)` keeps faces by any manifest field (family, style, licence, index).
|
||
- `for_ui()` keeps CJK faces only as the interface faces (see below).
|
||
- `subset_to(catalogs)` (feature `subset`) cuts the interface faces to the catalogs' characters;
|
||
it implies `for_ui()`.
|
||
- `check_text(paths)` warns about characters in these files that no embedded face covers.
|
||
- `embedded_elsewhere(&["Noto Sans Math", …])` names families another table of the app embeds,
|
||
so `check_text` and the catalog check count their characters as covered.
|
||
- `dir(path)` and `required(bool)` override the two environment variables.
|
||
|
||
`write` returns the embedded `ManifestEntry`s, for build-time checks such as "the interface 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.
|
||
`EmbeddedFace` itself has `name()`, `has_script(tag)`, `is_cjk()` and `weight()`.
|
||
|
||
## Document fonts
|
||
|
||
An app that draws document text (titles, layouts, vector text) embeds the families its documents
|
||
use, and only those, in a table of its own:
|
||
|
||
```rust
|
||
let embedded = craft_fonts_build::Embed::documents(&["Noto Sans", "Shippori Mincho"])
|
||
// The interface table holds the CJK cuts and the symbol faces.
|
||
.embedded_elsewhere(&["Noto Sans Math", "Noto Sans Symbols 2"])
|
||
.write("document_fonts.rs");
|
||
```
|
||
|
||
Each named family brings all its styles, as whole files: a CJK document face takes 5–17 MB, so a
|
||
release embeds exactly the faces the app's documents need. A family named here is embedded even
|
||
when it is also an interface face's source (`BIZ UDPGothic` brings its Regular too). A name the
|
||
manifest lacks is an `Error::UnknownFamilies` listing the manifest's families: a build error with
|
||
`CRAFT_FONTS_REQUIRED=1`, and a `cargo::warning` with an empty table otherwise, as for any
|
||
checkout that can't be used. `scripts` and `filter` narrow the families further, for example to
|
||
their Regular faces.
|
||
|
||
## Serving the faces (web builds)
|
||
|
||
`Embed::serve(dir, url_prefix)` copies the selected faces into `dir` instead of embedding them,
|
||
and writes a table of `ServedFace { family, style, scripts, index, url, size, integrity, coverage }`
|
||
literals for the app to fetch at run time (craft-ui's `fonts::UiFonts::served_faces` does). Each
|
||
font file and its licence keep their path under the checkout's `fonts/` directory, and `url` is
|
||
`url_prefix` plus that path: `serve("web/fonts", "fonts/")` copies
|
||
`fonts/noto-sans-arabic/NotoSansArabic.ttf` to `web/fonts/noto-sans-arabic/NotoSansArabic.ttf`, with
|
||
the URL `fonts/noto-sans-arabic/NotoSansArabic.ttf`. Faces of one collection share a file and a
|
||
URL. `integrity` is the manifest's SHA-256 as a Subresource Integrity value. `coverage` holds the
|
||
code points the face's `cmap` maps, as the `coverage` module encodes them (sorted ranges as LEB128
|
||
gaps and lengths; `coverage::contains` reads them), so an app fetches a face only for characters
|
||
it has: about 3 KB for a CJK interface face cut to an app's catalogs, under 1 KB for a full one.
|
||
|
||
An interface face cut by `subset_to` is served from its cut in `OUT_DIR`, with the start of the
|
||
cut's SHA-256 in its name (`web/fonts/ui/NotoSansTCUI-Regular.1234abcd5678ef90.ttf`) and the cut's
|
||
own `integrity`, so changed catalogs give a new URL rather than a cached copy that fails its check.
|
||
A relative `dir` is under the package directory. Files already there with the same bytes are not
|
||
copied again, a deleted one is copied again on the next build, and files of faces no longer
|
||
selected, and earlier cuts, stay.
|
||
|
||
```rust
|
||
// CJK faces only as the interface faces, cut to the app's catalogs (see below).
|
||
let fonts = craft_fonts_build::Embed::new().subset_to(catalogs("filmcraft"));
|
||
let written = if craft_fonts_build::target_is_wasm() {
|
||
fonts.serve("web/fonts", "fonts/").write("craft_fonts.rs")
|
||
} else {
|
||
fonts.write("craft_fonts.rs")
|
||
};
|
||
```
|
||
|
||
The crate includes the table as `&[ServedFace]` on wasm and `&[EmbeddedFace]` elsewhere. craft-ui's
|
||
README shows a whole eframe web app, Trunk copy included.
|
||
|
||
## Interface faces
|
||
|
||
The full CJK faces take 5–17 MB each. For the interface, craft-fonts marks one Regular TrueType
|
||
face per CJK script with the role `ui`: `Noto Sans SC UI` (`Hans`), `Noto Sans TC UI` (`Hant`),
|
||
`Noto Sans HK UI` (`Hant-HK`), `Noto Sans KR UI` (`Kore`) and BIZ UDPGothic Regular (`Jpan`). The
|
||
app's build cuts them to the characters its translation catalogs use; for FilmCraft's catalogs
|
||
the five cuts take about 3 MB together.
|
||
|
||
- `for_ui()` drops every face with a CJK script tag (`Hans`, `Hant`, `Hant-HK`, `Jpan`, `Kore`,
|
||
`Hani`) that is not an interface face, and keeps the rest. Document text embeds the full faces
|
||
it names with `Embed::documents` instead. Without `for_ui()` or `subset_to`, the interface
|
||
faces' sources are left out unless `Embed::documents` names their family.
|
||
- `subset_to(catalogs)` cuts each interface face to the characters the catalogs use from U+2E80 up
|
||
(CJK and later blocks, without emoji, variation selectors and tags), plus CJK punctuation
|
||
(U+3000–303F), kana (U+3040–30FF, U+31F0–31FF) and full-width forms (U+FF00–FFEF), and keeps
|
||
every layout feature, so vertical forms (`vert`) still work. Every face gets the characters of
|
||
every catalog, so a Japanese label in a Chinese catalog still draws. The cuts are written to
|
||
`OUT_DIR/craft-fonts-ui/` and embedded from there; the same inputs give the same bytes, for
|
||
reproducible releases. Cutting all five faces takes well under a second.
|
||
- Without `subset_to`, or with an empty catalog list, `for_ui()` leaves the interface faces out
|
||
with a `cargo::warning`: a full CJK face is never embedded for the interface by accident. A
|
||
checkout whose manifest has no interface face is a `cargo::warning` too. With `CRAFT_FONTS_REQUIRED=1` that
|
||
makes `scripts(&["Hant"])` an error; with catalogs, an interface face satisfies it.
|
||
- An interface face with CFF outlines is an error (`Error::NotTrueType`), even when not required:
|
||
the subsetter cuts TrueType (`glyf`) faces correctly, but not CFF ones.
|
||
|
||
The catalogs come from craft-i18n. An app's build reads them from the checkout `CRAFT_I18N_DIR`
|
||
names (craft-i18n's `docs/integration.md`); the fonts build script reads the same variable, as in
|
||
the example above. Catalogs kept in the app's own repository are passed by path, relative to the
|
||
package directory.
|
||
|
||
A character the faces lack still shows: craft-ui's system font provider draws it with an installed
|
||
font. The build says so: for each catalog, and each file passed to `check_text`, it prints one
|
||
`cargo::warning` with the count and the first few characters from U+2E80 up that no embedded face
|
||
covers. Since the cuts hold every catalog character their sources have, these are the characters
|
||
the sources lack. Faces the app embeds in another table count as covering their characters when
|
||
named with `embedded_elsewhere`; they are read whole from the checkout, so a face cut elsewhere is
|
||
exact only for the catalogs it was cut to. It also reruns the build script when a file changes.
|
||
|
||
## The manifest
|
||
|
||
One face per line, eight or nine 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. The ninth
|
||
field, `role`, is `ui` on interface faces and absent elsewhere. Fields after the ninth are ignored.
|