Files
designcraft/crates/fonts/src/lib.rs
T

708 lines
31 KiB
Rust

//! DesignCraft fonts: the font database (bundled OFL families + user/system fonts), vertical
//! metrics, glyph outlines and OpenType shaping.
//!
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
//! and advances in font units. `designcraft-compose` applies sizes, tracking, scaling and
//! justification on top.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod features;
mod fontdb;
mod group;
mod kind;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(any(test, feature = "testing"))]
pub mod testing;
mod vertical;
pub use features::{FontFeatures, font_features};
pub use fontdb::{
DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES,
MAX_DOCUMENT_FONTS_TOTAL, ReloadedFonts, ScopedFonts, base_style, bundled, same_style, system_font_dirs,
};
pub use group::{FamilyInfo, FontGroup, sort_for_menu};
pub use harfrust::Feature;
use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer};
pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance};
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::Size;
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
pub struct CraftFont {
pub family: &'static str,
pub style: &'static str,
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
pub scripts: &'static [&'static str],
pub bytes: &'static [u8],
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
/// The craft-fonts faces for Japanese (`"Jpan"`), in manifest order. Empty without craft-fonts.
pub fn japanese_fonts() -> impl Iterator<Item = &'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan"))
}
/// Japanese faces in document-fallback order: Mincho (serif) families first, matching the
/// serif default text font, then the others; Regular before other styles.
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
let mut v: Vec<_> = japanese_fonts().collect();
v.sort_by_key(|f| (!f.family.contains("Mincho"), f.style != "Regular"));
v
}
/// Japanese faces in UI order: BIZ UDPGothic first (the UI face), then the others; `bold` puts
/// bold styles before regular ones.
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
let mut v: Vec<_> = japanese_fonts().collect();
v.sort_by_key(|f| (f.family != "BIZ UDPGothic", (f.style == "Bold") != bold));
v
}
/// InDesign's default text font is a serif; ours is Source Serif 4.
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
/// One shaped glyph, in font units (y up).
///
/// Horizontal runs ([`shape`]) advance by `x_advance` (`y_advance` is 0) and the offsets move the
/// glyph from its pen position. Vertical runs ([`shape_vertical`]) advance down the line by
/// `-y_advance` (`x_advance` is 0) and the offsets move the glyph from its vertical origin
/// ([`FontFace::v_origin`]) on the line's centre, as `vpal` or `vkrn` place it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub y_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
pub safe_tatweel_before: bool,
/// Direction used for shaping, including Unicode mirroring.
pub rtl: bool,
}
/// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent
/// (negative below the baseline), line gap) — what text layouts such as egui's build lines from.
/// `None` if the font can't be read.
pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> {
use skrifa::MetadataProvider;
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading))
}
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
pub fn feature(tag: &str) -> Option<Feature> {
let (on, t) = match tag.strip_prefix('-') {
Some(r) => (false, r),
None => (true, tag.strip_prefix('+').unwrap_or(tag)),
};
let b = t.as_bytes();
if b.len() != 4 {
return None;
}
Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..))
}
/// A strong right-to-left character (Hebrew, Arabic, …)?
pub fn is_rtl(c: char) -> bool {
use unicode_bidi::BidiClass::{AL, R};
matches!(unicode_bidi::bidi_class(c), R | AL)
}
/// Resolve directional and script runs before choosing the OpenType shaper.
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool, unicode_script::Script)> {
use unicode_script::{Script, UnicodeScript};
let info = unicode_bidi::BidiInfo::new(text, None);
let mut runs = Vec::new();
let mut script =
text.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited | Script::Unknown)).unwrap_or(Script::Common);
let mut start = 0;
let mut rtl = info.levels.first().is_some_and(|l| l.is_rtl());
for (i, c) in text.char_indices() {
let next_rtl = info.levels.get(i).is_some_and(|l| l.is_rtl());
let next_script = match c.script() {
Script::Common | Script::Inherited | Script::Unknown => script,
s => s,
};
if i > start && (next_rtl != rtl || next_script != script) {
runs.push((start..i, rtl, script));
start = i;
}
rtl = next_rtl;
script = next_script;
}
if start < text.len() {
runs.push((start..text.len(), rtl, script));
}
runs
}
/// What surrounds a run being shaped: the text before and after it (so Arabic letters join across a
/// change of style) and its language, a BCP 47 tag such as `tr` or `zh-Hant` that picks the font's
/// localized forms (OpenType `locl`; `None` or a tag the shaper can't read: the font's defaults).
#[derive(Clone, Copy, Default)]
pub struct ShapeContext<'a> {
pub before: &'a str,
pub after: &'a str,
pub language: Option<&'a str>,
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
shape_with_context(face, text, features, map, ShapeContext::default())
}
/// [`shape`] `text` in its `context`: its language and the text around it.
pub fn shape_with_context(
face: &FontFace,
text: &str,
features: &[Feature],
map: impl Fn(char) -> char,
context: ShapeContext<'_>,
) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
for (r, rtl, script) in direction_runs(text) {
let local = ShapeContext {
before: if r.start == 0 { context.before } else { &text[..r.start] },
after: if r.end == text.len() { context.after } else { &text[r.end..] },
language: context.language,
};
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl, script, local);
for x in &mut g {
x.cluster += r.start;
}
if rtl {
// Visual (clusters descending) → logical, cluster by cluster.
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
for x in g {
match groups.last_mut() {
Some(last) if last[0].cluster == x.cluster => last.push(x),
_ => groups.push(vec![x]),
}
}
groups.sort_by_key(|grp| grp[0].cluster);
g = groups.into_iter().flatten().collect();
}
out.extend(g);
}
out
}
/// `features` as a run in `script` takes them. Contextual alternates are part of how Arabic joins
/// (connecting strokes, tatweel and letter variants): InDesign keeps them in Arabic text with
/// Contextual Alternates off, so `calt` off is dropped there.
fn script_features(features: &[Feature], script: unicode_script::Script) -> std::borrow::Cow<'_, [Feature]> {
use unicode_script::Script;
let calt = Tag::new(b"calt");
let off = |f: &Feature| f.tag == calt && f.value == 0;
if script == Script::Arabic && features.iter().any(off) {
features.iter().filter(|f| !off(f)).copied().collect::<Vec<_>>().into()
} else {
features.into()
}
}
fn shape_dir(
face: &FontFace,
text: &str,
features: &[Feature],
map: &impl Fn(char) -> char,
rtl: bool,
script: unicode_script::Script,
context: ShapeContext<'_>,
) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let features = script_features(features, script);
let features = &features[..];
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.set_pre_context(context.before);
buf.set_post_context(context.after);
if let Ok(script) = script.short_name().parse() {
buf.set_script(script);
}
if let Some(language) = context.language.and_then(|t| t.parse::<Language>().ok()) {
buf.set_language(language);
}
buf.set_flags(harfrust::BufferFlags::PRODUCE_SAFE_TO_INSERT_TATWEEL);
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
y_advance: 0,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
safe_tatweel_before: info.safe_to_insert_tatweel(),
rtl,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph {
gid: g.to_u32(),
cluster: i,
x_advance: adv.round() as i32,
y_advance: 0,
x_offset: 0,
y_offset: 0,
safe_tatweel_before: false,
rtl: false,
});
}
}
out
}
/// Shape upright text top to bottom (`text`, `features`, `map` and `context` as in
/// [`shape_with_context`]): the shaper applies `vert` (vertical forms), and `vkrn` and `vpal` when
/// `features` turn them on. Advances and origins are the face's vertical metrics
/// ([`FontFace::v_advance`], [`FontFace::v_origin`]).
pub fn shape_vertical(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, context: ShapeContext<'_>) -> Vec<ShapedGlyph> {
let mut funcs = vertical::VerticalFuncs { face, metrics: vertical::VMetrics::new(face) };
let mut out = Vec::with_capacity(text.len());
let mut origins = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.set_pre_context(context.before);
buf.set_post_context(context.after);
if let Some(language) = context.language.and_then(|t| t.parse::<Language>().ok()) {
buf.set_language(language);
}
buf.guess_segment_properties();
buf.set_direction(Direction::TopToBottom);
let gb = shaper.shape(buf, ShapeOptions::new().features(features).font_funcs(Some(&mut funcs)));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
y_advance: pos.y_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
safe_tatweel_before: false,
rtl: false,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
let cmap = f.charmap();
for (i, c) in text.char_indices() {
let gid = cmap.map(map(c)).unwrap_or_default().to_u32();
let (x, y) = funcs.origin(gid);
let adv = -(face.v_advance(gid).round() as i32);
out.push(ShapedGlyph {
gid,
cluster: i,
x_advance: 0,
y_advance: adv,
x_offset: -x,
y_offset: -y,
safe_tatweel_before: false,
rtl: false,
});
}
}
// The shaper hangs each glyph from its vertical origin; keep only what GPOS moved it by.
origins.extend(out.iter().map(|g| funcs.origin(g.gid)));
for (g, (x, y)) in out.iter_mut().zip(origins) {
g.x_offset = g.x_offset.saturating_add(x);
g.y_offset = g.y_offset.saturating_add(y);
}
out
}
/// Glyph id of the first of `chars` the face has (0 = .notdef).
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arabic_keeps_contextual_alternates() {
use unicode_script::Script;
let off = feature("-calt").unwrap();
let liga = feature("-liga").unwrap();
let tags = |s: Script| script_features(&[off, liga], s).iter().map(|f| (f.tag, f.value)).collect::<Vec<_>>();
assert_eq!(tags(Script::Arabic), vec![(liga.tag, 0)]);
assert_eq!(tags(Script::Latin), vec![(off.tag, 0), (liga.tag, 0)]);
}
#[test]
fn arabic_script_runs_do_not_inherit_the_hebrew_shaper() {
use unicode_script::Script;
let text = "אב بب";
let runs = direction_runs(text);
assert_eq!(runs.len(), 2);
assert_eq!(runs[0], (0..5, true, Script::Hebrew));
assert_eq!(runs[1], (5..text.len(), true, Script::Arabic));
}
#[test]
fn last_resort_face_parses() {
let f = fontdb::last_resort_face();
assert_eq!(f.family, fontdb::FALLBACK_FAMILY);
assert!(f.upem > 0.0);
}
#[test]
fn bundled_families_load() {
let db = FontDb::global();
let fams = db.families();
assert!(fams.iter().any(|f| f == DEFAULT_FAMILY), "{fams:?}");
assert!(fams.iter().any(|f| f == "Source Sans 3"));
let styles = db.styles(DEFAULT_FAMILY);
for s in ["Regular", "Italic", "Bold", "Semibold"] {
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
}
}
#[test]
fn style_linking_finds_bold_and_italic_in_the_family() {
let db = FontDb::with_font_dirs(vec![]);
let link = |cur: &str, bold: Option<bool>, italic: Option<bool>| db.linked_style("Source Serif 4", cur, bold, italic);
assert_eq!(link("Regular", Some(true), None).as_deref(), Some("Bold"));
assert_eq!(link("Italic", Some(true), None).as_deref(), Some("Bold Italic"), "keeps the italic");
assert_eq!(link("Bold", None, Some(true)).as_deref(), Some("Bold Italic"), "keeps the weight");
assert_eq!(link("Bold Italic", Some(false), None).as_deref(), Some("Italic"));
assert_eq!(link("Bold Italic", None, Some(false)).as_deref(), Some("Bold"));
assert_eq!(link("Semibold", Some(false), None).as_deref(), Some("Regular"));
assert_eq!(db.traits_of("Source Serif 4", "Bold Italic"), (700.0, true));
// Source Sans 3 is bundled without a bold italic.
assert_eq!(db.linked_style("Source Sans 3", "Italic", Some(true), None), None);
assert_eq!(db.linked_style("No Such Family", "Regular", Some(true), None), None);
}
#[test]
fn shaping_produces_clusters_and_advances() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let g = shape(&face, "Hello", &[], |c| c);
assert_eq!(g.len(), 5);
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![0, 1, 2, 3, 4]);
assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0));
}
#[test]
fn ligatures_can_be_disabled() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let on = shape(&face, "office", &[], |c| c);
let off = shape(&face, "office", &[feature("-liga").unwrap()], |c| c);
assert!(on.len() <= off.len());
assert_eq!(off.len(), 6);
}
#[test]
fn language_picks_localized_forms() {
// Source Serif 4's `locl` has a Turkish `i`.
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let gid = |language| shape_with_context(&face, "i", &[], |c| c, ShapeContext { language, ..Default::default() })[0].gid;
assert_eq!(gid(None), face.glyph_for('i'));
assert_eq!(gid(Some("en-US")), face.glyph_for('i'));
assert_ne!(gid(Some("tr")), face.glyph_for('i'));
assert_eq!(gid(Some("not a tag")), face.glyph_for('i'));
}
#[test]
fn mapping_keeps_clusters() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase());
assert_eq!(g[0].gid, face.glyph_for('A'));
assert_eq!(g[1].cluster, 1);
}
#[test]
fn variable_font_named_instances_are_styles() {
// Uses a variable system font when one is installed (macOS ships several).
let Some(data) =
["/System/Library/Fonts/Supplemental/Skia.ttf", "/System/Library/Fonts/NewYork.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
.iter()
.find_map(|p| std::fs::read(p).ok())
else {
return;
};
let db = FontDb::global();
db.add_font(data.clone());
let Some(fam) = skrifa::FontRef::new(&data).ok().and_then(|f| {
(f.named_instances().len() > 1)
.then(|| f.localized_strings(skrifa::string::StringId::FAMILY_NAME).english_or_first().map(|s| s.to_string()))?
}) else {
return;
};
let styles = db.styles(&fam);
assert!(styles.len() > 1, "{styles:?}");
let faces: Vec<_> = styles.iter().map(|s| db.face(&fam, s)).filter(|f| f.is_variable()).collect();
let light = faces.iter().min_by(|a, b| a.weight.total_cmp(&b.weight)).unwrap();
let heavy = faces.iter().max_by(|a, b| a.weight.total_cmp(&b.weight)).unwrap();
assert!(heavy.weight > light.weight, "{light:?} {heavy:?}");
let ink = |f: &FontFace| {
let g = shape(f, "H", &[], |c| c);
let b = kurbo::Shape::bounding_box(&*db.outline(f, g[0].gid));
((b.area() * 100.0).round() as i64, g[0].x_advance)
};
assert_ne!(ink(light), ink(heavy), "instances draw differently");
// Free axis values: a style with settings makes that instance.
let axes = db.axes(&fam, &light.style);
let wght = axes.iter().find(|a| a.0 == "wght").expect("a weight axis");
let mid = (wght.2 + wght.4) / 2.0;
let custom = db.face(&fam, &format!("{} {{wght:{mid}}}", light.style));
assert!(custom.is_variable() && custom.coords.iter().any(|(t, v)| t == b"wght" && *v == mid));
assert!(!db.styles(&fam).iter().any(|s| s.contains('{')), "instances aren't listed");
let static_face = db.face(DEFAULT_FAMILY, "Regular {wght:700}");
assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings");
}
#[test]
fn an_exact_family_name_wins_over_the_name_without_its_format_suffix() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
db.add_font(testing::font_with("DC Test Suffix (OTF)", &['a']).unwrap());
assert_eq!(db.face("DC Test Suffix (OTF)", "Regular").family, "DC Test Suffix (OTF)");
assert_eq!(db.face(&format!("{DEFAULT_FAMILY} (TT)"), "Regular").family, DEFAULT_FAMILY);
assert!(db.has_family(&format!("{DEFAULT_FAMILY} (T1)")));
assert!(!db.has_family(&format!("{DEFAULT_FAMILY} (Bold)")));
assert!(!db.has_family("DC Test Suffix"));
}
/// IDML files from InDesign can name a family with its `$ID/` prefix (`$ID/Arial`).
#[test]
fn an_indesign_id_prefix_names_the_plain_family() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let prefixed = format!("$ID/{DEFAULT_FAMILY}");
assert_eq!(db.face(&prefixed, "Bold").family, DEFAULT_FAMILY);
assert!(db.has_family(&prefixed));
assert_eq!(db.styles(&prefixed), db.styles(DEFAULT_FAMILY));
assert!(db.has_family(&format!("$ID/{DEFAULT_FAMILY} (OTF)")));
assert!(!db.has_family("$ID/"));
assert!(!db.has_family("$ID/DC No Such Family"));
}
#[test]
fn a_family_name_keeps_its_spaces_and_falls_back_to_the_trimmed_name() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
db.add_font(testing::font_with("DC Test Space ", &['a']).unwrap());
db.add_font(testing::font_with("DC Test Space", &['a']).unwrap());
assert_eq!(db.face("DC Test Space ", "Regular").family, "DC Test Space ");
assert_eq!(db.face("DC Test Space", "Regular").family, "DC Test Space");
assert_eq!(db.face(&format!(" {DEFAULT_FAMILY}\n"), "Regular").family, DEFAULT_FAMILY);
assert!(db.has_family(&format!("{DEFAULT_FAMILY} ")));
assert_eq!(db.styles(&format!("{DEFAULT_FAMILY} ")), db.styles(DEFAULT_FAMILY));
}
#[test]
fn outlines_and_metrics() {
let db = FontDb::global();
let face = db.face(DEFAULT_FAMILY, "Bold");
assert_eq!(face.style, "Bold");
let o = db.outline(&face, face.glyph_for('O'));
assert!(!o.elements().is_empty());
assert!(face.ascent > 0.0 && face.descent > 0.0 && face.cap_height > face.x_height);
assert!(feature("abc").is_none());
}
#[test]
fn a_notdef_without_an_outline_is_drawn_as_a_box() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let font = testing::font_with("DC Test Empty Notdef", &['a']).unwrap();
db.add_font(testing::with_empty_notdef(font).unwrap());
let face = db.face("DC Test Empty Notdef", "Regular");
assert!(face.skrifa().unwrap().outline_glyphs().get(skrifa::GlyphId::NOTDEF).is_some_and(|g| {
let mut pen = kurbo::BezPath::new();
let _ = g.draw(skrifa::outline::DrawSettings::unhinted(skrifa::instance::Size::unscaled(), face.location()), &mut BezPen(&mut pen));
pen.elements().is_empty()
}));
// The box spans the glyph's advance, above the baseline (outlines are y-down).
let b = kurbo::Shape::bounding_box(&*db.outline(&face, 0));
assert!(b.width() > 0.0 && b.width() <= face.advance(0), "{b:?}");
assert!(b.y0 < 0.0 && b.y1 <= 0.0, "{b:?}");
assert!(db.missing_box(&face).is_some());
// A font's own .notdef stays as drawn.
let serif = db.face(DEFAULT_FAMILY, "Regular");
assert!(db.missing_box(&serif).is_none());
assert!(!db.outline(&serif, 0).elements().is_empty());
}
struct BezPen<'a>(&'a mut kurbo::BezPath);
impl skrifa::outline::OutlinePen for BezPen<'_> {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64));
}
fn close(&mut self) {
self.0.close_path();
}
}
#[test]
fn craft_fonts_japanese_faces_cover_japanese_and_have_vertical_forms() {
if CRAFT_FONTS.is_empty() {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
return;
}
let fonts = japanese_document_fonts();
assert!(!fonts.is_empty(), "craft-fonts has Japanese fonts");
assert!(fonts[0].family.contains("Mincho"), "documents fall back to a Mincho first");
assert_eq!(japanese_ui_fonts(false)[0].family, "BIZ UDPGothic");
assert_eq!(japanese_ui_fonts(true)[0].style, "Bold");
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
for cf in fonts {
// Inspect the embedded face directly so this cannot pass through an OS fallback.
let font = skrifa::FontRef::new(cf.bytes).unwrap();
for ch in "日本語縦書き横書きルビ、。「」".chars() {
assert_ne!(font.charmap().map(ch).unwrap_or_default(), skrifa::GlyphId::NOTDEF, "{} lacks {ch}", cf.family);
}
let face = db.face(cf.family, cf.style);
assert_eq!(face.family, cf.family);
let horizontal = shape(&face, "「」、。", &[], |c| c);
let vertical = shape(&face, "「」、。", &[feature("vert").unwrap(), feature("vrt2").unwrap()], |c| c);
assert_eq!(horizontal.len(), vertical.len());
assert!(horizontal.iter().zip(&vertical).any(|(h, v)| h.gid != v.gid), "{} has vertical forms", cf.family);
}
// Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts, in
// Japanese text (the Japanese chain) and without a language (the first loaded face).
let latin = db.face(DEFAULT_FAMILY, "Regular");
for language in [Some("ja"), None] {
let fb = db.fallback_for('語', latin.id(), language).unwrap();
assert!(fb.family.contains("Mincho"), "{language:?}: {fb:?}");
}
}
#[test]
fn works_without_craft_fonts() {
// Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load.
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.face(DEFAULT_FAMILY, "Regular").family, DEFAULT_FAMILY);
assert!(!shape(&db.face(DEFAULT_FAMILY, "Regular"), "Hello", &[], |c| c).is_empty());
if CRAFT_FONTS.is_empty() {
assert!(japanese_fonts().next().is_none() && japanese_document_fonts().is_empty());
let latin = db.face(DEFAULT_FAMILY, "Regular");
for language in [Some("ja"), None] {
assert!(db.fallback_for('語', latin.id(), language).is_none(), "no bundled Japanese font: {language:?}");
}
}
}
}
#[cfg(test)]
mod resolution_tests {
use super::*;
#[test]
fn localized_family_and_style_names_resolve_without_false_missing_status() {
let bytes = testing::font_with("Alias Face", &['A']).unwrap();
let bytes = testing::with_names(
&bytes,
&[(3, 1, 0x409, 1, "Alias Face"), (3, 1, 0x804, 1, "别名字体"), (3, 1, 0x409, 2, "Regular"), (3, 1, 0x804, 2, "常规")],
)
.unwrap();
let db = FontDb::with_font_dirs(Vec::new());
db.add_font(bytes);
let scope = db.scoped(0);
assert!(scope.has_family("别名字体"));
assert_eq!(scope.face("别名字体", "常规").family, "Alias Face");
assert!(scope.has_style("别名字体", "常规"));
assert!(!scope.has_style("别名字体", "Unreal Style"));
assert_eq!(scope.styles("别名字体"), ["Regular"]);
}
#[test]
fn a_style_name_beats_another_face_s_legacy_subfamily() {
// Static families often name each weight's legacy (RIBBI) family "Legacy Sans Black" with
// subfamily "Regular": that alias must not shadow the face actually styled Regular.
let face =
|records: &[(u16, u16, u16, u16, &str)]| testing::with_names(&testing::font_with("Legacy Sans", &['A']).unwrap(), records).unwrap();
let db = FontDb::with_font_dirs(Vec::new());
db.add_font(face(&[
(3, 1, 0x409, 1, "Legacy Sans Black"),
(3, 1, 0x409, 2, "Regular"),
(3, 1, 0x409, 16, "Legacy Sans"),
(3, 1, 0x409, 17, "Black"),
]));
db.add_font(face(&[(3, 1, 0x409, 1, "Legacy Sans"), (3, 1, 0x409, 2, "Regular")]));
let scope = db.scoped(0);
assert_eq!(scope.face("Legacy Sans", "Regular").style, "Regular");
assert_eq!(scope.face("Legacy Sans", "Black").style, "Black");
}
#[test]
fn known_substitution_preserves_missing_original_status() {
let db = FontDb::with_font_dirs(Vec::new());
db.add_font(testing::font_with("Carlito", &['A']).unwrap());
let scope = db.scoped(0);
assert_eq!(scope.substitute_family("Calibri"), "Carlito");
assert!(scope.canonical_family("An Unavailable Original Family").is_none());
assert_eq!(scope.face("An Unavailable Original Family", "Regular").family, FALLBACK_FAMILY);
}
}
#[cfg(test)]
mod coverage_recommendation_tests {
use super::*;
#[test]
fn placeholder_fonts_do_not_satisfy_glyph_coverage() {
let db = FontDb::with_font_dirs(Vec::new());
db.add_font(testing::font_with(".LastResort", &['\u{10fffd}']).unwrap());
let face = db.face(".LastResort", "Regular");
assert!(face.is_placeholder());
assert!(!face.covers('\u{10fffd}'));
assert!(db.fallback_for('\u{10fffd}', u32::MAX, None).is_none());
}
#[test]
fn arabic_replacement_also_covers_surrounding_punctuation() {
let db = FontDb::with_font_dirs(Vec::new());
db.add_font(testing::font_with("Geeza Pro", &['ع']).unwrap());
db.add_font(testing::font_with("Al Bayan", &['ع', '“']).unwrap());
let scope = db.scoped(0);
assert_eq!(scope.face("Traditional Arabic", "Regular").family, "Geeza Pro");
assert_eq!(scope.replacement_face("Traditional Arabic", "Regular", &['ع', '“']).family, "Al Bayan");
assert!(!scope.has_family("Traditional Arabic"));
}
}