diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 29b4c595..bf87a96d 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1157,7 +1157,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?, 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?, 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/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 28048389..37ed9da0 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -465,6 +465,38 @@ pub(crate) fn sanitize(s: &mut Session) { } } +/// The OpenType features of a font (`font` / `style`, else the target text layer's style at the +/// caret or its first character), and which Character panel options it sets with its own glyphs +/// (the others are synthesized or have no effect). +pub fn font_features(s: &mut Session, p: &Value) -> Result { + let (family, style) = match p.get("font").and_then(Value::as_str) { + Some(f) => (f.to_string(), p.get("style").and_then(Value::as_str).unwrap_or("Regular").to_string()), + None => { + let lid = target(s, p, "text.fontFeatures")?; + let doc = layer_doc(s, lid).ok_or_else(|| bad("text.fontFeatures", "not a text layer"))?; + let at = s.state.text_edit.as_ref().filter(|e| e.layer == lid).map_or(0, |e| e.range().start); + let st = doc.style_at(at); + (st.font, st.style) + } + }; + let r = effectcraft_text::resolve(&family, &style); + let face = effectcraft_text::fonts::face(r.face); + let feats = face.features(); + let has = |t: &str| feats.iter().any(|f| f == t); + let sets: Vec = (1..=20u32).filter(|n| has(&format!("ss{n:02}"))).collect(); + Ok(json!({ + "family": face.info.family, "style": face.info.style, "missing": r.missing, + "features": feats, + "options": { + "kerning": has("kern"), "ligatures": has("liga") || has("clig"), "discretionaryLigatures": has("dlig"), + "contextualAlternates": has("calt"), "stylisticAlternates": has("salt"), "stylisticSets": sets, + "swash": has("swsh"), "titling": has("titl"), "ordinals": has("ordn"), "fractions": has("frac"), + "smallCaps": has("smcp"), "allSmallCaps": has("c2sc"), "superscript": has("sups"), "subscript": has("subs"), + "oldStyleFigures": has("onum"), "liningFigures": has("lnum"), "tabularFigures": has("tnum"), "proportionalFigures": has("pnum"), + }, + })) +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } @@ -485,6 +517,15 @@ pub fn specs() -> Vec { edit ), cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit), + cmd!( + "text.fontFeatures", + "OpenType Features", + [], + None, + "{layer?, font?, style?} → {family, style, features: [tags], options: {smallCaps, superscript, stylisticSets: [n], fractions, …}} (what the font sets with its own glyphs)", + always_ok, + font_features + ), cmd!( "text.setSelection", "Set Text Selection", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9333329a..f28c07a7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -747,6 +747,11 @@ pub fn text_presets() -> Vec<(String, String)> { pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } + +/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to. +pub fn font_features(family: &str, style: &str) -> Vec { + effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features() +} #[cfg(test)] mod tests_paint; #[cfg(test)] diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 2476727f..46610786 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -202,3 +202,28 @@ fn edit_state_round_trips_through_serde() { let back: crate::EditorState = serde_json::from_value(j).unwrap(); assert_eq!(back.text_edit, s.state.text_edit); } + +#[test] +fn opentype_features_per_range_render_and_query() { + let (mut s, t) = session("Office 1/2"); + s.execute("layer.setText", json!({"layer": t, "range": [7, 10], "fractions": true, "ss01": true})).unwrap(); + let d = doc(&s, t); + assert!(!d.style_at(0).opentype.fractions); + assert!(d.style_at(8).opentype.fractions && d.style_at(8).opentype.stylistic_set(1)); + // The laid-out glyphs change (frac turns 1/2 into a fraction). + let plain = effectcraft_text::layout_doc(&TextDoc { text: "1/2".into(), ..Default::default() }); + let frac = effectcraft_text::layout_doc(&d.slice(7..10)); + let ids = |l: &effectcraft_text::TextLayout| l.glyphs.iter().map(|g| g.gid).collect::>(); + assert_ne!(ids(&plain), ids(&frac)); + // Undo restores the uniform style. + s.execute("edit.undo", json!({})).unwrap(); + assert!(doc(&s, t).is_uniform()); + // Font feature query (font by name, or the layer's). + let f = s.execute("text.fontFeatures", json!({"font": "Noto Serif"})).unwrap(); + assert_eq!(f["options"]["smallCaps"], true); + assert!(f["features"].as_array().unwrap().iter().any(|t| t == "onum")); + let f = s.execute("text.fontFeatures", json!({"layer": t})).unwrap(); + assert_eq!(f["family"], "Inter"); + assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1))); + assert_eq!(f["options"]["smallCaps"], false); +} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index ec2b4754..8932530d 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -13,7 +13,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; -pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun}; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 2e9d5c6e..ae0b2e56 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -143,6 +143,125 @@ fn yes() -> bool { true } +/// Figure style (OpenType `lnum` / `onum`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FigureStyle { + /// The font's default figures. + #[default] + Default, + Lining, + OldStyle, +} + +/// Figure spacing (OpenType `pnum` / `tnum`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FigureWidth { + #[default] + Default, + Proportional, + Tabular, +} + +/// OpenType layout features of a run of characters (Character panel ▸ OpenType). Kerning, +/// standard ligatures, small caps and superscript / subscript have their own [`CharStyle`] +/// fields (they use the font's `smcp` / `sups` / `subs` glyphs when it has them and synthesize +/// them otherwise). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct OpenType { + /// `dlig`. + pub discretionary_ligatures: bool, + /// `calt` (on by default, as in the font). + pub contextual_alternates: bool, + /// `salt`. + pub stylistic_alternates: bool, + /// Stylistic sets: bit `n - 1` turns on `ssNN` (n = 1…20). + pub stylistic_sets: u32, + /// `swsh`. + pub swash: bool, + /// `titl`. + pub titling: bool, + /// `ordn`. + pub ordinals: bool, + /// `frac`. + pub fractions: bool, + /// All Small Caps: capitals become small capitals too (`c2sc` with `smcp`). + pub all_small_caps: bool, + pub figure_style: FigureStyle, + pub figure_width: FigureWidth, +} + +impl Default for OpenType { + fn default() -> Self { + OpenType { + discretionary_ligatures: false, + contextual_alternates: true, + stylistic_alternates: false, + stylistic_sets: 0, + swash: false, + titling: false, + ordinals: false, + fractions: false, + all_small_caps: false, + figure_style: FigureStyle::Default, + figure_width: FigureWidth::Default, + } + } +} + +impl OpenType { + pub fn is_default(&self) -> bool { + *self == OpenType::default() + } + /// Whether stylistic set `n` (1…20) is on. + pub fn stylistic_set(&self, n: u32) -> bool { + (1..=20).contains(&n) && self.stylistic_sets & (1 << (n - 1)) != 0 + } + pub fn set_stylistic_set(&mut self, n: u32, on: bool) { + if (1..=20).contains(&n) { + if on { + self.stylistic_sets |= 1 << (n - 1); + } else { + self.stylistic_sets &= !(1 << (n - 1)); + } + } + } + /// The OpenType feature settings these options ask for (tag, value), beyond the font's + /// defaults: e.g. `[("dlig", 1), ("ss02", 1), ("calt", 0)]`. + pub fn feature_settings(&self) -> Vec<([u8; 4], u32)> { + let mut v = Vec::new(); + for (tag, f) in [ + (b"dlig", self.discretionary_ligatures), + (b"salt", self.stylistic_alternates), + (b"swsh", self.swash), + (b"titl", self.titling), + (b"ordn", self.ordinals), + (b"frac", self.fractions), + (b"c2sc", self.all_small_caps), + (b"smcp", self.all_small_caps), + (b"lnum", self.figure_style == FigureStyle::Lining), + (b"onum", self.figure_style == FigureStyle::OldStyle), + (b"pnum", self.figure_width == FigureWidth::Proportional), + (b"tnum", self.figure_width == FigureWidth::Tabular), + ] { + if f { + v.push((*tag, 1)); + } + } + if !self.contextual_alternates { + v.push((*b"calt", 0)); + } + for n in 1..=20u32 { + if self.stylistic_set(n) { + let d = format!("ss{n:02}"); + let b = d.as_bytes(); + v.push(([b[0], b[1], b[2], b[3]], 1)); + } + } + v + } +} + /// The formatting of a run of characters (Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -177,6 +296,9 @@ pub struct CharStyle { /// Vertical type: keep Roman (half-width) characters upright instead of turning them on /// their side (Character panel menu ▸ Standard Vertical Roman Alignment). pub vertical_roman_upright: bool, + /// OpenType features (stylistic sets, figures, fractions…). + #[serde(skip_serializing_if = "OpenType::is_default")] + pub opentype: OpenType, } impl Default for CharStyle { @@ -258,6 +380,9 @@ pub struct TextDoc { /// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]). pub tate_chu_yoko: bool, pub vertical_roman_upright: bool, + /// Base OpenType features (see [`CharStyle::opentype`]). + #[serde(skip_serializing_if = "OpenType::is_default")] + pub opentype: OpenType, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -309,6 +434,7 @@ impl Default for TextDoc { ligatures: true, tate_chu_yoko: false, vertical_roman_upright: false, + opentype: OpenType::default(), indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -395,6 +521,7 @@ impl TextDoc { ligatures: self.ligatures, tate_chu_yoko: self.tate_chu_yoko, vertical_roman_upright: self.vertical_roman_upright, + opentype: self.opentype, } } @@ -422,6 +549,7 @@ impl TextDoc { self.ligatures = s.ligatures; self.tate_chu_yoko = s.tate_chu_yoko; self.vertical_roman_upright = s.vertical_roman_upright; + self.opentype = s.opentype; } /// The base (first paragraph's) settings. @@ -777,6 +905,9 @@ fn color_json(v: &J) -> Option<[f32; 4]> { fn num(key: &str, v: &J) -> Result { v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number")) } +fn norm(v: &J) -> Option { + v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")) +} fn flag(key: &str, v: &J) -> Result { v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean")) } @@ -808,6 +939,18 @@ pub const CHAR_ATTRS: &[&str] = &[ "ligatures", "tateChuYoko", "verticalRomanUpright", + "discretionaryLigatures", + "contextualAlternates", + "stylisticAlternates", + "stylisticSets", + "swash", + "titling", + "ordinals", + "fractions", + "allSmallCaps", + "figureStyle", + "figureWidth", + "figures", ]; /// Paragraph attribute keys of `layer.setText`. @@ -850,6 +993,7 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { @@ -869,6 +1013,65 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result s.ligatures = flag(key, v)?, "tateChuYoko" => s.tate_chu_yoko = flag(key, v)?, "verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?, + "discretionaryLigatures" => s.opentype.discretionary_ligatures = flag(key, v)?, + "contextualAlternates" => s.opentype.contextual_alternates = flag(key, v)?, + "stylisticAlternates" => s.opentype.stylistic_alternates = flag(key, v)?, + "swash" => s.opentype.swash = flag(key, v)?, + "titling" => s.opentype.titling = flag(key, v)?, + "ordinals" => s.opentype.ordinals = flag(key, v)?, + "fractions" => s.opentype.fractions = flag(key, v)?, + "allSmallCaps" => { + s.opentype.all_small_caps = flag(key, v)?; + if s.opentype.all_small_caps { + s.all_caps = false; + } + } + "stylisticSets" => { + s.opentype.stylistic_sets = match v { + J::Array(a) => { + let mut m = 0u32; + for n in a { + let n = n.as_u64().filter(|n| (1..=20).contains(n)).ok_or("stylisticSets: expected set numbers 1–20")?; + m |= 1 << (n - 1); + } + m + } + J::Number(_) => (num(key, v)? as u64 & 0xF_FFFF) as u32, + _ => return Err("stylisticSets: expected [1, 2, …] or a bit mask".into()), + } + } + "figureStyle" => { + s.opentype.figure_style = match norm(v).as_deref() { + Some("default") => FigureStyle::Default, + Some("lining") => FigureStyle::Lining, + Some("oldstyle") => FigureStyle::OldStyle, + _ => return Err("figureStyle: expected default|lining|oldStyle".into()), + } + } + "figureWidth" => { + s.opentype.figure_width = match norm(v).as_deref() { + Some("default") => FigureWidth::Default, + Some("proportional") => FigureWidth::Proportional, + Some("tabular") => FigureWidth::Tabular, + _ => return Err("figureWidth: expected default|proportional|tabular".into()), + } + } + "figures" => { + let (st, w) = match norm(v).as_deref() { + Some("default") => (FigureStyle::Default, FigureWidth::Default), + Some("tabularlining") => (FigureStyle::Lining, FigureWidth::Tabular), + Some("proportionallining") => (FigureStyle::Lining, FigureWidth::Proportional), + Some("tabularoldstyle") => (FigureStyle::OldStyle, FigureWidth::Tabular), + Some("proportionaloldstyle") => (FigureStyle::OldStyle, FigureWidth::Proportional), + _ => return Err("figures: expected default|tabularLining|proportionalLining|tabularOldstyle|proportionalOldstyle".into()), + }; + s.opentype.figure_style = st; + s.opentype.figure_width = w; + } + 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)?); + } _ => return Ok(false), } Ok(true) @@ -915,6 +1118,13 @@ pub fn char_style_json(s: &CharStyle) -> J { "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), "ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright, + "discretionaryLigatures": s.opentype.discretionary_ligatures, "contextualAlternates": s.opentype.contextual_alternates, + "stylisticAlternates": s.opentype.stylistic_alternates, + "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, + "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" }, }) } @@ -1127,4 +1337,37 @@ mod tests { assert_eq!(word_at(t, 8), 7..10); assert_eq!(word_at(t, 6), 6..7); } + + #[test] + fn opentype_attributes_per_range_and_serde() { + let mut d = TextDoc::plain("Office 1/2 1st"); + // Untouched documents serialize without an OpenType block. + assert!(!serde_json::to_string(&d).unwrap().contains("opentype")); + d.set_attr("ss02", &J::Bool(true), Some(0..6)).unwrap(); + d.set_attr("fractions", &J::Bool(true), Some(7..10)).unwrap(); + d.set_attr("figures", &J::from("tabularOldstyle"), None).unwrap(); + assert!(d.style_at(0).opentype.stylistic_set(2)); + assert!(!d.style_at(8).opentype.stylistic_set(2)); + assert!(d.style_at(8).opentype.fractions); + assert_eq!(d.style_at(12).opentype.figure_width, FigureWidth::Tabular); + assert!(d.set_attr("stylisticSets", &serde_json::json!([1, 20]), Some(0..1)).unwrap()); + assert_eq!(d.style_at(0).opentype.stylistic_sets, 1 | (1 << 19)); + assert!(d.set_attr("stylisticSets", &serde_json::json!([21]), None).is_err()); + let f = d.style_at(8).opentype.feature_settings(); + assert!(f.contains(&(*b"frac", 1)) && f.contains(&(*b"onum", 1)) && f.contains(&(*b"tnum", 1))); + let mut s = d.style_at(0); + s.opentype.contextual_alternates = false; + assert!(s.opentype.feature_settings().contains(&(*b"calt", 0))); + // Round trip, and the JSON form names the attributes as layer.setText does. + let back: TextDoc = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back, d); + let j = char_style_json(&d.style_at(0)); + assert_eq!(j["stylisticSets"], serde_json::json!([1, 20])); + assert_eq!(j["figureStyle"], "oldStyle"); + // All Small Caps and All Caps exclude each other. + let mut s = CharStyle::default(); + apply_char_attr(&mut s, "allCaps", &J::Bool(true)).unwrap(); + apply_char_attr(&mut s, "allSmallCaps", &J::Bool(true)).unwrap(); + assert!(!s.all_caps && s.opentype.all_small_caps); + } } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c3a60f5d..297a816c 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -212,6 +212,21 @@ impl Face { underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)), } } + /// The OpenType layout features the face offers (GSUB and GPOS tags, sorted, deduplicated). + pub fn features(&self) -> Vec { + use skrifa::raw::TableProvider; + let Some(f) = self.font() else { return vec![] }; + let mut out: Vec = vec![]; + if let Ok(list) = f.gsub().and_then(|g| g.feature_list()) { + out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned())); + } + if let Ok(list) = f.gpos().and_then(|g| g.feature_list()) { + out.extend(list.feature_records().iter().map(|r| String::from_utf8_lossy(&r.feature_tag().to_be_bytes()).into_owned())); + } + out.sort(); + out.dedup(); + out + } /// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`). pub fn has_feature(&self, tag: &[u8; 4]) -> bool { use skrifa::raw::TableProvider; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 824b7373..3333cdd1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -18,6 +18,8 @@ use std::sync::{Arc, Mutex, OnceLock}; use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; use unicode_bidi::{BidiInfo, Level}; +use effectcraft_keyframe::OpenType; + use crate::fonts::{self, FaceId, Resolved}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -81,6 +83,8 @@ pub struct TextStyle { pub script: Script, /// Baseline-to-baseline distance in pixels; None = auto (120% of the size). pub leading: Option, + /// OpenType features (stylistic sets, figures, fractions, all small caps…). + pub opentype: OpenType, } impl Default for TextStyle { @@ -104,6 +108,7 @@ impl Default for TextStyle { tsume: 0.0, script: Script::Normal, leading: None, + opentype: OpenType::default(), } } } @@ -188,6 +193,7 @@ impl Hash for TextStyle { self.manual_kern.map(f32::to_bits).hash(h); 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); } } @@ -342,6 +348,8 @@ struct ShapedGlyph { dy: f32, size: f32, run: usize, + /// Upward baseline offset (baseline shift, plus a synthesized superscript / subscript's). + rise: f32, } struct Item { @@ -360,6 +368,50 @@ fn upper_single(c: char) -> char { const SMALL_CAPS_SCALE: f32 = 0.78; +/// How a character is set: as itself, as a synthesized small capital (a scaled-down capital), +/// with the font's small-capital glyph (`smcp` / `c2sc`), or with the font's superior / inferior +/// glyph (`sups` / `subs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Form { + Normal, + FauxSmall, + TrueSmall, + TrueScript, +} + +/// Whether OpenType feature `tag` gives character `c` its own glyph in `face` (cached): the +/// true small caps / superscripts / subscripts test, with synthesized forms as the fallback. +pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool { + static C: OnceLock>> = OnceLock::new(); + let cache = C.get_or_init(Default::default); + if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, *tag, c)) { + return *v; + } + let f = fonts::face(face); + let v = f.has_feature(tag) + && match (f.font(), f.shaper_data()) { + (Some(font), Some(data)) => { + let shaper = data.shaper(&font).build(); + let shape = |feats: &[Feature]| -> Vec { + let mut buf = UnicodeBuffer::new(); + buf.add(c, 0); + buf.guess_segment_properties(); + shaper.shape(buf, harfrust::ShapeOptions::new().features(feats)).glyph_infos().iter().map(|i| i.glyph_id).collect() + }; + let plain = shape(&[]); + let with = shape(&[Feature::new(Tag::new(tag), 1, ..)]); + plain != with && !with.contains(&0) + } + _ => false, + }; + let mut m = cache.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 100_000 { + m.clear(); + } + m.insert((face, *tag, c), v); + v +} + /// Horizontal ink extent `(x0, x1)` of a glyph in font units. fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { let p = crate::outline_units(face, gid)?; @@ -370,7 +422,7 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { Some((r.x0 as f32, r.x1 as f32)) } -fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec { +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(); @@ -388,6 +440,30 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, feats.push(Feature::new(Tag::new(b"liga"), 0, ..)); feats.push(Feature::new(Tag::new(b"clig"), 0, ..)); } + // OpenType options; small caps go per character (true where the font has them). + for (tag, v) in style.opentype.feature_settings() { + if &tag != b"smcp" && &tag != b"c2sc" { + feats.push(Feature::new(Tag::new(&tag), v, ..)); + } + } + if style.caps == Caps::All { + // Case-sensitive forms (punctuation raised to sit with capitals). + feats.push(Feature::new(Tag::new(b"case"), 1, ..)); + } + let rise = match form { + Form::TrueScript => style.baseline_shift, + _ => style.rise(), + }; + match form { + Form::TrueSmall => { + feats.push(Feature::new(Tag::new(b"smcp"), 1, ..)); + if style.opentype.all_small_caps { + feats.push(Feature::new(Tag::new(b"c2sc"), 1, ..)); + } + } + Form::TrueScript => feats.push(Feature::new(Tag::new(if style.script == Script::Sub { b"subs" } else { b"sups" }), 1, ..)), + _ => {} + } let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats)); let k = size / f.units_per_em(); let hs = style.h_scale; @@ -404,6 +480,7 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, dy: p.y_offset as f32 * k, size, run, + rise, }) .collect() } @@ -539,38 +616,54 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let bidi = BidiInfo::new(text, default_level); let pinfo = &bidi.paragraphs[0]; let base_rtl = pinfo.level.is_rtl(); - // per char: (byte, char, shaped char, face, rtl, small caps, run) - let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text + // per char: (byte, char, shaped char, face, rtl, form, run) + let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text .char_indices() .map(|(b, c)| { let si = run_at(runs, base + b); let style = &runs[si].1; - let (sc, small) = match style.caps { - Caps::Normal => (c, false), - Caps::All => (upper_single(c), false), - Caps::Small if c.is_lowercase() => (upper_single(c), true), - Caps::Small => (c, false), - }; let primary = primaries[si].face; - let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) }; - (b, c, sc, face, bidi.levels[b].is_rtl(), small, si) + let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) }; + let all_small = style.opentype.all_small_caps && style.caps != Caps::All; + let small = (style.caps == Caps::Small || all_small) && c.is_lowercase(); + let small_cap = all_small && c.is_uppercase(); + let (sc, mut form) = match style.caps { + Caps::All => (upper_single(c), Form::Normal), + _ if small && feature_substitutes(face_of(c), b"smcp", c) => (c, Form::TrueSmall), + _ if small_cap && feature_substitutes(face_of(c), b"c2sc", c) => (c, Form::TrueSmall), + _ if small => (upper_single(c), Form::FauxSmall), + _ if small_cap => (c, Form::FauxSmall), + _ => (c, Form::Normal), + }; + let face = face_of(sc); + if form == Form::Normal && style.script != Script::Normal && !sc.is_whitespace() { + let tag = if style.script == Script::Sub { b"subs" } else { b"sups" }; + if feature_substitutes(face, tag, sc) { + form = Form::TrueScript; + } + } + (b, c, sc, face, bidi.levels[b].is_rtl(), form, si) }) .collect(); // items: runs of equal (face, rtl, small, style) let mut items: Vec = Vec::new(); let mut i = 0; while i < chars.len() { - let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6); + let key = |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6); let k0 = key(&chars[i]); let mut j = i + 1; while j < chars.len() && key(&chars[j]) == k0 { j += 1; } - let (face, rtl, small, si) = k0; + let (face, rtl, form, si) = k0; let style = &runs[si].1; let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); - let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 }; - let mut glyphs = shape_item(&sub, rtl, face, size, style, si); + let size = match form { + Form::FauxSmall => style.glyph_size() * SMALL_CAPS_SCALE, + Form::TrueScript => style.size, + _ => style.glyph_size(), + }; + let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form); space_item(&mut glyphs, style, text, true); let end = if j < chars.len() { chars[j].0 } else { text.len() }; items.push(Item { range: chars[i].0..end, rtl, glyphs }); @@ -718,7 +811,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar face: g.face, id: g.id, x: pen + g.dx, - y: -g.dy - st.rise(), + y: -g.dy - g.rise, size: g.size, cluster: base + g.cluster, synth_bold: primaries[g.run].synth_bold || st.faux_bold, @@ -960,6 +1053,82 @@ mod tests { TextStyle { size, ..Default::default() } } + fn ids(text: &str, s: &TextStyle) -> Vec { + layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect() + } + + fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle { + let mut s = st(100.0); + f(&mut s.opentype); + s + } + + fn serif() -> TextStyle { + TextStyle { family: "Noto Serif".into(), ..st(100.0) } + } + + #[test] + fn bundled_faces_list_their_features() { + let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features(); + for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] { + assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}"); + } + let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features(); + for t in ["smcp", "c2sc", "onum", "lnum"] { + assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}"); + } + } + + #[test] + fn opentype_features_substitute_glyphs() { + let plain = st(100.0); + // Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures. + assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits"); + assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20"); + assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac"); + assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn"); + let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular)); + let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional)); + assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}"); + // Old-style figures (Noto Serif). + let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() }; + assert_ne!(ids("123", &serif()), ids("123", &old), "onum"); + // Contextual alternates are on by default; turning them off can only keep or change glyphs. + let no_calt = ot(|o| o.contextual_alternates = false); + assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len()); + } + + #[test] + fn true_small_caps_and_superscripts_with_faux_fallback() { + // Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size. + let sc = TextStyle { caps: Caps::Small, ..serif() }; + let l = layout_uncached("Ab", &sc, &ParagraphStyle::default()); + let upper_b = ids("B", &serif())[0]; + assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital"); + assert_eq!(l.glyphs[1].size, 100.0); + // Inter has no smcp: a capital scaled down. + let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default()); + assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]); + assert!((faux.glyphs[1].size - 78.0).abs() < 0.01); + // All Small Caps: capitals become small capitals too (c2sc). + let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() }; + let a = layout_uncached("AB", &all, &ParagraphStyle::default()); + assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]); + // Superscript: Inter's sups figures at full size on the baseline; letters it lacks are + // synthesized (smaller and raised). + let sup = TextStyle { script: Script::Super, ..st(100.0) }; + let s2 = layout_uncached("2", &sup, &ParagraphStyle::default()); + assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]); + assert_eq!(s2.glyphs[0].size, 100.0); + assert_eq!(s2.glyphs[0].y, 0.0); + let sq = layout_uncached("q", &sup, &ParagraphStyle::default()); + if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') { + assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]); + } + let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default()); + assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id); + } + #[test] fn kerning_and_ligatures_change_advances() { let kern = measure("AVAVAV", &st(100.0)); @@ -1110,7 +1279,11 @@ mod tests { let base = st(100.0); let sup = TextStyle { script: Script::Super, ..base.clone() }; let sub = TextStyle { script: Script::Sub, ..base.clone() }; - let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); + // A letter the font has no superior / inferior glyph for: synthesized. + let inter = fonts::resolve("Inter", "Regular").face; + let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap(); + let text: String = ['x', c, 'y', c].iter().collect(); + let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); let g = &l.glyphs; assert!((g[1].size - 58.3).abs() < 0.1); assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size"); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 26a659bd..beef0382 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -161,6 +161,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle { BaselineOption::Subscript => Script::Sub, }, leading: s.leading.map(|l| l as f32), + opentype: s.opentype, } } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index fef958a7..61c0f215 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -2,7 +2,7 @@ //! Character and Paragraph panels show and change the selected text (creating style runs); //! otherwise they apply to the whole selected text layer. -use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, Justify, Kerning, TextDoc}; +use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -103,7 +103,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(json!({"font": fams[i]})); } y += 28.0; - let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0)); let spop = egui::Id::new("char-style-pop"); if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("char-style")).clicked() && enabled { widgets::open_popup(ui, spop); @@ -232,6 +232,16 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("character.ligatures", lr, "Ligatures"); p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim); + // OpenType: a popup with the font's layout features (like the Character panel's OpenType menu). + let or = Rect::from_min_size(pos2(sr.max.x + 6.0, sr.min.y), vec2(78.0, 22.0)); + let opop = egui::Id::new("char-opentype-pop"); + let n_on = doc.opentype.feature_settings().len(); + let olabel = if n_on > 0 { format!("OT ({n_on})") } else { "OpenType".to_string() }; + if widgets::dropdown(ui, or, &olabel, &t, egui::Id::new("char-opentype")).clicked() && enabled { + widgets::open_popup(ui, opop); + } + app.auto.add("character.opentype", or, "OpenType"); + opentype_popup(app, ui, opop, pos2((or.max.x - 300.0).max(rect.min.x), or.max.y), &doc, &mut actions); // Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu). if doc.vertical { y += 22.0; @@ -267,6 +277,100 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The Character panel's OpenType popup: feature toggles (dimmed when the font lacks them), +/// figure styles and the twenty stylistic sets. Every row is an automation target +/// (`character.opentype.`), and every change is a `layer.setText` attribute. +fn opentype_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, doc: &TextDoc, actions: &mut Vec) { + let open_id = id.with("open"); + if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { + return; + } + let t = app.tokens; + let feats = effectcraft_engine::font_features(&doc.font, &doc.style); + let has = |tag: &str| feats.iter().any(|f| f == tag); + let o = doc.opentype; + let mut rects: Vec<(String, Rect, String)> = vec![]; + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(230.0); + ui.label(egui::RichText::new(format!("{} {}", doc.font, doc.style)).small().color(t.text_dim)); + let toggles: [(&str, &str, bool, &[&str]); 12] = [ + ("ligatures", "Standard Ligatures", doc.ligatures, &["liga", "clig"]), + ("discretionaryLigatures", "Discretionary Ligatures", o.discretionary_ligatures, &["dlig"]), + ("contextualAlternates", "Contextual Alternates", o.contextual_alternates, &["calt"]), + ("stylisticAlternates", "Stylistic Alternates", o.stylistic_alternates, &["salt"]), + ("swash", "Swash", o.swash, &["swsh"]), + ("titling", "Titling Alternates", o.titling, &["titl"]), + ("ordinals", "Ordinals", o.ordinals, &["ordn"]), + ("fractions", "Fractions", o.fractions, &["frac"]), + ("smallCaps", "Small Caps", doc.small_caps, &["smcp"]), + ("allSmallCaps", "All Small Caps", o.all_small_caps, &["c2sc"]), + ("superscript", "Superscript / Superior", doc.baseline == BaselineOption::Superscript, &["sups"]), + ("subscript", "Subscript / Inferior", doc.baseline == BaselineOption::Subscript, &["subs"]), + ]; + for (key, label, on, tags) in toggles { + let avail = tags.iter().any(|g| has(g)); + // Unavailable features stay clickable: small caps and scripts are synthesized. + let synth = matches!(key, "smallCaps" | "allSmallCaps" | "superscript" | "subscript"); + let text = match (avail, synth) { + (true, _) => label.to_string(), + (false, true) => format!("{label} (synthesized)"), + (false, false) => format!("{label} (not in font)"), + }; + let r = ui.selectable_label(on, egui::RichText::new(text).color(if avail { t.text } else { t.text_faint })); + rects.push((format!("character.opentype.{key}"), r.rect, label.to_string())); + if r.clicked() { + actions.push(json!({key: !on})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Figures").small().color(t.text_dim)); + let figs = [ + ("default", "Default Figure Style", FigureStyle::Default, FigureWidth::Default), + ("tabularLining", "Tabular Lining", FigureStyle::Lining, FigureWidth::Tabular), + ("proportionalOldstyle", "Proportional Oldstyle", FigureStyle::OldStyle, FigureWidth::Proportional), + ("proportionalLining", "Proportional Lining", FigureStyle::Lining, FigureWidth::Proportional), + ("tabularOldstyle", "Tabular Oldstyle", FigureStyle::OldStyle, FigureWidth::Tabular), + ]; + for (key, label, st, fw) in figs { + let on = o.figure_style == st && o.figure_width == fw; + let r = ui.selectable_label(on, label); + rects.push((format!("character.opentype.figures.{key}"), r.rect, label.to_string())); + if r.clicked() { + actions.push(json!({"figures": key})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Stylistic Sets").small().color(t.text_dim)); + egui::Grid::new(id.with("sets")).spacing(vec2(3.0, 3.0)).show(ui, |ui| { + for n in 1..=20u32 { + let key = format!("ss{n:02}"); + let on = o.stylistic_set(n); + let avail = has(&key); + let r = ui.add_sized( + vec2(38.0, 20.0), + egui::Button::selectable(on, egui::RichText::new(format!("{n}")).color(if avail || on { t.text } else { t.text_faint })), + ); + rects.push((format!("character.opentype.{key}"), r.rect, format!("Stylistic Set {n}"))); + if r.clicked() { + actions.push(json!({key: !on})); + } + if n % 5 == 0 { + ui.end_row(); + } + } + }); + }); + }); + for (aid, r, label) in rects { + app.auto.add(&aid, r, &label); + } + let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered(); + if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(open_id, false)); + } +} + /// The seven Paragraph panel alignment buttons: (justify, `layer.setText` key). const ALIGNS: [(Justify, &str); 7] = [ (Justify::Left, "left"),