Merge pull request #4746 from mockydevs/fix/lam-alef-legacy-arial

Type: required lam-alef is not an alternate in fonts that put it in liga (#394)
This commit is contained in:
BFlat
2026-10-31 03:32:31 +07:00
committed by GitHub
6 changed files with 104 additions and 7 deletions
+1 -1
View File
@@ -355,7 +355,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?);
let tolerance = number_or(p, "tolerance", 3.0, C)?;
if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) {
if !(x.is_finite() && y.is_finite() && tolerance >= 0.0 && tolerance.is_finite()) {
return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)"));
}
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?;
+35 -1
View File
@@ -118,7 +118,9 @@ fn arabic_joining_forms_are_not_alternates() {
assert_eq!(alternates(&t), 0);
}
/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either.
/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either. With an
/// older font that files it under `liga` (as the legacy Arial of Microsoft's core fonts does) it
/// is still required: [`lam_alef_in_liga_is_not_an_alternate`] checks that without an OS font.
#[test]
fn lam_alef_is_not_an_alternate() {
// Looking up an installed font loads it, which changes the fonts.
@@ -129,6 +131,38 @@ fn lam_alef_is_not_an_alternate() {
}
}
/// #394: a font whose lam-alef ligature is in `liga` (older Arabic fonts) or `rlig` gets no
/// highlight on it: the ligature is required by the characters, not chosen by an optional
/// 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};
let _adding = crate::tests_fontchange::adding_fonts();
let db = vectorcraft_text::FontDb::global();
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 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));
}
}
}
/// 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}"]
{
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}"] {
assert!(!crate::text_geom::is_lam_alef(s), "{s:?}");
}
}
/// #394: an optional feature the characters turn on (a stylistic set, swash, stylistic or
/// discretionary alternate…) is highlighted on the glyphs it changes, and only on those.
#[test]
+14 -2
View File
@@ -48,7 +48,8 @@ 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. Measured once.
/// 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.
pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath {
self.alternate_glyphs.get_or_init(|| {
let mut out = BezPath::new();
@@ -84,6 +85,17 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
}
/// 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.
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}');
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))
}
/// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its
/// box.
#[derive(Debug)]
@@ -181,7 +193,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) {
if is_opentype_alternate(nominal, g.gid, cluster_chars) && !is_lam_alef(cluster) {
alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell });
}
}
+50
View File
@@ -343,6 +343,56 @@ pub fn half_form_font(half: bool) -> Option<Vec<u8>> {
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 {
b"liga" => "Lamtest Liga",
b"rlig" => "Lamtest Rlig",
_ => "Lamtest Other",
}
}
/// 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.
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::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 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);
let full = format!("{family} Regular");
let postscript = format!("{}-Regular", family.replace(' ', ""));
let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (6, &postscript)])?;
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 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![]))]);
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 [`pair_kerning_font`].
pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans";
+1 -1
View File
@@ -660,7 +660,7 @@ fn the_panel_menu_saves_deletes_and_hides_presets() {
let mut p = Panel::new();
let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu);
let save = text_at(&texts, "Save Gradient Preset…").unwrap_or_else(|| panic!("{texts:?}"));
assert!(!app.session.active_saved_gradient_preset().is_some());
assert!(app.session.active_saved_gradient_preset().is_none());
p.menu_click(&mut app, save);
let d = app.ui.dialog.take().expect("the name dialog");
assert_eq!((d.fields["__command"].as_str(), d.fields["name"].as_str()), (Some("gradient.presets.save"), Some("Gradient Preset 1")));
+3 -2
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** 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) · **Target:** Adobe Illustrator 2026 (30.x)
> **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)
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; 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); 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-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 |
| 2026-10-25 | minor | Threading ports: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break`); the story flows into the frames before the break; scores unchanged |