mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M13.5: Animate Text > Variable Font Axes
Text: text.animatorFontAxes (replaces the stub) lists the layer font's variation axes (skrifa) or adds an animator property that offsets one axis (weighted by the selectors, from the axis default); the renderer redraws each character's outline at its design-space position (cached per location). The Timeline's Animate: menu lists the font's axes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5b4e936e9d
commit
7537eea85c
7 files changed
+310
-5
No files matched your search
+20
-3
@@ -10,6 +10,7 @@ pub mod layout;
|
||||
pub mod path_text;
|
||||
pub mod selectors;
|
||||
pub mod sfnt;
|
||||
pub mod variable;
|
||||
|
||||
pub use kurbo;
|
||||
|
||||
@@ -24,7 +25,7 @@ use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
struct Pen(BezPath);
|
||||
pub(crate) struct Pen(pub(crate) BezPath);
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
@@ -70,10 +71,15 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
/// 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() };
|
||||
glyph_affine(g) * (*units).clone()
|
||||
}
|
||||
|
||||
/// Font units → the glyph's outline in pixels (size, horizontal / vertical scale, faux italic).
|
||||
pub fn glyph_affine(g: &Glyph) -> Affine {
|
||||
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
|
||||
let slant = if g.synth_italic { 0.21 } else { 0.0 };
|
||||
let (hs, vs) = (g.h_scale as f64, g.v_scale as f64);
|
||||
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone()
|
||||
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0])
|
||||
}
|
||||
|
||||
/// One character of laid-out text.
|
||||
@@ -97,6 +103,10 @@ pub struct CharGlyph {
|
||||
pub size: f64,
|
||||
/// Index into [`TextLayout::styles`].
|
||||
pub run: usize,
|
||||
/// Face and glyph id, and font units → `path` (for variable-font redraws).
|
||||
pub face: FaceId,
|
||||
pub gid: u32,
|
||||
pub outline_xf: Affine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -266,6 +276,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
let st = styles.get(g.run);
|
||||
let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src };
|
||||
let mut path = glyph_outline(g);
|
||||
let mut outline_xf = glyph_affine(g);
|
||||
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
|
||||
let advance = (next_x - g.x) as f64;
|
||||
let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64);
|
||||
@@ -293,10 +304,13 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
// Keep the character upright around its centre.
|
||||
let c = Point::new(advance / 2.0, -0.35 * g.size as f64);
|
||||
let rc = ROT90 * c;
|
||||
path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path;
|
||||
let t = Affine::translate((rc.x - c.x, rc.y - c.y));
|
||||
path = t * path;
|
||||
outline_xf = t * outline_xf;
|
||||
} else {
|
||||
// Roman characters turn on their side with the column.
|
||||
path = ROT90 * path;
|
||||
outline_xf = ROT90 * outline_xf;
|
||||
}
|
||||
}
|
||||
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
|
||||
@@ -318,6 +332,9 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
synth_bold: g.synth_bold,
|
||||
size: g.size as f64,
|
||||
run: g.run,
|
||||
face: g.face,
|
||||
gid: g.id,
|
||||
outline_xf,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Variable fonts: a face's variation axes and glyph outlines at a point of its design space
|
||||
//! (Animate Text ▸ Variable Font Axes).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::DrawSettings;
|
||||
use skrifa::{GlyphId, MetadataProvider, Tag};
|
||||
|
||||
use crate::fonts::{self, FaceId};
|
||||
use crate::{CharGlyph, Pen};
|
||||
|
||||
/// One variation axis of a face, in user units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FontAxis {
|
||||
/// Four-letter tag (`wght`, `wdth`, `opsz`, `slnt`, `ital` or a custom one).
|
||||
pub tag: String,
|
||||
pub name: String,
|
||||
pub min: f32,
|
||||
pub default: f32,
|
||||
pub max: f32,
|
||||
}
|
||||
|
||||
/// The variation axes of a face (empty for static fonts).
|
||||
pub fn font_axes(face: FaceId) -> Vec<FontAxis> {
|
||||
let f = fonts::face(face);
|
||||
let Some(font) = f.font() else { return Vec::new() };
|
||||
font.axes()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
|
||||
let name = font.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
|
||||
FontAxis { tag, name, min: a.min_value(), default: a.default_value(), max: a.max_value() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tag string as a [`Tag`] (padded with spaces, at most four characters).
|
||||
fn tag_of(s: &str) -> Tag {
|
||||
let mut b = [b' '; 4];
|
||||
for (i, c) in s.bytes().take(4).enumerate() {
|
||||
b[i] = c;
|
||||
}
|
||||
Tag::new(&b)
|
||||
}
|
||||
|
||||
type Key = (FaceId, u32, Vec<(String, i32)>);
|
||||
|
||||
/// A glyph's outline in font units at the given axis values (user units, unspecified axes at
|
||||
/// their defaults).
|
||||
pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option<Arc<BezPath>> {
|
||||
if coords.is_empty() {
|
||||
return crate::outline_units(face, gid);
|
||||
}
|
||||
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<BezPath>>>>> = OnceLock::new();
|
||||
let c = C.get_or_init(Default::default);
|
||||
// Quantise to 1/100 of a unit so animation frames reuse outlines.
|
||||
let key: Key = (face, gid, coords.iter().map(|(t, v)| (t.clone(), (v * 100.0).round() as i32)).collect());
|
||||
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return v.clone();
|
||||
}
|
||||
let f = fonts::face(face);
|
||||
let v = f.font().and_then(|font| {
|
||||
let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v)));
|
||||
let g = font.outline_glyphs().get(GlyphId::new(gid))?;
|
||||
let mut pen = Pen(BezPath::new());
|
||||
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::from(&loc)), &mut pen).ok()?;
|
||||
Some(Arc::new(pen.0))
|
||||
});
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 20_000 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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();
|
||||
if coords.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let units = outline_units_at(g.face, g.gid, &coords)?;
|
||||
Some(g.outline_xf * (*units).clone())
|
||||
}
|
||||
|
||||
/// 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>)> {
|
||||
fonts::scan_system();
|
||||
(0..fonts::all_faces().len()).map(|f| (f, font_axes(f))).find(|(_, a)| a.iter().any(|x| x.max > x.min))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
#[test]
|
||||
fn static_fonts_have_no_axes_and_variable_outlines_move() {
|
||||
let inter = crate::resolve("Inter", "Regular").face;
|
||||
assert!(font_axes(inter).is_empty());
|
||||
// A variable system font, when one is installed (macOS ships several): its outline
|
||||
// changes along an axis. Skipped on machines without one.
|
||||
let Some((face, axes)) = find_variable_face() else {
|
||||
eprintln!("no variable font installed: outline check skipped");
|
||||
return;
|
||||
};
|
||||
let a = axes.iter().find(|x| x.max > x.min).unwrap();
|
||||
let gid = fonts::face(face).glyph('H').or_else(|| fonts::face(face).glyph('A')).unwrap_or(1);
|
||||
let lo = outline_units_at(face, gid, &[(a.tag.clone(), a.min)]).unwrap();
|
||||
let hi = outline_units_at(face, gid, &[(a.tag.clone(), a.max)]).unwrap();
|
||||
assert_ne!(lo.area(), hi.area(), "{} {}..{}", a.tag, a.min, a.max);
|
||||
// The default location is the static outline.
|
||||
let d = outline_units_at(face, gid, &[(a.tag.clone(), a.default)]).unwrap();
|
||||
let s = crate::outline_units(face, gid).unwrap();
|
||||
assert!((d.area() - s.area()).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user