diff --git a/crates/fonts-build/README.md b/crates/fonts-build/README.md index fadc0897..e1e3654c 100644 --- a/crates/fonts-build/README.md +++ b/crates/fonts-build/README.md @@ -35,8 +35,9 @@ fn main() { println!("cargo::rerun-if-changed=build.rs"); let wasm = craft_fonts_build::target_is_wasm(); let embedded = craft_fonts_build::Embed::new() + .for_ui() // Web builds have a size budget: embed only the Japanese UI face there. - .filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular")) + .filter(move |face| !wasm || face.has_script("Jpan")) .write("craft_fonts.rs"); if let Err(e) = embedded { println!("cargo::error={e}"); @@ -59,15 +60,47 @@ pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/cr 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 their UI subsets (see below). +- `check_text(paths)` warns about characters in these files that no selected face covers. - `dir(path)` and `required(bool)` override the two environment variables. `write` returns the embedded `ManifestEntry`s, for build-time checks such as "the UI face is present". The generated file is a slice expression of `EmbeddedFace { family, style, scripts, index, bytes }` literals; any struct with those fields and that name in scope works too. +## UI subsets + +The full CJK faces take several megabytes each. For the interface, craft-fonts also ships +Regular-only UI subsets cut to the characters the translation catalogs use: `Noto Sans SC UI` +(`Hans`), `Noto Sans TC UI` (`Hant`), `Noto Sans HK UI` (`Hant-HK`), `Noto Sans KR UI` (`Kore`) and +`BIZ UDPGothic UI` (`Jpan`), about 4 MB together. Their manifest lines have the role `ui`. + +`for_ui()` drops every face with a CJK script tag (`Hans`, `Hant`, `Hant-HK`, `Jpan`, `Kore`, +`Hani`) that is not a UI subset, and keeps the rest. Apps embed their interface faces this way; +document text (FigCraft, DeckCraft) uses the full faces. With `CRAFT_FONTS_REQUIRED=1`, a UI subset +satisfies `scripts(&["Hant"])`. + +A character the subsets lack still shows: craft-ui's system font provider draws it with an +installed font. `check_text` makes the build say so. It reads the character maps of the selected +faces and prints one `cargo::warning` per file that has characters from U+2E80 up that none of +them covers, with the count and the first few. It also reruns the build script when a file +changes. The subsets are regenerated in craft-fonts with `tools/ui-chars` and `tools/ui-subset`. + +```rust +// The catalogs `CRAFT_I18N_DIR` holds for this app, when it is set. +let locales = std::env::var_os("CRAFT_I18N_DIR").map(|dir| std::path::Path::new(&dir).join("locales")); +let catalogs: Vec<_> = locales + .iter() + .flat_map(|dir| std::fs::read_dir(dir).into_iter().flatten().flatten()) + .flat_map(|locale| ["common.tsv", "filmcraft.tsv"].map(|file| locale.path().join(file))) + .filter(|path| path.is_file()) + .collect(); +let embedded = craft_fonts_build::Embed::new().for_ui().check_text(catalogs).write("craft_fonts.rs"); +``` + ## The manifest -One face per line, eight fields separated by `" | "`; craft-fonts' `docs/integration.md` defines -them. Lines starting with `#` are comments. A file field ending in `#` names face `n` of a font -collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. Fields after the -eighth are ignored. +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 UI subsets 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 00000000..1bd919dd --- /dev/null +++ b/crates/fonts-build/src/cmap.rs @@ -0,0 +1,110 @@ +//! 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 subtable = cmap.get(start..)?; + if matches!(u16_at(subtable, 0), Some(4 | 12)) { + 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 index 1d8cf917..f8fdcd67 100644 --- a/crates/fonts-build/src/embed.rs +++ b/crates/fonts-build/src/embed.rs @@ -1,10 +1,12 @@ //! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table. +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"; @@ -31,6 +33,8 @@ pub struct Embed { filter: Option, dir: Option, required: Option, + ui: bool, + texts: Vec, } impl Embed { @@ -52,6 +56,24 @@ impl Embed { self } + /// Embed faces for an app's interface: CJK faces only as their UI subsets (`role` `ui` in the + /// manifest), which hold the characters the translation catalogs use. Other faces are kept. + /// Combines with [`Embed::scripts`] and [`Embed::filter`]; with `CRAFT_FONTS_REQUIRED=1`, a + /// UI subset satisfies its script tags. + pub fn for_ui(mut self) -> Self { + self.ui = true; + self + } + + /// Check that the selected 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) that no + /// selected 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 + } + /// Read this checkout instead of `CRAFT_FONTS_DIR`. pub fn dir(mut self, dir: impl Into) -> Self { self.dir = Some(dir.into()); @@ -83,6 +105,9 @@ impl Embed { 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.texts { + 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), @@ -95,6 +120,9 @@ impl Embed { None => Err(Error::NotSet), Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }), }; + if let Ok(faces) = &selected { + self.check_coverage(faces, cargo); + } let (table, faces) = match selected { Ok(faces) => (table(&faces), faces.into_iter().map(|(entry, _)| entry).collect()), Err(e) if required => { @@ -120,7 +148,8 @@ impl Embed { let mut faces = Vec::new(); for entry in entries { let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t))); - if !wanted || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) { + 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)) { continue; } let file = dir.join(&entry.file); @@ -132,6 +161,46 @@ impl Embed { Ok(faces) } + /// Warn about each [`Embed::check_text`] file's characters that no selected face covers. + fn check_coverage(&self, faces: &[(ManifestEntry, PathBuf)], cargo: &mut Vec) { + if self.texts.is_empty() { + return; + } + let fonts: Vec<(&ManifestEntry, Option>)> = faces.iter().map(|(entry, path)| (entry, std::fs::read(path).ok())).collect(); + let mut cmaps = Vec::new(); + for (entry, bytes) in &fonts { + match bytes.as_deref().and_then(|bytes| Cmap::read(bytes, entry.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 &self.texts { + 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 selected face covers ({shown}{more}); system fonts will draw them. \ + To embed them, regenerate the craft-fonts UI subsets with tools/ui-chars and tools/ui-subset.", + path.display(), + missing.len(), + if missing.len() == 1 { "" } else { "s" }, + )); + } + } + fn check_required(&self, dir: &Path, faces: Vec<(ManifestEntry, PathBuf)>) -> Result, Error> { let manifest = dir.join("fonts/manifest.txt"); if faces.is_empty() { @@ -148,6 +217,12 @@ impl Embed { } } +/// Whether [`Embed::check_text`] checks `c`: CJK and later blocks, except a byte order mark and +/// variation selectors, which fonts needn't map. +fn needs_glyph(c: char) -> bool { + c >= '\u{2E80}' && !matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{E0100}'..='\u{E01EF}') +} + /// The environment a build script sees, separated out for tests. pub(crate) struct Env { pub dir: Option, diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs index fce73f4c..a8c8d485 100644 --- a/crates/fonts-build/src/lib.rs +++ b/crates/fonts-build/src/lib.rs @@ -19,8 +19,10 @@ //! println!("cargo::rerun-if-changed=build.rs"); //! let wasm = craft_fonts_build::target_is_wasm(); //! let embedded = craft_fonts_build::Embed::new() +//! // CJK faces as their UI subsets, cut to the characters the translation catalogs use. +//! .for_ui() //! // Web builds have a size budget: embed only the Japanese UI face there. -//! .filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular")) +//! .filter(move |face| !wasm || face.has_script("Jpan")) //! .write("craft_fonts.rs"); //! if let Err(e) = embedded { //! println!("cargo::error={e}"); @@ -38,13 +40,14 @@ //! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with //! the same fields and that name in scope works too. +mod cmap; mod embed; mod manifest; #[cfg(test)] mod tests; pub use embed::{Embed, Error, embed, target_is_wasm}; -pub use manifest::{ManifestEntry, ParseError, parse_manifest}; +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)] diff --git a/crates/fonts-build/src/manifest.rs b/crates/fonts-build/src/manifest.rs index 9a76c04f..43a23e92 100644 --- a/crates/fonts-build/src/manifest.rs +++ b/crates/fonts-build/src/manifest.rs @@ -1,12 +1,14 @@ -//! craft-fonts' `fonts/manifest.txt`: one font face per line, eight fields separated by `" | "`: +//! craft-fonts' `fonts/manifest.txt`: one font face per line, eight or nine fields separated by +//! `" | "`: //! //! ```text -//! family | style | file | scripts | licence | licence file | sha256 | source +//! family | style | file | scripts | licence | licence file | sha256 | source [| role] //! ``` //! //! Blank lines and lines starting with `#` are skipped. `file` is relative to the checkout and may -//! end in `#` to pick one face of a font collection (`.ttc`). Fields after the eighth are -//! ignored, so a manifest that gains a field still embeds. +//! end in `#` to pick one face of a font collection (`.ttc`). The optional `role` is `ui` for +//! a UI subset: a face cut to the characters the apps' translation catalogs use. Fields after the +//! ninth are ignored, so a manifest that gains a field still embeds. use std::fmt; @@ -31,6 +33,8 @@ pub struct ManifestEntry { pub sha256: String, /// Where the file came from. pub source: String, + /// The optional ninth field: `Some("ui")` for a UI subset, `None` when the line has no role. + pub role: Option, } impl ManifestEntry { @@ -39,8 +43,25 @@ impl ManifestEntry { pub fn has_script(&self, tag: &str) -> bool { self.scripts.iter().any(|s| script_matches(s, tag)) } + + /// Whether the face is a UI subset (`role` is `ui`). + pub fn is_ui(&self) -> bool { + self.role.as_deref() == Some(UI_ROLE) + } + + /// Whether the face has a CJK script tag ([`CJK_SCRIPTS`]). + pub fn is_cjk(&self) -> bool { + CJK_SCRIPTS.iter().any(|tag| self.has_script(tag)) + } } +/// The `role` of a UI subset face. +pub const UI_ROLE: &str = "ui"; + +/// Script tags of the CJK faces, which craft-fonts also ships as UI subsets. `Hant` covers +/// `Hant-HK`. +pub const CJK_SCRIPTS: &[&str] = &["Hans", "Hant", "Jpan", "Kore", "Hani"]; + /// `face_tag` is `wanted` or `wanted` plus a `-` subtag. pub(crate) fn script_matches(face_tag: &str, wanted: &str) -> bool { face_tag.strip_prefix(wanted).is_some_and(|rest| rest.is_empty() || rest.starts_with('-')) @@ -72,7 +93,7 @@ pub fn parse_manifest(text: &str) -> Result, ParseError> { continue; } let fields: Vec<&str> = trimmed.split(" | ").map(str::trim).collect(); - let [family, style, file, scripts, licence, licence_file, sha256, source, ..] = fields.as_slice() else { + let [family, style, file, scripts, licence, licence_file, sha256, source, rest @ ..] = fields.as_slice() else { return Err(ParseError { line, reason: format!("expected 8 fields separated by \" | \", found {}", fields.len()) }); }; for (name, value) in [("family", family), ("style", style), ("file", file), ("scripts", scripts)] { @@ -97,6 +118,7 @@ pub fn parse_manifest(text: &str) -> Result, ParseError> { licence_file: (*licence_file).to_owned(), sha256: (*sha256).to_owned(), source: (*source).to_owned(), + role: rest.first().filter(|role| !role.is_empty()).map(|role| (*role).to_owned()), }); } Ok(entries) diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs index 29141ec2..d9a6a451 100644 --- a/crates/fonts-build/src/tests.rs +++ b/crates/fonts-build/src/tests.rs @@ -2,6 +2,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; +use crate::cmap::Cmap; use crate::embed::Env; use crate::{Embed, EmbeddedFace, Error, parse_manifest}; @@ -63,8 +64,13 @@ fn parses_the_manifest_with_collection_indices() { assert!(err.to_string().contains("expected 8 fields"), "{err}"); let bad_index = "A | Regular | a.ttc#x | Latn | OFL-1.1 | OFL.txt | 00 | src\n"; assert!(parse_manifest(bad_index).unwrap_err().to_string().contains("face index")); - let extra = "A | Regular | a.ttf | Latn | OFL-1.1 | OFL.txt | 00 | src | future field\n"; - assert_eq!(parse_manifest(extra).unwrap().len(), 1); + let ui = "A UI | Regular | a.ttf | Hant | OFL-1.1 | OFL.txt | 00 | src | ui | future field\nA | Regular | a.ttf | Hant | OFL-1.1 | OFL.txt | 00 | src |\n"; + let entries = parse_manifest(ui).unwrap(); + assert_eq!(entries[0].role.as_deref(), Some("ui")); + assert!(entries[0].is_ui() && entries[0].is_cjk()); + assert_eq!(entries[1].role, None); + assert!(!entries[1].is_ui()); + assert!(entries.iter().all(|e| e.role.is_none() || e.is_ui())); } #[test] @@ -139,3 +145,199 @@ fn embedded_face_helpers() { let weights: Vec = ["Regular", "Bold", "SemiBold", "Extra Light", "Black Italic", "Italic"].map(|s| face(s).weight()).into(); assert_eq!(weights, [400, 700, 600, 200, 900, 400]); } + +/// A font with only a `cmap` table: a Windows format 4 subtable with `bmp` segments `(first, +/// last, glyphs)`, mapped to glyphs 1.. when `glyphs` is `None` and through the glyph array +/// otherwise, and a format 12 subtable with `groups` `(first, last)` when there are any. +fn tiny_font(bmp: &[(u16, u16, Option<&[u16]>)], groups: &[(u32, u32)]) -> Vec { + let be16 = |v: &mut Vec, x: u16| v.extend_from_slice(&x.to_be_bytes()); + let be32 = |v: &mut Vec, x: u32| v.extend_from_slice(&x.to_be_bytes()); + + let mut segments = bmp.to_vec(); + segments.push((0xFFFF, 0xFFFF, None)); + let n = u16::try_from(segments.len()).unwrap(); + let (mut ends, mut starts, mut deltas, mut offsets, mut glyphs) = (vec![], vec![], vec![], vec![], vec![]); + for (i, &(first, last, ids)) in segments.iter().enumerate() { + be16(&mut ends, last); + be16(&mut starts, first); + match ids { + None => { + // The last segment maps 0xFFFF to glyph 0, as fonts do. + be16(&mut deltas, 1u16.wrapping_sub(if first == 0xFFFF { 0 } else { first })); + be16(&mut offsets, 0); + } + Some(ids) => { + be16(&mut deltas, 0); + be16(&mut offsets, 2 * (n - u16::try_from(i).unwrap()) + u16::try_from(glyphs.len()).unwrap()); + ids.iter().for_each(|&g| be16(&mut glyphs, g)); + } + } + } + let mut format4 = vec![]; + for x in [4, 0, 0, 2 * n, 0, 0, 0] { + be16(&mut format4, x); + } + format4.extend(ends.iter().chain(&[0, 0]).chain(&starts).chain(&deltas).chain(&offsets).chain(&glyphs)); + let len = u16::try_from(format4.len()).unwrap(); + format4[2..4].copy_from_slice(&len.to_be_bytes()); + + let mut format12 = vec![]; + be16(&mut format12, 12); + be16(&mut format12, 0); + be32(&mut format12, 16 + 12 * u32::try_from(groups.len()).unwrap()); + be32(&mut format12, 0); + be32(&mut format12, u32::try_from(groups.len()).unwrap()); + for &(first, last) in groups { + [first, last, 1].into_iter().for_each(|x| be32(&mut format12, x)); + } + + let subtables: Vec<(u16, Vec)> = [(1, format4), (10, format12)].into_iter().filter(|(enc, _)| *enc == 1 || !groups.is_empty()).collect(); + let mut cmap = vec![]; + be16(&mut cmap, 0); + be16(&mut cmap, u16::try_from(subtables.len()).unwrap()); + let mut at = 4 + 8 * u32::try_from(subtables.len()).unwrap(); + for (encoding, table) in &subtables { + [3, *encoding].into_iter().for_each(|x| be16(&mut cmap, x)); + be32(&mut cmap, at); + at += u32::try_from(table.len()).unwrap(); + } + subtables.iter().for_each(|(_, table)| cmap.extend(table)); + + let mut font = vec![]; + be32(&mut font, 0x0001_0000); + for x in [1, 0, 0, 0] { + be16(&mut font, x); + } + font.extend_from_slice(b"cmap"); + for x in [0, 28, u32::try_from(cmap.len()).unwrap()] { + be32(&mut font, x); + } + font.extend(cmap); + font +} + +/// A `.ttc` of fonts made by [`tiny_font`]. +fn tiny_collection(fonts: &[Vec]) -> Vec { + let header = 12 + 4 * fonts.len(); + let mut ttc = b"ttcf\0\x01\0\0".to_vec(); + ttc.extend_from_slice(&u32::try_from(fonts.len()).unwrap().to_be_bytes()); + let mut body = vec![]; + for font in fonts { + let start = u32::try_from(header + body.len()).unwrap(); + ttc.extend_from_slice(&start.to_be_bytes()); + let mut font = font.clone(); + // The table offsets of a collection's faces count from the start of the file. + let offset = u32::from_be_bytes(font[20..24].try_into().unwrap()) + start; + font[20..24].copy_from_slice(&offset.to_be_bytes()); + body.extend(font); + } + ttc.extend(body); + ttc +} + +#[test] +fn cmap_reads_formats_4_and_12_and_collections() { + let font = tiny_font(&[(0x41, 0x5A, None), (0x4E00, 0x4E02, Some(&[5, 0, 7]))], &[(0x2_0000, 0x2_0001)]); + let cmap = Cmap::read(&font, 0).unwrap(); + let covered: Vec = "AZa一丁丂七\u{FFFF}\u{20000}\u{20001}\u{20002}".chars().filter(|&c| cmap.covers(c)).collect(); + assert_eq!(covered, ['A', 'Z', '一', '丂', '\u{20000}', '\u{20001}']); + + let bmp_in_format12 = tiny_font(&[], &[(0x4E00, 0x4E0F)]); + assert!(Cmap::read(&bmp_in_format12, 0).unwrap().covers('丁')); + + let ttc = tiny_collection(&[tiny_font(&[(0x41, 0x41, None)], &[]), tiny_font(&[(0x4E00, 0x4E00, None)], &[])]); + let second = Cmap::read(&ttc, 1).unwrap(); + assert!(second.covers('一') && !second.covers('A')); + assert!(Cmap::read(&ttc, 0).unwrap().covers('A')); + assert!(Cmap::read(&ttc, 2).is_none()); + assert!(Cmap::read(b"fixture: Sans JP Regular", 0).is_none()); + assert!(Cmap::read(&font[..40], 0).is_none()); +} + +/// A craft-fonts checkout in `scratch` with these `(family, scripts, role, font)` faces, all +/// Regular. +fn checkout(scratch: &Scratch, faces: &[(&str, &str, &str, Vec)]) -> PathBuf { + let dir = scratch.0.join("craft-fonts"); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + let mut manifest = String::new(); + for (i, (family, scripts, role, font)) in faces.iter().enumerate() { + std::fs::write(dir.join(format!("fonts/{i}.ttf")), font).unwrap(); + manifest += &format!("{family} | Regular | fonts/{i}.ttf | {scripts} | OFL-1.1 | fonts/OFL.txt | 00 | src | {role}\n"); + } + std::fs::write(dir.join("fonts/manifest.txt"), manifest).unwrap(); + dir +} + +#[test] +fn for_ui_embeds_cjk_faces_only_as_ui_subsets() { + let scratch = Scratch::new("for-ui"); + let latin = || tiny_font(&[(0x20, 0x7E, None)], &[]); + let dir = checkout( + &scratch, + &[ + ("Noto Sans TC", "Hant,Latn", "", latin()), + ("Noto Sans TC UI", "Hant,Latn", "ui", latin()), + ("Noto Sans HK", "Hant-HK,Latn", "", latin()), + ("Noto Sans HK UI", "Hant-HK,Latn", "ui", latin()), + ("Noto Sans JP", "Jpan,Latn", "", latin()), + ("Noto Sans Arabic", "Arab", "", latin()), + ], + ); + let r = run(Embed::new().for_ui(), Some(&dir), Some("1")); + assert_eq!(r.result.unwrap(), ["Noto Sans TC UI Regular", "Noto Sans HK UI Regular", "Noto Sans Arabic Regular"]); + let r = run(Embed::new().for_ui().scripts(&["Hant"]), Some(&dir), Some("1")); + assert_eq!(r.result.unwrap(), ["Noto Sans TC UI Regular", "Noto Sans HK UI Regular"]); + let r = run(Embed::new().scripts(&["Hant-HK"]).for_ui().filter(|f| f.style == "Regular"), Some(&dir), Some("1")); + assert_eq!(r.result.unwrap(), ["Noto Sans HK UI Regular"]); + // Japanese has only a full face here, which `for_ui` leaves out. + let r = run(Embed::new().for_ui().scripts(&["Hant", "Jpan"]), Some(&dir), Some("1")); + let Err(Error::MissingScripts { scripts, .. }) = r.result else { panic!("{:?}", r.result) }; + assert_eq!(scripts, ["Jpan"]); + assert_eq!(run(Embed::new().scripts(&["Jpan"]), Some(&dir), Some("1")).result.unwrap(), ["Noto Sans JP Regular"]); +} + +#[test] +fn check_text_warns_about_characters_no_face_covers() { + let scratch = Scratch::new("check-text"); + let dir = checkout( + &scratch, + &[ + ("Noto Sans TC", "Hant,Latn", "", tiny_font(&[(0x20, 0x7E, None), (0x3000, 0x9FFF, None)], &[])), + ("Noto Sans TC UI", "Hant,Latn", "ui", tiny_font(&[(0x20, 0x7E, None), (0x4E00, 0x4E03, None)], &[])), + ("Noto Sans Arabic", "Arab", "", tiny_font(&[(0x600, 0x6FF, None)], &[])), + ], + ); + let catalogs = scratch.0.join("i18n"); + std::fs::create_dir_all(&catalogs).unwrap(); + std::fs::write(catalogs.join("zh-TW.ftl"), "\u{FEFF}open = 一丁万。\nclose = 丁万\u{FE0F}\n").unwrap(); + std::fs::write(catalogs.join("ar.ftl"), "open = فتح\nok = 一\n").unwrap(); + let long: String = (0x4E10..0x4E20).filter_map(char::from_u32).collect(); + std::fs::write(catalogs.join("long.ftl"), format!("x = {long}\n")).unwrap(); + let paths = ["zh-TW.ftl", "ar.ftl", "long.ftl", "missing.ftl"].map(|f| catalogs.join(f)); + let warnings = |r: &Run| r.cargo.iter().filter(|l| l.starts_with("cargo::warning")).cloned().collect::>(); + + let r = run(Embed::new().for_ui().check_text(paths.clone()), Some(&dir), None); + assert_eq!(r.result.as_ref().unwrap().len(), 2); + for path in &paths { + assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", path.display())), "{:?}", r.cargo); + } + let w = warnings(&r); + assert_eq!(w.len(), 3, "{w:#?}"); + assert!(w[0].contains("zh-TW.ftl has 2 characters no selected face covers (万。); system fonts"), "{}", w[0]); + assert!(w[0].contains("tools/ui-chars and tools/ui-subset"), "{}", w[0]); + assert!(w[1].contains("long.ftl has 16 characters") && w[1].contains("(丐丑丒专且丕世丗 …)"), "{}", w[1]); + assert!(w[2].contains("cannot check") && w[2].contains("missing.ftl"), "{}", w[2]); + + // The full face covers everything; without a checkout there is nothing to check. + let r = run(Embed::new().check_text(paths[..3].to_vec()), Some(&dir), None); + assert_eq!(warnings(&r), Vec::::new()); + let r = run(Embed::new().for_ui().check_text(paths.clone()), None, None); + assert_eq!(warnings(&r), Vec::::new()); + assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", paths[0].display()))); + + // The fixture's "fonts" are text, so they cover nothing. + let r = run(Embed::new().scripts(&["Arab"]).check_text([&paths[1]]), Some(&fixture()), None); + let w = warnings(&r); + assert!(w[0].contains("cannot read the character map of Sans Arabic Regular"), "{w:#?}"); + assert!(w[1].contains("ar.ftl has 1 character no selected face covers (一)"), "{w:#?}"); +}