From fae49d31fd2dd409305410724a593866b7e65ce8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 08:35:20 -0800 Subject: [PATCH] Glyphs: the default glyph can be chosen back under a substituting feature Follows #5442. - text.setGlyphAlternate keeps the default glyph as a choice when a feature of the character's style (salt, a stylistic set) draws another glyph for it, so the default glyph is drawn. A character that is not laid out (overflowing text) is shaped alone in its style to decide. gid: null reports the glyph the style draws. - The Glyphs panel's alternates grid shows the default glyph first when the font does not list it, so a chosen alternate can be put back. - A test for a chosen character that starts a longer grapheme (f with a combining acute), which is not split from its mark. - The alternates tests in the engine hold the shared font lock while they add fonts to the global font database. --- crates/engine/src/cmd/textedit.rs | 46 +++++++++++++++++++++-------- crates/engine/src/tests/textedit.rs | 42 ++++++++++++++++++++++++++ crates/text/src/tests/alternates.rs | 15 ++++++++++ crates/ui-egui/src/panels/glyphs.rs | 39 ++++++++++++++++++++---- docs/mcp.md | 7 +++-- 5 files changed, 129 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 4b7703a20..5c860a9e6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1160,15 +1160,6 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); - let choice = match gid.filter(|g| *g != default) { - None => None, - Some(g) if face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), - Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), - }; - let drawn = choice.as_ref().map_or(default, |c| c.gid); - // The run holding the character. The request changes nothing when the choice is the one the - // character's run holds and that run holds the character alone; otherwise the edit splits the - // run, so that the following text does not take the character into a ligature. let holder = t .runs .iter() @@ -1178,15 +1169,46 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { Some((start..*at, r)) }) .find(|(bytes_of_run, _)| bytes_of_run.contains(&bytes.start)); + let styled = + holder.as_ref().is_some_and(|(_, r)| r.style.all_caps || (r.style.small_caps.is_some() && ch.is_lowercase()) || r.style.digits.map(ch) != ch); + // The glyph the character is drawn with when it holds no choice, drawn alone in its face: a + // feature of its style (`salt`, a stylistic set) can draw another glyph than the default one. + // The default glyph is then kept as a choice, so that it is drawn. A character that is not laid + // out (overflowing text) is shaped alone in its style. + let alone_in_face = |lay: &vectorcraft_text::TextLayout| { + let found = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)); + found.map(|g| (g.byte == bytes.start && g.len == bytes.len() && g.font_id == face.id()).then_some(g.gid)) + }; + let unchosen = match holder.as_ref().filter(|_| !styled) { + None => None, + Some((_, run)) => { + let mut probe = t.clone(); + edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); + let db = vectorcraft_text::FontDb::global(); + alone_in_face(&vectorcraft_text::layout(db, &probe)).unwrap_or_else(|| { + let st = CharStyle { glyph_alternate: None, ..run.style.clone() }; + let single = TextObject::point(Point::ZERO, &ch.to_string(), st); + let lay = vectorcraft_text::layout(db, &single); + lay.glyphs.iter().find(|g| g.byte == 0 && g.len == bytes.len() && g.font_id == face.id()).map(|g| g.gid) + }) + } + }; + let substituted = unchosen.is_some_and(|g| g != default); + let choice = match gid.filter(|g| *g != default || substituted) { + None => None, + Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), + Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), + }; + let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid); + // The run holding the character. The request changes nothing when the choice is the one the + // character's run holds and that run holds the character alone; otherwise the edit splits the + // run, so that the following text does not take the character into a ligature. let held = holder.as_ref().filter(|(bytes_of_run, _)| bytes_of_run.start == bytes.start).and_then(|(_, r)| r.style.glyph_alternate.clone()); let alone = holder.as_ref().is_some_and(|(bytes_of_run, _)| *bytes_of_run == bytes); // A choice that the character's style keeps from being drawn (All Caps, Small Caps, another // numeral system) is an error, not an edit that leaves the page as it was. The style decides // for a character outside its frame too; one that is laid out is checked in the layout. if let Some(c) = &choice { - let styled = holder - .as_ref() - .is_some_and(|(_, r)| r.style.all_caps || (r.style.small_caps.is_some() && ch.is_lowercase()) || r.style.digits.map(ch) != ch); let shown = || { let mut probe = t.clone(); edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = Some(c.clone())); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 4fe6ac847..ca9dca27a 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2301,6 +2301,7 @@ fn a_family_without_its_style_takes_its_closest_style() { #[test] fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { 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"); @@ -2385,6 +2386,7 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { #[test] fn a_glyph_alternate_already_drawn_is_not_an_edit() { 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"); @@ -2443,6 +2445,7 @@ fn a_glyph_alternate_already_drawn_is_not_an_edit() { #[test] fn a_glyph_alternate_that_the_style_does_not_draw_is_an_error() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let _fonts = crate::adding_fonts(); vectorcraft_text::FontDb::global().add_font(alternates_font(true).expect("the test font builds")); let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); let mut s = session(); @@ -2457,9 +2460,48 @@ fn a_glyph_alternate_that_the_style_does_not_draw_is_an_error() { assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none())); } +/// With a style feature that draws another glyph (`salt` draws the a as b), choosing the default +/// glyph draws it, and `gid: null` takes the choice away and reports the glyph the feature draws. +#[test] +fn the_default_glyph_chosen_under_a_substituting_feature_is_drawn() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let g = |c| face.glyph_for(c); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "features": ["salt"]})).unwrap(); + let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(&s), [g('x'), g('b'), g('b'), g('y')], "salt draws each a as b"); + + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": g('a')})).unwrap(); + assert_eq!(r["gid"], json!(g('a'))); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(gids(&s), [g('x'), g('a'), g('b'), g('y')], "the first a draws its default glyph"); + assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 1})).unwrap()["chosen"], json!(g('a'))); + + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('b')), "the glyph salt draws"); + assert_eq!(gids(&s), [g('x'), g('b'), g('b'), g('y')]); + + // A character that overflows its area is not laid out: the choice is kept for when it is. + let area = json!({"x": 0, "y": 300, "text": "xa", "size": 20, "font": alternates_family(true), "area": {"width": 200, "height": 4}}); + let boxed = NodeId(s.execute("text.create", &area).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": boxed.0, "start": 0, "end": 2, "features": ["salt"]})).unwrap(); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + s.execute("text.setGlyphAlternate", &json!({"id": boxed.0, "start": 1, "gid": g('a')})).unwrap(); + let held: Vec<_> = obj(&s, boxed).runs.iter().filter_map(|r| r.style.glyph_alternate.as_ref().map(|c| c.gid)).collect(); + assert_eq!(held, [g('a')], "the default glyph is kept as the choice"); +} + #[test] fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + let _fonts = crate::adding_fonts(); let db = vectorcraft_text::FontDb::global(); db.add_font(alternates_font(true).expect("the test font builds")); let requested = db.face(alternates_family(true), "Regular").expect("the test face"); diff --git a/crates/text/src/tests/alternates.rs b/crates/text/src/tests/alternates.rs index 74fef9d04..cc70780eb 100644 --- a/crates/text/src/tests/alternates.rs +++ b/crates/text/src/tests/alternates.rs @@ -117,6 +117,21 @@ fn a_chosen_character_is_not_taken_into_a_ligature_in_its_run() { assert_eq!(gids(chosen), [i, i], "the f draws the chosen glyph and the i stays apart"); } +/// A run "f" + combining acute + "i" whose f has a chosen glyph is not split between the f and its +/// mark: the f and the mark stay one cluster, shaped as they are without a choice. +#[test] +fn a_chosen_character_that_starts_a_longer_grapheme_is_not_split_from_it() { + let db = FontDb::with_font_dirs(vec![]); + let face = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face"); + let plain = CharStyle { font_family: FALLBACK_FAMILY.into(), size: 100.0, ..CharStyle::default() }; + let glyphs = + |st: CharStyle| layout(&db, &TextObject::point(Point::ZERO, "f\u{301}i", st)).glyphs.into_iter().map(|g| (g.gid, g.byte)).collect::>(); + let shaped = glyphs(plain.clone()); + assert!(shaped.iter().filter(|g| g.1 == 0).count() >= 2, "the mark is shaped in the f's cluster: {shaped:?}"); + let chosen = CharStyle { glyph_alternate: Some(face.choice('f', face.glyph_for('i'))), ..plain }; + assert_eq!(glyphs(chosen), shaped, "the f and its mark are shaped together, as without a choice"); +} + /// The font's pair kerning was looked up for the default glyph: a character drawn with another /// glyph takes no part in it, before or after it. #[test] diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index dce106543..5c07441be 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -342,9 +342,9 @@ fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: /// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's /// selected character in the face its text is set in, whatever font the panel browses -/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The glyph -/// the character is drawn with is highlighted. A double-click draws the character with the glyph -/// (`text.setGlyphAlternate`); the default glyph, when the font lists it, puts it back. +/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The default +/// glyph comes first when the font does not list it. The glyph the character is drawn with is +/// highlighted. A double-click draws the character with the glyph (`text.setGlyphAlternate`). fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let t = Tokens::get(ui.ctx()); let db = FontDb::global(); @@ -392,7 +392,8 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let tex = glyph_texture(ui.ctx(), &face, a.gid, px); ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]); - let resp = resp.on_hover_text(format!("{glyph} ({})\n{}", String::from_utf8_lossy(&a.feature), tl!("Double-click to replace"))); + let glyph = if a.feature == [0; 4] { glyph } else { format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)) }; + let resp = resp.on_hover_text(format!("{glyph}\n{}", tl!("Double-click to replace"))); if resp.double_clicked() { pick = Some(a.gid); } @@ -440,7 +441,12 @@ fn alternates_and_drawn( }) .and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len))) .map(|g| (g.font_id, g.gid)); - let found = (face.alternates(ch), drawn); + let mut list = face.alternates(ch); + let default = face.glyph_for(ch); + if !list.is_empty() && default != 0 && !list.iter().any(|a| a.gid == default) { + list.insert(0, vectorcraft_text::GlyphAlternate { gid: default, feature: [0; 4] }); + } + let found = (list, drawn); if let Some(key) = key { ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); } @@ -771,6 +777,29 @@ mod tests { assert_eq!(highlighted(&found, &t), [cells[1].center()], "the next revision finds the chosen glyph"); } + /// A font whose `aalt` does not list the default glyph (here no `aalt` at all) still shows it, + /// first in the grid, so a chosen alternate can be put back. + #[test] + fn the_default_glyph_is_listed_when_the_font_leaves_it_out() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(false).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(false)})).unwrap(); + select(&mut app, 2, 2); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let found = shapes(&ctx, &mut app, vec![]); + let cells = squares(&found); + assert_eq!(cells.len(), 7, "the default glyph and the six alternates"); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the default glyph is first and drawn"); + app.run("text.setGlyphAlternate", json!({"id": id, "start": 1, "gid": u1})).unwrap(); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(squares(&found).len(), 7); + assert_eq!(highlighted(&found, &t), [squares(&found)[5].center()], "the chosen glyph, after b, u2, u4 and u3"); + } + #[test] fn zoom_stops_at_the_ends() { assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); diff --git a/docs/mcp.md b/docs/mcp.md index cd23eb23e..5a2279b80 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5605,14 +5605,15 @@ 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), 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), 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, 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 `titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph -`gid`, one of those alternates, as one undo step; the default glyph or `gid: null` puts the default glyph back. The text +`gid`, one of those alternates or the default glyph, as one undo step; `gid: null` takes the choice away, and the +character is drawn as its style draws it (a feature such as `salt` can draw another glyph than the default). The text keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands