From b563976b4d8db6be40f11268cababeb60ece8b00 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:13:48 +0430 Subject: [PATCH] Text: OpenType feature values (salt=2), a GSUB reader and runs that differ only in features shaped as one piece Features take a value (tag=N) in style commands, SVG and native files. Runs that differ only in their feature lists are shaped in one buffer with ranged features, so an alternate on one letter of an Arabic word keeps the word joined. FontFace reads GSUB once (alternate sets, singles, the feature reaching a glyph), names unencoded glyphs, and alternates_in_context finds a character's alternates in its word. text.setRangeStyle takes glyphAlternate: null. --- crates/doc/src/text/char_style.rs | 4 +- crates/engine/src/cmd/textedit.rs | 25 +- crates/engine/src/cmd/textstyles/tests.rs | 6 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/tests/textedit.rs | 83 +++++ crates/svg/src/css.rs | 11 +- crates/svg/tests/it/text_layout.rs | 13 + crates/text/src/context_alternates.rs | 147 +++++++++ crates/text/src/features.rs | 208 ++++++++++-- crates/text/src/fontdb.rs | 3 + crates/text/src/fontdb/alternates.rs | 156 +++------ crates/text/src/fontdb/build.rs | 2 + crates/text/src/fontdb/face.rs | 5 + crates/text/src/fontdb/gsub.rs | 298 ++++++++++++++++++ crates/text/src/lib.rs | 12 +- crates/text/src/shape.rs | 56 +++- crates/text/src/test_fonts.rs | 84 +++++ crates/text/src/tests.rs | 1 + crates/text/src/tests/glyph_alternates.rs | 293 +++++++++++++++++ .../ui-egui/src/dialogs/char_style_options.rs | 12 +- crates/ui-egui/src/panels/opentype.rs | 24 +- docs/control-protocol.md | 20 +- docs/mcp.md | 36 ++- 23 files changed, 1317 insertions(+), 184 deletions(-) create mode 100644 crates/text/src/context_alternates.rs create mode 100644 crates/text/src/fontdb/gsub.rs create mode 100644 crates/text/src/tests/glyph_alternates.rs diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 67ccb60a44..6ad201561d 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -51,7 +51,9 @@ pub struct CharStyle { #[serde(default)] pub all_caps: bool, /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. + /// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's + /// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that + /// differ only in this list are shaped together, each with its own features. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index fba84acafe..6aa546767b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), 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\" (#rrggbbaa and #rgba are refused in both), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), 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), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), 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\" (#rrggbbaa and #rgba are refused in both), 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\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), 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), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} 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 ), @@ -464,6 +464,8 @@ pub(crate) struct CharChange { language: Option>, font_axes: Option, font_height_reference: Option, + /// Take away the glyph choice ([`CharStyle::glyph_alternate`]) of the characters (`glyphAlternate: null`). + clear_glyph_alternate: bool, } /// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` @@ -565,7 +567,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { } } -/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). +/// `features: ["dlig", "-liga", "salt=2", …]` → the canonical tag list (differences from the +/// defaults; `salt=1` is written `salt`, `salt=0` and `-salt` leave it out). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { 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(); @@ -573,7 +576,8 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> 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, numr, dnom; 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, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + vectorcraft_text::MAX_FEATURE_VALUE ), )); } @@ -583,9 +587,9 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> /// The most entries a `featureChanges` list may have. const MAX_FEATURE_CHANGES: usize = 256; -/// A feature tag without its `-` or `+` prefix. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn feature_name(tag: &str) -> &str { - tag.strip_prefix(['-', '+']).unwrap_or(tag) + vectorcraft_text::OtFeatures::tag_name(tag) } /// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of @@ -752,6 +756,13 @@ impl CharChange { language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, + clear_glyph_alternate: match p.get("glyphAlternate") { + None => false, + Some(Value::Null) => true, + Some(_) => { + return Err(bad(cmd, "`glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one)")); + } + }, }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -804,6 +815,7 @@ impl CharChange { && self.language.is_none() && self.font_axes.is_none() && self.font_height_reference.is_none() + && !self.clear_glyph_alternate } pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -943,6 +955,9 @@ impl CharChange { if let Some(v) = &self.language { st.language.clone_from(v); } + if self.clear_glyph_alternate { + st.glyph_alternate = None; + } } } diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 7c0fd99638..821bd7d999 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() { // The command takes the color of the link at the tint, whatever color is sent. tinted["tint"] = json!(0.4); let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false, - "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]}); + "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]}); s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap(); let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap(); assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false)); - assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]); + assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]); let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone(); assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap()); let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); @@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() { (json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"), (json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"), (json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"), + (json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"), + (json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"), (json!({"features": many}), "features lists at most 256 tags"), (json!({"fill": {"type": "solid"}}), "missing field"), ] { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 519d282e84..c0706fbb2c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "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|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), 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)}", + "{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|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; 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 ), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index ed3e2eac39..3578a67dbd 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -139,6 +139,54 @@ fn feature_changes_keep_each_runs_other_features() { assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]); } +/// A feature with a value (`salt=2`): `features` and `featureChanges` take it, the next value +/// replaces it, `salt=1` is a plain `salt` and `salt=0` and `-salt` are off, a value this build +/// does not know is an error, one undo step each, and the native format keeps the plain string. +#[test] +fn feature_values_are_set_replaced_and_saved() { + let mut s = session(); + let id = text(&mut s, "abcd"); + let features = |s: &Session| obj(s, id).runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>(); + let range = |s: &mut Session, p: Value| { + let mut p = p; + p["id"] = json!(id.0); + p["start"] = json!(1); + p["end"] = json!(2); + s.execute("text.setRangeStyle", &p).map(|_| ()) + }; + let undo = s.doc().unwrap().history.undo.len(); + range(&mut s, json!({"features": ["salt=2"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(features(&s), [("a".into(), vec![]), ("b".into(), vec!["salt=2".to_string()]), ("cd".into(), vec![])]); + range(&mut s, json!({"features": ["salt=3"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["salt=3"], "the next value replaces it"); + range(&mut s, json!({"featureChanges": ["salt=2", "ss01"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "written in the canonical order"); + range(&mut s, json!({"featureChanges": ["salt=1"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt"], "salt=1 is a plain salt"); + range(&mut s, json!({"featureChanges": ["salt=2"]})).unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&saved).unwrap(); + let Some(NodeKind::Text(t)) = back.node(id).map(|n| &n.kind) else { panic!("the text survives") }; + assert_eq!(t.runs[1].style.features, ["ss01", "salt=2"], "the native format keeps the string"); + assert!(String::from_utf8_lossy(&saved).contains("\"salt=2\"")); + range(&mut s, json!({"featureChanges": ["-salt"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "-salt turns it off"); + range(&mut s, json!({"featureChanges": ["salt=2"], "features": null})).unwrap(); + range(&mut s, json!({"featureChanges": ["salt=0"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "salt=0 turns it off"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "undo takes it back"); + let before = features(&s); + for tag in ["salt=", "salt=x", "salt=65536", "-salt=2", "liga=2", "salt=2=3", "zzzz=2"] { + for key in ["features", "featureChanges"] { + let e = range(&mut s, json!({ key: [tag] })).unwrap_err().to_string(); + assert!(e.contains(&format!("`{tag}`")), "{key} {tag}: {e}"); + } + } + assert_eq!(features(&s), before, "the refused commands change nothing"); +} + /// `text.setStyle {stroke, strokeWidth}` sets the character stroke of all the text or of a range, as /// `text.setRangeStyle` does, and a call with `fill` and `stroke` sets both in one undo step. #[test] @@ -2810,6 +2858,41 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available")); } +/// `glyphAlternate: null` in `text.setRangeStyle` takes a glyph choice away in the same undo step +/// as the other style it sets (a feature: what the Glyphs panel does for a variant a feature +/// draws); anything but null is refused. +#[test] +fn set_range_style_takes_a_glyph_choice_away_with_a_feature() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap(); + let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(&s)[1], u1); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": 5})).unwrap_err().to_string(); + assert!(e.contains("takes only null"), "{e}"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null, "featureChanges": ["ss01"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + assert_eq!( + gids(&s)[1], + face.substitutions(face.glyph_for('a')).iter().find(|x| &x.feature == b"ss01").map(|x| x.glyphs[0]).unwrap(), + "ss01 draws its glyph" + ); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gids(&s)[1], u1, "undone: the choice again"); + // With nothing else to set it is still a change. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null})).unwrap(); + assert_eq!(gids(&s)[1], face.glyph_for('a')); +} + /// The glyph already drawn, chosen again, and null for a character drawn with its default glyph /// leave the document and its undo history as they are. An empty run before the character does /// not stand in for the character's own choice. diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 78113dcd8c..76a04bea84 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -174,11 +174,18 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .iter() .map(|t| match t.strip_prefix('-') { Some(off) => (off, 0), - None => (t.as_str(), 1), + // `tag=N` picks the font's alternate N: CSS takes the number as the feature's value. + None => { + let on = t.strip_prefix('+').unwrap_or(t); + match on.split_once('=').map(|(tag, n)| (tag, n.parse::())) { + Some((tag, Ok(n))) => (tag, n), + _ => (on, 1), + } + } }) // A feature tag is four letters or digits: anything else could end the CSS string. .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) - .map(|(tag, on)| format!("\"{tag}\" {on}")) + .map(|(tag, value)| format!("\"{tag}\" {value}")) .collect(); // Proportional Metrics: `palt` (SVG writes vertical type as outlines). if st.proportional_metrics { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index eee6e2fbf5..6d0d6489e5 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -123,6 +123,19 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// A feature with a value (`salt=2`) is written as that value, `-tag` as 0, a plain tag as 1; a +/// tag whose value is not a number is left out. +#[test] +fn feature_values_write_their_number() { + let valued = CharStyle { features: vec!["salt=2".into(), "-liga".into(), "ss01".into(), "swsh=12".into(), "salt=x".into()], ..style(12.0) }; + let d = area_doc(vec![run("ab ", style(12.0)), run("cd", valued)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 2, "{svg}"); + assert!(tags[1].contains("font-feature-settings:"salt" 2, "liga" 0, "ss01" 1, "swsh" 12"), "{}", tags[1]); + assert!(!tags[1].contains("salt=x") && !tags[0].contains("salt"), "{svg}"); +} + /// Character Direction: live text writes each run set Left to Right as a bidi embedding /// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. #[test] diff --git a/crates/text/src/context_alternates.rs b/crates/text/src/context_alternates.rs new file mode 100644 index 0000000000..034f65f1d5 --- /dev/null +++ b/crates/text/src/context_alternates.rs @@ -0,0 +1,147 @@ +//! The alternate glyphs of one character as it stands in its text: the glyph the shaper makes for +//! it in its word (a medial letter's medial form, not its isolated one), the alternates the font +//! gives that glyph, and for each the OpenType feature value that draws it on that character alone +//! (`salt=2`, `ss03`). Applying that feature to the character's range swaps the glyph in place +//! while the text stays real Unicode and the word is shaped as one piece, so the letters still +//! join. Adobe's Glyphs panel does the same for its Show › Alternates for Current Selection: +//! + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextObject}; + +use crate::{FontDb, GlyphAlternate, OtFeatures, edit, layout}; + +/// The most characters on each side of the selected one that are shaped with it: the word it is in. +const MAX_WORD: usize = 256; + +/// The most alternates tried against the text. +const MAX_CANDIDATES: usize = 512; + +/// Does feature `tag` (as a style lists it, with its prefix and value) choose a glyph among a +/// glyph's alternates: `aalt`, `salt`, `swsh` or a stylistic set `ss01` to `ss20`? +pub fn is_alternate_feature(tag: &str) -> bool { + let name = OtFeatures::tag_name(tag); + matches!(name, "aalt" | "salt" | "swsh") || (name.len() == 4 && name.starts_with("ss") && OtFeatures::known_tag(name)) +} + +/// The tags to change a character's features by, as `featureChanges` takes them, so that it has +/// `tag` as its only feature that chooses an alternate: each such feature of `features` turned +/// off, and then `tag` (`salt=2`) when it is given. Empty when the character already has exactly +/// that. +pub fn alternate_changes(features: &[String], tag: Option<&str>) -> Vec { + let mut changes: Vec = vec![]; + for t in features.iter().filter(|t| is_alternate_feature(t) && !t.starts_with('-')) { + let off = format!("-{}", OtFeatures::tag_name(t)); + if tag.is_none_or(|n| OtFeatures::tag_name(n) != OtFeatures::tag_name(t)) && !changes.contains(&off) { + changes.push(off); + } + } + if let Some(tag) = tag { + let now = OtFeatures::from_tags(features.iter().map(String::as_str)); + let wanted = OtFeatures::from_tags([tag]); + let name = OtFeatures::tag_name(tag); + if now.feature_value(name) != wanted.feature_value(name) { + changes.push(tag.to_string()); + } + } + changes +} + +/// One glyph [`alternates_in_context`] lists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternate { + pub gid: u32, + /// The feature that lists the glyph among the default glyph's alternates (`aalt` first). + pub feature: [u8; 4], + /// The feature tag that draws the glyph on the character in its word (`salt=2`), when one does. + pub apply: Option, +} + +/// What [`alternates_in_context`] finds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternates { + /// The face that supplied the glyph ([`crate::PositionedGlyph::font_id`]). + pub font_id: u32, + /// The glyph the shaper makes for the character with none of its own alternate features: its + /// form in its position. + pub default: u32, + /// The glyph it is drawn with now. + pub current: u32, + /// The alternates of [`Self::default`] in the font's order (`aalt`, `salt`, `ss01`… as + /// [`crate::FontFace::alternates`]), without it unless `aalt` lists it. + pub alternates: Vec, +} + +/// The glyph of the layout `text` that draws the character at `byte`, as (face id, glyph id). +fn glyph_at(db: &FontDb, text: &TextObject, byte: usize) -> Option<(u32, u32, usize)> { + layout(db, text).glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)).map(|g| (g.font_id, g.gid, g.len)) +} + +/// The alternate glyphs of the character at `byte` of `text` as it stands in its word, from the +/// glyph the shaper makes for it there, and the feature value that draws each on that character +/// alone. The character's own alternate features (`salt`, `ss01`…) are left out when the default +/// glyph is found; each alternate's feature is tried on the character in its word, and the one +/// that draws that glyph there is its `apply` (`salt` before the stylistic sets, `swsh` and +/// `aalt`; a glyph no feature of the font draws in this position has none). `None` when `byte` is +/// not a character of the text or the layout holds no glyph for it. Only the word is shaped (up to +/// 256 characters each side), so the cost does not grow with the text. +pub fn alternates_in_context(db: &FontDb, text: &TextObject, byte: usize) -> Option { + let plain = text.plain_text(); + let ch = plain.get(byte..)?.chars().next()?; + let end = byte + ch.len_utf8(); + let breaks = |c: char| c.is_whitespace() || matches!(c, '\u{2028}' | '\u{2029}'); + let start = plain[..byte].char_indices().rev().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(byte, |(i, _)| i); + let stop = plain[end..].char_indices().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(end, |(i, c)| end + i + c.len_utf8()); + let mut probe = TextObject::point(Point::ZERO, "", CharStyle::default()); + probe.runs = edit::slice_runs(&text.runs, start, stop); + probe.vertical = text.vertical; + probe.para = text.para.clone(); + let (a, b) = (byte - start, end - start); + let (_, current, _) = glyph_at(db, &probe, a)?; + let own = |probe: &TextObject, tag: Option<&str>| { + let mut p = probe.clone(); + edit::style_range(&mut p.runs, a, b, |st| { + st.glyph_alternate = None; + st.features.retain(|t| !is_alternate_feature(t)); + st.features.extend(tag.map(str::to_string)); + }); + glyph_at(db, &p, a) + }; + let (default_font, default, len) = own(&probe, None)?; + let face = db.face_by_id(default_font)?; + // A glyph that more than the character makes (a ligature) has no alternates of its own here. + let subs = if len == ch.len_utf8() { face.substitutions(default) } else { vec![] }; + let mut alternates: Vec = vec![]; + let mut tried: Vec<(String, u32)> = vec![]; + for sub in &subs { + let name = String::from_utf8_lossy(&sub.feature).to_string(); + let values = if sub.glyphs.is_empty() { 0 } else { u16::try_from(sub.glyphs.len()).unwrap_or(u16::MAX) }; + for value in 1..=values { + let tag = if value == 1 { name.clone() } else { format!("{name}={value}") }; + if !is_alternate_feature(&tag) || !OtFeatures::known_tag(&tag) || tried.len() >= MAX_CANDIDATES || tried.iter().any(|(t, _)| *t == tag) { + continue; + } + // A single substitution gives its glyph at any value: only the plain tag is tried. + if sub.kind == crate::SubstitutionKind::Single && value > 1 { + continue; + } + if let Some((font, gid, _)) = own(&probe, Some(&tag)) + && font == default_font + { + tried.push((tag, gid)); + } + } + } + for GlyphAlternate { gid, feature } in crate::fontdb::listed(&subs, default) { + let rank = |tag: &str| match OtFeatures::tag_name(tag) { + "salt" => 0, + "swsh" => 2, + "aalt" => 3, + _ => 1, + }; + let mut drawing: Vec<&(String, u32)> = tried.iter().filter(|(_, g)| *g == gid).collect(); + drawing.sort_by_key(|(t, _)| rank(t)); + alternates.push(ContextAlternate { gid, feature, apply: drawing.first().map(|(t, _)| t.clone()) }); + } + Some(ContextAlternates { font_id: default_font, default, current, alternates }) +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index ddf9e5a03c..9f2b555e23 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -43,20 +43,32 @@ pub struct OtFeatures { pub swash: bool, /// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic /// sets, alternates, titling, proportional and lining figures, superscript, subscript, - /// numerators and denominators. + /// numerators and denominators, `aalt`. pub extra: u32, + /// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for + /// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for + /// the first). 0 stands for 1, a plain `salt`. Only read for a feature whose bit of + /// [`Self::extra`] is on; use [`Self::set_extra`] to change a bit and its value together. + pub extra_values: [u16; EXTRA_FEATURES.len()], + /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`. + pub swash_value: u16, } +/// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's +/// position in a set. +pub const MAX_FEATURE_VALUE: u16 = u16::MAX; + /// The most entries of a style's feature list read: every segment of its text reads the list again. pub const MAX_STYLE_FEATURES: usize = 256; /// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: /// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` -/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr` and -/// denominators `dnom`. -pub const EXTRA_FEATURES: [&str; 28] = [ +/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`, +/// denominators `dnom` and access all alternates `aalt`. +/// +pub const EXTRA_FEATURES: [&str; 29] = [ "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", - "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", ]; impl Default for OtFeatures { @@ -73,12 +85,53 @@ impl Default for OtFeatures { ordinals: false, swash: false, extra: 0, + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, } } } -fn f(tag: &[u8; 4], on: bool) -> Feature { - Feature::new(Tag::new(tag), on as u32, ..) +/// A feature of `value` over the whole text: 1 and 0 turn it on and off, more picks the font's +/// alternate of that number. +fn f(tag: &[u8; 4], value: impl Into) -> Feature { + Feature::new(Tag::new(tag), value.into(), ..) +} + +/// What a feature tag of a style's list says about one feature ([`parse_tag`]). +struct ParsedTag<'a> { + /// The tag without a `-` or `+` prefix or a `=N` value. + name: &'a str, + /// On (`tag`, `+tag`, `tag=N` for `N` from 1) or off (`-tag`, `tag=0`). + on: bool, + /// The value `tag=N` gives; 1 for a plain tag. + value: u16, +} + +/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can: +/// each substitutes glyphs, and the value says which alternate. +fn takes_value(name: &str) -> bool { + name == "swsh" || EXTRA_FEATURES.contains(&name) +} + +/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N` +/// from 0 to [`MAX_FEATURE_VALUE`] in decimal digits; `-tag=N` is refused). `None` for a tag this +/// set does not understand. +fn parse_tag(tag: &str) -> Option> { + let (off, rest) = match tag.strip_prefix('-') { + Some(r) => (true, r), + None => (false, tag.strip_prefix('+').unwrap_or(tag)), + }; + let (name, value) = match rest.split_once('=') { + None => (rest, None), + Some((name, digits)) => { + if off || digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|b| b.is_ascii_digit()) || !takes_value(name) { + return None; + } + (name, Some(u16::try_from(digits.parse::().ok()?).ok()?)) + } + }; + let known = matches!(name, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&name); + known.then_some(ParsedTag { name, on: !off && value != Some(0), value: value.unwrap_or(1) }) } impl OtFeatures { @@ -104,6 +157,8 @@ impl OtFeatures { ordinals: true, swash: true, extra: u32::MAX >> (32 - EXTRA_FEATURES.len()), + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, }; change(&mut off); change(&mut on); @@ -137,28 +192,68 @@ impl OtFeatures { (self.swash, d.swash, "swsh"), ] { if on != def { - v.push(if on { tag.to_string() } else { format!("-{tag}") }); + v.push(match (on, tag, self.swash_value) { + (true, "swsh", n @ 2..) => format!("{tag}={n}"), + (true, ..) => tag.to_string(), + (false, ..) => format!("-{tag}"), + }); } } - v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string())); + v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(i, t)| match self.extra_values.get(i) { + None | Some(0 | 1) => t.to_string(), + Some(n) => format!("{t}={n}"), + })); v } - /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? + /// Is `tag` one this set understands? A feature tag, optionally prefixed with `-` or `+`, or + /// `tag=N` (`N` from 0 to [`MAX_FEATURE_VALUE`], no prefix but `+`) for a feature of + /// [`EXTRA_FEATURES`] or `swsh`, which picks the font's alternate number `N` (`salt=2`; 0 is + /// off, 1 is a plain tag). pub fn known_tag(tag: &str) -> bool { + parse_tag(tag).is_some() + } + + /// The feature a tag of a style's list names, without its `-` or `+` prefix or `=N` value + /// (`salt` for `-salt`, `+salt` and `salt=2`). + pub fn tag_name(tag: &str) -> &str { let t = tag.strip_prefix(['-', '+']).unwrap_or(tag); - matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t) + t.split_once('=').map_or(t, |(name, _)| name) + } + + /// The value feature `tag` of [`EXTRA_FEATURES`] or `swsh` is on with: `Some(1)` for a plain + /// tag, `Some(N)` for `tag=N`, `None` when it is off or is another tag. + pub fn feature_value(&self, tag: &str) -> Option { + if tag == "swsh" { + return self.swash.then_some(self.swash_value.max(1)); + } + let i = EXTRA_FEATURES.iter().position(|e| *e == tag)?; + (self.extra & (1 << i) != 0).then(|| self.extra_values.get(i).copied().unwrap_or(0).max(1)) + } + + /// Turn the features of [`EXTRA_FEATURES`] in `mask` (bit `i` for the feature `i`) on, with + /// value 1 (a plain tag), or off. + pub fn set_extra(&mut self, mask: u32, on: bool) { + for (i, value) in self.extra_values.iter_mut().enumerate() { + if mask & (1 << i) != 0 { + *value = 0; + } + } + if on { + self.extra |= mask; + } else { + self.extra &= !mask; + } } /// This set with `tags` applied on top. pub fn with_tags<'a>(mut self, tags: impl IntoIterator) -> Self { let o = &mut self; for t in tags { - let (on, t) = match t.strip_prefix('-') { - Some(r) => (false, r), - None => (true, t.strip_prefix('+').unwrap_or(t)), - }; - match t { + let Some(ParsedTag { name, on, value }) = parse_tag(t) else { continue }; + // 1 is a plain tag: only a value from 2 is kept. + let kept = if on && value > 1 { value } else { 0 }; + match name { "liga" => o.ligatures = on, "calt" => o.contextual = on, "dlig" => o.discretionary_ligatures = on, @@ -167,13 +262,15 @@ impl OtFeatures { "onum" => o.oldstyle_figures = on, "tnum" => o.tabular_figures = on, "ordn" => o.ordinals = on, - "swsh" => o.swash = on, + "swsh" => { + o.swash = on; + o.swash_value = kept; + } _ => { - if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) { - if on { - o.extra |= 1 << i; - } else { - o.extra &= !(1 << i); + if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == name) { + o.set_extra(1 << i, on); + if let Some(slot) = o.extra_values.get_mut(i) { + *slot = kept; } } } @@ -214,12 +311,12 @@ impl OtFeatures { (s.swash, b"swsh"), ] { if on { - v.push(f(tag, true)); + v.push(f(tag, if tag == b"swsh" { u32::from(s.swash_value.max(1)) } else { 1 })); } } for (i, tag) in EXTRA_FEATURES.iter().enumerate() { if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { - v.push(f(&t, true)); + v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } if self.vertical { @@ -354,6 +451,69 @@ mod tests { assert_eq!(OtFeatures::from_tags(start.iter().copied()).with_tags(tags.iter().map(String::as_str)), want, "{start:?}"); } } + + /// `tag=N` gives a feature a value: parsed, validated, kept in the style's list as written + /// (1 is a plain tag, 0 is off), replaced by the next value or by `-tag`, and given to the + /// shaper as that value. + #[test] + fn feature_values_are_parsed_validated_and_shaped() { + for t in ["salt=2", "+salt=2", "ss03=12", "aalt=65535", "swsh=3", "salt=0", "salt=01", "titl=2"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + for t in [ + "salt=", + "salt=-1", + "salt=65536", + "salt=1.5", + "salt=x", + "-salt=2", + "liga=2", + "dlig=1", + "zzzz=2", + "salt==2", + "=2", + "salt=2=3", + "salt=123456", + "salt= 2", + ] { + assert!(!OtFeatures::known_tag(t), "{t}"); + } + assert_eq!(OtFeatures::tag_name("salt=2"), "salt"); + assert_eq!((OtFeatures::tag_name("-ss01"), OtFeatures::tag_name("+salt=3"), OtFeatures::tag_name("liga")), ("ss01", "salt", "liga")); + let o = OtFeatures::from_tags(["salt=2", "ss01", "-liga", "swsh=4"]); + assert_eq!(o.to_tags(), ["-liga", "swsh=4", "ss01", "salt=2"]); + assert_eq!( + (o.feature_value("salt"), o.feature_value("ss01"), o.feature_value("swsh"), o.feature_value("ss02")), + (Some(2), Some(1), Some(4), None) + ); + // The next value replaces the last, `salt=1` is a plain `salt`, `salt=0` and `-salt` are off. + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=3"]).to_tags(), ["salt=3"]); + assert_eq!(OtFeatures::from_tags(["salt=3", "salt"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=1"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=0"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt", "salt"]).to_tags(), ["salt"], "a value does not outlive being turned off"); + // A tag with a value this set does not know is ignored. + assert_eq!(OtFeatures::from_tags(["salt=x", "liga=2"]), OtFeatures::default()); + // The panel's own switches turn a feature on at value 1. + let mut f = OtFeatures::from_tags(["salt=3"]); + f.set_extra(1 << 20, false); + f.set_extra(1 << 20, true); + assert_eq!(f.to_tags(), ["salt"]); + let values = |tags: &[&str]| -> Vec<(String, u32)> { + let st = CharStyle { features: tags.iter().map(|t| t.to_string()).collect(), ..CharStyle::default() }; + OtFeatures::default() + .resolve(&st) + .iter() + .filter(|f| f.value > 0 && f.tag != harfrust::Tag::new(b"kern")) + .map(|f| (f.tag.to_string(), f.value)) + .collect() + }; + assert_eq!(values(&["salt=2"]), [("salt".to_string(), 2)]); + assert_eq!(values(&["salt"]), [("salt".to_string(), 1)]); + assert_eq!(values(&["swsh=3", "ss01"]), [("swsh".to_string(), 3), ("ss01".to_string(), 1)]); + assert!(values(&["-salt", "salt=0"]).is_empty()); + } use crate::{FontDb, layout}; use vectorcraft_doc::TextObject; use vectorcraft_geom::Point; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d42e8f3516..c7d116a29d 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -24,6 +24,7 @@ mod db; mod dirs; mod face; mod glyph_pen; +mod gsub; mod lookup; mod names; mod scan; @@ -32,6 +33,7 @@ mod traits; mod wanted; pub use alternates::GlyphAlternate; +pub(crate) use alternates::listed; pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; @@ -49,6 +51,7 @@ use face::Instances; pub(crate) use face::MAX_LIVE_INSTANCES; pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins}; pub(crate) use glyph_pen::FlipPen; +pub use gsub::{Substitution, SubstitutionKind}; #[cfg(not(target_arch = "wasm32"))] use names::SysFallback; #[cfg(test)] diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs index 2a582bbe71..d74a55ff64 100644 --- a/crates/text/src/fontdb/alternates.rs +++ b/crates/text/src/fontdb/alternates.rs @@ -1,16 +1,16 @@ //! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show › //! Alternates for Current Selection. The application finds the character's default glyph in the -//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph -//! for another, as the OpenType registry describes `aalt`. +//! `cmap` and lists what the features that substitute one glyph for another give it, as the +//! OpenType registry describes `aalt` ([`FontFace::substitutions`] reads them). //! //! use skrifa::MetadataProvider; use skrifa::raw::TableProvider; -use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables}; use vectorcraft_doc::GlyphChoice; use super::FontFace; +use super::gsub::Substitution; /// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -19,108 +19,52 @@ pub struct GlyphAlternate { pub feature: [u8; 4], } -/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read: -/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per -/// alternate set and glyphs listed in all. -const MAX_FEATURES: usize = 4096; -const MAX_FEATURE_LOOKUPS: usize = 256; -const MAX_LOOKUPS: usize = 4096; -const MAX_SUBTABLES: usize = 256; -const MAX_SUBTABLE_READS: usize = 16384; -const MAX_SET: usize = 512; +/// The most glyphs [`FontFace::alternates`] lists. const MAX_ALTERNATES: usize = 1024; -/// The other discretionary features that substitute one glyph for another (historical forms, -/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in -/// tag order. -const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; - -/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`, -/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed. -fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { - let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); - match &tag { - b"aalt" => Some((0, 0)), - b"salt" => Some((1, 0)), - [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), - b"swsh" => Some((3, 0)), - [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), - _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), +/// The alternates of glyph `default` that its substitutions `subs` ([`FontFace::substitutions`]) +/// list, in their order, each glyph once with the first feature that gives it: the default glyph +/// itself only when `aalt` gives it. +pub(crate) fn listed(subs: &[Substitution], default: u32) -> Vec { + let mut out: Vec = vec![]; + for substitution in subs { + for &gid in &substitution.glyphs { + let wanted = gid != default || substitution.feature == *b"aalt"; + if wanted && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { + out.push(GlyphAlternate { gid, feature: substitution.feature }); + } + } } + out } impl FontFace { /// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions /// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in - /// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first, - /// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and - /// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out. - /// The default glyph is listed only when `aalt` lists it. Ligatures and contextual - /// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or - /// the table cannot be read; glyph ids past the face's glyph count are left out. + /// extension lookups (type 7) too, put in place of `c`'s default glyph + /// ([`FontFace::substitutions`]). `aalt` comes first, then `salt`, `ss01` to `ss20`, `swsh`, + /// `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`; within a feature its lookups go + /// in lookup order. A glyph found again is left out. The default glyph is listed only when + /// `aalt` lists it. Ligatures and contextual substitutions are not read. Empty when the face + /// does not map `c`, has no `GSUB` table, or the table cannot be read; glyph ids past the face's + /// glyph count are left out. pub fn alternates(&self, c: char) -> Vec { - let mut out: Vec = vec![]; - let Some(f) = self.skrifa() else { return out }; - let Some(default) = f.charmap().map(c) else { return out }; - let Ok(gsub) = f.gsub() else { return out }; - let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out }; - let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs())); - // The lookups of the listed features, by rank and lookup index. - // The listed feature records are read in rank order, so the cap on lookups in all leaves - // out the lowest ranked ones. - let records = features.feature_records(); - let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records - .iter() - .take(MAX_FEATURES) - .enumerate() - .filter_map(|(i, r)| { - let tag = r.feature_tag().to_be_bytes(); - feature_rank(tag).map(|rank| (rank, i, tag)) + let Some(default) = self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()) else { return vec![] }; + listed(&self.substitutions(default), default) + } + + /// The glyphs no character maps (its `cmap` gives no character for them), by glyph id, without + /// `.notdef`: the positional forms, alternates and ligatures the `GSUB` table reaches and any + /// other glyph of the font. Read once for the face. + pub fn unencoded_glyphs(&self) -> std::sync::Arc<[u32]> { + self.unencoded + .get_or_init(|| { + let Some(font) = self.skrifa() else { return std::sync::Arc::from([]) }; + let mapped: std::collections::HashSet = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect(); + let count = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + (1..count).filter(|g| !mapped.contains(g)).collect() }) - .collect(); - ranked.sort_unstable_by_key(|r| (r.0, r.1)); - let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; - for (rank, i, tag) in ranked { - let room = MAX_LOOKUPS.saturating_sub(wanted.len()); - if room == 0 { - break; - } - let Some(record) = records.get(i) else { continue }; - let Ok(feature) = record.feature(features.offset_data()) else { continue }; - wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); - } - wanted.sort_unstable_by_key(|w| (w.0, w.1)); - wanted.dedup_by_key(|w| (w.0, w.1)); - let mut push = |gid: u32, feature: [u8; 4]| { - let listed = gid != default.to_u32() || feature == *b"aalt"; - if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { - out.push(GlyphAlternate { gid, feature }); - } - }; - let all = lookups.lookups(); - let mut reads = MAX_SUBTABLE_READS; - for (_, index, tag) in wanted { - if reads == 0 { - break; - } - let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue }; - let n = MAX_SUBTABLES.min(reads); - // A glyph takes the first subtable of a lookup whose coverage holds it. - match subtables { - SubstitutionSubtables::Single(s) => { - if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) { - push(gid, tag); - } - } - SubstitutionSubtables::Alternate(s) => { - if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) { - set.into_iter().for_each(|gid| push(gid, tag)); - } - } - _ => {} - } - } - out + .clone() } /// How many glyphs the face has (its `maxp` table; 0 when it can't be read). @@ -141,25 +85,3 @@ impl FontFace { GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() } } } - -/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`. -fn single(t: &SingleSubst<'_>, gid: u32) -> Option { - match t { - SingleSubst::Format1(t) => { - t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - // The delta is added modulo 65536. - Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32) - } - SingleSubst::Format2(t) => { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16())) - } - } -} - -/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`. -fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option> { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - let set = t.alternate_sets().get(usize::from(i)).ok()?; - Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()) -} diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 40bcb1df1b..261e1b2877 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -78,6 +78,8 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt colour_bitmaps, colour_layers, svg_glyphs, + gsub: std::sync::OnceLock::new(), + unencoded: std::sync::OnceLock::new(), }) } diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 5ab31eeae0..3e03e5c1da 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -199,6 +199,11 @@ pub struct FontFace { pub(super) colour_layers: bool, /// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]). pub(super) svg_glyphs: bool, + /// What the `GSUB` table substitutes ([`Self::substitutions`]), read the first time it is asked + /// for. + pub(super) gsub: std::sync::OnceLock, + /// [`Self::unencoded_glyphs`], read once. + pub(super) unencoded: std::sync::OnceLock>, } /// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs new file mode 100644 index 0000000000..3b07aad668 --- /dev/null +++ b/crates/text/src/fontdb/gsub.rs @@ -0,0 +1,298 @@ +//! What a face's `GSUB` table substitutes for a glyph: the alternates of one glyph (the Glyphs +//! panel's Show › Alternates for Current Selection), the feature that gives a glyph no character +//! maps to (Show › Entire Font) and glyph names. Read once for a face, the first time one is asked +//! for, and kept with it ([`FontFace`]). +//! +//! A feature of alternates (an alternate substitution, lookup type 3; `salt`, `aalt`, `swsh`) +//! takes the alternate its value says: with the feature at value `N`, a glyph in its coverage is +//! replaced by the `N`th glyph of its set (the first at value 1, none at 0 or past the set), which +//! is how the shaper reads it. A feature of single substitutions (type 1; `ss01`, `smcp`) replaces +//! the glyph at any value from 1. +//! +//! + +use std::collections::{HashMap, HashSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::gsub::{AlternateSubstFormat1, LigatureSubstFormat1, MultipleSubstFormat1, SingleSubst, SubstitutionSubtables}; + +use super::FontFace; + +/// Caps on what a damaged or very large `GSUB` table can make a face read: feature records, +/// lookups per feature and in all, subtables per lookup and in all, glyphs per alternate set, and +/// glyphs and substitutions listed in all. +pub(super) const MAX_FEATURES: usize = 4096; +const MAX_FEATURE_LOOKUPS: usize = 256; +const MAX_LOOKUPS: usize = 4096; +const MAX_SUBTABLES: usize = 256; +const MAX_SUBTABLE_READS: usize = 16384; +pub(super) const MAX_SET: usize = 512; +const MAX_ENTRIES: usize = 1 << 18; + +/// The other discretionary features that substitute one glyph for another (historical forms, +/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in +/// tag order. +const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; + +/// Where the substitutions of feature `tag` come in a glyph's list: `aalt`, `salt`, `ss01` to +/// `ss20`, `swsh`, `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not +/// listed. +fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { + let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); + match &tag { + b"aalt" => Some((0, 0)), + b"salt" => Some((1, 0)), + [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), + b"swsh" => Some((3, 0)), + [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), + _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), + } +} + +/// The rank of the features that give no listed alternates but reach glyphs ([`FontFace::feature_reaching`]). +const UNLISTED: (u8, u8) = (9, 0); + +/// How a feature gives its glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SubstitutionKind { + /// One glyph, whatever the feature's value is from 1 (a single substitution, type 1). + Single, + /// A set of glyphs, the one at the feature's value (an alternate substitution, type 3). + Alternate, +} + +/// What one feature substitutes for a glyph ([`FontFace::substitutions`]). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Substitution { + /// The feature's tag (`salt`). + pub feature: [u8; 4], + pub kind: SubstitutionKind, + /// The glyphs, in the font's order: the one glyph of a single substitution, or the alternate + /// set (the feature at value `N` gives the `N`th). + pub glyphs: Vec, +} + +impl Substitution { + /// The value the feature takes to give `glyph` (`salt=2` for 2), `None` when it never gives it. + pub fn value_for(&self, glyph: u32) -> Option { + match self.kind { + SubstitutionKind::Single => (self.glyphs.first() == Some(&glyph)).then_some(1), + SubstitutionKind::Alternate => self.glyphs.iter().position(|g| *g == glyph).and_then(|i| u16::try_from(i + 1).ok()), + } + } +} + +/// A face's substitutions, by the glyph they replace and by the glyph they give. +#[derive(Default)] +pub(super) struct GsubIndex { + /// The listed features' ([`feature_rank`]) substitutions of each glyph, by feature rank and + /// lookup. + by_input: HashMap>, + /// The first feature, listed ones first, that gives each glyph: by a single, multiple, + /// alternate or ligature substitution. + reached: HashMap, +} + +/// The glyph single substitution `t` puts in place of each glyph its coverage holds. +fn single_pairs(t: &SingleSubst<'_>, room: usize) -> Vec<(u32, u32)> { + match t { + SingleSubst::Format1(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + // The delta is added modulo 65536. + let delta = i32::from(t.delta_glyph_id()); + coverage.iter().take(room).map(|g| (g.to_u32(), (g.to_u32() as i32 + delta).rem_euclid(0x1_0000) as u32)).collect() + } + SingleSubst::Format2(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + let out = t.substitute_glyph_ids(); + coverage.iter().zip(out).take(room).map(|(g, to)| (g.to_u32(), u32::from(to.get().to_u16()))).collect() + } + } +} + +/// The alternate set of each glyph alternate substitution `t`'s coverage holds. +fn alternate_sets(t: &AlternateSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.alternate_sets(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let set = sets.get(i).ok()?; + Some((g.to_u32(), set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +/// The glyphs multiple substitution `t` puts in place of its covered glyphs, and the ligature +/// glyphs of ligature substitution `l`, as (glyph, glyphs it becomes). +fn multiple_pairs(t: &MultipleSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sequences = t.sequences(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let sequence = sequences.get(i).ok()?; + Some((g.to_u32(), sequence.substitute_glyph_ids().iter().map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.ligature_sets(); + let mut out = vec![]; + for (i, _) in coverage.iter().enumerate().take(room) { + let Ok(set) = sets.get(i) else { continue }; + out.extend(set.ligatures().iter().flatten().take(room).map(|l| u32::from(l.ligature_glyph().to_u16()))); + if out.len() >= room { + break; + } + } + out +} + +impl GsubIndex { + /// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past + /// the face's glyph count are left out; the caps above bound what a damaged table makes it read. + fn read(face: &FontFace) -> Self { + let mut index = Self::default(); + let Some(font) = face.skrifa() else { return index }; + let Ok(gsub) = font.gsub() else { return index }; + let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return index }; + let glyphs = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + // The lookups of the features, by rank and lookup index. The feature records are read in + // rank order, so the cap on lookups in all leaves out the lowest ranked ones. + let records = features.feature_records(); + let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records + .iter() + .take(MAX_FEATURES) + .enumerate() + .map(|(i, r)| { + let tag = r.feature_tag().to_be_bytes(); + (feature_rank(tag).unwrap_or(UNLISTED), i, tag) + }) + .collect(); + ranked.sort_unstable_by_key(|r| (r.0, r.1)); + let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; + for (rank, i, tag) in ranked { + let room = MAX_LOOKUPS.saturating_sub(wanted.len()); + if room == 0 { + break; + } + let Some(record) = records.get(i) else { continue }; + let Ok(feature) = record.feature(features.offset_data()) else { continue }; + wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); + } + wanted.sort_by_key(|w| (w.0, w.1)); + wanted.dedup_by_key(|w| (w.0, w.1)); + let all = lookups.lookups(); + let mut reads = MAX_SUBTABLE_READS; + let mut entries = 0_usize; + for (rank, lookup, tag) in wanted { + if reads == 0 || entries >= MAX_ENTRIES { + break; + } + let Ok(subtables) = all.get(usize::from(lookup)).and_then(|l| l.subtables()) else { continue }; + let n = MAX_SUBTABLES.min(reads); + let listed = rank != UNLISTED; + // A glyph takes the first subtable of a lookup whose coverage holds it. + let mut seen: HashSet = HashSet::new(); + let mut note = |index: &mut Self, input: u32, kind: SubstitutionKind, out: Vec| { + if !seen.insert(input) { + return; + } + let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect(); + for g in &out { + if *g != input && index.reached.len() < MAX_ENTRIES { + index.reached.entry(*g).or_insert(tag); + } + } + if listed && !out.is_empty() && entries < MAX_ENTRIES { + entries += 1; + index.by_input.entry(input).or_default().push(Substitution { feature: tag, kind, glyphs: out }); + } + }; + match subtables { + SubstitutionSubtables::Single(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in single_pairs(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Single, vec![to]); + } + } + } + SubstitutionSubtables::Alternate(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, set) in alternate_sets(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Alternate, set); + } + } + } + SubstitutionSubtables::Multiple(s) => { + let mut covered: HashSet = HashSet::new(); + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in multiple_pairs(&t, MAX_ENTRIES) { + // Not listed as alternates, only reached. + if covered.insert(input) { + for g in to.into_iter().filter(|g| *g < glyphs && *g != input) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert(tag); + } + } + } + } + } + } + SubstitutionSubtables::Ligature(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert(tag); + } + } + } + } + _ => {} + } + } + index + } +} + +impl FontFace { + fn gsub_index(&self) -> &GsubIndex { + self.gsub.get_or_init(|| GsubIndex::read(self)) + } + + /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, + /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in + /// that order of features and within a feature in lookup order: the glyph of each single + /// substitution (lookup type 1) and the whole set of each alternate substitution (type 3), in + /// extension lookups (type 7) too, as the shaper reads them. A feature gives the glyph its + /// first substitution (the first subtable of the lookup whose coverage holds it). Ligatures and + /// contextual substitutions are not read, nor is the choice of a variable font's instance of + /// `FeatureVariations`. Empty when the face has no `GSUB` table or it cannot be read; glyph ids + /// past the face's glyph count are left out. + pub fn substitutions(&self, gid: u32) -> Vec { + self.gsub_index().by_input.get(&gid).cloned().unwrap_or_default() + } + + /// The feature of the `GSUB` table that gives glyph `gid` as a substitute (by a single, + /// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`]) + /// before another (`init`, `liga`), if any does. Not read through a contextual lookup. + pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> { + self.gsub_index().reached.get(&gid).copied() + } + + /// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the + /// face's glyphs, or when the font has no names (the ids are then `gid12`). + pub fn glyph_name(&self, gid: u32) -> Option { + let font = self.skrifa()?; + let name = font.glyph_names().get(skrifa::GlyphId::new(gid))?; + (!name.is_synthesized()).then(|| name.as_str().to_string()) + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9763ea4b6d..b593594fbf 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,6 +13,7 @@ mod bitmap; mod colr; mod composer; +mod context_alternates; pub mod craft_fonts; pub mod edit; pub mod embed; @@ -32,12 +33,15 @@ pub mod test_fonts; pub mod thread; pub use bitmap::GlyphImage; +pub use context_alternates::{ContextAlternate, ContextAlternates, alternate_changes, alternates_in_context, is_alternate_feature}; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; -pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; +pub use features::{ + EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by, +}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont, - WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, - style_weight, system_font_dirs, user_font_dirs, + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, Substitution, + SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, + set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use std::sync::Arc; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 1a6716c5f2..60661795c5 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -769,8 +769,9 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard -/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// run's, see [`shape_segment`]), the OpenType features (each run's apply to its own characters, +/// as features over a range of the one buffer) and the tracking, as long as the tracking keeps or +/// drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -793,7 +794,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { underline: _, strikethrough: _, all_caps, - features, + // Runs with different feature lists shape as one buffer: each run's features apply to its + // own characters only ([`shape_segment`]). + features: _, style_name: _, overprint_fill: _, overprint_stroke: _, @@ -826,7 +829,6 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *h_scale == b.h_scale && *v_scale == b.v_scale && *all_caps == b.all_caps - && *features == b.features && *position == b.position && *small_caps == b.small_caps && *proportional_metrics == b.proportional_metrics @@ -978,6 +980,23 @@ struct Shaped { vpal: Vec>, } +/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put +/// after `base` so that they win there: each feature of `own` that `base` lacks or has with +/// another value, and each of `base` that `own` leaves out, back at the value the shaper has +/// without it (on for the default features, off for the rest). The range is given with an +/// inclusive end because harfrust's `Feature::new` takes an exclusive end one cluster short, but +/// applies it as exclusive: a one-byte character would get an empty range. +fn ranged_features(base: &[Feature], own: &[Feature], range: Range, out: &mut Vec) { + const ON_BY_DEFAULT: [&[u8; 4]; 7] = [b"kern", b"liga", b"clig", b"calt", b"apkn", b"vkrn", b"vapk"]; + for f in own.iter().filter(|f| !base.iter().any(|b| b.tag == f.tag && b.value == f.value)) { + out.push(Feature::new(f.tag, f.value, range.start..=range.end)); + } + for b in base.iter().filter(|b| !own.iter().any(|f| f.tag == b.tag)) { + let default = ON_BY_DEFAULT.iter().any(|t| Tag::new(t) == b.tag); + out.push(Feature::new(b.tag, u32::from(default), range.start..=range.end)); + } +} + fn is_mark(c: char) -> bool { use unicode_general_category::{GeneralCategory, get_general_category}; matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark) @@ -1043,6 +1062,23 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe return shape_apart(manual_ends, out); } + // Runs that differ only in their OpenType feature lists are shaped as one buffer: the features + // of each run apply to its own characters only (a feature with a range of clusters, which here + // are source bytes), over those of `base_style`, whose features apply to the whole segment. So + // a feature or an alternate (`salt=2`) on one letter of an Arabic word leaves the joining, + // ligatures and kerning of the whole word to the shaper, as the same text without it. + // `variant` makes a style's features and `base` is what it makes for `base_style`. + let run_features = |variant: &dyn Fn(&CharStyle) -> Vec, base_style: &CharStyle, base: &[Feature]| { + let mut ranged = Vec::new(); + for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() { + let (a, b) = (r.start.max(range.start), r.end.min(range.end)); + if run_style.features != base_style.features && a < b { + let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() }); + ranged_features(base, &own, a..b, &mut ranged); + } + } + ranged + }; // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { // A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one. @@ -1056,8 +1092,10 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // gid, cluster, x and y advance, x and y offset. - let shape = |feats: &[Feature], direction: Direction| { - let feats: Vec = feats.iter().chain(more).cloned().collect(); + let shape = |variant: &dyn Fn(&CharStyle) -> Vec, direction: Direction| { + let mut feats = variant(st); + feats.extend(run_features(variant, st, &feats)); + feats.extend(more.iter().cloned()); let mut buf = UnicodeBuffer::new(); for (i, c) in text_seg.char_indices() { let cl = (range.start + i) as u32; @@ -1097,13 +1135,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .collect::>() }; let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }; - *raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); + *raw = shape(&|s| feats.resolve(s), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); let raw = &*raw; let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0); if feats.proportional(st) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). - let full = shape(&feats.resolve_fixed_width(st), along); + let full = shape(&|s| feats.resolve_fixed_width(s), along); if same_glyphs(&full) { *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } @@ -1114,7 +1152,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe // and without either tell how it moves each and changes its advance down the column // (y up, advances negative). Which advances `vpal` itself changed comes from setting // them with it alone. - let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom); + let ttb = |proportional, kerning| shape(&|s| feats.resolve_vertical(s, proportional, kerning), Direction::TopToBottom); let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false)); if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) { *vpal = with diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 69ccbfe700..77929bbee2 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -735,6 +735,90 @@ pub fn alternates_font(aalt: bool) -> Option> { Some(builder.build()) } +/// The family of [`joining_alternates_font`]. +pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans"; + +/// Beh, teh, noon and alef: the letters [`joining_alternates_font`] maps. +pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{0627}']; + +/// A test-only Arabic font over the bundled Source Sans 3, whose glyphs stand for the positional +/// forms of beh, teh, noon and alef ([`JOINING_LETTERS`]), each a Latin letter's outline: isolated +/// b t n I, initial B T N, medial v u m, final V U M A (alef joins to the right only). Its `arab` +/// `GSUB` has `init`, `medi` and `fina` single substitutions from the isolated glyphs, `salt` +/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U +/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions +/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each +/// letter's alternates for the position they join in. Family [`JOINING_ALTERNATES_FAMILY`]. `None` +/// when it can't be built. +pub fn joining_alternates_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let font = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let charmap = font.charmap(); + let gid = |c: char| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let family = JOINING_ALTERNATES_FAMILY; + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?; + let mapping = [(JOINING_LETTERS[0], 'b'), (JOINING_LETTERS[1], 't'), (JOINING_LETTERS[2], 'n'), (JOINING_LETTERS[3], 'I'), (' ', ' ')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + // A single substitution by (glyph, replacement) pairs, sorted by glyph id as coverage needs. + let single = |pairs: &[(char, char)]| { + let mut ids = pairs.iter().map(|(from, to)| Some((gid(*from)?, gid(*to)?))).collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let to = ids.iter().map(|p| p.1).collect(); + Some(SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(from).into(), to))], + ))) + }; + let alternates = |sets: &[(char, &[char])]| { + let mut ids = sets + .iter() + .map(|(from, set)| Some((gid(*from)?, set.iter().map(|c| gid(*c)).collect::>>()?))) + .collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let sets = ids.into_iter().map(|p| AlternateSet::new(p.1)).collect(); + Some(SubstitutionLookup::Alternate(Lookup::new( + Default::default(), + vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)], + ))) + }; + // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01. + let lookups = SubstitutionLookupList::new(vec![ + single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?, + single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?, + single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?, + alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?, + single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?, + ]); + // Features sorted by tag, as OpenType requires: fina, init, medi, salt, ss01. + let records: Vec = [(b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)] + .into_iter() + .map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup]))) + .collect(); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 031d35bdce..b9504b6ac9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -12,6 +12,7 @@ mod combos; mod devanagari; mod embed; mod fit; +mod glyph_alternates; mod hang; mod inline; mod joiners; diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs new file mode 100644 index 0000000000..edec6de6f0 --- /dev/null +++ b/crates/text/src/tests/glyph_alternates.rs @@ -0,0 +1,293 @@ +//! A feature with a value (`salt=2`) on some characters of an Arabic word: the font's alternate +//! for them, with the word shaped once so that every other letter keeps its joining form and the +//! letters still touch. Read from a font built in code (and, when it is installed, the owner's +//! Kufi Murabba PIIIX). + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextRun}; + +use crate::test_fonts::{JOINING_ALTERNATES_FAMILY, JOINING_LETTERS, joining_alternates_font, source_sans_gid}; +use crate::*; + +const BEH: &str = "\u{0628}"; +const TEH: &str = "\u{062A}"; +const NOON: &str = "\u{0646}"; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(joining_alternates_font().expect("the test font builds")); + db +} + +fn style(family: &str, features: &[&str]) -> CharStyle { + CharStyle { font_family: family.into(), size: 100.0, features: features.iter().map(|f| f.to_string()).collect(), ..CharStyle::default() } +} + +/// `text` as one text object in `family`, each piece with its own features. +fn set(db: &FontDb, family: &str, pieces: &[(&str, &[&str])]) -> Vec { + let mut text = TextObject::point(Point::ZERO, "", style(family, &[])); + text.runs = pieces.iter().map(|(t, features)| TextRun { text: t.to_string(), style: style(family, features), inline: None }).collect(); + // In reading order of the characters, whichever way the line runs. + let mut glyphs = layout(db, &text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs +} + +fn joined(db: &FontDb, pieces: &[(&str, &[&str])]) -> Vec { + set(db, JOINING_ALTERNATES_FAMILY, pieces) +} + +fn gids(glyphs: &[PositionedGlyph]) -> Vec { + glyphs.iter().map(|g| g.gid).collect() +} + +fn g(c: char) -> u32 { + u32::from(source_sans_gid(c).expect("a Source Sans glyph")) +} + +/// The gap between each glyph and the next along the line, whichever way the line runs: 0 when the +/// letters touch. +fn gaps(glyphs: &[PositionedGlyph]) -> Vec { + // `origin` is the pen position at the glyph's left edge, also in right-to-left text. + let mut spans: Vec<(f64, f64)> = glyphs.iter().map(|g| (g.origin.x, g.origin.x + g.advance)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// Beh, teh, noon: initial, medial and final in one word, teh drawn with `features`. +fn word(db: &FontDb, teh_features: &[&str]) -> Vec { + joined(db, &[(BEH, &[]), (TEH, teh_features), (NOON, &[])]) +} + +/// Without a feature the word is beh initial, teh medial, noon final. +#[test] +fn the_fixture_word_takes_its_joining_forms() { + let db = db(); + assert_eq!(gids(&joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])])), [g('B'), g('u'), g('M')]); + assert_eq!(JOINING_LETTERS.len(), 4); +} + +/// `salt=N` on the middle letter draws the font's Nth alternate for it, its neighbours keep their +/// initial and final forms (they are not shaped as lone letters), and the letters still touch. +#[test] +fn a_salt_value_on_one_letter_picks_its_alternate_and_keeps_the_word_joined() { + let db = db(); + let plain = word(&db, &[]); + assert_eq!(gids(&plain), [g('B'), g('u'), g('M')]); + let plain_gaps = gaps(&plain); + assert!(plain_gaps.iter().all(|gap| gap.abs() < 1e-6), "the word's glyphs touch: {plain_gaps:?}"); + for (features, middle) in [(&["salt"][..], 'w'), (&["salt=1"][..], 'w'), (&["salt=2"][..], 'x'), (&["salt=3"][..], 'y')] { + let glyphs = word(&db, features); + assert_eq!(gids(&glyphs), [g('B'), g(middle), g('M')], "{features:?}: the alternate between the word's own initial and final forms"); + let gap = gaps(&glyphs); + assert!(gap.iter().all(|gap| gap.abs() < 1e-6), "{features:?}: the letters touch: {gap:?}"); + } + // An alternate number the font has no glyph for, and the feature off, draw the default glyph. + for features in [&["salt=4"][..], &["-salt"][..], &["salt=0"][..], &["salt=2", "-salt"][..], &["ss03"][..]] { + assert_eq!(gids(&word(&db, features)), [g('B'), g('u'), g('M')], "{features:?}"); + } + // The last value of a feature wins. + assert_eq!(gids(&word(&db, &["salt=3", "salt=2"]))[1], g('x')); + // The font's salt lookup comes before its ss01 lookup, so salt takes the glyph first. + assert_eq!(gids(&word(&db, &["salt=2", "ss01"]))[1], g('x')); +} + +/// A feature on the first or the last letter of the word does not split it either. +#[test] +fn a_feature_on_an_end_letter_does_not_break_the_joining() { + let db = db(); + for (pieces, expected) in [ + (vec![(BEH, &["salt=2"][..]), (TEH, &[][..]), (NOON, &[][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &[][..]), (TEH, &[][..]), (NOON, &["salt=2"][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &["ss01"][..]), (TEH, &["ss01"][..]), (NOON, &["ss01"][..])], [g('B'), g('w'), g('M')]), + ] { + let glyphs = joined(&db, &pieces); + assert_eq!(gids(&glyphs), expected, "{pieces:?}"); + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-6), "{pieces:?}"); + } +} + +/// The alternates of a letter depend on the form it takes where it stands: the final teh has two, +/// the medial teh three, so `salt=3` is an alternate of one and nothing for the other. +#[test] +fn the_alternate_a_value_picks_depends_on_the_letters_position() { + let db = db(); + let medial = |features: &[&str]| gids(&word(&db, features))[1]; + let final_form = |features: &[&str]| gids(&joined(&db, &[(BEH, &[]), (TEH, features)]))[1]; + assert_eq!((medial(&["salt=3"]), final_form(&["salt=3"])), (g('y'), g('U')), "no third alternate for the final form"); + assert_eq!((medial(&["salt=2"]), final_form(&["salt=2"])), (g('x'), g('X'))); + // A letter alone takes the isolated form's alternate. + assert_eq!(gids(&joined(&db, &[(TEH, &["salt"])])), [g('E')]); + assert_eq!(gids(&joined(&db, &[(TEH, &[])])), [g('t')]); +} + +/// A run with its own feature next to runs of another style in the word shapes with them: the +/// same glyph ids and places as a word typed in one run, when the feature changes nothing. +#[test] +fn runs_that_differ_only_in_features_shape_as_one_word() { + let db = db(); + let one_run = joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])]); + let three_runs = joined(&db, &[(BEH, &[]), (TEH, &["salt=9"]), (NOON, &["-liga"])]); + assert_eq!(gids(&three_runs), gids(&one_run)); + let (a, b): (Vec, Vec) = (one_run.iter().map(|g| g.origin.x).collect(), three_runs.iter().map(|g| g.origin.x).collect()); + assert_eq!(a, b, "the same places"); +} + +/// The Kufi Murabba PIIIX of the font's author, when it is installed: the middle letter of a word +/// takes each of its `salt` alternates and every other letter keeps the glyph it has without one. +#[test] +fn kufi_murabba_alternates_keep_the_word_joined() { + let Some(path) = + std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join("Library/Fonts/KufiMurabbaPIIIX-Regular.ttf")).filter(|p| p.exists()) + else { + return; + }; + let Ok(bytes) = std::fs::read(path) else { return }; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(bytes); + let Some(face) = db.face("Kufi Murabba PIIIX", "Regular") else { return }; + let family = face.family.clone(); + let letters: Vec = "الخوارزمی".chars().collect(); + let text = |pieces: &[(String, Vec<&str>)]| { + let pieces: Vec<(&str, &[&str])> = pieces.iter().map(|(t, f)| (t.as_str(), f.as_slice())).collect(); + set(&db, &family, &pieces) + }; + let plain = text(&[(letters.iter().collect(), vec![])]); + assert_eq!(plain.len(), letters.len(), "one glyph for each letter"); + assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain)); + // The medial khah. + let at = 2; + let default = plain[at].gid; + let salt = + face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates"); + for (i, alternate) in salt.glyphs.iter().enumerate() { + let value = format!("salt={}", i + 1); + let before: String = letters[..at].iter().collect(); + let after: String = letters[at + 1..].iter().collect(); + let glyphs = text(&[(before, vec![]), (letters[at].to_string(), vec![value.as_str()]), (after, vec![])]); + assert_eq!(glyphs.len(), plain.len(), "{value}"); + assert_eq!(glyphs[at].gid, *alternate, "{value}: the alternate"); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != at) { + assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form"); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs)); + } +} + +/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's +/// value), single substitutions, the feature that reaches a glyph, names and the glyph count. +#[test] +fn the_gsub_reader_lists_alternate_sets_singles_and_the_feature_reaching_a_glyph() { + use crate::{Substitution, SubstitutionKind}; + let db = db(); + let face = db.face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let salt = Substitution { feature: *b"salt", kind: SubstitutionKind::Alternate, glyphs: vec![g('w'), g('x'), g('y')] }; + let ss01 = Substitution { feature: *b"ss01", kind: SubstitutionKind::Single, glyphs: vec![g('w')] }; + assert_eq!(face.substitutions(g('u')), [salt.clone(), ss01.clone()], "salt (an alternate set) before ss01 (a single)"); + assert_eq!(salt.value_for(g('x')), Some(2)); + assert_eq!(salt.value_for(g('u')), None); + assert_eq!((ss01.value_for(g('w')), ss01.value_for(g('x'))), (Some(1), None)); + // Each form of a letter has its own alternates. + let sets = |c| face.substitutions(g(c)).iter().map(|s| (s.feature, s.glyphs.len())).collect::>(); + assert_eq!(sets('U'), [(*b"salt", 2), (*b"ss01", 1)]); + assert_eq!(sets('t'), [(*b"salt", 1), (*b"ss01", 1)]); + assert!(sets('B').is_empty() && sets('M').is_empty(), "the initial and final forms of the others have none"); + assert!(face.substitutions(face.glyph_count()).is_empty(), "past the glyphs"); + // The feature that reaches a glyph no character maps: the listed ones first, then positional forms. + assert_eq!(face.feature_reaching(g('x')), Some(*b"salt")); + assert_eq!(face.feature_reaching(g('w')), Some(*b"salt"), "salt ranks before ss01"); + assert_eq!(face.feature_reaching(g('B')), Some(*b"init")); + assert_eq!(face.feature_reaching(g('m')), Some(*b"medi")); + assert_eq!(face.feature_reaching(g('A')), Some(*b"fina")); + assert_eq!(face.feature_reaching(g('b')), None, "the isolated form is reached by no feature"); + // Names and the glyph count. + let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face"); + assert_eq!(face.glyph_count(), bundled.glyph_count()); + assert_eq!(face.glyph_name(g('B')).as_deref(), Some("B")); + assert_eq!(face.glyph_name(face.glyph_count()), None); + // `alternates` is the same reading, for a character. + let listed: Vec<([u8; 4], u32)> = face.alternates('\u{062A}').into_iter().map(|a| (a.feature, a.gid)).collect(); + assert_eq!(listed, [(*b"salt", g('E'))], "the isolated teh"); +} + +/// The alternates of a letter as it stands in its word: the glyph the shaper makes for it there, +/// its alternates, and the feature value that draws each on that letter alone. +#[test] +fn alternates_in_context_list_the_letters_positional_form_and_the_feature_that_draws_each() { + let db = db(); + let text = |teh: &[&str]| { + let mut t = TextObject::point(Point::ZERO, "", style(JOINING_ALTERNATES_FAMILY, &[])); + t.runs = [(BEH, &[][..]), (TEH, teh), (NOON, &[][..])] + .into_iter() + .map(|(s, f)| TextRun { text: s.to_string(), style: style(JOINING_ALTERNATES_FAMILY, f), inline: None }) + .collect(); + t + }; + let teh = BEH.len(); + let found = alternates_in_context(&db, &text(&[]), teh).expect("the medial teh"); + assert_eq!((found.default, found.current), (g('u'), g('u'))); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!( + listed, + [(g('w'), Some("salt".to_string())), (g('x'), Some("salt=2".to_string())), (g('y'), Some("salt=3".to_string()))], + "the medial form's three alternates, not the isolated form's one" + ); + // Drawn with salt=2 now: the default is still the form without it. + let now = alternates_in_context(&db, &text(&["salt=2"]), teh).expect("the medial teh"); + assert_eq!((now.default, now.current, now.alternates.len()), (g('u'), g('x'), 3)); + // The first and last letters take their own forms. + let beh = alternates_in_context(&db, &text(&[]), 0).expect("the initial beh"); + assert_eq!((beh.default, beh.alternates.len()), (g('B'), 0)); + let noon = alternates_in_context(&db, &text(&[]), teh + TEH.len()).expect("the final noon"); + assert_eq!((noon.default, noon.alternates.len()), (g('M'), 0)); + assert_eq!(alternates_in_context(&db, &text(&[]), 1), None, "not a character boundary"); + // alternate_changes: the character keeps only the alternate feature it is given. + let own = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig", "-ss02"]), Some("salt=3")), ["-ss01", "salt=3"]); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig"]), None), ["-ss01", "-salt"]); + assert!(alternate_changes(&own(&["salt=2", "dlig"]), Some("salt=2")).is_empty()); + assert_eq!(alternate_changes(&own(&["salt"]), Some("salt=2")), ["salt=2"]); + assert!(alternate_changes(&own(&["dlig"]), None).is_empty()); + assert!(is_alternate_feature("-ss03") && is_alternate_feature("salt=2") && !is_alternate_feature("dlig") && !is_alternate_feature("titl")); +} + +/// A glyph no feature of the style draws is listed without one, and a feature's glyph is found +/// with the feature that draws it. +#[test] +fn a_variant_no_feature_draws_has_no_feature_to_apply() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let apply = |gid: u32| found.alternates.iter().find(|a| a.gid == gid).map(|a| a.apply.clone()); + assert_eq!(found.default, face.glyph_for('a')); + assert_eq!(apply(u1), Some(Some("swsh=2".to_string())), "swsh before aalt"); + assert_eq!(apply(u2), Some(Some("ss01".to_string()))); + assert_eq!(apply(u3), Some(Some("swsh".to_string()))); + assert_eq!(apply(u4), Some(Some("ss02".to_string()))); + assert_eq!(apply(face.glyph_for('c')), Some(None), "cv01 has no style tag"); + assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a'))); +} + +/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is +/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is +/// inclusive, which would leave a one-byte character out. +#[test] +fn a_feature_on_a_one_byte_character_reaches_it() { + use crate::test_fonts::{alternates_family, alternates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = |features: &[&str]| style(alternates_family(true), features); + let mut text = TextObject::point(Point::ZERO, "", st(&[])); + text.runs = vec![ + TextRun { text: "x".into(), style: st(&[]), inline: None }, + TextRun { text: "a".into(), style: st(&["salt"]), inline: None }, + TextRun { text: "y".into(), style: st(&[]), inline: None }, + ]; + let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone"); +} diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 41530de0e9..32333848c7 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -183,9 +183,10 @@ pub(crate) fn swatch_choices(doc: &vectorcraft_doc::Document) -> Value { Value::Array(choices.collect()) } -/// A feature tag without its `-` or `+`. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn bare(tag: &str) -> &str { - tag.trim_start_matches(['-', '+']) + let tag = tag.trim_start_matches(['-', '+']); + tag.split_once('=').map_or(tag, |(name, _)| name) } /// The OpenType Features fields for a style's `features`: each control is unset unless the list @@ -486,9 +487,14 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M } if !unchanged("ot_sets") { let sets = &vectorcraft_text::EXTRA_FEATURES[..20]; + // A set still on keeps the value its tag gives (`ss01=2`). + let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t)) && !t.starts_with('-')).cloned().collect(); tags.retain(|t| !sets.contains(&bare(t))); let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); - tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(str::to_string)); + tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| { + let kept = before.iter().rev().find(|b| !t.starts_with('-') && bare(b) == bare(t)); + kept.map_or_else(|| t.to_string(), Clone::clone) + })); } if !tags.is_empty() { attrs.insert("features".into(), json!(tags)); diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 1f98c74cdf..591524b794 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -44,8 +44,7 @@ impl Flag { fn put(self, f: &mut OtFeatures, on: bool) { match self { Flag::Field(field) => *field(f) = on, - Flag::Extra(tag) if on => f.extra |= extra_bit(tag), - Flag::Extra(tag) => f.extra &= !extra_bit(tag), + Flag::Extra(tag) => f.set_extra(extra_bit(tag), on), } } } @@ -69,13 +68,16 @@ pub(crate) fn figure(f: &OtFeatures) -> usize { fn set_figure(f: &mut OtFeatures, i: usize) { f.tabular_figures = i == 1 || i == 4; f.oldstyle_figures = i >= 3; - f.extra &= !match i { - 1 => extra_bit("pnum"), - 3 => extra_bit("lnum"), - _ => extra_bit("pnum") | extra_bit("lnum"), - }; + f.set_extra( + match i { + 1 => extra_bit("pnum"), + 3 => extra_bit("lnum"), + _ => extra_bit("pnum") | extra_bit("lnum"), + }, + false, + ); if i == 2 { - f.extra |= extra_bit("pnum") | extra_bit("lnum"); + f.set_extra(extra_bit("pnum") | extra_bit("lnum"), true); } } @@ -95,11 +97,7 @@ pub(crate) fn position(f: &OtFeatures) -> Option { /// Default Position turns all four off. fn set_position(f: &mut OtFeatures, i: usize) { for (k, tag) in POSITION_TAGS.iter().enumerate() { - if k + 1 == i { - f.extra |= extra_bit(tag); - } else { - f.extra &= !extra_bit(tag); - } + f.set_extra(extra_bit(tag), k + 1 == i); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f09..88b80aecde 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-11-07 · **Change:** text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -922,6 +922,23 @@ handle); a Mesh tool click inside a mesh envelope adds a row and a column. While selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command. +## OpenType feature values in text commands + +`text.setRangeStyle`, `text.setStyle` and the character style commands take OpenType features as a list of tags in +`features` (the whole list) or `featureChanges` (changes on top of each character's own): `"dlig"` on, `"-liga"` off. +A feature with alternates (`swsh`, `ss01`–`ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, `subs`, `numr`, `dnom`, +`aalt`) also takes `tag=N`, `N` from 0 to 65535, the font's alternate number: `"salt=2"` draws the second alternate of +each glyph in the range that has one. It is the value the shaper gets (an OpenType alternate substitution picks the +glyph at that position of its set), so `"salt"` is `"salt=1"`, and `"-salt"`, `"salt=0"` and a missing tag are off. A +later value replaces an earlier one; `salt=x`, `salt=65536`, `-salt=2` and `liga=2` are refused. The style's +`features` list and the saved file keep the string as typed (`["salt=2"]`), and SVG export writes CSS +`font-feature-settings: "salt" 2`. Runs that differ only in `features` are shaped as one piece of text with each run's +features over its own characters, so one letter of an Arabic word with `salt=2` keeps its neighbours' joining forms. + +```json +{"id":1,"method":"engine.execute","params":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +``` + ## Shared panel docking The canvas and the panels form one craft-ui docking layout: the protected `canvas` panel beside a dock at the @@ -1017,6 +1034,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. | | 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). | diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1b..238bd07ce8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5726,8 +5726,10 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s ## Alternate glyphs The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with -the Type tool (a range's first character, else the one before the caret), with the default glyph first when the font -does not list it, and a double-click on one draws that character with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, +the Type tool (a range's first character, else the one before the caret) as it stands in its word, with the glyph the +shaper makes for it first, and a double-click on one applies the OpenType feature that draws it to that character alone +(see [Alternates as OpenType feature values](#alternates-as-opentype-feature-values)); a glyph no feature draws there is +drawn by `text.setGlyphAlternate`. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and @@ -5748,6 +5750,36 @@ system). Choosing the glyph a character already draws is not an edit. Character {"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} ``` +### Alternates as OpenType feature values + +`text.setGlyphAlternate`'s choice swaps a glyph id into one character and shapes that character apart from its neighbours. +A feature with a value keeps the text real and joined: `features`, `featureChanges` (`text.setRangeStyle`, +`text.setStyle`, `charStyle.*`) take `tag=N` for `swsh`, `ss01` to `ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, +`subs`, `numr`, `dnom` and `aalt`, `N` from 0 to 65535, and the style's `features` list keeps the string: +`"salt=2"` draws the font's second alternate of every glyph in the run that has one (the OpenType alternate +substitution picks the glyph at that position of its set; a glyph without one, or with fewer alternates, keeps the +glyph it has). A plain `"salt"` is `"salt=1"` and is written so; `"salt=0"`, `"-salt"` and leaving it out are off. The +next value for a feature replaces the last one, in `features` and in `featureChanges`, and a tag the build does not know +(`salt=x`, `salt=65536`, `-salt=2`, `liga=2`) is refused with the unknown-feature error. SVG export writes the value as +CSS `font-feature-settings: "salt" 2`. + +Runs that differ only in their `features` are shaped together as one piece of text, each run's features over its own +characters: styling the middle letter of an Arabic word with `salt=2` leaves every other letter its initial, medial or +final form and the letters still join, as the same word typed with no style change. Runs that differ in font, size, +scale, case, language, script or direction are still shaped apart. + +`text.glyphAlternates` lists a character's alternates by the glyph the font's character map gives it; the Glyphs panel +instead lists those of the glyph the shaper makes for the character in its word (a medial letter's medial form), finds +for each the feature value that draws it there, and on a double-click runs one `text.setRangeStyle` on exactly that +character: `featureChanges` turns its other alternate features (`salt`, `aalt`, `swsh`, `ss01`–`ss20`) off and sets the +value (`["-ss01", "salt=2"]`), and `glyphAlternate: null` takes away a glyph choice that would otherwise keep drawing +its own glyph (`text.setRangeStyle` takes it; any other value is refused). It is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"featureChanges":["-salt"]}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em