OpenType: the panel's Position menu sets superscript, subscript, numerator or denominator glyphs

The OpenType panel gets a Position menu after Figure, with Default
Position, Superscript, Subscript, Numerator and Denominator. `numr` and
`dnom` join the extra features (the existing bits keep their indices),
so they round-trip through the stored tags and reach the shaper.

text.setStyle and text.setRangeStyle take `featureChanges`, which turns
features on or off on top of each character's own features in one
command and keeps every tag the list does not name. The Figure menu,
the Position menu and the checkboxes send it, so on a selection whose
characters have different features each character keeps its other
features. Reset OpenType Features still sends `features`.

Catalog rows for Position:, Default Position, Numerator and
Denominator; one line each in docs/type-parity.md and
docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-02 12:04:15 +07:00
committed by bflatastic
parent a6c64e2cf2
commit 3cb252198a
34 changed files with 525 additions and 23 deletions
+66 -2
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -355,6 +355,8 @@ pub(crate) struct CharChange {
strikethrough: Option<bool>,
all_caps: Option<bool>,
features: Option<Vec<String>>,
/// Features turned on or off on top of each character's own (`featureChanges`).
feature_changes: Option<Vec<String>>,
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
@@ -468,13 +470,70 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
return Err(bad(
cmd,
format!(
"unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs; prefix - to turn off)"
"unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)"
),
));
}
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
/// The most entries a `featureChanges` list may have.
const MAX_FEATURE_CHANGES: usize = 256;
/// A feature tag without its `-` or `+` prefix.
fn feature_name(tag: &str) -> &str {
tag.strip_prefix(['-', '+']).unwrap_or(tag)
}
/// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of
/// each character's own, one change for each feature (the last one listed).
pub(crate) fn feature_changes_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("featureChanges").filter(|v| !v.is_null()) else { return Ok(None) };
let not_tags = || bad(cmd, "featureChanges must be a list of tags");
let list = v.as_array().ok_or_else(not_tags)?;
if list.len() > MAX_FEATURE_CHANGES {
return Err(bad(cmd, format!("featureChanges lists at most {MAX_FEATURE_CHANGES} tags")));
}
let mut tags: Vec<String> = Vec::new();
for t in list {
let t = t.as_str().ok_or_else(not_tags)?;
if !vectorcraft_text::OtFeatures::known_tag(t) || feature_name(t).starts_with(['-', '+']) {
return Err(bad(cmd, format!("unknown OpenType feature `{t}` in featureChanges (the tags `features` takes)")));
}
tags.retain(|o| feature_name(o) != feature_name(t));
tags.push(t.to_string());
}
Ok(Some(tags))
}
/// `features` with `changes` (a `featureChanges` list) made: the tags of the features `changes`
/// names are replaced by the ones that differ from the defaults, as `features` writes them, and
/// every other tag stays as it was. Standard ligatures turned on are written as `liga`, which keeps
/// them on in tracked type. The new tags go after the others. When the list would run past the
/// [`vectorcraft_text::MAX_STYLE_FEATURES`] entries that shaping reads, the other tags are first cut
/// to the ones shaping uses: the entries it reads, without tags it does not know and with only the
/// last tag of each feature.
pub(crate) fn change_features(features: &[String], changes: &[String]) -> Vec<String> {
let named = |t: &String| changes.iter().any(|c| feature_name(c) == feature_name(t));
let mut made = vectorcraft_text::OtFeatures::from_tags(changes.iter().map(String::as_str)).to_tags();
if changes.iter().any(|c| c == "liga" || c == "+liga") {
made.push("liga".into());
}
let mut kept: Vec<String> = features.iter().filter(|t| !named(t)).cloned().collect();
if kept.len() + made.len() > vectorcraft_text::MAX_STYLE_FEATURES {
let read = features.get(..vectorcraft_text::MAX_STYLE_FEATURES).unwrap_or(features);
let shaped = |t: &String| vectorcraft_text::OtFeatures::known_tag(t) || feature_name(t) == "clig";
kept = read
.iter()
.enumerate()
.filter(|(i, t)| !named(t) && shaped(t) && !read.iter().skip(i + 1).any(|l| feature_name(l) == feature_name(t)))
.map(|(_, t)| t.clone())
.collect();
}
kept.extend(made);
kept
}
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"|"icfTop"|"icfBottom"` (Character Alignment).
pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::CharAlign>> {
use vectorcraft_doc::CharAlign;
@@ -569,6 +628,7 @@ impl CharChange {
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
features: features_param(p, cmd)?,
feature_changes: feature_changes_param(p, cmd)?,
position,
small_caps,
char_align: char_align_param(p, cmd)?,
@@ -617,6 +677,7 @@ impl CharChange {
&& self.strikethrough.is_none()
&& self.all_caps.is_none()
&& self.features.is_none()
&& self.feature_changes.is_none()
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
@@ -712,6 +773,9 @@ impl CharChange {
if let Some(v) = &self.features {
st.features = v.clone();
}
if let Some(v) = &self.feature_changes {
st.features = change_features(&st.features, v);
}
if let Some(v) = self.position {
st.position = v;
}
+9 -1
View File
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_style
),
@@ -791,6 +791,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(vectorcraft_color::Paint::solid(super::paint::new_color(s, p, color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?))),
};
let features = super::textedit::features_param(p, C)?;
let feature_changes = super::textedit::feature_changes_param(p, C)?;
if font.is_none()
&& style.is_none()
&& size.is_none()
@@ -799,6 +800,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
&& justify.is_none()
&& fill.is_none()
&& features.is_none()
&& feature_changes.is_none()
{
return Err(bad(C, "nothing to change"));
}
@@ -844,6 +846,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &features {
st.features = f.clone();
}
if let Some(c) = &feature_changes {
st.features = super::textedit::change_features(&st.features, c);
}
});
if let Some(before) = before {
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
@@ -897,6 +902,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &features {
st.features = f.clone();
}
if let Some(c) = &feature_changes {
st.features = super::textedit::change_features(&st.features, c);
}
});
}
Ok(())
+52
View File
@@ -87,6 +87,58 @@ fn set_range_style_splits_runs() {
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err());
}
/// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in
/// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error.
#[test]
fn feature_changes_keep_each_runs_other_features() {
let mut s = session();
let id = text(&mut s, "1/23");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "features": ["numr", "smcp"]})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "features": ["swsh"]})).unwrap();
let features = |s: &Session| obj(s, id).runs.iter().map(|r| r.style.features.clone()).collect::<Vec<_>>();
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setStyle", &json!({"featureChanges": ["dnom", "-numr"]})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(features(&s), [vec!["smcp", "dnom"], vec!["swsh", "dnom"], vec!["dnom"]]);
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 2, "featureChanges": ["dnom", "-liga", "-dnom"]})).unwrap();
assert_eq!(features(&s), [vec!["smcp", "-liga"], vec!["swsh", "-liga"], vec!["dnom"]], "the last change of a feature counts");
for cmd in ["text.setStyle", "text.setRangeStyle"] {
for tag in ["zzzz", "--liga", "+-dnom"] {
let e = s.execute(cmd, &json!({"id": id.0, "featureChanges": [tag]})).unwrap_err().to_string();
assert!(e.contains(&format!("`{tag}`")), "{cmd}: {e}");
}
for bad in [json!(["numr", 12]), json!([12]), json!("numr")] {
let e = s.execute(cmd, &json!({"id": id.0, "featureChanges": bad})).unwrap_err().to_string();
assert!(e.contains("featureChanges must be a list of tags"), "{cmd} {bad}: {e}");
}
let e = s.execute(cmd, &json!({"id": id.0, "featureChanges": vec!["numr"; 257]})).unwrap_err().to_string();
assert!(e.contains("at most 256"), "{cmd}: {e}");
}
assert_eq!(features(&s), [vec!["smcp", "-liga"], vec!["swsh", "-liga"], vec!["dnom"]], "the refused commands change nothing");
// Tags of features the change does not name stay as they were: one the list does not know
// (kept from an opened file) and an explicit `liga`, which keeps tracked type ligated.
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).map(|n| &mut n.kind) {
t.runs[2].style.features = vec!["zzzz".into(), "liga".into(), "dnom".into()];
}
s.execute("text.setStyle", &json!({"featureChanges": ["numr", "-dnom"]})).unwrap();
assert_eq!(features(&s), [vec!["smcp", "-liga", "numr"], vec!["swsh", "-liga", "numr"], vec!["zzzz", "liga", "numr"]]);
// Ligatures turned on are written as `liga`, so tracked type keeps them.
s.execute("text.setStyle", &json!({"featureChanges": ["+liga"]})).unwrap();
assert_eq!(features(&s), [vec!["smcp", "numr", "liga"], vec!["swsh", "numr", "liga"], vec!["zzzz", "numr", "liga"]]);
// A list longer than shaping reads keeps the tags shaping uses, and the new tag is read too:
// `-onum` overrides `onum`, unknown tags do nothing, and `ordn` lies past the entries read.
let max = vectorcraft_text::MAX_STYLE_FEATURES;
let mut long = vec!["onum".to_string(), "-onum".into()];
long.extend(std::iter::repeat_n("zzzz".to_string(), max - 3));
long.extend(["swsh".to_string(), "ordn".into()]);
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).map(|n| &mut n.kind) {
t.runs[2].style.features = long;
}
s.execute("text.setStyle", &json!({"featureChanges": ["dnom"]})).unwrap();
assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]);
}
#[test]
fn optical_command_manual_pair_undo_redo_and_native_reopen() {
let mut s = session();