M13.6: variable font axes in the character style

CharStyle/TextDoc variations (layer.setText variations), shaped with harfrust instances so
advances follow HVAR/gvar, outlines drawn at the design-space position, animator deltas relative
to the style's values, Character panel Variable Font Axes fields (character.axis.<tag>).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 07:36:53 -04:00
co-authored by Claude Opus 5.5
parent 7b0a449c5f
commit f89af341a6
7 changed files with 198 additions and 7 deletions
+70 -4
View File
@@ -12,6 +12,32 @@ use skrifa::{GlyphId, MetadataProvider, Tag};
use crate::fonts::{self, FaceId};
use crate::{CharGlyph, Pen};
/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them.
pub fn intern(coords: &[(String, f32)]) -> u32 {
if coords.is_empty() {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
if let Some(i) = t.iter().position(|c| c.as_slice() == coords) {
return i as u32 + 1;
}
t.push(Arc::new(coords.to_vec()));
t.len() as u32
}
/// The axis values of an interned id (empty for 0).
pub fn coords(id: u32) -> Arc<Vec<(String, f32)>> {
if id == 0 {
return Arc::new(Vec::new());
}
table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default()
}
fn table() -> &'static Mutex<Vec<Arc<Vec<(String, f32)>>>> {
static T: OnceLock<Mutex<Vec<Arc<Vec<(String, f32)>>>>> = OnceLock::new();
T.get_or_init(Default::default)
}
/// One variation axis of a face, in user units.
#[derive(Clone, Debug, PartialEq)]
pub struct FontAxis {
@@ -38,7 +64,7 @@ pub fn font_axes(face: FaceId) -> Vec<FontAxis> {
}
/// A tag string as a [`Tag`] (padded with spaces, at most four characters).
fn tag_of(s: &str) -> Tag {
pub(crate) fn tag_of(s: &str) -> Tag {
let mut b = [b' '; 4];
for (i, c) in s.bytes().take(4).enumerate() {
b[i] = c;
@@ -78,14 +104,21 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt
}
/// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes
/// moved by `deltas` (tag, user-unit offset from the axis default).
/// moved by `deltas` (tag, user-unit offset from the character's own axis value — its style's
/// Variable Font Axes setting, else the axis default).
pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<BezPath> {
let axes = font_axes(g.face);
let coords: Vec<(String, f32)> =
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect();
let base = |t: &str, d: f32| g.variations.iter().find(|(x, _)| x.trim_end() == t.trim_end()).map(|(_, v)| *v).unwrap_or(d);
let mut coords: Vec<(String, f32)> =
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (base(&a.tag, a.default) + d).clamp(a.min, a.max)))).collect();
if coords.is_empty() {
return None;
}
for (t, v) in &g.variations {
if !coords.iter().any(|(c, _)| c == t) {
coords.push((t.clone(), *v));
}
}
let units = outline_units_at(g.face, g.gid, &coords)?;
Some(g.outline_xf * (*units).clone())
}
@@ -122,4 +155,37 @@ mod tests {
let s = crate::outline_units(face, gid).unwrap();
assert!((d.area() - s.area()).abs() < 1e-3);
}
#[test]
fn style_variations_change_advances_and_outlines() {
assert_eq!(intern(&[]), 0);
let id = intern(&[("wght".into(), 700.0)]);
assert_eq!(intern(&[("wght".into(), 700.0)]), id);
assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]);
let Some((face, axes)) = find_variable_face() else {
eprintln!("no variable font installed: skipped");
return;
};
// An axis that changes widths (wdth or wght, else the first that moves).
let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]);
let info = crate::fonts::face(face).info.clone();
let st = |v: f32| crate::TextStyle {
family: info.family.clone(),
style: info.style.clone(),
size: 100.0,
variations: vec![(a.tag.clone(), v)],
..Default::default()
};
let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max)));
if crate::resolve(&info.family, &info.style).face != face {
eprintln!("variable face not reachable by family/style: skipped");
return;
}
assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max);
let ink = |v: f32| {
let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default());
l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::<f64>()
};
assert!((ink(a.min) - ink(a.max)).abs() > 1.0);
}
}