Glyphs: a character's glyph is the first of its glyphs in its cluster

Ka, the halant, Ka again and the vowel sign I are shaped as one cluster:
the vowel sign, the first Ka's half form, then the second Ka's default
glyph. The glyph lookup looked for the character's default glyph across
the whole cluster before its alternates, so for the first Ka it found
the second Ka's default glyph instead of the half form. It again goes
through the cluster in order, and takes the first glyph that is the
character's default glyph or one of its alternates and is not the
default glyph of another character of the grapheme, so a vowel sign's
glyph that the font also lists as an alternate of the consonant is not
reported for the consonant.
This commit is contained in:
dotCooCoo
2026-11-05 07:30:43 +07:00
committed by bflatastic
parent f619fcf64b
commit e33be0eea9
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";