mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 04:22:58 +00:00
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:
1 parent
ed0f6f611c
commit
1a8573d523
6 files changed
+460
-15
No files matched your search
@@ -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.
|
||||
@@ -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()?))
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:#?}");
|
||||
}
|
||||
Reference in new issue
Block a user