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 {