Merge pull request #5697 from dotCooCoo/fix/glyph-alternates-own-glyph

Glyphs: the Glyphs query reports a consonant's glyph, not its vowel sign's
This commit is contained in:
BFlat
2026-11-05 04:36:39 +07:00
committed by GitHub
3 changed files with 180 additions and 29 deletions
+39 -29
View File
@@ -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<usize>,
face: &vectorcraft_text::FontFace,
) -> Option<Option<u32>> {
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<u32> = 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<Value> {
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<Value> {
// 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<u32> = 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<Value> {
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<Value> {
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<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}))
}
+90
View File
@@ -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::<Vec<_>>();
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<Value>| {
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};
+51
View File
@@ -423,6 +423,57 @@ pub fn half_form_font(half: bool) -> Option<Vec<u8>> {
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<Vec<u8>> {
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::<Option<Vec<_>>>()?).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 {