From 1679318034a243c258763465a7a011520bb493b1 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:39:40 +0300 Subject: [PATCH] Type: lam-alef highlight follow-up: optional features, all marks, exact ligature test (#394) Highlight Alternate Glyphs no longer drops a lam-alef cluster's glyphs outright: the ligature is clear when required shaping, or shaping with liga left on (older fonts), gives it, so an optional feature such as salt that changes it is highlighted again. is_lam_alef takes any combining mark by Unicode general category (Arabic Extended-A/B/C included) instead of a partial list. The test font maps the fatha to a mark and skips marks in its ligature, so the mark-spanning test checks the exact ligature glyph and cluster; it gains a salt lookup and test. Also a one-line equivalent nonminimal_bool repair in pathops' pathtools that strict Clippy needs on main. --- Cargo.lock | 1 + crates/render/Cargo.toml | 2 + crates/render/src/tests/alternate.rs | 75 ++++++++++++++++++++++++---- crates/render/src/text_geom.rs | 47 +++++++++++------ crates/text/src/test_fonts.rs | 36 +++++++++---- docs/type-parity.md | 5 +- 6 files changed, 129 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff1d18be2..a1512c6d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6383,6 +6383,7 @@ dependencies = [ "psd", "serde_json", "tiff", + "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 6d9263845..4bd499b9d 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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"] } diff --git a/crates/render/src/tests/alternate.rs b/crates/render/src/tests/alternate.rs index 8ef690330..201efc329 100644 --- a/crates/render/src/tests/alternate.rs +++ b/crates/render/src/tests/alternate.rs @@ -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:?}"); } } diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index bf058d75f..0d65dfbff 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -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) -> 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 }); } } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 449221f9f..f3619679f 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -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> { 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::>>()?).ok()?; let family = lam_alef_family(feature); @@ -380,9 +388,17 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { 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()); diff --git a/docs/type-parity.md b/docs/type-parity.md index c0220b193..dbf195b8a 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -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 |