diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 5c860a9e6..c83707ee5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1174,10 +1174,28 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { // 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. + // out (overflowing text) is shaped alone in its style. A character that starts a longer + // grapheme (a letter and its combining marks) is drawn in the grapheme's cluster, and is shaped + // with the grapheme. Of a cluster of several glyphs (a mark, a vowel sign drawn before the + // letter), the character's glyph is its default glyph or one of its alternates. + let plain = t.plain_text(); + let grapheme = plain.get(bytes.start..edit::next_cluster(&plain, bytes.start).max(bytes.end)).map_or_else(|| ch.to_string(), str::to_owned); + let listed = face.alternates(ch); + let own_glyph = |lay: &vectorcraft_text::TextLayout, at: usize| { + let cluster: Vec = lay + .glyphs + .iter() + .filter(|g| g.byte == at && [bytes.len(), grapheme.len()].contains(&g.len) && g.font_id == face.id()) + .map(|g| g.gid) + .collect(); + match cluster[..] { + [one] => Some(one), + _ => cluster.into_iter().find(|&g| g == default || listed.iter().any(|a| a.gid == g)), + } + }; 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)) + found.map(|_| own_glyph(lay, bytes.start)) }; let unchosen = match holder.as_ref().filter(|_| !styled) { None => None, @@ -1187,9 +1205,8 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { 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 single = TextObject::point(Point::ZERO, &grapheme, st); + own_glyph(&vectorcraft_text::layout(db, &single), 0) }) } }; diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index ca9dca27a..8e0b072cc 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2498,6 +2498,44 @@ fn the_default_glyph_chosen_under_a_substituting_feature_is_drawn() { assert_eq!(held, [g('a')], "the default glyph is kept as the choice"); } +/// A character followed by a combining mark is drawn in one cluster with the mark: with `salt`, +/// `gid: null` reports the glyph the feature draws, also when the text overflows, and choosing the +/// default glyph draws it. +#[test] +fn the_glyph_drawn_for_a_character_with_a_combining_mark_is_reported() { + 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 text = "xa\u{331}y"; + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": text, "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": text.len(), "features": ["salt"]})).unwrap(); + let lay = vectorcraft_text::layout(db, &obj(&s, id)); + let at = lay.glyphs.iter().find(|gl| gl.byte == 1).expect("the a is laid out"); + assert_eq!((at.gid, at.len), (g('b'), 3), "salt draws the a as b, in one cluster with its mark"); + + let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision); + 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"); + let undo = state(&s).0; + 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!(state(&s).0, undo + 1, "one undo step"); + let lay = vectorcraft_text::layout(db, &obj(&s, id)); + assert_eq!(lay.glyphs.iter().find(|gl| gl.byte == 1).map(|gl| gl.gid), Some(g('a')), "the a draws its default glyph"); + + let area = json!({"x": 0, "y": 300, "text": "xa\u{331}", "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": 4, "features": ["salt"]})).unwrap(); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + let r = s.execute("text.setGlyphAlternate", &json!({"id": boxed.0, "start": 1, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('b')), "the overflowing a is shaped with its mark"); +} + #[test] fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font};