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
parent 7b0a449c5f
commit f89af341a6
7 changed files with 198 additions and 7 deletions

View File

@ -1163,7 +1163,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),

View File

@ -326,6 +326,51 @@ fn variable_font_axes_animator() {
let _ = info;
}
#[test]
fn variable_font_axes_in_the_character_style() {
let mut s = comp(600, 200);
let t = s.execute("layer.newText", json!({"text": "Hamburg", "size": 60})).unwrap()["layer"].as_u64().unwrap();
// The attribute round-trips and is undoable even for static fonts (it is kept, unused).
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert_eq!(d.style_at(0).variations, vec![("wght".to_string(), 650.0)]);
assert_eq!(effectcraft_keyframe::text_doc::char_style_json(&d.style_at(0))["variations"], json!({"wght": 650.0}));
s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "variations": {"wght": null}})).unwrap();
let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert!(d.style_at(0).variations.is_empty() && !d.style_at(5).variations.is_empty());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(0).variations.is_empty());
assert!(s.execute("layer.setText", json!({"layer": t, "variations": 3})).is_err());
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
eprintln!("no variable font installed: rendering check skipped");
return;
};
let info = effectcraft_text::fonts::face(face).info.clone();
if effectcraft_text::resolve(&info.family, &info.style).face != face {
eprintln!("variable face not reachable by family/style: skipped");
return;
}
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).or_else(|| axes.iter().find(|a| a.max > a.min)).unwrap().clone();
let geom = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
let l = comp.layer(LayerId(t)).unwrap();
let paths = effectcraft_render::text::glyph_paths(&ctx, l);
let area: f64 = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum();
let right = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x1).fold(f64::MIN, f64::max);
(area, right)
};
s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.min}})).unwrap();
let lo = geom(&s);
s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.max}})).unwrap();
let hi = geom(&s);
assert!((hi.0 - lo.0).abs() > 1.0, "{} outlines {lo:?} → {hi:?}", a.tag);
assert!((hi.1 - lo.1).abs() > 0.5, "{} advances {lo:?} → {hi:?}", a.tag);
}
#[test]
fn camera_focus_commands_undo_and_serde() {
let mut s = comp(400, 300);

View File

@ -299,6 +299,10 @@ pub struct CharStyle {
/// OpenType features (stylistic sets, figures, fractions…).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
/// Variable font axis values (tag, user units; axes not listed stay at their defaults):
/// the Character panel's Variable Font Axes.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub variations: Vec<(String, f32)>,
}
impl Default for CharStyle {
@ -383,6 +387,9 @@ pub struct TextDoc {
/// Base OpenType features (see [`CharStyle::opentype`]).
#[serde(skip_serializing_if = "OpenType::is_default")]
pub opentype: OpenType,
/// Base variable font axis values (see [`CharStyle::variations`]).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub variations: Vec<(String, f32)>,
pub indent_left: f64,
pub indent_right: f64,
pub indent_first: f64,
@ -435,6 +442,7 @@ impl Default for TextDoc {
tate_chu_yoko: false,
vertical_roman_upright: false,
opentype: OpenType::default(),
variations: vec![],
indent_left: 0.0,
indent_right: 0.0,
indent_first: 0.0,
@ -522,6 +530,7 @@ impl TextDoc {
tate_chu_yoko: self.tate_chu_yoko,
vertical_roman_upright: self.vertical_roman_upright,
opentype: self.opentype,
variations: self.variations.clone(),
}
}
@ -550,6 +559,7 @@ impl TextDoc {
self.tate_chu_yoko = s.tate_chu_yoko;
self.vertical_roman_upright = s.vertical_roman_upright;
self.opentype = s.opentype;
self.variations = s.variations.clone();
}
/// The base (first paragraph's) settings.
@ -951,6 +961,7 @@ pub const CHAR_ATTRS: &[&str] = &[
"figureStyle",
"figureWidth",
"figures",
"variations",
];
/// Paragraph attribute keys of `layer.setText`.
@ -1068,6 +1079,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
s.opentype.figure_style = st;
s.opentype.figure_width = w;
}
// Variable font axes: `{wght: 650, wdth: 90}` (null removes an axis), or `{}` / null to
// reset every axis to its default.
"variations" => match v {
J::Null => s.variations.clear(),
J::Object(m) => {
if m.is_empty() {
s.variations.clear();
}
for (tag, val) in m {
if tag.is_empty() || tag.len() > 4 {
return Err(format!("variations: `{tag}` is not an axis tag"));
}
s.variations.retain(|(t, _)| t != tag);
match val {
J::Null => {}
_ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)),
}
}
s.variations.sort_by(|a, b| a.0.cmp(&b.0));
}
_ => return Err("variations: expected {tag: number, …}".into()),
},
k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::<u32>().is_ok_and(|n| (1..=20).contains(&n)) => {
let n = k[2..].parse::<u32>().unwrap_or(0);
s.opentype.set_stylistic_set(n, flag(key, v)?);
@ -1123,6 +1156,7 @@ pub fn char_style_json(s: &CharStyle) -> J {
"stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::<Vec<_>>(),
"swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions,
"allSmallCaps": s.opentype.all_small_caps,
"variations": s.variations.iter().map(|(t, v)| (t.clone(), J::from(*v as f64))).collect::<serde_json::Map<String, J>>(),
"figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" },
"figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" },
})

View File

@ -85,6 +85,9 @@ pub struct TextStyle {
pub leading: Option<f32>,
/// OpenType features (stylistic sets, figures, fractions, all small caps…).
pub opentype: OpenType,
/// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar
/// phantom points) and outlines use this design-space position.
pub variations: Vec<(String, f32)>,
}
impl Default for TextStyle {
@ -109,6 +112,7 @@ impl Default for TextStyle {
script: Script::Normal,
leading: None,
opentype: OpenType::default(),
variations: Vec::new(),
}
}
}
@ -194,6 +198,10 @@ impl Hash for TextStyle {
self.leading.map(f32::to_bits).hash(h);
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
self.opentype.hash(h);
for (t, v) in &self.variations {
t.hash(h);
hf(h, *v);
}
}
}
@ -225,6 +233,9 @@ pub struct Glyph {
pub v_scale: f32,
/// Index of the style run the glyph belongs to.
pub run: usize,
/// The run's variable font axis values, interned ([`crate::variable::coords`]; 0 = the
/// default instance): outlines are drawn there.
pub variations: u32,
}
/// One laid-out line.
@ -425,7 +436,13 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let shaper = data.shaper(&font).build();
let instance = (!style.variations.is_empty()).then(|| {
harfrust::ShaperInstance::from_variations(
&font,
style.variations.iter().map(|(t, v)| harfrust::Variation { tag: crate::variable::tag_of(t), value: *v }),
)
});
let shaper = data.shaper(&font).instance(instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for &(b, _, sc) in chars {
buf.add(if sc == '\t' { ' ' } else { sc }, b as u32);
@ -819,6 +836,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: crate::variable::intern(&st.variations),
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {

View File

@ -70,7 +70,8 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its
/// horizontal / vertical scale and faux italic.
pub fn glyph_outline(g: &Glyph) -> BezPath {
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) };
let Some(units) = units else { return BezPath::new() };
glyph_affine(g) * (*units).clone()
}
@ -107,6 +108,8 @@ pub struct CharGlyph {
pub face: FaceId,
pub gid: u32,
pub outline_xf: Affine,
/// The character's variable font axis values (its style's Variable Font Axes).
pub variations: Vec<(String, f32)>,
}
#[derive(Clone, Debug, Default)]
@ -162,6 +165,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle {
},
leading: s.leading.map(|l| l as f32),
opentype: s.opentype,
variations: s.variations.clone(),
}
}
@ -336,6 +340,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
face: g.face,
gid: g.id,
outline_xf,
variations: variable::coords(g.variations).to_vec(),
});
}
}

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);
}
}

View File

@ -257,6 +257,29 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim);
}
}
// Variable Font Axes: one value per axis of a variable font (`character.axis.<tag>`).
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
let axes = effectcraft_engine::text::variable::font_axes(face);
if !axes.is_empty() {
y += 26.0;
p.text(pos2(x0, y + 7.0), Align2::LEFT_CENTER, "Variable Font Axes", Tokens::semibold(11.0), t.text_dim);
for (i, a) in axes.iter().enumerate() {
let (col, row) = (i % 2, i / 2);
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + 20.0 + row as f32 * 24.0;
let tag = a.tag.trim_end().to_string();
let v = doc.variations.iter().find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
if let Some(nv) = nv
&& enabled
{
actions.push(json!({"variations": {tag.clone(): nv}, "merge": format!("char-axis-{tag}")}));
}
}
}
match &target {
None => {
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);