mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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:
co-authored by
Claude Opus 5.5
parent
02c9a3f13c
commit
30a92ba82f
@@ -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
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user