craft-fonts-build: for_ui embeds CJK faces as their UI subsets; check_text warns about characters no face covers

The manifest's optional ninth field, role, marks the UI subsets (ui). check_text reads
the selected faces' cmaps (formats 4 and 12) with a small reader, so the crate keeps
no dependencies.
This commit is contained in:
aiongg committed 2026-10-21 12:45:51 +00:00
1 parent ed0f6f611c
commit 1a8573d523
6 files changed
+460 -15

No files matched your search

+38 -5
View File
@@ -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 `#<n>` names face `n` of a font
collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. Fields after the
eighth are ignored.
One face per line, eight or nine fields separated by `" | "`; craft-fonts' `docs/integration.md`
defines them. Lines starting with `#` are comments. A file field ending in `#<n>` names face `n` of
a font collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. The ninth
field, `role`, is `ui` on UI subsets and absent elsewhere. Fields after the ninth are ignored.
+110
View File
@@ -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<Self> {
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<u32> {
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<u32> {
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<u32> {
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<u16> {
Some(u16::from_be_bytes(data.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(data: &[u8], at: usize) -> Option<u32> {
Some(u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
+76 -1
View File
@@ -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<Filter>,
dir: Option<PathBuf>,
required: Option<bool>,
ui: bool,
texts: Vec<PathBuf>,
}
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<P: Into<PathBuf>>(mut self, paths: impl IntoIterator<Item = P>) -> 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<PathBuf>) -> 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<String>) -> Result<Vec<ManifestEntry>, Error> {
cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}"));
cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}"));
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<String>) {
if self.texts.is_empty() {
return;
}
let fonts: Vec<(&ManifestEntry, Option<Vec<u8>>)> = 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<char> = 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<Vec<(ManifestEntry, PathBuf)>, 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<OsString>,
+5 -2
View File
@@ -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)]
+27 -5
View File
@@ -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 `#<index>` to pick one face of a font collection (`.ttc`). Fields after the eighth are
//! ignored, so a manifest that gains a field still embeds.
//! end in `#<index>` 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<String>,
}
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<Vec<ManifestEntry>, 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<Vec<ManifestEntry>, 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)
+204 -2
View File
@@ -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<u16> = ["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<u8> {
let be16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_be_bytes());
let be32 = |v: &mut Vec<u8>, 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<u8>)> = [(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<u8>]) -> Vec<u8> {
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<char> = "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<u8>)]) -> 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::<Vec<_>>();
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::<String>::new());
let r = run(Embed::new().for_ui().check_text(paths.clone()), None, None);
assert_eq!(warnings(&r), Vec::<String>::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:#?}");
}