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.
This commit is contained in:
dotCooCoo
2026-11-05 06:55:55 +07:00
committed by bflatastic
parent 259677711d
commit 8f8f71f803
3 changed files with 106 additions and 16 deletions
+34 -16
View File
@@ -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<usize>, face: &vectorcraft_text::FontFace) -> Option<u32> {
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<Value> {
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<Value> {
// 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<Value> {
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<Value> = 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}))
}
+66
View File
@@ -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.
+6
View File
@@ -813,6 +813,12 @@ pub fn paragraph_is_rtl(text: &str, direction: Option<vectorcraft_doc::ParaDirec
layout::is_rtl(layout::para_bidi(text, direction).as_ref())
}
/// Is the character at byte `at` of paragraph `text` set right to left: its bidi level is odd, with
/// the paragraph's `direction` (None: from its first strong character)?
pub fn char_is_rtl(text: &str, direction: Option<vectorcraft_doc::ParaDirection>, 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