mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
The font database made one style per font file, so a variable font such as Figtree[wght].ttf listed only its default instance (Light). Faces now also yield their fvar named instances, each a style of the family named by its subfamily name (the default instance is the face itself; unnamed or duplicate instances are left out; axes and instances read are capped). The system font scan reads the fvar table beside the name table, so installed instances are listed before the file is loaded and found by name and by PostScript name. An instance face carries its axis settings: skrifa draws its outlines and reads its metrics and advances at that location, harfrust shapes with a ShaperInstance, so layout, the canvas, Create Outlines and hit testing all use the instance. PDF export embeds it through krilla's Font::new_variable, SVG @font-face through subsetter's subset_with_variations: both write a static font of that instance. Tests build a small variable font in code (test_fonts, via write-fonts; no font file is committed) and check listing, resolving, glyphs and advances, PDF and SVG export and damaged fvar/gvar tables; Bahnschrift and Segoe UI Variable are checked where installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
12 KiB
Rust
249 lines
12 KiB
Rust
//! Variable fonts: each named instance is a style of its own, listed, resolved, shaped, drawn and
|
|
//! embedded at its axis settings (#296: Figtree listed only Light, its default instance).
|
|
|
|
use kurbo::{Point, Shape};
|
|
use skrifa::MetadataProvider;
|
|
use skrifa::instance::{LocationRef, Size};
|
|
use skrifa::raw::TableProvider;
|
|
use vectorcraft_doc::CharStyle;
|
|
|
|
use super::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY as FAMILY, VARIABLE_WIDEN, name_table, variable_font};
|
|
use super::*;
|
|
|
|
fn font() -> Vec<u8> {
|
|
variable_font().expect("the test font builds")
|
|
}
|
|
|
|
/// A database of the bundled fonts and the test variable font.
|
|
fn db() -> FontDb {
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
assert_eq!(db.add_font(font()), 3, "Regular, SemiBold and Bold");
|
|
db
|
|
}
|
|
|
|
/// The width of `c`'s outline in font units.
|
|
fn ink_width(db: &FontDb, face: &FontFace, c: char) -> f64 {
|
|
db.outline(face, face.glyph_for(c)).bounding_box().width()
|
|
}
|
|
|
|
fn text(family: &str, style: &str, s: &str) -> TextObject {
|
|
TextObject::point(Point::ZERO, s, CharStyle { font_family: family.into(), font_style: style.into(), size: 100.0, ..CharStyle::default() })
|
|
}
|
|
|
|
#[test]
|
|
fn named_instances_are_styles_of_the_family() {
|
|
let db = db();
|
|
// The default instance is the face itself; the unnamed and the twice-named ones are left out.
|
|
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
|
|
let bold = db.face(FAMILY, "Bold").unwrap();
|
|
assert_eq!((bold.family.as_str(), bold.style.as_str(), bold.weight, bold.italic), (FAMILY, "Bold", 700.0, false));
|
|
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
|
|
let regular = db.face(FAMILY, "Regular").unwrap();
|
|
assert!(regular.variations().is_empty() && regular.weight == 400.0);
|
|
assert_eq!(db.face(FAMILY, "SemiBold").unwrap().weight, 550.0);
|
|
// Fonts added again add nothing.
|
|
assert_eq!(db.add_font(font()), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn an_instance_is_found_by_its_names_and_by_weight() {
|
|
let db = db();
|
|
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.style.clone(), m)).unwrap();
|
|
assert_eq!(m(FAMILY, "Bold"), ("Bold".into(), FontMatch::Exact));
|
|
assert_eq!(m("varitest sans", "semi bold"), ("SemiBold".into(), FontMatch::Exact), "names are normalized");
|
|
// The default instance's own name names the face.
|
|
assert_eq!(m(FAMILY, "Normal"), ("Regular".into(), FontMatch::Exact));
|
|
assert!(!db.styles(FAMILY).contains(&"Normal".to_string()));
|
|
// A style the font hasn't: the closest weight.
|
|
assert_eq!(m(FAMILY, "Black"), ("Bold".into(), FontMatch::Style));
|
|
assert_eq!(m(FAMILY, "Medium"), ("SemiBold".into(), FontMatch::Style));
|
|
// PostScript names (as PDF and .ai files name fonts): `<family>-<style>` without spaces.
|
|
assert_eq!(m("VaritestSans-Bold", "Regular"), ("Bold".into(), FontMatch::Exact));
|
|
assert_eq!(db.find_postscript("VaritestSans-SemiBold").unwrap().style, "SemiBold");
|
|
}
|
|
|
|
#[test]
|
|
fn an_instance_draws_and_measures_its_own_glyphs() {
|
|
let db = db();
|
|
let (regular, semi, bold) = (db.face(FAMILY, "Regular").unwrap(), db.face(FAMILY, "SemiBold").unwrap(), db.face(FAMILY, "Bold").unwrap());
|
|
let widen = f64::from(VARIABLE_WIDEN);
|
|
for c in VARIABLE_CHARS.chars() {
|
|
let g = regular.glyph_for(c);
|
|
assert_eq!(bold.glyph_for(c), g);
|
|
// Advances (gvar phantom points) and outlines: a quarter wider at Bold, half that at SemiBold.
|
|
let (r, b, s) = (regular.advance(g), bold.advance(g), semi.advance(g));
|
|
assert!((b - r * (1.0 + widen)).abs() <= 1.0, "{c}: advance {r} -> {b}");
|
|
assert!((s - r * (1.0 + widen / 2.0)).abs() <= 1.0, "{c}: SemiBold advance {s}");
|
|
let (ri, bi) = (ink_width(&db, ®ular, c), ink_width(&db, &bold, c));
|
|
assert!((bi - ri * (1.0 + widen)).abs() <= 2.0, "{c}: outline {ri} -> {bi}");
|
|
}
|
|
// Characters the axis doesn't change stay as they are.
|
|
let o = regular.glyph_for('o');
|
|
assert_eq!(regular.advance(o), bold.advance(o));
|
|
assert_eq!(db.outline(®ular, o).to_svg(), db.outline(&bold, o).to_svg());
|
|
|
|
// Laid out (shaped with the instance): the glyphs come from the Bold face, advance as it does
|
|
// and are drawn with its outlines (what the canvas, Create Outlines and hit tests use).
|
|
let (lr, lb) = (layout(&db, &text(FAMILY, "Regular", VARIABLE_CHARS)), layout(&db, &text(FAMILY, "Bold", VARIABLE_CHARS)));
|
|
assert!(lb.glyphs.iter().all(|g| g.font_id == bold.id()));
|
|
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
|
|
let expected: f64 = VARIABLE_CHARS.chars().map(|c| bold.advance(bold.glyph_for(c))).sum::<f64>() * 100.0 / bold.units_per_em();
|
|
assert!((width(&lb) - expected).abs() < 0.5, "shaped {} vs {expected}", width(&lb));
|
|
assert!(width(&lb) > width(&lr) * (1.0 + widen * 0.9));
|
|
let ink = |l: &TextLayout| l.to_bezpath().bounding_box().width();
|
|
assert!(ink(&lb) > ink(&lr) * (1.0 + widen * 0.9), "{} vs {}", ink(&lb), ink(&lr));
|
|
}
|
|
|
|
#[test]
|
|
fn an_embedded_instance_is_a_static_font_of_that_instance() {
|
|
let db = db();
|
|
let bold = db.face(FAMILY, "Bold").unwrap();
|
|
let e = bold.embed(&['l', 'o']).unwrap();
|
|
assert!(e.subset && !e.cff);
|
|
let f = skrifa::FontRef::new(&e.data).unwrap();
|
|
assert!(f.fvar().is_err() && f.gvar().is_err(), "no variations left");
|
|
let g = f.charmap().map('l').unwrap();
|
|
let adv = f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(g).unwrap();
|
|
assert!((f64::from(adv) - bold.advance(bold.glyph_for('l'))).abs() <= 1.0, "the Bold advance: {adv}");
|
|
// The default face embeds as before.
|
|
let regular = db.face(FAMILY, "Regular").unwrap();
|
|
let data = regular.embed(&['l']).unwrap().data;
|
|
let r = skrifa::FontRef::new(&data).unwrap();
|
|
let rl = r.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(r.charmap().map('l').unwrap()).unwrap();
|
|
assert!((f64::from(rl) - regular.advance(regular.glyph_for('l'))).abs() <= 1.0 && rl < adv, "{rl} vs {adv}");
|
|
}
|
|
|
|
#[test]
|
|
fn installed_variable_fonts_list_their_instances_before_loading() {
|
|
let dir = std::env::temp_dir().join(format!("vc-varfonts-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("Varitest[wght].ttf"), font()).unwrap();
|
|
let db = FontDb::with_font_dirs(vec![dir]);
|
|
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
|
|
assert!(!db.is_loaded(FAMILY), "listed from the name and fvar tables alone");
|
|
assert_eq!(db.by_postscript_name("VaritestSans-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
|
|
let (bold, m) = db.resolve(FAMILY, "Bold").unwrap();
|
|
assert_eq!((bold.style.as_str(), m), ("Bold", FontMatch::Exact));
|
|
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
|
|
assert!(bold.path().is_some());
|
|
}
|
|
|
|
/// Damaged or hostile `fvar` tables give fewer styles, never a panic, and the face still works.
|
|
#[test]
|
|
fn damaged_variation_tables_never_panic() {
|
|
let name = name_table(&[(1, "Broken Var"), (2, "Regular"), (256, "W")]).unwrap();
|
|
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
|
|
// Header: version 1.0, axes offset 16, reserved, axis count, axis size 20, instance count,
|
|
// instance size.
|
|
let header = |axes: u16, axis_size: u16, instances: u16, instance_size: u16| {
|
|
let mut h = be(&[0x0001_0000]);
|
|
for v in [16, 2, axes, axis_size, instances, instance_size] {
|
|
h.extend(v.to_be_bytes());
|
|
}
|
|
h
|
|
};
|
|
let axis = |tag: &[u8; 4], min: i32, def: i32, max: i32| {
|
|
let mut a = tag.to_vec();
|
|
a.extend(be(&[(min << 16) as u32, (def << 16) as u32, (max << 16) as u32]));
|
|
a.extend([0, 0, 1, 0]);
|
|
a
|
|
};
|
|
let mut damaged: Vec<Vec<u8>> = vec![
|
|
vec![],
|
|
vec![0; 3],
|
|
header(0xffff, 20, 0xffff, 8),
|
|
[header(1, 20, 0xffff, 8), axis(b"wght", 100, 400, 900)].concat(),
|
|
[header(1, 20, 3, 0xffff), axis(b"wght", 100, 400, 900)].concat(),
|
|
[header(1, 0, 3, 8), axis(b"wght", 100, 400, 900)].concat(),
|
|
[header(200, 20, 1, 804), vec![0; 4000]].concat(),
|
|
];
|
|
// Many instances (all named): the list is capped.
|
|
let mut many = [header(1, 20, 2000, 8), axis(b"wght", 0, 0, 30000)].concat();
|
|
for i in 0..2000u32 {
|
|
many.extend(be(&[256 << 16, (i + 1) << 16]));
|
|
}
|
|
damaged.push(many);
|
|
for fvar in &damaged {
|
|
let f = fontdb::sfnt_of(&[(b"name", &name), (b"fvar", fvar)]).unwrap();
|
|
let styles = fontdb::face_styles(&skrifa::FontRef::new(&f).unwrap());
|
|
assert!(!styles.is_empty() && styles.len() <= 2, "{} styles", styles.len());
|
|
}
|
|
// A real font with a damaged fvar (and its gvar) still loads and lays out.
|
|
let good = font();
|
|
let at = |tag: &[u8; 4]| {
|
|
let tables = u16::from_be_bytes([good[4], good[5]]) as usize;
|
|
let r = (0..tables).map(|i| 12 + 16 * i).find(|r| &good[*r..*r + 4] == tag).unwrap();
|
|
let off = u32::from_be_bytes(good[r + 8..r + 12].try_into().unwrap()) as usize;
|
|
let len = u32::from_be_bytes(good[r + 12..r + 16].try_into().unwrap()) as usize;
|
|
off..off + len
|
|
};
|
|
let (fvar, gvar) = (at(b"fvar"), at(b"gvar"));
|
|
// Deterministic byte damage across both tables.
|
|
let mut seed = 0x2545_f491_u32;
|
|
for round in 0..300 {
|
|
let mut bad = good.clone();
|
|
for _ in 0..1 + round % 8 {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 17;
|
|
seed ^= seed << 5;
|
|
let range = if seed.is_multiple_of(2) { fvar.clone() } else { gvar.clone() };
|
|
let i = range.start + (seed as usize >> 3) % range.len();
|
|
bad[i] = (seed >> 24) as u8;
|
|
}
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
db.add_font(bad);
|
|
for style in db.styles(FAMILY) {
|
|
let l = layout(&db, &text(FAMILY, &style, "lHio"));
|
|
assert!(l.glyphs.len() <= 8, "{style}: {} glyphs", l.glyphs.len());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Windows ships variable system fonts: Bahnschrift (weight and width axes) lists its named
|
|
/// instances and draws them. Skipped where it isn't installed.
|
|
#[test]
|
|
fn bahnschrift_lists_and_draws_its_instances() {
|
|
let db = FontDb::with_font_dirs(system_font_dirs());
|
|
if !db.has_family("Bahnschrift") {
|
|
eprintln!("skipped: Bahnschrift is not installed");
|
|
return;
|
|
}
|
|
let styles = db.styles("Bahnschrift");
|
|
eprintln!("Bahnschrift: {styles:?}");
|
|
for s in ["Light", "Regular", "SemiBold", "Bold"] {
|
|
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
|
|
}
|
|
let light = db.face("Bahnschrift", "Light").unwrap();
|
|
let bold = db.face("Bahnschrift", "Bold").unwrap();
|
|
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light", "Bold"));
|
|
assert!(bold.weight > light.weight, "{} vs {}", bold.weight, light.weight);
|
|
let (lw, bw) = (ink_width(&db, &light, 'l'), ink_width(&db, &bold, 'l'));
|
|
assert!(bw > lw * 1.05, "the Bold l is wider: {lw} -> {bw}");
|
|
let w = |s: &str| layout(&db, &text("Bahnschrift", s, "Hamburgefonstiv")).to_bezpath().bounding_box().width();
|
|
assert!(w("Bold") > w("Light"), "{} vs {}", w("Bold"), w("Light"));
|
|
}
|
|
|
|
/// Segoe UI Variable (Windows 11; optical size and weight axes). Skipped where it isn't installed.
|
|
#[test]
|
|
fn segoe_ui_variable_lists_its_instances() {
|
|
let db = FontDb::with_font_dirs(system_font_dirs());
|
|
let families: Vec<String> = db.families().into_iter().filter(|f| f.starts_with("Segoe UI Variable")).collect();
|
|
if families.is_empty() {
|
|
eprintln!("skipped: Segoe UI Variable is not installed");
|
|
return;
|
|
}
|
|
for f in &families {
|
|
let styles = db.styles(f);
|
|
eprintln!("{f}: {styles:?}");
|
|
assert!(styles.len() > 1, "{f}: {styles:?}");
|
|
}
|
|
if families.iter().any(|f| f == "Segoe UI Variable") {
|
|
let (light, bold) = (db.face("Segoe UI Variable", "Light Text").unwrap(), db.face("Segoe UI Variable", "Bold Text").unwrap());
|
|
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light Text", "Bold Text"));
|
|
assert!(bold.weight > light.weight && !bold.variations().is_empty());
|
|
let w = |f: &FontFace| ink_width(&db, f, 'm');
|
|
assert!(w(&bold) > w(&light), "{} vs {}", w(&bold), w(&light));
|
|
}
|
|
}
|