From 0d68c1283adb71265d28479422c49a575eab8457 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:03:48 -0700 Subject: [PATCH] M7: keep ligatures under small tracking Only real letterspacing (|tracking| > LIGATURE_TRACKING_LIMIT = 50/1000 em) drops standard ligatures now, and an explicit liga/clig in the character's features always wins. The OpenType panel's hint follows the same rule. --- crates/text/src/features.rs | 37 +++++++++++++++++++++++++-- crates/text/src/lib.rs | 2 +- crates/text/src/tests_typo.rs | 19 +++++++++++--- crates/ui-egui/src/panels/opentype.rs | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 9e47666d..ef446dde 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -3,13 +3,25 @@ use harfrust::{Feature, Tag}; use vectorcraft_doc::CharStyle; +/// Tracking (in 1/1000 em) beyond which standard ligatures are dropped automatically. +/// +/// Letterspaced type should not ligate: an `fi` glyph keeps its letters tight while everything +/// around it opens up, which reads as a blot. Small tracking adjustments made to fit a line +/// (a few thousandths either way) are not letterspacing and keep the ligatures, so only +/// `|tracking|` above this threshold suppresses them. An explicit `liga` / `clig` in the +/// character's own features always wins. +pub const LIGATURE_TRACKING_LIMIT: f64 = 50.0; + /// OpenType features applied during shaping. Kerning, `case` and ligature suppression are driven /// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options. +/// Standard ligatures are dropped when `|tracking|` exceeds [`LIGATURE_TRACKING_LIMIT`], unless +/// the character's features turn `liga` (or `clig`) on explicitly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OtFeatures { /// Vertical glyph alternates (`vert`), set by the writing direction. pub vertical: bool, - /// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero. + /// Standard ligatures (`liga`, `clig`). Suppressed automatically when `|tracking|` exceeds + /// [`LIGATURE_TRACKING_LIMIT`], unless the character turns them on explicitly. pub ligatures: bool, /// Contextual alternates (`calt`). pub contextual: bool, @@ -117,7 +129,7 @@ impl OtFeatures { if st.kerning.is_some() { v.push(f(b"kern", false)); } - let liga = s.ligatures && st.tracking.abs() < 1e-9; + let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(&st.features)); if !liga { v.push(f(b"liga", false)); v.push(f(b"clig", false)); @@ -150,6 +162,27 @@ impl OtFeatures { } } +/// Does `tracking` (1/1000 em) count as letterspacing that drops standard ligatures? +pub fn ligatures_suppressed_by(tracking: f64) -> bool { + // A non-finite value is not a real letterspacing request; keep the default. + tracking.is_finite() && tracking.abs() > LIGATURE_TRACKING_LIMIT +} + +/// Do `features` turn standard ligatures on explicitly (`liga`, `+liga`, `clig`, `+clig`)? The last +/// ligature tag wins, matching [`OtFeatures::with_tags`]. Absence of a tag means "default", which +/// [`OtFeatures::to_tags`] never writes, so this is distinguishable from an explicit request. +pub fn explicit_ligatures>(features: &[S]) -> bool { + features + .iter() + .rev() + .find_map(|t| match t.as_ref() { + "liga" | "+liga" | "clig" | "+clig" => Some(true), + "-liga" | "-clig" => Some(false), + _ => None, + }) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7833601a..cb79ed0f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -23,7 +23,7 @@ pub mod test_fonts; pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; -pub use features::OtFeatures; +pub use features::{LIGATURE_TRACKING_LIMIT, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index dcd91800..a486224f 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -370,9 +370,22 @@ fn opentype_ligature_switches() { assert_eq!(layout(db(), &t).glyphs.len(), 1, "fi ligature by default"); let off = LayoutOptions { features: OtFeatures { ligatures: false, ..Default::default() }, ..Default::default() }; assert_eq!(layout_with(db(), &t, &off).glyphs.len(), 2); - // Tracking suppresses ligatures (as letterspaced type should). - let tracked = TextObject::point(Point::ZERO, "fi", CharStyle { tracking: 100.0, ..serif }); - assert_eq!(layout(db(), &tracked).glyphs.len(), 2); + let fi = |tracking: f64, features: &[&str]| { + let st = CharStyle { tracking, features: features.iter().map(|s| s.to_string()).collect(), ..serif.clone() }; + layout(db(), &TextObject::point(Point::ZERO, "fi", st)).glyphs.len() + }; + // Small tracking adjustments (fitting a line) keep the ligature. + assert_eq!(fi(-5.0, &[]), 1, "tracking -5 keeps fi"); + assert_eq!(fi(20.0, &[]), 1, "tracking +20 keeps fi"); + assert_eq!(fi(LIGATURE_TRACKING_LIMIT, &[]), 1, "the limit itself keeps fi"); + // Real letterspacing suppresses ligatures (as letterspaced type should)... + assert_eq!(fi(100.0, &[]), 2, "tracking 100 drops fi"); + assert_eq!(fi(-100.0, &[]), 2, "tracking -100 drops fi"); + // ...unless the character turns them on explicitly. + assert_eq!(fi(100.0, &["liga"]), 1, "explicit liga wins over tracking"); + assert_eq!(fi(100.0, &["+liga"]), 1); + assert_eq!(fi(100.0, &["liga", "-liga"]), 2, "the last tag wins"); + assert_eq!(fi(0.0, &["-liga"]), 2, "explicit off still turns it off"); let f = OtFeatures::from_tags(["dlig", "-liga", "smcp", "onum", "bogus"]); assert!(f.discretionary_ligatures && !f.ligatures && f.small_caps && f.oldstyle_figures && !f.fractions); } diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 9f737233..2be67f8e 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -63,7 +63,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { *field(&mut next) = !on; } } - if st.tracking.abs() > 1e-9 && cur.ligatures { + if cur.ligatures && vectorcraft_text::ligatures_suppressed_by(st.tracking) && !vectorcraft_text::explicit_ligatures(&st.features) { widgets::dim_label(ui, tl!("Ligatures are off while the text is tracked.")); } if next != cur {