M13.12: Variable Font Axes animators change advances (text re-spaced)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 09:34:15 -04:00
parent fdeb146fb2
commit c3532df7ef
3 changed files with 64 additions and 5 deletions

View File

@ -314,13 +314,32 @@ fn variable_font_axes_animator() {
let l = comp.layer(LayerId(t)).unwrap();
effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::<f64>()
};
// The distance between the two H's left edges: the H advance.
let gap = |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 x0: Vec<f64> = effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x0).collect();
x0[1] - x0[0]
};
let before = area(&s);
let gap_before = gap(&s);
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone();
let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid;
let range = (axis.max - axis.min) as f64;
s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap();
let after = area(&s);
assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag);
// Advances follow the axis too (the text is re-spaced, not only redrawn).
let a = axes.iter().find(|a| a.tag == tag).unwrap();
let f = effectcraft_text::fonts::face(face);
let gid = f.glyph('H').unwrap();
let upem = f.units_per_em() as f64;
let units = |v: f32| effectcraft_text::variable::advance_units_at(face, gid, &[(a.tag.clone(), v)]).unwrap() as f64;
let want = (units((a.default + range as f32).min(a.max)) - units(a.default)) * 60.0 / upem;
let moved = gap(&s) - gap_before;
assert!((moved - want).abs() < 0.5, "{} axis: advance change {moved}, expected {want}", a.tag);
s.execute("edit.undo", json!({})).unwrap();
assert!((area(&s) - before).abs() < 1e-6);
let _ = info;

View File

@ -568,8 +568,17 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
(lay, xfs)
};
// Tracking (before / after each character) and Line Anchor, per line.
// Variable Font Axes: the advance at the animated design-space position (later characters
// on the line move with it).
let n = lay.glyphs.len();
let vadv: Vec<f64> = (0..n)
.map(|gi| match (subs[gi], xfs[gi].axis_deltas()) {
(None, d) if !d.is_empty() => effectcraft_text::variable::char_advance_delta(&lay.glyphs[gi], &d).unwrap_or(0.0),
_ => 0.0,
})
.collect();
let advance = |gi: usize| lay.glyphs[gi].advance + vadv[gi];
// Tracking (before / after each character) and Line Anchor, per line.
let mut shift = vec![0.0f64; n];
let mut li_start = 0;
while li_start < n {
@ -585,7 +594,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
pen += x.track_before * em;
shift[gi] = pen;
pen += x.track_after * em;
pen += x.track_after * em + vadv[gi];
if x.line_anchor_k > la_k {
la = x.line_anchor;
la_k = x.line_anchor_k;
@ -622,7 +631,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let o = origin(gi);
let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]);
e[0] = e[0].min(o[0]);
e[2] = e[2].max(o[0] + g.advance);
e[2] = e[2].max(o[0] + advance(gi));
e[1] = e[1].min(o[1]);
e[3] = e[3].max(o[1]);
}
@ -645,7 +654,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
while j < n && lay.glyphs[j].line_index == line {
j += 1;
}
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + lay.glyphs[gi].advance / 2.0).collect();
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + advance(gi) / 2.0).collect();
let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]);
let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force);
arc[i..j].copy_from_slice(&pos);
@ -684,7 +693,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
}
// Variable Font Axes: the outline redrawn at the animated design-space position.
None => match x.axis_deltas() {
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance),
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), advance(gi)),
_ => (g.path.clone(), g.advance),
},
};

View File

@ -123,6 +123,37 @@ pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<Be
Some(g.outline_xf * (*units).clone())
}
/// A glyph's advance in font units at the given axis values (user units, unspecified axes at
/// their defaults).
pub fn advance_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option<f32> {
let f = fonts::face(face);
let font = f.font()?;
let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v)));
font.glyph_metrics(Size::unscaled(), LocationRef::from(&loc)).advance_width(GlyphId::new(gid))
}
/// The change of a laid-out character's advance (layout units) when its face's axes move by
/// `deltas` (as [`char_outline_varied`]): Variable Font Axes animators re-space the text.
pub fn char_advance_delta(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<f64> {
let axes = font_axes(g.face);
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 varied = advance_units_at(g.face, g.gid, &coords)?;
let own = advance_units_at(g.face, g.gid, &g.variations)?;
// Font units → layout units: the outline transform's horizontal scale.
let sx = g.outline_xf.as_coeffs()[0];
Some((varied - own) as f64 * sx)
}
/// The first installed face with variation axes (system fonts are scanned), for tests and
/// demos: (face, its axes).
pub fn find_variable_face() -> Option<(FaceId, Vec<FontAxis>)> {