M7.5: OpenType as character attributes — per-run features (ligatures, contextual alternates, discretionary ligatures, swash, small caps, ordinals, fractions, figure styles) drive shaping; OpenType panel; SVG font-feature-settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:02:13 -04:00
co-authored by Claude Opus 5.5
parent d9df5b0135
commit d5aa387367
10 changed files with 234 additions and 16 deletions
+17 -1
View File
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool} style a character range (runs are split at the range ends) → {id, runs}",
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -137,6 +137,17 @@ pub(crate) struct CharChange {
underline: Option<bool>,
strikethrough: Option<bool>,
all_caps: Option<bool>,
features: Option<Vec<String>>,
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
if let Some(t) = tags.iter().find(|t| !drawcraft_text::OtFeatures::known_tag(t)) {
return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)")));
}
Ok(Some(drawcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
fn paint_param(p: &Value, k: &str, cmd: &str) -> Result<Option<Paint>> {
@@ -176,6 +187,7 @@ impl CharChange {
underline: flag("underline"),
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
features: features_param(p, cmd)?,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -200,6 +212,7 @@ impl CharChange {
&& self.underline.is_none()
&& self.strikethrough.is_none()
&& self.all_caps.is_none()
&& self.features.is_none()
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
@@ -258,6 +271,9 @@ impl CharChange {
if let Some(v) = self.all_caps {
st.all_caps = v;
}
if let Some(v) = &self.features {
st.features = v.clone();
}
}
}
+14 -2
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
has_doc,
set_style
),
@@ -188,7 +188,16 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(drawcraft_color::Paint::None),
Some(v) => Some(drawcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)),
};
if font.is_none() && style.is_none() && size.is_none() && leading.is_none() && tracking.is_none() && justify.is_none() && fill.is_none() {
let features = super::textedit::features_param(p, C)?;
if font.is_none()
&& style.is_none()
&& size.is_none()
&& leading.is_none()
&& tracking.is_none()
&& justify.is_none()
&& fill.is_none()
&& features.is_none()
{
return Err(bad(C, "nothing to change"));
}
let ids = text_targets(s, p, C)?;
@@ -215,6 +224,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &fill {
st.fill = f.clone();
}
if let Some(f) = &features {
st.features = f.clone();
}
}
if let Some(j) = justify {
t.para.justify = j;