From 8f8f71f8033870fa69c4f525e98d64f38e681700 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 13:30:19 -0800 Subject: [PATCH] Glyphs: the Glyphs query reports the glyph of an overflowing character text.glyphAlternates reported the default glyph as `chosen` for any character that overflows its area. A character whose style substitutes another glyph (`salt`, or `vert` in vertical type) was reported with a glyph it is not drawn with. The query now shapes such a character alone, with the rest of its grapheme in their own styles and in the text's writing direction, by the same helper text.setGlyphAlternate uses. The helper also shapes the character in the direction its paragraph sets it in, from its bidi level, so a parenthesis set right to left is reported with its mirrored glyph in both commands. --- crates/engine/src/cmd/textedit.rs | 50 +++++++++++++++------- crates/engine/src/tests/textedit.rs | 66 +++++++++++++++++++++++++++++ crates/text/src/lib.rs | 6 +++ 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 3b9045bd2..1283eb4a7 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1192,6 +1192,27 @@ fn own_glyph( }) } +/// The glyph of `face` drawn for the character `ch` at `bytes` of `t` when the character is shaped +/// alone in the writing direction of `t` and the direction its paragraph sets it in, with the rest +/// of its grapheme in the grapheme's own styles: the glyph of a character that is not laid out +/// (overflowing text). +fn shaped_alone(t: &TextObject, ch: char, bytes: &std::ops::Range, face: &vectorcraft_text::FontFace) -> Option { + use vectorcraft_doc::ParaDirection; + let plain = t.plain_text(); + let end = edit::next_cluster(&plain, bytes.start).max(bytes.end); + let mut single = TextObject::point(Point::ZERO, "", CharStyle::default()); + single.runs = edit::slice_runs(&t.runs, bytes.start, end); + single.vertical = t.vertical; + // The bidi level of the character in its paragraph (a parenthesis is mirrored at an odd one). + let before = plain.get(..bytes.start).unwrap_or_default(); + let (paragraph, start) = (before.matches('\n').count(), before.rfind('\n').map_or(0, |i| i + 1)); + let stop = plain.get(bytes.start..).and_then(|after| after.find('\n')).map_or(plain.len(), |i| bytes.start + i); + let rtl = vectorcraft_text::char_is_rtl(plain.get(start..stop).unwrap_or_default(), t.para_at(paragraph).direction, bytes.start - start); + single.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &single); + own_glyph(&single.plain_text(), &lay, ch, &(0..bytes.len()), face).flatten() +} + 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`"))?; @@ -1220,21 +1241,12 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { // 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 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(); - 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(&grapheme, &vectorcraft_text::layout(db, &single), ch, &(0..bytes.len()), &face).flatten() - }) - } - }; + let unchosen = holder.as_ref().filter(|_| !styled).and_then(|_| { + let mut probe = t.clone(); + edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face)) + }); let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, @@ -1283,7 +1295,13 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result { let default = face.glyph_for(ch); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); 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); + // A character that is not laid out (overflowing text) reports the glyph it is drawn with when + // it is shaped alone, as `text.setGlyphAlternate` finds it. + let chosen = match own_glyph(&t.plain_text(), &lay, ch, &bytes, &face) { + Some(own) => own.or(first), + None => shaped_alone(&t, ch, &bytes, &face), + } + .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 c910ed052..68bafc09f 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2561,6 +2561,72 @@ fn the_default_glyph_chosen_in_overflowing_vertical_type_is_kept() { assert_eq!(held, [g('!')], "the default glyph is kept as the choice"); } +/// A character that overflows its area reports as `chosen` the glyph it is drawn with when it is +/// shaped alone: the glyph `salt` draws, a chosen default glyph, and the `vert` glyph of vertical +/// type. +#[test] +fn the_glyph_query_shapes_an_overflowing_character() { + use vectorcraft_text::test_fonts::{VERTICAL_PUNCTUATION_FAMILY, alternates_family, alternates_font, vertical_punctuation_font}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + db.add_font(vertical_punctuation_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 chosen = |s: &mut Session, id: NodeId, start: usize| { + s.execute("text.glyphAlternates", &json!({"id": id.0, "start": start})).unwrap()["chosen"].clone() + }; + + let area = json!({"x": 0, "y": 300, "text": "xa", "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": 2, "features": ["salt"]})).unwrap(); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + assert_eq!(chosen(&mut s, boxed, 1), json!(g('b')), "the glyph salt draws"); + s.execute("text.setGlyphAlternate", &json!({"id": boxed.0, "start": 1, "gid": g('a')})).unwrap(); + assert_eq!(chosen(&mut s, boxed, 1), json!(g('a')), "the chosen default glyph"); + + let vface = db.face(VERTICAL_PUNCTUATION_FAMILY, "Regular").expect("the test face"); + let text = + json!({"x": 0, "y": 0, "text": "!!", "size": 20, "font": VERTICAL_PUNCTUATION_FAMILY, "vertical": true, "area": {"width": 4, "height": 4}}); + let vertical = NodeId(s.execute("text.create", &text).unwrap()["id"].as_u64().unwrap()); + assert!(vectorcraft_text::layout(db, &obj(&s, vertical)).glyphs.is_empty(), "the text overflows"); + assert_eq!(chosen(&mut s, vertical, 0), json!(vface.glyph_for('I')), "vert draws ! as I"); +} + +/// A parenthesis set right to left is drawn mirrored: in a paragraph whose direction is right to +/// left, and after Hebrew in a paragraph of automatic direction, but not when its character +/// direction is left to right. When it overflows its area, the glyph query shapes it in the same +/// direction and reports the glyph the text draws when it fits. +#[test] +fn the_glyph_query_shapes_an_overflowing_character_in_its_paragraph_direction() { + let db = vectorcraft_text::FontDb::global(); + let face = db.face("Source Sans 3", "Regular").expect("the bundled face"); + let mut s = session(); + let create = |s: &mut Session, text: &str, rtl: bool, char_ltr: bool, height: f64| { + let spec = json!({"x": 0, "y": 0, "text": text, "size": 20, "font": "Source Sans 3", "area": {"width": 200, "height": height}}); + let id = NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap()); + if rtl { + s.execute("text.setFormat", &json!({"ids": [id.0], "direction": "rightToLeft", "start": 2, "end": 3})).unwrap(); + } + if char_ltr { + s.execute("text.setFormat", &json!({"ids": [id.0], "charDirection": "leftToRight", "start": 2, "end": 3})).unwrap(); + } + id + }; + for (text, rtl, char_ltr, at, drawn) in [("a\n(", true, false, 2, ')'), ("a\n\u{5D0}(", false, false, 4, ')'), ("a\n(", true, true, 2, '(')] { + let open = create(&mut s, text, rtl, char_ltr, 200.0); + let lay = vectorcraft_text::layout(db, &obj(&s, open)); + assert_eq!(lay.glyphs.iter().find(|g| g.byte == at).map(|g| g.gid), Some(face.glyph_for(drawn)), "{text:?}: the parenthesis drawn"); + + let boxed = create(&mut s, text, rtl, char_ltr, 30.0); + let lay = vectorcraft_text::layout(db, &obj(&s, boxed)); + assert!(lay.glyphs.iter().all(|g| g.byte < 2), "{text:?}: the second paragraph overflows"); + let r = s.execute("text.glyphAlternates", &json!({"id": boxed.0, "start": at})).unwrap(); + assert_eq!(r["chosen"], json!(face.glyph_for(drawn)), "{text:?}: the glyph drawn when it fits"); + } +} + /// 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. diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 54333a83c..9763ea4b6 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -813,6 +813,12 @@ pub fn paragraph_is_rtl(text: &str, direction: Option, at: usize) -> bool { + layout::para_bidi(text, direction).is_some_and(|info| info.levels.get(at).is_some_and(|level| level.is_rtl())) +} + /// Text stored in visual order (as PDF content draws it, each glyph where it is drawn), back in /// logical order: the order of its characters (indices into `visual.chars()`) and whether its /// paragraph runs right to left; laid out that way, it reads as drawn. `None` when nothing in it is