Merge pull request #5657 from dotCooCoo/fix/glyph-alternates-default-and-graphemes

Glyphs: the default glyph can be chosen back under a substituting feature
This commit is contained in:
BFlat
2026-11-05 00:10:49 +07:00
committed by GitHub
5 changed files with 129 additions and 20 deletions
+34 -12
View File
@@ -1160,15 +1160,6 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
let t = text_ref(s, id)?;
let (ch, bytes, face) = glyph_target(&t, p, C)?;
let default = face.glyph_for(ch);
let choice = match gid.filter(|g| *g != default) {
None => None,
Some(g) if face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)),
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
};
let drawn = choice.as_ref().map_or(default, |c| c.gid);
// The run holding the character. The request changes nothing when the choice is the one the
// character's run holds and that run holds the character alone; otherwise the edit splits the
// run, so that the following text does not take the character into a ligature.
let holder = t
.runs
.iter()
@@ -1178,15 +1169,46 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
Some((start..*at, r))
})
.find(|(bytes_of_run, _)| bytes_of_run.contains(&bytes.start));
let styled =
holder.as_ref().is_some_and(|(_, r)| r.style.all_caps || (r.style.small_caps.is_some() && ch.is_lowercase()) || r.style.digits.map(ch) != ch);
// 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.
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(|g| (g.byte == bytes.start && g.len == bytes.len() && g.font_id == face.id()).then_some(g.gid))
};
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 single = TextObject::point(Point::ZERO, &ch.to_string(), st);
let lay = vectorcraft_text::layout(db, &single);
lay.glyphs.iter().find(|g| g.byte == 0 && g.len == bytes.len() && g.font_id == face.id()).map(|g| g.gid)
})
}
};
let substituted = unchosen.is_some_and(|g| g != default);
let choice = match gid.filter(|g| *g != default || substituted) {
None => None,
Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)),
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
};
let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid);
// The run holding the character. The request changes nothing when the choice is the one the
// character's run holds and that run holds the character alone; otherwise the edit splits the
// run, so that the following text does not take the character into a ligature.
let held = holder.as_ref().filter(|(bytes_of_run, _)| bytes_of_run.start == bytes.start).and_then(|(_, r)| r.style.glyph_alternate.clone());
let alone = holder.as_ref().is_some_and(|(bytes_of_run, _)| *bytes_of_run == bytes);
// A choice that the character's style keeps from being drawn (All Caps, Small Caps, another
// numeral system) is an error, not an edit that leaves the page as it was. The style decides
// for a character outside its frame too; one that is laid out is checked in the layout.
if let Some(c) = &choice {
let styled = holder
.as_ref()
.is_some_and(|(_, r)| r.style.all_caps || (r.style.small_caps.is_some() && ch.is_lowercase()) || r.style.digits.map(ch) != ch);
let shown = || {
let mut probe = t.clone();
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = Some(c.clone()));
+42
View File
@@ -2301,6 +2301,7 @@ fn a_family_without_its_style_takes_its_closest_style() {
#[test]
fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_font(true).expect("the test font builds"));
let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
@@ -2385,6 +2386,7 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() {
#[test]
fn a_glyph_alternate_already_drawn_is_not_an_edit() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_font(true).expect("the test font builds"));
let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
@@ -2443,6 +2445,7 @@ fn a_glyph_alternate_already_drawn_is_not_an_edit() {
#[test]
fn a_glyph_alternate_that_the_style_does_not_draw_is_an_error() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
let _fonts = crate::adding_fonts();
vectorcraft_text::FontDb::global().add_font(alternates_font(true).expect("the test font builds"));
let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
let mut s = session();
@@ -2457,9 +2460,48 @@ fn a_glyph_alternate_that_the_style_does_not_draw_is_an_error() {
assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()));
}
/// With a style feature that draws another glyph (`salt` draws the a as b), choosing the default
/// glyph draws it, and `gid: null` takes the choice away and reports the glyph the feature draws.
#[test]
fn the_default_glyph_chosen_under_a_substituting_feature_is_drawn() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_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 r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xaay", "size": 20, "font": alternates_family(true)})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "features": ["salt"]})).unwrap();
let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::<Vec<_>>();
assert_eq!(gids(&s), [g('x'), g('b'), g('b'), g('y')], "salt draws each a as b");
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": g('a')})).unwrap();
assert_eq!(r["gid"], json!(g('a')));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(gids(&s), [g('x'), g('a'), g('b'), g('y')], "the first a draws its default glyph");
assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 1})).unwrap()["chosen"], json!(g('a')));
let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": null})).unwrap();
assert_eq!(r["gid"], json!(g('b')), "the glyph salt draws");
assert_eq!(gids(&s), [g('x'), g('b'), g('b'), g('y')]);
// A character that overflows its area is not laid out: the choice is kept for when it is.
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");
s.execute("text.setGlyphAlternate", &json!({"id": boxed.0, "start": 1, "gid": g('a')})).unwrap();
let held: Vec<_> = obj(&s, boxed).runs.iter().filter_map(|r| r.style.glyph_alternate.as_ref().map(|c| c.gid)).collect();
assert_eq!(held, [g('a')], "the default glyph is kept as the choice");
}
#[test]
fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(alternates_font(true).expect("the test font builds"));
let requested = db.face(alternates_family(true), "Regular").expect("the test face");
+15
View File
@@ -117,6 +117,21 @@ fn a_chosen_character_is_not_taken_into_a_ligature_in_its_run() {
assert_eq!(gids(chosen), [i, i], "the f draws the chosen glyph and the i stays apart");
}
/// A run "f" + combining acute + "i" whose f has a chosen glyph is not split between the f and its
/// mark: the f and the mark stay one cluster, shaped as they are without a choice.
#[test]
fn a_chosen_character_that_starts_a_longer_grapheme_is_not_split_from_it() {
let db = FontDb::with_font_dirs(vec![]);
let face = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face");
let plain = CharStyle { font_family: FALLBACK_FAMILY.into(), size: 100.0, ..CharStyle::default() };
let glyphs =
|st: CharStyle| layout(&db, &TextObject::point(Point::ZERO, "f\u{301}i", st)).glyphs.into_iter().map(|g| (g.gid, g.byte)).collect::<Vec<_>>();
let shaped = glyphs(plain.clone());
assert!(shaped.iter().filter(|g| g.1 == 0).count() >= 2, "the mark is shaped in the f's cluster: {shaped:?}");
let chosen = CharStyle { glyph_alternate: Some(face.choice('f', face.glyph_for('i'))), ..plain };
assert_eq!(glyphs(chosen), shaped, "the f and its mark are shaped together, as without a choice");
}
/// The font's pair kerning was looked up for the default glyph: a character drawn with another
/// glyph takes no part in it, before or after it.
#[test]
+34 -5
View File
@@ -342,9 +342,9 @@ fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w:
/// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's
/// selected character in the face its text is set in, whatever font the panel browses
/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The glyph
/// the character is drawn with is highlighted. A double-click draws the character with the glyph
/// (`text.setGlyphAlternate`); the default glyph, when the font lists it, puts it back.
/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The default
/// glyph comes first when the font does not list it. The glyph the character is drawn with is
/// highlighted. A double-click draws the character with the glyph (`text.setGlyphAlternate`).
fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
let t = Tokens::get(ui.ctx());
let db = FontDb::global();
@@ -392,7 +392,8 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
let tex = glyph_texture(ui.ctx(), &face, a.gid, px);
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong);
let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]);
let resp = resp.on_hover_text(format!("{glyph} ({})\n{}", String::from_utf8_lossy(&a.feature), tl!("Double-click to replace")));
let glyph = if a.feature == [0; 4] { glyph } else { format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)) };
let resp = resp.on_hover_text(format!("{glyph}\n{}", tl!("Double-click to replace")));
if resp.double_clicked() {
pick = Some(a.gid);
}
@@ -440,7 +441,12 @@ fn alternates_and_drawn(
})
.and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)))
.map(|g| (g.font_id, g.gid));
let found = (face.alternates(ch), drawn);
let mut list = face.alternates(ch);
let default = face.glyph_for(ch);
if !list.is_empty() && default != 0 && !list.iter().any(|a| a.gid == default) {
list.insert(0, vectorcraft_text::GlyphAlternate { gid: default, feature: [0; 4] });
}
let found = (list, drawn);
if let Some(key) = key {
ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone())));
}
@@ -771,6 +777,29 @@ mod tests {
assert_eq!(highlighted(&found, &t), [cells[1].center()], "the next revision finds the chosen glyph");
}
/// A font whose `aalt` does not list the default glyph (here no `aalt` at all) still shows it,
/// first in the grid, so a chosen alternate can be put back.
#[test]
fn the_default_glyph_is_listed_when_the_font_leaves_it_out() {
use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
FontDb::global().add_font(alternates_font(false).expect("the test font builds"));
let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
let (mut app, id) = editing("xay");
app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(false)})).unwrap();
select(&mut app, 2, 2);
let ctx = egui::Context::default();
let t = Tokens::get(&ctx);
choose(&ctx, &mut app, "Alternates for Current Selection");
let found = shapes(&ctx, &mut app, vec![]);
let cells = squares(&found);
assert_eq!(cells.len(), 7, "the default glyph and the six alternates");
assert_eq!(highlighted(&found, &t), [cells[0].center()], "the default glyph is first and drawn");
app.run("text.setGlyphAlternate", json!({"id": id, "start": 1, "gid": u1})).unwrap();
let found = shapes(&ctx, &mut app, vec![]);
assert_eq!(squares(&found).len(), 7);
assert_eq!(highlighted(&found, &t), [squares(&found)[5].center()], "the chosen glyph, after b, u2, u4 and u3");
}
#[test]
fn zoom_stops_at_the_ends() {
assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1);
+4 -3
View File
@@ -5605,14 +5605,15 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s
## Alternate glyphs
The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with
the Type tool (a range's first character, else the one before the caret), and a double-click on one draws that character
with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default,
the Type tool (a range's first character, else the one before the caret), with the default glyph first when the font
does not list it, and a double-click on one draws that character with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default,
chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph
drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default
glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and
`titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures
and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph
`gid`, one of those alternates, as one undo step; the default glyph or `gid: null` puts the default glyph back. The text
`gid`, one of those alternates or the default glyph, as one undo step; `gid: null` takes the choice away, and the
character is drawn as its style draws it (a feature such as `salt` can draw another glyph than the default). The text
keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is
saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version
string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands