diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index a973e37f4..70f0f71e7 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -25,5 +25,5 @@ jobs: with: components: rustfmt, clippy - run: cargo fmt --check - - run: cargo clippy -p craft-i18n --all-targets --locked -- -D warnings - - run: cargo test -p craft-i18n --locked -- --nocapture + - run: cargo clippy -p craft-i18n --all-targets --features collate --locked -- -D warnings + - run: cargo test -p craft-i18n --features collate --locked -- --nocapture diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bf1d6ff3a..7a2a751fb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,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/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 | Original assets are covered by the repository licence. diff --git a/Cargo.lock b/Cargo.lock index 78556661a..8c109ef8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,6 +624,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -786,15 +795,43 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +dependencies = [ + "craft-fonts-build-consumer", + "skera", + "skrifa 0.48.0", +] + +[[package]] +name = "craft-fonts-build-consumer" +version = "0.1.0" +dependencies = [ + "craft-fonts-build", +] + [[package]] name = "craft-i18n" version = "0.1.0" dependencies = [ - "intl_pluralrules", + "fixed_decimal", + "icu_collator", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "log", + "objc2-foundation 0.3.2", "sys-locale", - "unic-langid", "unicode-bidi", + "windows-registry", + "writeable", ] [[package]] @@ -1252,6 +1289,18 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1324,7 +1373,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.6", + "font-types 0.12.7", "harfrust", "log", "nohash-hasher", @@ -1438,6 +1487,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flagset" version = "0.4.7" @@ -1509,9 +1569,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -2064,6 +2124,24 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "serde", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2072,12 +2150,30 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2086,11 +2182,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2101,7 +2218,11 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", + "serde", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2111,6 +2232,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2122,6 +2269,7 @@ dependencies = [ "icu_locale_core", "icu_properties_data", "icu_provider", + "serde", "zerotrie", "zerovec", ] @@ -2140,6 +2288,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2147,6 +2298,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2214,15 +2389,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -2467,6 +2633,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3334,12 +3503,26 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3618,7 +3801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3629,10 +3812,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" +dependencies = [ + "bytemuck", + "foldhash", + "font-types 0.12.7", + "once_cell", + "yoke", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -4333,6 +4529,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531045338e36ad05b9652912a47ba38a935d0eaa0293c3e33d8112cfa414c14b" +dependencies = [ + "hashbrown 0.17.1", + "skrifa 0.48.0", + "thiserror 2.0.21", + "write-fonts 0.54.1", +] + [[package]] name = "skrifa" version = "0.42.1" @@ -4363,6 +4571,16 @@ dependencies = [ "read-fonts 0.44.0", ] +[[package]] +name = "skrifa" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034" +dependencies = [ + "bytemuck", + "read-fonts 0.45.1", +] + [[package]] name = "slab" version = "0.4.12" @@ -4522,7 +4740,7 @@ dependencies = [ "kurbo 0.13.1", "rustc-hash 2.1.3", "skrifa 0.42.1", - "write-fonts", + "write-fonts 0.48.1", ] [[package]] @@ -5002,24 +5220,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5129,6 +5329,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6079,11 +6285,31 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write-fonts" +version = "0.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55716cfa7e8bcdb90289e07dfac478ccb2e3ec0f9a604fe682c7a03a7984d705" +dependencies = [ + "font-types 0.12.7", + "log", + "read-fonts 0.45.1", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11rb" @@ -6347,8 +6573,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ede0e9a3e..d9ccea7bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,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" } diff --git a/README.md b/README.md index 2dc92f8a1..c47d382a5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Vec { + 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 = 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. + +## 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 `#` 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. diff --git a/crates/fonts-build/src/cmap.rs b/crates/fonts-build/src/cmap.rs new file mode 100644 index 000000000..b4dd4a7f1 --- /dev/null +++ b/crates/fonts-build/src/cmap.rs @@ -0,0 +1,118 @@ +//! Just enough of an OpenType reader to ask which characters a face maps: the `cmap` table's +//! Unicode subtables in formats 4 and 12, in a single font or one face of a collection. + +/// The Unicode character maps of one face. +pub(crate) struct Cmap<'a> { + subtables: Vec<&'a [u8]>, +} + +impl<'a> Cmap<'a> { + /// The cmap of face `index` of `font` (a `.ttf`/`.otf`, or a `.ttc` collection); `None` when the + /// file isn't a font or has no Unicode subtable in format 4 or 12. + pub(crate) fn read(font: &'a [u8], index: u32) -> Option { + let face = if font.get(0..4)? == b"ttcf" { + if index >= u32_at(font, 8)? { + return None; + } + usize::try_from(u32_at(font, 12 + 4 * usize::try_from(index).ok()?)?).ok()? + } else { + 0 + }; + let tables = usize::from(u16_at(font, face + 4)?); + let record = (0..tables).map(|i| face + 12 + 16 * i).find(|&r| font.get(r..r + 4) == Some(b"cmap"))?; + let cmap = slice(font, u32_at(font, record + 8)?, u32_at(font, record + 12)?)?; + let mut subtables = Vec::new(); + for i in 0..usize::from(u16_at(cmap, 2)?) { + let (platform, encoding) = (u16_at(cmap, 4 + 8 * i)?, u16_at(cmap, 6 + 8 * i)?); + // Unicode platform, or Windows Unicode BMP (1) and full repertoire (10). + if platform != 0 && !(platform == 3 && matches!(encoding, 1 | 10)) { + continue; + } + let start = usize::try_from(u32_at(cmap, 8 + 8 * i)?).ok()?; + let rest = cmap.get(start..)?; + // Bound each subtable by its own length so that reads can't stray into the next one. + // Some large CJK fonts declare a format 4 length past the end of the table, so that + // one is clamped; a format 12 length that doesn't fit is skipped. + let len = match u16_at(rest, 0) { + Some(4) => u16_at(rest, 2).map(|len| usize::from(len).min(rest.len())), + Some(12) => u32_at(rest, 4).and_then(|len| usize::try_from(len).ok()), + _ => None, + }; + if let Some(subtable) = len.and_then(|len| rest.get(..len)) { + subtables.push(subtable); + } + } + (!subtables.is_empty()).then_some(Self { subtables }) + } + + /// Whether any Unicode subtable maps `c` to a glyph other than `.notdef`. + pub(crate) fn covers(&self, c: char) -> bool { + self.subtables.iter().any(|t| glyph(t, u32::from(c)).is_some_and(|g| g != 0)) + } +} + +fn glyph(subtable: &[u8], c: u32) -> Option { + match u16_at(subtable, 0)? { + 4 => format4(subtable, u16::try_from(c).ok()?), + 12 => format12(subtable, c), + _ => None, + } +} + +fn format4(t: &[u8], c: u16) -> Option { + let seg_x2 = usize::from(u16_at(t, 6)?); + let (ends, starts, deltas, range_offsets) = (14, 16 + seg_x2, 16 + 2 * seg_x2, 16 + 3 * seg_x2); + // End codes are sorted: the first segment ending at or after `c` is the only candidate. + let segments = seg_x2 / 2; + let i = partition_point(segments, |i| u16_at(t, ends + 2 * i).is_some_and(|end| end < c)); + if i == segments { + return None; + } + let start = u16_at(t, starts + 2 * i)?; + if c < start { + return None; + } + let delta = u16_at(t, deltas + 2 * i)?; + let range_offset_at = range_offsets + 2 * i; + let range_offset = usize::from(u16_at(t, range_offset_at)?); + if range_offset == 0 { + return Some(u32::from(c.wrapping_add(delta))); + } + let g = u16_at(t, range_offset_at + range_offset + 2 * usize::from(c - start))?; + Some(if g == 0 { 0 } else { u32::from(g.wrapping_add(delta)) }) +} + +fn format12(t: &[u8], c: u32) -> Option { + let groups = usize::try_from(u32_at(t, 12)?).ok()?; + // Groups are sorted by start code and don't overlap. + let i = partition_point(groups, |i| u32_at(t, 16 + 12 * i + 4).is_some_and(|end| end < c)); + if i == groups { + return None; + } + let g = 16 + 12 * i; + let (start, end, glyph) = (u32_at(t, g)?, u32_at(t, g + 4)?, u32_at(t, g + 8)?); + (start..=end).contains(&c).then(|| glyph.wrapping_add(c - start)) +} + +/// The first index in `0..len` for which `before` is false, given that it is true for a prefix. +fn partition_point(len: usize, before: impl Fn(usize) -> bool) -> usize { + let (mut lo, mut hi) = (0, len); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if before(mid) { lo = mid + 1 } else { hi = mid } + } + lo +} + +fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> { + let offset = usize::try_from(offset).ok()?; + data.get(offset..offset.checked_add(usize::try_from(len).ok()?)?) +} + +fn u16_at(data: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(data.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(data: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs new file mode 100644 index 000000000..69fc5657d --- /dev/null +++ b/crates/fonts-build/src/embed.rs @@ -0,0 +1,516 @@ +//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table. + +#[cfg(feature = "subset")] +use std::collections::BTreeSet; +use std::collections::HashSet; +use std::ffi::OsString; +use std::fmt::{self, Write as _}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::cmap::Cmap; +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"; + +/// 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 bool>; + +/// Which faces to embed, and from where. [`Embed::write`] does the work. +#[derive(Default)] +pub struct Embed { + scripts: Option>, + filter: Option, + dir: Option, + required: Option, + ui: bool, + catalogs: Option>, + texts: Vec, + families: Option>, + elsewhere: Vec, +} + +/// A selected face and the file the table embeds it from. +struct Face { + entry: ManifestEntry, + path: PathBuf, + index: u32, + /// An interface face to cut to the catalogs' characters before it is embedded. + #[cfg_attr(not(feature = "subset"), expect(dead_code))] + cut: bool, +} + +impl Embed { + /// Every face except the interface faces' sources (`role` `ui`), from `CRAFT_FONTS_DIR`, + /// required when `CRAFT_FONTS_REQUIRED=1`. Narrow it with [`Embed::scripts`] and + /// [`Embed::filter`]; a table of document fonts starts from [`Embed::documents`] instead. + pub fn new() -> Self { + Self::default() + } + + /// Every face of these families, whole, and nothing else: an app's document fonts. A family + /// is named as the manifest spells it (`"Noto Sans"`, `"Shippori Mincho"`) and contributes all + /// its styles, interface faces included (`"BIZ UDPGothic"` brings its Regular as well). + /// A name the manifest lacks is an [`Error::UnknownFamilies`], so the build fails with + /// `CRAFT_FONTS_REQUIRED=1` and warns and embeds nothing otherwise. Narrow it further with + /// [`Embed::scripts`] and [`Embed::filter`]. + pub fn documents(families: &[&str]) -> Self { + Self { families: Some(families.iter().map(|f| (*f).to_owned()).collect()), ..Self::default() } + } + + /// Embed only faces with at least one of these script tags (see + /// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED=1`, 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 + } + + /// Embed faces for an app's interface: CJK faces only as the interface faces (`role` `ui` in + /// the manifest), cut to the characters of the catalogs given to `Embed::subset_to`. Other + /// CJK faces are dropped and the rest are kept. Without catalogs the interface faces are left + /// out too, with a `cargo::warning`, so a full CJK face is never embedded for the interface. + /// A manifest with no interface face is also a `cargo::warning` when that drops a CJK face. + /// Combines with [`Embed::scripts`] and [`Embed::filter`]; with `CRAFT_FONTS_REQUIRED=1`, an + /// interface face satisfies its script tags. + pub fn for_ui(mut self) -> Self { + self.ui = true; + self + } + + /// Cut the interface faces to the characters these translation catalogs use, and embed the + /// cut faces, written to `OUT_DIR`. Implies [`Embed::for_ui`]. An empty list counts as no + /// catalogs: the interface faces are left out, with a `cargo::warning`. + /// + /// The cut keeps the catalogs' characters 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), with every layout feature. The same inputs + /// give the same bytes. A face with CFF outlines is an [`Error::NotTrueType`]. + /// + /// The catalogs are also checked as by [`Embed::check_text`]. Relative paths are under the + /// package directory; an unreadable catalog is a `cargo::warning`. + #[cfg(feature = "subset")] + pub fn subset_to>(mut self, catalogs: impl IntoIterator) -> Self { + self.ui = true; + self.catalogs.get_or_insert_default().extend(catalogs.into_iter().map(Into::into)); + self + } + + /// Check that the embedded faces cover these text files, such as translation catalogs: one + /// `cargo::warning` per file that has characters from U+2E80 up (CJK and later blocks, without + /// emoji and variation selectors) that no embedded face maps. Those characters fall back to + /// system fonts at run time. Relative paths are under the package directory. The check runs + /// only when a checkout was read. + pub fn check_text>(mut self, paths: impl IntoIterator) -> Self { + self.texts.extend(paths.into_iter().map(Into::into)); + self + } + + /// Families the app embeds in another table, such as its interface faces next to its document + /// fonts: [`Embed::check_text`] and the catalog check count their characters as covered. Each + /// is read from the checkout whole, so for a face cut elsewhere this is exact only for the + /// catalogs it was cut to. A name the manifest lacks is a `cargo::warning`. + pub fn embedded_elsewhere(mut self, families: &[&str]) -> Self { + self.elsewhere.extend(families.iter().map(|f| (*f).to_owned())); + self + } + + /// Read this checkout instead of `CRAFT_FONTS_DIR`. + pub fn dir(mut self, dir: impl Into) -> Self { + self.dir = Some(dir.into()); + self + } + + /// Override `CRAFT_FONTS_REQUIRED` (required only when it is exactly `1`). + 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) -> Result, 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) -> Result, Error> { + cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}")); + cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}")); + for text in self.checked_files() { + cargo.push(format!("cargo::rerun-if-changed={}", text.display())); + } + 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 == "1")); + 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 mut selected = selected; + if let Ok(faces) = &mut selected { + // A face that can't be cut is a broken manifest line: an error even when not required. + if let Err(e) = self.cut(faces, out.parent().unwrap_or(Path::new(""))) { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + if let Some(dir) = &dir { + self.check_coverage(faces, dir, cargo); + } + } + let tabled = selected.and_then(|faces| Ok((table(&faces)?, faces.into_iter().map(|face| face.entry).collect()))); + let (table, faces) = match tabled { + Ok(tabled) => tabled, + 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) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?; + // Cargo treats a missing watched file as changed, which would rerun the build every time. + cargo.push(format!("cargo::rerun-if-changed={}", manifest.display())); + let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?; + if let Some(families) = &self.families { + let unknown: Vec = families.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)).cloned().collect(); + if !unknown.is_empty() { + let mut known: Vec = Vec::new(); + for entry in &entries { + if !known.contains(&entry.family) { + known.push(entry.family.clone()); + } + } + return Err(Error::UnknownFamilies { manifest, families: unknown, known }); + } + } + let has_ui = entries.iter().any(ManifestEntry::is_ui); + let no_catalogs = self.catalogs.as_ref().is_none_or(Vec::is_empty); + let mut faces = Vec::new(); + let mut uncut = Vec::new(); + let mut dropped_cjk = false; + for entry in entries { + // Interface faces are the sources of the cuts: embedded whole only when named. + let named = match &self.families { + Some(families) => families.contains(&entry.family), + None => self.ui || !entry.is_ui(), + }; + if !named { + continue; + } + let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t))); + let ui_dropped = self.ui && entry.is_cjk() && !entry.is_ui(); + if !wanted || ui_dropped || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) { + dropped_cjk |= wanted && ui_dropped; + continue; + } + let cut = self.ui && entry.is_ui(); + // An empty list would cut the faces to kana and punctuation, so it counts as none. + if cut && no_catalogs { + uncut.push(format!("{} {}", entry.family, entry.style)); + 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())); + let index = entry.index; + faces.push(Face { entry, path, index, cut }); + } + if !uncut.is_empty() { + let why = if self.catalogs.is_some() { "Embed::subset_to was given no translation catalogs" } else { "no translation catalogs" }; + cargo.push(format!( + "cargo::warning=craft-fonts: not embedding the interface faces {}: {why}; pass the catalogs to \ + Embed::subset_to (feature `subset`) to embed them cut to the catalogs' characters", + uncut.join(", ") + )); + } + if dropped_cjk && !has_ui { + cargo.push(format!( + "cargo::warning=craft-fonts: not embedding any CJK face for the interface: {} has no interface face \ + (role `ui`); use a craft-fonts checkout that has them", + manifest.display() + )); + } + Ok(faces) + } + + /// Replace each interface face with its cut, written to `out_dir`. + #[cfg(feature = "subset")] + fn cut(&self, faces: &mut [Face], out_dir: &Path) -> Result<(), Error> { + let Some(catalogs) = &self.catalogs else { return Ok(()) }; + // `select` leaves the faces to cut out when there are no catalogs. + if !faces.iter().any(|face| face.cut) { + return Ok(()); + } + let mut chars = BTreeSet::new(); + // An unreadable catalog adds nothing here; `check_coverage` reports it. + for text in catalogs.iter().filter_map(|path| std::fs::read_to_string(path).ok()) { + chars.extend(text.chars().filter(|&c| needs_glyph(c))); + } + for (start, end) in UI_RANGES { + chars.extend(start..=end); + } + let dir = out_dir.join("craft-fonts-ui"); + std::fs::create_dir_all(&dir).map_err(|source| Error::Write { path: dir.clone(), source })?; + for face in faces.iter_mut().filter(|face| face.cut) { + let source = std::fs::read(&face.path).map_err(|source| Error::Read { path: face.path.clone(), source })?; + let bytes = crate::subset::subset(&source, face.index, &chars).map_err(|failure| match failure { + crate::subset::Failure::NotTrueType => Error::NotTrueType { path: face.path.clone() }, + crate::subset::Failure::Other(reason) => Error::Subset { path: face.path.clone(), reason }, + })?; + let name: String = + format!("{}-{}", face.entry.family, face.entry.style).chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); + let out = dir.join(format!("{name}.ttf")); + // Rewriting identical bytes would make Cargo rebuild the crate that embeds them. + if std::fs::read(&out).ok().as_deref() != Some(&bytes[..]) { + std::fs::write(&out, &bytes).map_err(|source| Error::Write { path: out.clone(), source })?; + } + face.path = out; + face.index = 0; + } + Ok(()) + } + + /// Without the subsetter there are no catalogs, so [`Embed::select`] left out every face to cut. + #[cfg(not(feature = "subset"))] + fn cut(&self, _faces: &mut [Face], _out_dir: &Path) -> Result<(), Error> { + Ok(()) + } + + /// The catalogs, then the other [`Embed::check_text`] files. + fn checked_files(&self) -> Vec<&PathBuf> { + let mut files: Vec<&PathBuf> = self.catalogs.iter().flatten().collect(); + for text in &self.texts { + if !files.contains(&text) { + files.push(text); + } + } + files + } + + /// The manifest entries of the [`Embed::embedded_elsewhere`] families, with a + /// `cargo::warning` for a family the manifest lacks. + fn elsewhere(&self, dir: &Path, cargo: &mut Vec) -> Vec { + if self.elsewhere.is_empty() { + return Vec::new(); + } + let manifest = dir.join("fonts/manifest.txt"); + // `select` read and parsed this manifest already. + let entries = std::fs::read_to_string(&manifest).ok().and_then(|text| parse_manifest(&text).ok()).unwrap_or_default(); + for family in self.elsewhere.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)) { + cargo.push(format!("cargo::warning=craft-fonts: embedded_elsewhere names {family}, which {} lacks", manifest.display())); + } + entries.into_iter().filter(|e| self.elsewhere.contains(&e.family)).collect() + } + + /// Warn about each catalog's and [`Embed::check_text`] file's characters that neither an + /// embedded face nor an [`Embed::embedded_elsewhere`] face covers. + fn check_coverage(&self, faces: &[Face], dir: &Path, cargo: &mut Vec) { + let files = self.checked_files(); + if files.is_empty() { + return; + } + let elsewhere = self.elsewhere(dir, cargo); + let fonts: Vec<(&ManifestEntry, u32, Option>)> = faces + .iter() + .map(|face| (&face.entry, face.index, std::fs::read(&face.path).ok())) + .chain(elsewhere.iter().map(|entry| (entry, entry.index, std::fs::read(dir.join(&entry.file)).ok()))) + .collect(); + let mut cmaps = Vec::new(); + for (entry, index, bytes) in &fonts { + match bytes.as_deref().and_then(|bytes| Cmap::read(bytes, *index)) { + Some(cmap) => cmaps.push(cmap), + None => cargo + .push(format!("cargo::warning=craft-fonts: cannot read the character map of {} {} ({})", entry.family, entry.style, entry.file)), + } + } + for path in files { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) => { + cargo.push(format!("cargo::warning=craft-fonts: cannot check {}: {e}", path.display())); + continue; + } + }; + let mut seen = HashSet::new(); + let missing: Vec = text.chars().filter(|&c| needs_glyph(c) && seen.insert(c) && !cmaps.iter().any(|cmap| cmap.covers(c))).collect(); + if missing.is_empty() { + continue; + } + const SHOWN: usize = 8; + let shown: String = missing.iter().take(SHOWN).collect(); + let more = if missing.len() > SHOWN { " …" } else { "" }; + cargo.push(format!( + "cargo::warning=craft-fonts: {} has {} character{} no embedded face covers ({shown}{more}); system fonts will draw them.", + path.display(), + missing.len(), + if missing.len() == 1 { "" } else { "s" }, + )); + } + } + + fn check_required(&self, dir: &Path, faces: Vec) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + if faces.is_empty() { + return Err(Error::NoFaces { manifest }); + } + let missing: Vec = self + .scripts + .iter() + .flatten() + .filter(|tag| !faces.iter().any(|face| face.entry.scripts.iter().any(|s| script_matches(s, tag)))) + .cloned() + .collect(); + if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) } + } +} + +/// Whether the interface faces need `c`: CJK and later blocks, except emoji and other pictographs +/// (the emoji faces draw them), variation selectors, tags and a byte order mark. +fn needs_glyph(c: char) -> bool { + c >= '\u{2E80}' && !matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{1F000}'..='\u{1FFFF}' | '\u{E0000}'..) +} + +/// Kept in every cut, whatever the catalogs use: CJK punctuation, kana and full-width forms. +#[cfg(feature = "subset")] +const UI_RANGES: [(char, char); 4] = [('\u{3000}', '\u{303F}'), ('\u{3040}', '\u{30FF}'), ('\u{31F0}', '\u{31FF}'), ('\u{FF00}', '\u{FFEF}')]; + +/// The environment a build script sees, separated out for tests. +pub(crate) struct Env { + pub dir: Option, + pub required: Option, + pub out_dir: Option, +} + +/// The generated source; `include_bytes!` takes a string, so a face under a path that isn't UTF-8 +/// is an error rather than a lossy path the consumer's build can't find. +fn table(faces: &[Face]) -> Result { + let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n"); + for Face { entry, path, index, .. } in faces { + let file = path.to_str().ok_or_else(|| Error::NonUtf8Path { path: path.clone() })?; + let scripts: Vec = 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(", "), + index, + file, + ); + } + src.push_str("]\n"); + Ok(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=1` 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 }, + /// 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, + /// An interface face has CFF outlines, which can't be cut correctly; it must be TrueType. + NotTrueType { path: PathBuf }, + /// An interface face can't be cut. + Subset { path: PathBuf, reason: String }, + /// [`Embed::documents`] names families the manifest lacks; `known` lists the manifest's. + UnknownFamilies { manifest: PathBuf, families: Vec, known: Vec }, + /// A face to embed has a path that isn't UTF-8, which `include_bytes!` can't name. + NonUtf8Path { path: PathBuf }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSet => { + write!(f, "{REQUIRED_VAR}=1 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}=1 but no face in {} was selected", manifest.display()), + Self::MissingScripts { manifest, scripts } => { + write!(f, "{REQUIRED_VAR}=1 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"), + Self::NotTrueType { path } => write!( + f, + "{} has CFF outlines, which craft-fonts-build can't subset correctly: an interface face (role ui) must be a TrueType (glyf) font", + path.display() + ), + Self::Subset { path, reason } => write!(f, "cannot subset {}: {reason}", path.display()), + Self::UnknownFamilies { manifest, families, known } => write!( + f, + "{} has no family {}; Embed::documents names families as the manifest spells them: {}", + manifest.display(), + families.join(", "), + known.join(", ") + ), + Self::NonUtf8Path { path } => write!(f, "cannot embed {}: include_bytes! needs a UTF-8 path", path.display()), + } + } +} + +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, + } + } +} diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs new file mode 100644 index 000000000..36ef4ab8e --- /dev/null +++ b/crates/fonts-build/src/lib.rs @@ -0,0 +1,125 @@ +#![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; any other value is ignored): 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`]). The build-dependency enables the `subset` feature for `Embed::subset_to`, +//! which cuts the CJK interface faces to the translation catalogs' characters; the dependency +//! leaves it off, so the subsetter is not part of the app. In `build.rs`'s `main`: +//! +//! ```ignore +//! println!("cargo::rerun-if-changed=build.rs"); +//! let wasm = craft_fonts_build::target_is_wasm(); +//! let catalogs: Vec = /* the app's translation catalogs */; +//! let embedded = craft_fonts_build::Embed::new() +//! // CJK faces only as the interface faces, cut to the characters the catalogs use. +//! .subset_to(catalogs) +//! // 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}"); +//! } +//! ``` +//! +//! 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. +//! +//! An app's document fonts go in a table of their own that names its families, +//! `Embed::documents(&["Noto Sans", "Shippori Mincho"])`: nothing is embedded for documents +//! unless the app names it. + +mod cmap; +mod embed; +mod manifest; +#[cfg(feature = "subset")] +mod subset; +#[cfg(test)] +mod tests; + +pub use embed::{Embed, Error, target_is_wasm}; +pub use manifest::{CJK_SCRIPTS, ManifestEntry, ParseError, UI_ROLE, 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 { + /// `"