diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 58135b35..f8edd102 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1163,7 +1163,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index da6c6c3a..705425c0 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -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); diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index ae0b2e56..f1845993 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -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 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::().is_ok_and(|n| (1..=20).contains(&n)) => { let n = k[2..].parse::().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::>(), "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::>(), "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" }, }) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3333cdd1..f90aa7f3 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -85,6 +85,9 @@ pub struct TextStyle { pub leading: Option, /// 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 { 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, 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 { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index beef0382..65232f02 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -70,7 +70,8 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { /// 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(), }); } } diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index 95fdec18..2072c294 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -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> { + 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>>> { + static T: OnceLock>>>> = 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 { } /// 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 { 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::() + }; + assert!((ink(a.min) - ink(a.max)).abs() > 1.0); + } } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 81b9d077..09f60086 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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.`). + 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);