M13.2: OpenType features (stylistic sets, figures, fractions, ordinals, dlig, true small caps / superscripts with faux fallback), Character panel OpenType popup, text.fontFeatures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 03:53:05 -04:00
co-authored by Claude Opus 5.5
parent 02c9a3f13c
commit 30a92ba82f
10 changed files with 628 additions and 21 deletions
+1 -1
View File
@@ -1157,7 +1157,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?, 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
),
+41
View File
@@ -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<Value> {
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<u32> = (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<CommandSpec> {
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",
+5
View File
@@ -747,6 +747,11 @@ pub fn text_presets() -> Vec<(String, String)> {
pub fn text_families() -> Vec<String> {
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<String> {
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()
}
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
+25
View File
@@ -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::<Vec<_>>();
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);
}
+1 -1
View File
@@ -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.
+243
View File
@@ -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<f64, String> {
v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number"))
}
fn norm(v: &J) -> Option<String> {
v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], ""))
}
fn flag(key: &str, v: &J) -> Result<bool, String> {
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<bool, Stri
s.all_caps = flag(key, v)?;
if s.all_caps {
s.small_caps = false;
s.opentype.all_small_caps = false;
}
}
"smallCaps" => {
@@ -869,6 +1013,65 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result<bool, Stri
"ligatures" => 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::<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)?);
}
_ => 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::<Vec<_>>(),
"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);
}
}
+15
View File
@@ -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<String> {
use skrifa::raw::TableProvider;
let Some(f) = self.font() else { return vec![] };
let mut out: Vec<String> = 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;
+190 -17
View File
@@ -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<f32>,
/// 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<Mutex<HashMap<(FaceId, [u8; 4], char), bool>>> = 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<u32> {
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<ShapedGlyph> {
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();
@@ -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<usize>, 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<Item> = 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<usize>, 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<u32> {
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");
+1
View File
@@ -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,
}
}
+106 -2
View File
@@ -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.<key>`), 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<serde_json::Value>) {
let open_id = id.with("open");
if !ui.data(|d| d.get_temp::<bool>(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"),