From 92ecb4a6b097150b22916070fcce4e27ec6533d2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 13:49:02 -0800 Subject: [PATCH] Glyphs: a consonant's ligature in a cluster with its vowel sign is its glyph Ka with a nukta and the vowel sign I is shaped as one cluster: the vowel sign's glyph, then the ligature of Ka and the nukta. The ligature is neither Ka's default glyph nor an alternate, so the lookup found no glyph for Ka: text.glyphAlternates reported the vowel sign's glyph and text.setGlyphAlternate reported Ka's default glyph. When the cluster holds neither, the lookup now takes the first glyph that is not drawn for another character of the grapheme. --- crates/engine/src/cmd/textedit.rs | 20 +++++++++++++++++--- crates/engine/src/tests/textedit.rs | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 8f0a6eab9..3b9045bd2 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1153,7 +1153,9 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R /// text; `None` when the character is not laid out (overflowing text). A character that starts a /// longer grapheme (a letter and its combining marks) is drawn in the grapheme's cluster. 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; `Some(None)` when the cluster holds neither. +/// is its default glyph or one of its alternates, else the first glyph that is not the default glyph +/// or an alternate of another character of the grapheme (a ligature of the letter and a mark); +/// `Some(None)` when the cluster holds none of these. fn own_glyph( plain: &str, lay: &vectorcraft_text::TextLayout, @@ -1172,8 +1174,20 @@ fn own_glyph( Some(match cluster[..] { [one] => Some(one), _ => { - let (default, listed) = (face.glyph_for(ch), face.alternates(ch)); - cluster.into_iter().find(|&g| g == default || listed.iter().any(|a| a.gid == g)) + // The glyphs of each character are looked up once: a grapheme can repeat a mark. + let glyphs_of = |c: char| face.alternates(c).iter().map(|a| a.gid).chain([face.glyph_for(c)]).collect::>(); + let default = face.glyph_for(ch); + let listed = || { + let own = glyphs_of(ch); + cluster.iter().copied().find(|g| own.contains(g)) + }; + cluster.iter().copied().find(|&g| g == default).or_else(listed).or_else(|| { + let mut others: Vec = plain.get(bytes.end..bytes.start + grapheme).unwrap_or_default().chars().collect(); + others.sort_unstable(); + others.dedup(); + let theirs: Vec = others.into_iter().flat_map(glyphs_of).collect(); + cluster.iter().copied().find(|g| !theirs.contains(g)) + }) } }) } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index e3a96317b..c910ed052 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2589,6 +2589,28 @@ fn the_glyph_of_a_consonant_after_its_vowel_sign_is_reported() { assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('a'))); } +/// Ka with a nukta and the vowel sign I is one cluster: the vowel sign drawn first, then the +/// ligature of Ka and the nukta, which is neither Ka's default glyph nor one of its alternates. The +/// glyph reported for Ka is the ligature, not the vowel sign. +#[test] +fn the_ligature_of_a_consonant_after_its_vowel_sign_is_reported() { + use vectorcraft_text::test_fonts::{MATRA_FAMILY, matra_font, source_sans_gid}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(matra_font().expect("the test font builds")); + let g = |c| u32::from(source_sans_gid(c).expect("a Source Sans glyph")); + let mut s = session(); + let text = "क\u{93C}\u{93F}"; + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": text, "size": 20, "font": MATRA_FAMILY})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let gids: Vec<_> = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.iter().map(|gl| (gl.gid, gl.byte, gl.len)).collect(); + assert_eq!(gids, [(g('i'), 0, 9), (g('N'), 0, 9)], "the vowel sign is drawn first, then the ligature"); + + assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('N'))); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('N')), "the ligature is drawn for Ka"); +} + /// A character that overflows its area is shaped with its grapheme: the nukta after Ka makes the /// font's `nukt` ligature, which `salt` does not change, so `gid: null` reports the ligature as it /// does for the laid-out text.