diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index fdeeda92..e9299f37 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -3,9 +3,9 @@ name: Shared UI on: push: branches: [main] - paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] + paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] pull_request: - paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] + paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] workflow_dispatch: permissions: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a6493818..85fd3078 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -14,6 +14,11 @@ author, source and license. Library code and original assets are under the repos | `crates/text/assets/hyphen/en-us.pat` | DesignCraft contributors, trained from the Moby Hyphenator II list | `crates/text/examples/hyphgen.rs` | CC0-1.0 | | Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, feature `slovak-gpl` only) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT | | Word-break dictionaries and LSTM models embedded through `icu_segmenter_data` (optional `craft-text` features `thai`, `lao`, `khmer`, `burmese`) | Unicode, Inc. and ICU4X contributors | [ICU4X](https://github.com/unicode-org/icu4x) | Unicode-3.0 | +| `crates/ui/tests/fonts/NotoSansArabic-test.ttf` (subset to the test strings, default weight set to 100, width axis dropped) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) | +| `crates/ui/tests/fonts/NotoSansCJKsc-test.otf` (subset to the test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) | +| `crates/ui/tests/fonts/NotoSansArabic-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) | +| `crates/ui/tests/fonts/NotoSansCJKsc-screens.otf` (subset to printable ASCII and the screen test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) | +| `crates/ui/tests/fonts/NotoSansThai-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@8b0a1d0 `ofl/notosansthai`](https://github.com/google/fonts/tree/8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5/ofl/notosansthai), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansThai.txt`) | Original assets are covered by the repository licence. diff --git a/Cargo.lock b/Cargo.lock index 29056e69..868b4335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arrayref" version = "0.3.9" @@ -627,6 +633,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -900,7 +916,7 @@ version = "0.1.0" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", - "roxmltree", + "roxmltree 0.20.0", "tempfile", "winprint", "winsafe", @@ -946,17 +962,28 @@ dependencies = [ name = "craft-ui" version = "0.1.0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "eframe", "egui", "egui_extras", "egui_kittest", + "epaint", + "fontique", "image", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", "serde_json", + "skrifa 0.44.0", + "unicode-script", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", ] [[package]] @@ -1114,6 +1141,19 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -1344,14 +1384,17 @@ name = "egui_kittest" version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ + "dify", "eframe", "egui", "egui-wgpu", "image", "kittest", "log", + "open", "pollster", "serde", + "tempfile", "toml", "wgpu", ] @@ -1659,6 +1702,28 @@ dependencies = [ "serde", ] +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.41.0", + "roxmltree 0.21.2", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1783,6 +1848,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1871,6 +1945,7 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", + "png 0.18.1", "skrifa 0.44.0", "smallvec", "vello_common 0.2.0", @@ -2572,6 +2647,25 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -2760,6 +2854,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -2867,6 +2967,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3227,6 +3336,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3411,6 +3530,16 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3481,6 +3610,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + [[package]] name = "pdf-writer" version = "0.15.0" @@ -4203,6 +4338,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5586,7 +5730,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -5669,6 +5813,7 @@ dependencies = [ "guillotiere", "log", "peniko", + "png 0.18.1", "smallvec", "thiserror 2.0.21", ] @@ -5695,6 +5840,7 @@ dependencies = [ "bytemuck", "glifo 0.3.0", "hashbrown 0.17.1", + "png 0.18.1", "vello_common 0.2.0", ] @@ -6583,6 +6729,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/fonts-build/README.md b/crates/fonts-build/README.md index 62c71ced..3c54319b 100644 --- a/crates/fonts-build/README.md +++ b/crates/fonts-build/README.md @@ -121,6 +121,40 @@ manifest lacks is an `Error::UnknownFamilies` listing the manifest's families: a 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 right size 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 diff --git a/crates/fonts-build/src/cmap.rs b/crates/fonts-build/src/cmap.rs index b4dd4a7f..b817b7ef 100644 --- a/crates/fonts-build/src/cmap.rs +++ b/crates/fonts-build/src/cmap.rs @@ -49,6 +49,53 @@ impl<'a> Cmap<'a> { pub(crate) fn covers(&self, c: char) -> bool { self.subtables.iter().any(|t| glyph(t, u32::from(c)).is_some_and(|g| g != 0)) } + + /// Every character [`Self::covers`], as sorted, disjoint, non-adjacent inclusive ranges. + pub(crate) fn ranges(&self) -> Vec<(u32, u32)> { + let mut spans: Vec<(u32, u32)> = self.subtables.iter().flat_map(|t| spans(t)).collect(); + spans.sort_unstable(); + let mut ranges: Vec<(u32, u32)> = Vec::new(); + let mut next = 0; + for (start, end) in spans { + for c in start.max(next)..=end { + if !char::from_u32(c).is_some_and(|c| self.covers(c)) { + continue; + } + match ranges.last_mut() { + Some(last) if last.1 + 1 == c => last.1 = c, + _ => ranges.push((c, c)), + } + } + next = next.max(end.saturating_add(1)); + } + ranges + } +} + +/// The code point spans a subtable lists, mapped or not. +fn spans(t: &[u8]) -> Vec<(u32, u32)> { + let mut spans = Vec::new(); + match u16_at(t, 0) { + Some(4) => { + let seg_x2 = u16_at(t, 6).map_or(0, usize::from); + for i in 0..seg_x2 / 2 { + let (Some(end), Some(start)) = (u16_at(t, 14 + 2 * i), u16_at(t, 16 + seg_x2 + 2 * i)) else { break }; + if start <= end { + spans.push((u32::from(start), u32::from(end))); + } + } + } + Some(12) => { + for i in 0..u32_at(t, 12).and_then(|n| usize::try_from(n).ok()).unwrap_or(0) { + let (Some(start), Some(end)) = (u32_at(t, 16 + 12 * i), u32_at(t, 20 + 12 * i)) else { break }; + if start <= end { + spans.push((start, end.min(u32::from(char::MAX)))); + } + } + } + _ => {} + } + spans } fn glyph(subtable: &[u8], c: u32) -> Option { diff --git a/crates/fonts-build/src/coverage.rs b/crates/fonts-build/src/coverage.rs new file mode 100644 index 00000000..4cc25a02 --- /dev/null +++ b/crates/fonts-build/src/coverage.rs @@ -0,0 +1,69 @@ +//! The characters a face maps, as [`ServedFace::coverage`](crate::ServedFace::coverage) stores +//! them: its sorted, disjoint code point ranges, each as two unsigned LEB128 numbers, the gap from +//! the end of the previous range (from 0 for the first) and the range's length minus one. A +//! Chinese interface face cut to an app's catalogs takes about 3 KB. + +use crate::cmap::Cmap; + +/// The coverage of face `index` of `font` (a `.ttf`/`.otf`, or a `.ttc` collection); `None` when +/// the file isn't a font or has no Unicode `cmap` subtable in format 4 or 12. +pub fn of_face(font: &[u8], index: u32) -> Option> { + Some(encode(&Cmap::read(font, index)?.ranges())) +} + +/// The coverage of inclusive code point ranges, sorted by start. Overlaps are dropped. +pub fn encode(ranges: &[(u32, u32)]) -> Vec { + let mut out = Vec::new(); + let mut next = 0; + for &(start, end) in ranges { + let start = start.max(next); + if end < start { + continue; + } + leb128(&mut out, start - next); + leb128(&mut out, end - start); + let Some(after) = end.checked_add(1) else { break }; + next = after; + } + out +} + +/// Whether `coverage` holds `c`. +pub fn contains(coverage: &[u8], c: char) -> bool { + let c = u32::from(c); + let mut bytes = coverage.iter().copied(); + let mut next = 0u32; + while let (Some(gap), Some(len)) = (read(&mut bytes), read(&mut bytes)) { + let Some(start) = next.checked_add(gap) else { return false }; + let Some(end) = start.checked_add(len) else { return false }; + if c < start { + return false; + } + if c <= end { + return true; + } + let Some(after) = end.checked_add(1) else { return false }; + next = after; + } + false +} + +fn leb128(out: &mut Vec, mut n: u32) { + while n >= 0x80 { + out.push((n & 0x7F) as u8 | 0x80); + n >>= 7; + } + out.push(n as u8); +} + +fn read(bytes: &mut impl Iterator) -> Option { + let mut n = 0u32; + for shift in (0..35).step_by(7) { + let byte = bytes.next()?; + n |= u32::from(byte & 0x7F).checked_shl(shift)?; + if byte & 0x80 == 0 { + return Some(n); + } + } + None +} diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs index 69fc5657..2f9372fd 100644 --- a/crates/fonts-build/src/embed.rs +++ b/crates/fonts-build/src/embed.rs @@ -34,6 +34,13 @@ pub struct Embed { texts: Vec, families: Option>, elsewhere: Vec, + serve: Option, +} + +/// Where [`Embed::serve`] copies the faces, and the URL prefix they are served under. +struct Serve { + dir: PathBuf, + url_prefix: String, } /// A selected face and the file the table embeds it from. @@ -42,8 +49,9 @@ struct Face { 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, + /// Hex SHA-256 of the file: the manifest's, or the cut's. + sha256: String, } impl Embed { @@ -82,8 +90,8 @@ impl Embed { /// 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. + /// Combines with [`Embed::scripts`], [`Embed::filter`] and [`Embed::serve`]; with + /// `CRAFT_FONTS_REQUIRED=1`, an interface face satisfies its script tags. pub fn for_ui(mut self) -> Self { self.ui = true; self @@ -138,6 +146,22 @@ impl Embed { self } + /// Copy the selected faces into `dir` instead of embedding them, for a web build that serves + /// them beside its wasm, and write a table of [`ServedFace`](crate::ServedFace)s. + /// + /// Each font file and its licence file keep their path under the checkout's `fonts/` + /// directory (`noto-sans-arabic/NotoSansArabic.ttf`), and a face's `url` is `url_prefix` + /// followed by that path, so `serve("web/fonts", "fonts/")` suits a page that serves + /// `web/fonts` as `fonts/`. An interface face cut by `Embed::subset_to` is served from its cut, + /// with the start of the cut's SHA-256 in the file name (`ui/NotoSansTCUI-Regular..ttf`) + /// and the cut's own integrity value. A relative `dir` is under the package directory, where + /// Cargo runs build scripts. Files already there with the right size are left as they are; + /// files of faces no longer selected, and earlier cuts, are not removed. + pub fn serve(mut self, dir: impl Into, url_prefix: &str) -> Self { + self.serve = Some(Serve { dir: dir.into(), url_prefix: url_prefix.to_owned() }); + self + } + /// Write the face table to `out_file` and print the `cargo::` lines; returns the faces /// embedded. /// @@ -183,9 +207,18 @@ impl Embed { 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, + let selected = match (selected, &self.serve, &dir) { + (Ok(faces), Some(serve), Some(dir)) => match serve.copy(dir, &faces, cargo) { + Ok(table) => Ok((table, faces)), + Err(e) => { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + }, + (selected, ..) => selected.and_then(|faces| Ok((table(&faces)?, faces))), + }; + let (table, faces) = match selected { + Ok((table, faces)) => (table, faces.into_iter().map(|face| face.entry).collect()), Err(e) if required => { write(&out, EMPTY_TABLE)?; return Err(e); @@ -249,8 +282,8 @@ impl Embed { // `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 }); + let (index, sha256) = (entry.index, entry.sha256.clone()); + faces.push(Face { entry, path, index, cut, sha256 }); } if !uncut.is_empty() { let why = if self.catalogs.is_some() { "Embed::subset_to was given no translation catalogs" } else { "no translation catalogs" }; @@ -303,6 +336,7 @@ impl Embed { } face.path = out; face.index = 0; + face.sha256 = crate::sha256::hex(&bytes); } Ok(()) } @@ -439,6 +473,124 @@ fn table(faces: &[Face]) -> Result { Ok(src) } +impl Serve { + /// Copy the faces' files and licences into the served directory; returns the table. + fn copy(&self, checkout: &Path, faces: &[Face], cargo: &mut Vec) -> Result { + let mut copied: Vec = Vec::new(); + let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n"); + for face in faces { + let (entry, path) = (&face.entry, &face.path); + let served = served_file(face); + let font = std::fs::read(path).map_err(|source| Error::Read { path: path.clone(), source })?; + let size = font.len(); + if !copied.contains(&served) { + copied.push(served.clone()); + copy_file(path, &self.dir.join(&served), cargo)?; + let licence = checkout.join(&entry.licence_file); + if entry.licence_file.is_empty() || !licence.is_file() { + cargo.push(format!("cargo::warning=craft-fonts: no licence file beside {}", entry.file)); + } else { + copy_file(&licence, &self.dir.join(served_path(&entry.licence_file)), cargo)?; + } + } + let coverage = crate::coverage::of_face(&font, face.index).unwrap_or_else(|| { + cargo.push(format!("cargo::warning=craft-fonts: no character map in {}; the face is never fetched for a character", entry.file)); + Vec::new() + }); + let scripts: Vec = entry.scripts.iter().map(|s| format!("{s:?}")).collect(); + let _ = writeln!( + src, + " ServedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, url: {:?}, size: {size}, integrity: {:?}, coverage: {} }},", + entry.family, + entry.style, + scripts.join(", "), + face.index, + format!("{}{served}", self.url_prefix), + integrity(&face.sha256), + byte_string(&coverage), + ); + } + src.push_str("]\n"); + Ok(src) + } +} + +/// A byte string literal, printable ASCII as itself. +fn byte_string(bytes: &[u8]) -> String { + let mut out = String::from("b\""); + for &b in bytes { + match b { + b'"' | b'\\' => { + out.push('\\'); + out.push(char::from(b)); + } + 0x20..=0x7E => out.push(char::from(b)), + _ => { + let _ = write!(out, "\\x{b:02x}"); + } + } + } + out.push('"'); + out +} + +/// Where a face's file is served, under the served directory: its checkout path under `fonts/`. +/// A cut adds the start of its SHA-256 before the extension (`ui/A.1234abcd5678ef90.ttf`), so a +/// cut from changed catalogs gets a new URL instead of meeting a cached copy that fails its +/// integrity check. +fn served_file(face: &Face) -> String { + let file = served_path(&face.entry.file); + if !face.cut { + return file.to_owned(); + } + let hash = face.sha256.get(..16).unwrap_or(&face.sha256); + match file.rsplit_once('.') { + Some((stem, extension)) if !extension.contains('/') => format!("{stem}.{hash}.{extension}"), + _ => format!("{file}.{hash}"), + } +} + +/// A checkout path relative to the served directory: `fonts/a/A.ttf` → `a/A.ttf`. +fn served_path(file: &str) -> &str { + file.strip_prefix("fonts/").unwrap_or(file) +} + +/// Copy `from` unless `to` already has its size. The copy keeps `from`'s modification time and is +/// watched, so the build copies it again once it is deleted, without running every build. +fn copy_file(from: &Path, to: &Path, cargo: &mut Vec) -> Result<(), Error> { + cargo.push(format!("cargo::rerun-if-changed={}", to.display())); + let write_error = |source| Error::Write { path: to.to_path_buf(), source }; + let size = |path: &Path| std::fs::metadata(path).ok().map(|m| m.len()); + if size(to).is_some() && size(to) == size(from) { + return Ok(()); + } + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent).map_err(write_error)?; + } + std::fs::copy(from, to).map_err(write_error)?; + // Cargo reruns a build script whose watched files are newer than its last run. + let modified = std::fs::metadata(from).and_then(|m| m.modified()).map_err(|source| Error::Read { path: from.to_path_buf(), source })?; + std::fs::File::options().write(true).open(to).and_then(|f| f.set_modified(modified)).map_err(write_error) +} + +/// The Subresource Integrity value (`sha256-`) of a hex SHA-256, or `""` for a value that +/// isn't one. +pub(crate) fn integrity(sha256: &str) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let digest: Option> = + (sha256.len() == 64).then(|| (0..32).map(|i| u8::from_str_radix(sha256.get(2 * i..2 * i + 2)?, 16).ok()).collect()).flatten(); + let Some(digest) = digest else { return String::new() }; + let mut out = String::from("sha256-"); + for chunk in digest.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i))); + for i in 0..4 { + let c = if i <= chunk.len() { ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] } else { b'=' }; + out.push(char::from(c)); + } + } + out +} + fn write(path: &Path, contents: &str) -> Result<(), Error> { std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source }) } diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs index 36ef4ab8..fffd8f09 100644 --- a/crates/fonts-build/src/lib.rs +++ b/crates/fonts-build/src/lib.rs @@ -46,11 +46,18 @@ //! 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. +//! +//! Web builds can serve the faces beside the wasm instead: [`Embed::serve`] copies the selected +//! files into a directory and writes a table of [`ServedFace`]s, which carry a URL in place of the +//! bytes, for the app to fetch the faces it needs at run time. mod cmap; +pub mod coverage; mod embed; mod manifest; #[cfg(feature = "subset")] +mod sha256; +#[cfg(feature = "subset")] mod subset; #[cfg(test)] mod tests; @@ -59,6 +66,8 @@ 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`. +/// +/// [`Embed::serve`] writes [`ServedFace`]s instead. #[derive(Clone, Copy)] pub struct EmbeddedFace { /// Family name, e.g. `"Noto Sans Arabic"`. @@ -112,6 +121,53 @@ impl EmbeddedFace { } } +/// A craft-fonts face that [`Embed::serve`] copied beside the app, to fetch at run time: one line +/// of `fonts/manifest.txt`. +#[derive(Clone, Copy, Debug)] +pub struct ServedFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// Style, e.g. `"Regular"` or `"Bold"`. + pub style: &'static str, + /// Script tags, as in [`EmbeddedFace::scripts`]. + pub scripts: &'static [&'static str], + /// Face index in a font collection (`.ttc`); `0` for a single font. Faces of one collection + /// share a URL. + pub index: u32, + /// Where the file is served, relative to the page unless the prefix given to + /// [`Embed::serve`] makes it absolute. + pub url: &'static str, + /// File size in bytes. + pub size: u64, + /// Subresource Integrity value (`sha256-…`) from the manifest's hash; empty when the manifest + /// has none. + pub integrity: &'static str, + /// The characters the face maps, read from its `cmap` (see [`coverage`]). + pub coverage: &'static [u8], +} + +impl ServedFace { + /// `"