Merge pull request #4786 from mockydevs/fix/lam-alef-review-followup

Type: lam-alef highlight follow-up: optional features, all marks, exact ligature test (#394)
This commit is contained in:
BFlat
2026-10-31 20:17:54 +07:00
committed by GitHub
6 changed files with 129 additions and 37 deletions
Generated
+1
View File
@@ -6383,6 +6383,7 @@ dependencies = [
"psd",
"serde_json",
"tiff",
"unicode-general-category",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+2
View File
@@ -27,6 +27,8 @@ weezl = "0.2"
# PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader).
png = "0.18"
log = { workspace = true }
# Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text).
unicode-general-category = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
vello_cpu = { workspace = true, features = ["multithreading"] }
+66 -9
View File
@@ -136,17 +136,49 @@ fn lam_alef_is_not_an_alternate() {
/// feature. Built in code, so it runs whatever fonts the machine has.
#[test]
fn lam_alef_in_liga_is_not_an_alternate() {
use vectorcraft_text::test_fonts::{lam_alef_family, lam_alef_font};
use vectorcraft_text::test_fonts::{lam_alef_family, lam_alef_font, source_sans_gid};
let _adding = crate::tests_fontchange::adding_fonts();
let db = vectorcraft_text::FontDb::global();
let gid = |c| u32::from(source_sans_gid(c).unwrap());
// Text, the ligature glyph and its cluster (byte, length): lam + alef, lam + alef with hamza
// above, the pair after a beh, and the pair with a fatha on the lam (the ligature skips it).
let cases = [
("\u{0644}\u{0627}", 'H', (0, 4)),
("\u{0644}\u{0623}", 'K', (0, 4)),
("\u{0628}\u{0644}\u{0627}", 'H', (2, 4)),
("\u{0644}\u{064E}\u{0627}", 'H', (0, 6)),
];
for feature in [b"liga", b"rlig"] {
db.add_font(lam_alef_font(feature).unwrap());
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: lam_alef_family(feature).into(), ..Default::default() };
for s in ["\u{0644}\u{0627}", "\u{0644}\u{0623}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{064E}\u{0627}"] {
let family = lam_alef_family(feature);
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: family.into(), ..Default::default() };
for (s, ligature, (byte, len)) in cases {
let t = text(s, style.clone());
// The font's ligature glyph stands for the pair.
assert!(glyphs(&t).iter().any(|g| g.1 > 2), "{}: {s} is shaped to one ligature glyph", lam_alef_family(feature));
assert_eq!(alternates(&t), 0, "{}: {s}", lam_alef_family(feature));
// The font's ligature glyph stands for the pair, its cluster lam to alef.
let shaped = glyphs(&t);
assert!(shaped.contains(&(byte, len, gid(ligature))), "{family}: {s} is shaped to {ligature} over {byte}..{}: {shaped:?}", byte + len);
assert_eq!(alternates(&t), 0, "{family}: {s}");
}
}
}
/// #394: an optional feature that changes the lam-alef ligature (here `salt`) is highlighted:
/// only the required ligature itself is left clear, whichever feature the font files it under.
#[test]
fn an_optional_feature_on_the_lam_alef_ligature_is_an_alternate() {
use vectorcraft_text::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font, source_sans_gid};
let _adding = crate::tests_fontchange::adding_fonts();
let db = vectorcraft_text::FontDb::global();
let salted = u32::from(source_sans_gid(LAM_ALEF_SALT).unwrap());
for feature in [b"liga", b"rlig"] {
db.add_font(lam_alef_font(feature).unwrap());
let family = lam_alef_family(feature);
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: family.into(), features: vec!["salt".into()], ..Default::default() };
for (s, byte) in [("\u{0644}\u{0627}", 0), ("\u{0628}\u{0644}\u{0627}", 2)] {
let t = text(s, style.clone());
let shaped = glyphs(&t);
assert!(shaped.contains(&(byte, 4, salted)), "{family}: salt turns the ligature into {LAM_ALEF_SALT}: {shaped:?}");
assert_eq!(alternates(&t), 1, "{family}: {s}: the salted ligature, and nothing else");
}
}
}
@@ -154,11 +186,36 @@ fn lam_alef_in_liga_is_not_an_alternate() {
/// Only a lam followed by an alef (marks between or after allowed) is the required lam-alef.
#[test]
fn only_lam_alef_clusters_are_the_required_ligature() {
for s in ["\u{0644}\u{0627}", "\u{0644}\u{0622}", "\u{0644}\u{0623}", "\u{0644}\u{0625}", "\u{0644}\u{064E}\u{0627}", "\u{0644}\u{0627}\u{064B}"]
{
// Marks of the Arabic block, Arabic Extended-A (U+08E4, U+08F0), Extended-B (U+0898) and
// Extended-C (U+10EFD), and Quranic marks (U+0610, U+06ED).
for s in [
"\u{0644}\u{0627}",
"\u{0644}\u{0622}",
"\u{0644}\u{0623}",
"\u{0644}\u{0625}",
"\u{0644}\u{064E}\u{0627}",
"\u{0644}\u{0627}\u{064B}",
"\u{0644}\u{08E4}\u{0627}",
"\u{0644}\u{08F0}\u{0627}",
"\u{0644}\u{0898}\u{0627}",
"\u{0644}\u{0627}\u{10EFD}",
"\u{0644}\u{0610}\u{06ED}\u{0627}",
] {
assert!(crate::text_geom::is_lam_alef(s), "{s:?}");
}
for s in ["", "\u{0644}", "\u{0627}\u{0644}", "\u{0644}\u{0628}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{0627}\u{0627}", "fi", "\u{064E}"] {
// Not marks: tatweel (U+0640) and the number mark above (U+08E2, a format character).
for s in [
"",
"\u{0644}",
"\u{0627}\u{0644}",
"\u{0644}\u{0628}",
"\u{0628}\u{0644}\u{0627}",
"\u{0644}\u{0627}\u{0627}",
"fi",
"\u{064E}",
"\u{0644}\u{0640}\u{0627}",
"\u{0644}\u{08E2}\u{0627}",
] {
assert!(!crate::text_geom::is_lam_alef(s), "{s:?}");
}
}
+31 -16
View File
@@ -48,17 +48,28 @@ impl TextGeom {
/// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features
/// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping
/// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the
/// lam-alef ligature are what the script needs, not alternates (the lam-alef ligature also when
/// a font files it under `liga`: [`is_lam_alef`]). Measured once.
/// lam-alef ligature are what the script needs, not alternates. A font may file the lam-alef
/// ligature under `liga`, which required shaping turns off, so a lam-alef cluster
/// ([`is_lam_alef`]) is also compared with its shaping with `liga` left on: an optional feature
/// that changes its glyph beyond that (a stylistic alternate…) is still highlighted. Measured
/// once.
pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath {
self.alternate_glyphs.get_or_init(|| {
let mut out = BezPath::new();
if self.alternate_candidates.is_empty() {
return out;
}
let required = required_shaping(t);
let required = required_shaping(t, &["-liga", "-calt"]);
let plain = t.plain_text();
let in_lam_alef = |c: &AlternateCandidate| plain.get(c.byte..c.byte.saturating_add(c.len)).is_some_and(is_lam_alef);
// Laid out only when the text has a lam-alef candidate.
let with_liga = self.alternate_candidates.iter().any(in_lam_alef).then(|| required_shaping(t, &["-calt"]));
let gives = |map: &std::collections::HashMap<(usize, usize), Vec<(u32, u32)>>, c: &AlternateCandidate| {
map.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid)))
};
for c in &self.alternate_candidates {
if !required.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) {
let lam_alef_ligature = || in_lam_alef(c) && with_liga.as_ref().is_some_and(|m| gives(m, c));
if !gives(&required, c) && !lam_alef_ligature() {
out.extend(c.cell.iter());
}
}
@@ -86,12 +97,14 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c
}
/// Whether `cluster` is a lam followed by an alef (plain, with madda, or with hamza above or below),
/// with any Arabic marks between or after them: the lam-alef ligature, which the Unicode
/// Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made before
/// `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so it is required
/// by its characters whatever feature the font files it under.
/// with any combining marks (Unicode general category M: the Arabic harakat, Quranic marks and
/// those of Arabic Extended-A, -B and -C) between or after them: the lam-alef ligature, which the
/// Unicode Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made
/// before `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so
/// [`TextGeom::alternate_glyphs`] also accepts its glyph from shaping with `liga` on.
pub(crate) fn is_lam_alef(cluster: &str) -> bool {
let is_mark = |c: &char| matches!(c, '\u{0610}'..='\u{061A}' | '\u{064B}'..='\u{065F}' | '\u{0670}' | '\u{06D6}'..='\u{06DC}' | '\u{06DF}'..='\u{06E4}' | '\u{06E7}' | '\u{06E8}' | '\u{06EA}'..='\u{06ED}');
use unicode_general_category::{GeneralCategory as G, get_general_category};
let is_mark = |c: &char| matches!(get_general_category(*c), G::NonspacingMark | G::SpacingMark | G::EnclosingMark);
let mut letters = cluster.chars().filter(|c| !is_mark(c));
matches!((letters.next(), letters.next(), letters.next()), (Some('\u{0644}'), Some('\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}'), None))
}
@@ -108,14 +121,16 @@ pub(crate) struct AlternateCandidate {
}
/// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font,
/// glyph)`: laid out again with every optional feature off (standard and contextual ligatures and
/// alternates, and the features its characters turn on: stylistic sets, swashes, discretionary
/// ligatures, figure styles…). What is left is what the script needs: `ccmp`, `locl`, `rlig`,
/// Arabic's `init`, `medi`, `fina` and `isol`, the Indic and other complex scripts' required sets.
fn required_shaping(t: &TextObject) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> {
/// glyph)`: laid out again with every optional feature off (with `features` `-liga` and `-calt`:
/// standard and contextual ligatures and alternates, and the features its characters turn on:
/// stylistic sets, swashes, discretionary ligatures, figure styles…). What is left is what the
/// script needs: `ccmp`, `locl`, `rlig`, Arabic's `init`, `medi`, `fina` and `isol`, the Indic
/// and other complex scripts' required sets. With `features` `-calt` alone, standard ligatures
/// stay on (an older font's lam-alef).
fn required_shaping(t: &TextObject, features: &[&str]) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> {
let mut plain = t.clone();
for run in &mut plain.runs {
run.style.features = vec!["-liga".into(), "-calt".into()];
run.style.features = features.iter().map(|f| (*f).into()).collect();
}
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain);
let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new();
@@ -193,7 +208,7 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeo
let cluster_chars = cluster.chars().count();
let Some(first) = cluster.chars().next() else { continue };
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
if is_opentype_alternate(nominal, g.gid, cluster_chars) && !is_lam_alef(cluster) {
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell });
}
}
+26 -10
View File
@@ -352,21 +352,29 @@ pub fn lam_alef_family(feature: &[u8; 4]) -> &'static str {
}
}
/// The glyph [`lam_alef_font`]'s `salt` gives its lam + alef ligature (H): M.
pub const LAM_ALEF_SALT: char = 'M';
/// A test-only Arabic cmap over bundled Source Sans 3: lam uses l, alef I, alef with hamza above
/// J and beh b. The `GSUB` table's `arab` script has one ligature lookup, under `feature`, that
/// joins lam + alef into H and lam + alef with hamza above into K. Older fonts put the mandatory
/// lam-alef ligature in `liga` rather than `rlig`; this builds either, without an OS font. `None`
/// when it can't be built.
/// J, beh b and fatha the combining acute (a mark in the font's `GDEF`). The `GSUB` table's `arab`
/// script has one ligature lookup, under `feature`, that skips marks and joins lam + alef into H
/// and lam + alef with hamza above into K, and a `salt` lookup that turns H into
/// [`LAM_ALEF_SALT`]. Older fonts put the mandatory lam-alef ligature in `liga` rather than
/// `rlig`; this builds either, without an OS font. `None` when it can't be built.
pub fn lam_alef_font(feature: &[u8; 4]) -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList};
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
use write_fonts::tables::gsub::{
Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList,
};
use write_fonts::tables::layout::{
CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, LookupFlag, 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 = [('\u{0644}', 'l'), ('\u{0627}', 'I'), ('\u{0623}', 'J'), ('\u{0628}', 'b')];
let mapping = [('\u{0644}', 'l'), ('\u{0627}', 'I'), ('\u{0623}', 'J'), ('\u{0628}', 'b'), ('\u{064E}', '\u{0301}')];
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 = lam_alef_family(feature);
@@ -380,9 +388,17 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option<Vec<u8>> {
let coverage = CoverageFormat1::new(vec![gid('l')?]);
let ligatures = vec![Ligature::new(gid('H')?, vec![gid('I')?]), Ligature::new(gid('K')?, vec![gid('J')?])];
let subst = LigatureSubstFormat1::new(coverage.into(), vec![LigatureSet::new(ligatures)]);
let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(feature), Feature::new(None, vec![0]))]);
let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]);
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0])), vec![]))]);
let salt = SingleSubstFormat2::new(CoverageFormat1::new(vec![gid('H')?]).into(), vec![gid(LAM_ALEF_SALT)?]);
// Features sorted by tag, as OpenType requires: `liga` / `rlig`, then `salt`.
let features = FeatureList::new(vec![
FeatureRecord::new(Tag::new(feature), 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(LookupFlag::IGNORE_MARKS, vec![subst])),
SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt.into()])),
]);
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), 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());
+3 -2
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Highlight Alternate Glyphs: an optional feature (such as `salt`) that changes a lam-alef ligature is highlighted again, only the required ligature staying clear, and a lam-alef with any combining mark (Arabic Extended-A/B/C included) counts as the ligature (#394); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -28,7 +28,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
@@ -71,6 +71,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Highlight Alternate Glyphs: an optional feature (such as `salt`) that changes a lam-alef ligature is highlighted again, only the required ligature staying clear, and a lam-alef with any combining mark (Arabic Extended-A/B/C included) counts as the ligature (#394); scores unchanged |
| 2026-10-30 | minor | Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged |
| 2026-10-30 | minor | Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged |
| 2026-10-29 | minor | Font fallback keeps a letter and its combining marks in one face; Vietnamese stacked diacritics (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms (#4491); scores and hours unchanged |