Merge #583: M7: keep ligatures under small tracking

This commit is contained in:
bflatastic 2026-10-09 03:55:19 +07:00
commit 07654ff45d
4 changed files with 53 additions and 7 deletions

View File

@ -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<S: AsRef<str>>(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::*;

View File

@ -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};

View File

@ -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);
}

View File

@ -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 {