diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 1283eb4a7..4d9ffaef9 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1153,9 +1153,11 @@ 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, 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. +/// is the first in cluster order that is its default glyph or one of its alternates and not the +/// default glyph of another character of the grapheme; else the first that 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, @@ -1176,18 +1178,24 @@ fn own_glyph( _ => { // 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)) - }) + // In cluster order: a conjunct's half form comes before the default glyph of its + // second consonant, which can be the same character. A glyph drawn for another + // character (a vowel sign drawn first) is skipped even when the font lists it as an + // alternate of this one. + let own = glyphs_of(ch); + let mut others: Vec = plain.get(bytes.end..bytes.start + grapheme).unwrap_or_default().chars().filter(|&c| c != ch).collect(); + others.sort_unstable(); + others.dedup(); + let their_defaults: Vec = others.iter().map(|&c| face.glyph_for(c)).collect(); + cluster + .iter() + .copied() + .find(|g| own.contains(g) && !their_defaults.contains(g)) + .or_else(|| cluster.iter().copied().find(|g| own.contains(g))) + .or_else(|| { + let theirs: Vec = others.iter().flat_map(|&c| glyphs_of(c)).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 68bafc09f..a35ca13a8 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2677,6 +2677,39 @@ fn the_ligature_of_a_consonant_after_its_vowel_sign_is_reported() { assert_eq!(r["gid"], json!(g('N')), "the ligature is drawn for Ka"); } +/// Ka, the halant, Ka again and the vowel sign I are one cluster: the vowel sign drawn first, the +/// first Ka's half form, which the font lists as an alternate of Ka, then the second Ka's default +/// glyph. The glyph reported for the first Ka is its half form, the first of its glyphs in the +/// cluster, not the second Ka's default glyph. The font also lists the vowel sign's glyph as an +/// alternate of Ka; drawn for the vowel sign, it is not reported for Ka. +#[test] +fn the_first_of_a_characters_glyphs_in_its_cluster_is_reported() { + use vectorcraft_text::test_fonts::{CONJUNCT_FAMILY, conjunct_font, source_sans_gid}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(conjunct_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{94D}क\u{93F}"; + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": text, "size": 20, "font": CONJUNCT_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, 12), (g('k'), 0, 12), (g('K'), 0, 12)], "the vowel sign, the half form, then the second Ka"); + + assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('k'))); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('k')), "the half form is drawn for the first Ka"); + + let text = "क\u{93F}"; + let r = s.execute("text.create", &json!({"x": 100, "y": 200, "text": text, "size": 20, "font": CONJUNCT_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, 6), (g('K'), 0, 6)], "the vowel sign, then Ka"); + assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('K'))); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('K')), "Ka's default glyph, not the vowel sign's"); +} + /// 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. diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index a7c57a15b..ebdd08573 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -423,6 +423,61 @@ pub fn half_form_font(half: bool) -> Option> { Some(builder.build()) } +/// The family of [`conjunct_font`]. +pub const CONJUNCT_FAMILY: &str = "Conjuncttest Sans"; + +/// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses K, the halant uses a period and +/// the vowel sign I (drawn before its syllable) uses i. The `GSUB` table's `dev2` script has an +/// `aalt` single substitution of K by k and a `salt` one of K by i, which list k and the vowel +/// sign's glyph as alternates of Ka, and a `half` ligature of K and the period into k (Ka's half +/// form). Family [`CONJUNCT_FAMILY`]. `None` when it can't be built. +pub fn conjunct_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{ + Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [('क', 'K'), ('\u{094D}', '.'), ('\u{093F}', '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 family = CONJUNCT_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Conjuncttest Sans Regular"), (6, "ConjuncttestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let ka = gid('K')?; + let aalt = SingleSubstFormat2::new(CoverageFormat1::new(vec![ka]).into(), vec![gid('k')?]); + let half = + LigatureSubstFormat1::new(CoverageFormat1::new(vec![ka]).into(), vec![LigatureSet::new(vec![Ligature::new(gid('k')?, vec![gid('.')?])])]); + let salt = SingleSubstFormat2::new(CoverageFormat1::new(vec![ka]).into(), vec![gid('i')?]); + // Features sorted by tag, as OpenType requires: `aalt`, `half`, then `salt`. + let features = FeatureList::new(vec![ + FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])), + FeatureRecord::new(Tag::new(b"half"), Feature::new(None, vec![1])), + FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![2])), + ]); + let lookups = SubstitutionLookupList::new(vec![ + SubstitutionLookup::Single(Lookup::new(Default::default(), vec![aalt.into()])), + SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![half])), + SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt.into()])), + ]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]))]); + builder.add_table(&Gsub::new(scripts, features, 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 [`matra_font`]. pub const MATRA_FAMILY: &str = "Matratest Sans";