Merge pull request #5712 from dotCooCoo/fix/glyph-cluster-order

Glyphs: a character's glyph is the first of its glyphs in its cluster
This commit is contained in:
BFlat
2026-11-05 07:43:53 +07:00
committed by GitHub
3 changed files with 111 additions and 15 deletions
+23 -15
View File
@@ -1153,9 +1153,11 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R
/// 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, else the first glyph that is not the default glyph
/// or an alternate of another character of the grapheme (a ligature of the letter and a mark);
/// `Some(None)` when the cluster holds none of these.
/// is the first in cluster order that is its default glyph or one of its alternates and not the
/// default glyph of another character of the grapheme; else the first that is its default glyph or
/// one of its alternates; else the first glyph that is not the default glyph or an alternate of
/// another character of the grapheme (a ligature of the letter and a mark). `Some(None)` when the
/// cluster holds none of these.
fn own_glyph(
plain: &str,
lay: &vectorcraft_text::TextLayout,
@@ -1176,18 +1178,24 @@ fn own_glyph(
_ => {
// The glyphs of each character are looked up once: a grapheme can repeat a mark.
let glyphs_of = |c: char| face.alternates(c).iter().map(|a| a.gid).chain([face.glyph_for(c)]).collect::<Vec<u32>>();
let default = face.glyph_for(ch);
let listed = || {
let own = glyphs_of(ch);
cluster.iter().copied().find(|g| own.contains(g))
};
cluster.iter().copied().find(|&g| g == default).or_else(listed).or_else(|| {
let mut others: Vec<char> = plain.get(bytes.end..bytes.start + grapheme).unwrap_or_default().chars().collect();
others.sort_unstable();
others.dedup();
let theirs: Vec<u32> = others.into_iter().flat_map(glyphs_of).collect();
cluster.iter().copied().find(|g| !theirs.contains(g))
})
// In cluster order: a conjunct's half form comes before the default glyph of its
// second consonant, which can be the same character. A glyph drawn for another
// character (a vowel sign drawn first) is skipped even when the font lists it as an
// alternate of this one.
let own = glyphs_of(ch);
let mut others: Vec<char> = plain.get(bytes.end..bytes.start + grapheme).unwrap_or_default().chars().filter(|&c| c != ch).collect();
others.sort_unstable();
others.dedup();
let their_defaults: Vec<u32> = others.iter().map(|&c| face.glyph_for(c)).collect();
cluster
.iter()
.copied()
.find(|g| own.contains(g) && !their_defaults.contains(g))
.or_else(|| cluster.iter().copied().find(|g| own.contains(g)))
.or_else(|| {
let theirs: Vec<u32> = others.iter().flat_map(|&c| glyphs_of(c)).collect();
cluster.iter().copied().find(|g| !theirs.contains(g))
})
}
})
}
+33
View File
@@ -2677,6 +2677,39 @@ fn the_ligature_of_a_consonant_after_its_vowel_sign_is_reported() {
assert_eq!(r["gid"], json!(g('N')), "the ligature is drawn for Ka");
}
/// Ka, the halant, Ka again and the vowel sign I are one cluster: the vowel sign drawn first, the
/// first Ka's half form, which the font lists as an alternate of Ka, then the second Ka's default
/// glyph. The glyph reported for the first Ka is its half form, the first of its glyphs in the
/// cluster, not the second Ka's default glyph. The font also lists the vowel sign's glyph as an
/// alternate of Ka; drawn for the vowel sign, it is not reported for Ka.
#[test]
fn the_first_of_a_characters_glyphs_in_its_cluster_is_reported() {
use vectorcraft_text::test_fonts::{CONJUNCT_FAMILY, conjunct_font, source_sans_gid};
let _fonts = crate::adding_fonts();
let db = vectorcraft_text::FontDb::global();
db.add_font(conjunct_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{94D}क\u{93F}";
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": text, "size": 20, "font": CONJUNCT_FAMILY})).unwrap();
let id = NodeId(r["id"].as_u64().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('i'), 0, 12), (g('k'), 0, 12), (g('K'), 0, 12)], "the vowel sign, the half form, then the second Ka");
assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('k')));
let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap();
assert_eq!(r["gid"], json!(g('k')), "the half form is drawn for the first Ka");
let text = "क\u{93F}";
let r = s.execute("text.create", &json!({"x": 100, "y": 200, "text": text, "size": 20, "font": CONJUNCT_FAMILY})).unwrap();
let id = NodeId(r["id"].as_u64().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('i'), 0, 6), (g('K'), 0, 6)], "the vowel sign, then Ka");
assert_eq!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap()["chosen"], json!(g('K')));
let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 0, "gid": null})).unwrap();
assert_eq!(r["gid"], json!(g('K')), "Ka's default glyph, not the vowel sign's");
}
/// 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.
+55
View File
@@ -423,6 +423,61 @@ pub fn half_form_font(half: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`conjunct_font`].
pub const CONJUNCT_FAMILY: &str = "Conjuncttest Sans";
/// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses K, the halant uses a period and
/// the vowel sign I (drawn before its syllable) uses i. The `GSUB` table's `dev2` script has an
/// `aalt` single substitution of K by k and a `salt` one of K by i, which list k and the vowel
/// sign's glyph as alternates of Ka, and a `half` ligature of K and the period into k (Ka's half
/// form). Family [`CONJUNCT_FAMILY`]. `None` when it can't be built.
pub fn conjunct_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 = [('क', 'K'), ('\u{094D}', '.'), ('\u{093F}', 'i')];
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 = CONJUNCT_FAMILY;
let name = name_table(&[(1, family), (2, "Regular"), (4, "Conjuncttest Sans Regular"), (6, "ConjuncttestSans-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('K')?;
let aalt = SingleSubstFormat2::new(CoverageFormat1::new(vec![ka]).into(), vec![gid('k')?]);
let half =
LigatureSubstFormat1::new(CoverageFormat1::new(vec![ka]).into(), vec![LigatureSet::new(vec![Ligature::new(gid('k')?, vec![gid('.')?])])]);
let salt = SingleSubstFormat2::new(CoverageFormat1::new(vec![ka]).into(), vec![gid('i')?]);
// Features sorted by tag, as OpenType requires: `aalt`, `half`, then `salt`.
let features = FeatureList::new(vec![
FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])),
FeatureRecord::new(Tag::new(b"half"), Feature::new(None, vec![1])),
FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![2])),
]);
let lookups = SubstitutionLookupList::new(vec![
SubstitutionLookup::Single(Lookup::new(Default::default(), vec![aalt.into()])),
SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![half])),
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, 2])), 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 [`matra_font`].
pub const MATRA_FAMILY: &str = "Matratest Sans";