From 67140d5e543671cea70bd78496405b76686bdcff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 12:15:59 -0800 Subject: [PATCH] Glyphs: the Glyphs query reports a consonant's glyph, not its vowel sign's text.glyphAlternates reported the first glyph of the character's cluster as the chosen glyph. A vowel sign drawn before its consonant (Devanagari Ka with the vowel sign I) comes first in that cluster, so the query reported the vowel sign's glyph for Ka, while text.setGlyphAlternate reported Ka's own glyph. Both commands now take the character's glyph by the same rule, and so does the check in text.setGlyphAlternate that the chosen glyph is drawn. A character that overflows its area is shaped with its grapheme, and each character of the grapheme now keeps its own style there, so a nukta of another size no longer makes a ligature with its consonant that the laid-out text does not draw. A test font with a Devanagari nukta ligature checks both. --- crates/engine/src/cmd/textedit.rs | 68 ++++++++++++---------- crates/engine/src/tests/textedit.rs | 90 +++++++++++++++++++++++++++++ crates/text/src/test_fonts.rs | 51 ++++++++++++++++ 3 files changed, 180 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 465e02b45..8f0a6eab9 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1149,6 +1149,35 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R Err(bad(c, format!("no character at byte {start}"))) } +/// The glyph of `face` that `lay` draws for the character `ch` at `bytes` of `plain`, the laid-out +/// 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. +fn own_glyph( + plain: &str, + lay: &vectorcraft_text::TextLayout, + ch: char, + bytes: &std::ops::Range, + face: &vectorcraft_text::FontFace, +) -> Option> { + lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len))?; + let grapheme = edit::next_cluster(plain, bytes.start).max(bytes.end) - bytes.start; + let cluster: Vec = lay + .glyphs + .iter() + .filter(|g| g.byte == bytes.start && [bytes.len(), grapheme].contains(&g.len) && g.font_id == face.id()) + .map(|g| g.gid) + .collect(); + 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)) + } + }) +} + fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setGlyphAlternate"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; @@ -1174,40 +1203,21 @@ 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 and writing direction. 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. + // out (overflowing text) is shaped alone in its writing direction, with the rest of its grapheme + // in the grapheme's own styles. 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(|_| own_glyph(lay, bytes.start)) - }; 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 mut single = TextObject::point(Point::ZERO, &grapheme, st); + own_glyph(&plain, &vectorcraft_text::layout(db, &probe), ch, &bytes, &face).unwrap_or_else(|| { + let mut single = TextObject::point(Point::ZERO, &grapheme, run.style.clone()); + single.runs = edit::slice_runs(&probe.runs, bytes.start, bytes.start + grapheme.len()); single.vertical = t.vertical; - own_glyph(&vectorcraft_text::layout(db, &single), 0) + own_glyph(&grapheme, &vectorcraft_text::layout(db, &single), ch, &(0..bytes.len()), &face).flatten() }) } }; @@ -1230,10 +1240,9 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let shown = || { let mut probe = t.clone(); edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = Some(c.clone())); - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); - lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)).map(|g| g.gid) + own_glyph(&plain, &vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), ch, &bytes, &face) }; - if styled || shown().is_some_and(|g| g != c.gid) { + if styled || shown().is_some_and(|g| g != Some(c.gid)) { return Err(bad( C, format!("glyph {} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system)", c.gid), @@ -1259,7 +1268,8 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result { let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - let chosen = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)).map_or(default, |g| g.gid); + let first = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)).map(|g| g.gid); + let chosen = own_glyph(&t.plain_text(), &lay, ch, &bytes, &face).flatten().or(first).unwrap_or(default); let alternates: Vec = face.alternates(ch).iter().map(|a| json!({"gid": a.gid, "feature": String::from_utf8_lossy(&a.feature)})).collect(); Ok(json!({"char": ch.to_string(), "font": face.family, "style": face.style, "default": default, "chosen": chosen, "alternates": alternates})) } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 923cd1d12..e3a96317b 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2561,6 +2561,96 @@ fn the_default_glyph_chosen_in_overflowing_vertical_type_is_kept() { assert_eq!(held, [g('!')], "the default glyph is kept as the choice"); } +/// A vowel sign drawn before its consonant (Devanagari Ka with the vowel sign I) is in the +/// consonant's cluster, drawn first: with `salt`, the glyph reported for the consonant is the one +/// the feature draws, not the vowel sign's, and the default glyph chosen for it is drawn. +#[test] +fn the_glyph_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{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()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": text.len(), "features": ["salt"]})).unwrap(); + let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|gl| (gl.gid, gl.byte, gl.len)).collect::>(); + assert_eq!(gids(&s), [(g('i'), 0, 6), (g('b'), 0, 6)], "the vowel sign is drawn first, in the cluster of Ka as salt draws it"); + + let query = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap(); + assert_eq!(query["chosen"], json!(g('b')), "the glyph salt draws for Ka"); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('b'))); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": g('a')})).unwrap(); + assert_eq!(r["gid"], json!(g('a'))); + assert!(gids(&s).iter().any(|&(gl, byte, _)| (gl, byte) == (g('a'), 0)), "Ka draws its default glyph: {:?}", gids(&s)); + assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('a'))); +} + +/// 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. +#[test] +fn the_glyph_of_an_overflowing_character_is_shaped_with_its_grapheme() { + 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}"; + let point = json!({"x": 100, "y": 100, "text": text, "size": 20, "font": MATRA_FAMILY}); + let id = NodeId(s.execute("text.create", &point).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": text.len(), "features": ["salt"]})).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('N'), 0, 6)], "Ka and the nukta make the ligature"); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('N'))); + + let area = json!({"x": 0, "y": 300, "text": text, "size": 20, "font": MATRA_FAMILY, "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": text.len(), "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": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('N')), "the overflowing Ka is shaped with its nukta"); +} + +/// A grapheme whose characters have different styles is shaped in those styles when it overflows: +/// a nukta of another size does not make the `nukt` ligature with Ka, so `gid: null` reports the +/// glyph `salt` draws for Ka, as it does for the laid-out text. +#[test] +fn an_overflowing_grapheme_keeps_the_styles_of_its_characters() { + 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}"; + let styled = |s: &mut Session, area: Option| { + let mut spec = json!({"x": 0, "y": 300, "text": text, "size": 20, "font": MATRA_FAMILY}); + if let Some(area) = area { + spec["area"] = area; + } + let id = NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": text.len(), "features": ["salt"]})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 3, "end": text.len(), "size": 30})).unwrap(); + id + }; + let open = styled(&mut s, None); + let lay = vectorcraft_text::layout(db, &obj(&s, open)); + assert_eq!(lay.glyphs.iter().find(|gl| gl.byte == 0).map(|gl| gl.gid), Some(g('b')), "the nukta of another size makes no ligature"); + let r = s.execute("text.setGlyphAlternate", &json!({"id": open.0, "start": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('b'))); + + let boxed = styled(&mut s, Some(json!({"width": 200, "height": 4}))); + 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": 0, "gid": null})).unwrap(); + assert_eq!(r["gid"], json!(g('b')), "the overflowing Ka is shaped apart from the nukta of another size"); +} + #[test] fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 085bd6f0d..a7c57a15b 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -423,6 +423,57 @@ pub fn half_form_font(half: bool) -> Option> { Some(builder.build()) } +/// The family of [`matra_font`]. +pub const MATRA_FAMILY: &str = "Matratest Sans"; + +/// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses a, the vowel sign I (drawn +/// before its consonant) uses i and the nukta uses the period. The `GSUB` table's `dev2` script has +/// a `nukt` ligature of Ka and the nukta into N, and a `salt` single substitution of Ka by b. +/// Family [`MATRA_FAMILY`]. `None` when it can't be built. +pub fn matra_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 = [('क', 'a'), ('\u{093F}', 'i'), ('\u{093C}', '.')]; + 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 = MATRA_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Matratest Sans Regular"), (5, "Version 1.000"), (6, "MatratestSans-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('a')?; + let ligature = LigatureSet::new(vec![Ligature::new(gid('N')?, vec![gid('.')?])]); + let nukt = LigatureSubstFormat1::new(CoverageFormat1::new(vec![ka]).into(), vec![ligature]); + let salt = SingleSubstFormat2::new(CoverageFormat1::new(vec![ka]).into(), vec![gid('b')?]); + // Features sorted by tag, as OpenType requires: `nukt`, then `salt`. + let features = FeatureList::new(vec![ + FeatureRecord::new(Tag::new(b"nukt"), Feature::new(None, vec![0])), + FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1])), + ]); + let lookups = SubstitutionLookupList::new(vec![ + SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![nukt])), + 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])), 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 [`lam_alef_font`] whose lam-alef ligature is in `feature`. pub fn lam_alef_family(feature: &[u8; 4]) -> &'static str { match feature {