mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 05:22:22 +00:00
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:
@@ -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()));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user