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 |