//! OpenType feature selection (the OpenType panel), resolved per character style. use harfrust::{Feature, Tag}; use vectorcraft_doc::CharStyle; /// Tracking (in 1/1000 em) outside which standard ligatures are dropped automatically: tighter /// than the first, looser than the second. /// /// Letterspaced type should not ligate: an `fi` glyph keeps its letters tight while everything /// around it opens up (or stays open while it closes up), which reads as a blot. Small tracking /// adjustments made to fit a line are not letterspacing and keep the ligatures. The limits are /// about −8% and +22% of a typical word space (250/1000 em), the thresholds Adobe's composers are /// described as using (John Hudson, www-style, 2012-07): condensing shows the fixed ligature /// sooner than expanding. An explicit `liga` / `clig` in the character's own features always wins. pub const LIGATURE_TRACKING_LIMITS: (f64, f64) = (-20.0, 55.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 is outside [`LIGATURE_TRACKING_LIMITS`], 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 outside /// [`LIGATURE_TRACKING_LIMITS`], unless the character turns them on explicitly. pub ligatures: bool, /// Contextual alternates (`calt`). pub contextual: bool, /// Discretionary ligatures (`dlig`). pub discretionary_ligatures: bool, /// Small capitals (`smcp`). pub small_caps: bool, /// Diagonal fractions (`frac`). pub fractions: bool, /// Oldstyle figures (`onum`). pub oldstyle_figures: bool, /// Tabular figures (`tnum`). pub tabular_figures: bool, /// Ordinals (`ordn`). pub ordinals: bool, /// Swashes (`swsh`). pub swash: bool, } impl Default for OtFeatures { fn default() -> Self { Self { vertical: false, ligatures: true, contextual: true, discretionary_ligatures: false, small_caps: false, fractions: false, oldstyle_figures: false, tabular_figures: false, ordinals: false, swash: false, } } } fn f(tag: &[u8; 4], on: bool) -> Feature { Feature::new(Tag::new(tag), on as u32, ..) } impl OtFeatures { /// Parse a feature list like `["dlig", "smcp", "-liga"]` (unknown tags are ignored). pub fn from_tags<'a>(tags: impl IntoIterator) -> Self { Self::default().with_tags(tags) } /// The tags this set differs from the defaults by (what `CharStyle::features` stores). pub fn to_tags(&self) -> Vec { let d = Self::default(); let mut v = vec![]; for (on, def, tag) in [ (self.ligatures, d.ligatures, "liga"), (self.contextual, d.contextual, "calt"), (self.discretionary_ligatures, d.discretionary_ligatures, "dlig"), (self.small_caps, d.small_caps, "smcp"), (self.fractions, d.fractions, "frac"), (self.oldstyle_figures, d.oldstyle_figures, "onum"), (self.tabular_figures, d.tabular_figures, "tnum"), (self.ordinals, d.ordinals, "ordn"), (self.swash, d.swash, "swsh"), ] { if on != def { v.push(if on { tag.to_string() } else { format!("-{tag}") }); } } v } /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? pub fn known_tag(tag: &str) -> bool { let t = tag.trim_start_matches(['-', '+']); matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") } /// This set with `tags` applied on top. pub fn with_tags<'a>(mut self, tags: impl IntoIterator) -> Self { let o = &mut self; for t in tags { let (on, t) = match t.strip_prefix('-') { Some(r) => (false, r), None => (true, t.strip_prefix('+').unwrap_or(t)), }; match t { "liga" => o.ligatures = on, "calt" => o.contextual = on, "dlig" => o.discretionary_ligatures = on, "smcp" => o.small_caps = on, "frac" => o.fractions = on, "onum" => o.oldstyle_figures = on, "tnum" => o.tabular_figures = on, "ordn" => o.ordinals = on, "swsh" => o.swash = on, _ => {} } } self } /// The harfrust features for text in style `st`. pub(crate) fn resolve(&self, st: &CharStyle) -> Vec { // The character's own OpenType settings override the layout-wide ones. let own = self.with_tags(st.features.iter().map(String::as_str)); let s = &own; let mut v = Vec::with_capacity(8); if st.kerning.is_some() { v.push(f(b"kern", false)); } 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)); } if !s.contextual { v.push(f(b"calt", false)); } if st.all_caps { v.push(f(b"case", true)); } for (on, tag) in [ (s.discretionary_ligatures, b"dlig"), (s.small_caps, b"smcp"), (s.fractions, b"frac"), (s.oldstyle_figures, b"onum"), (s.tabular_figures, b"tnum"), (s.ordinals, b"ordn"), (s.swash, b"swsh"), ] { if on { v.push(f(tag, true)); } } if self.vertical { // Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin // and digits itself (they would lie upside down, and tate-chu-yoko break). v.push(f(b"vert", true)); } v } } /// 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. let (tight, loose) = LIGATURE_TRACKING_LIMITS; tracking.is_finite() && (tracking < tight || tracking > loose) } /// 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::*; use crate::{FontDb, layout}; use vectorcraft_doc::TextObject; use vectorcraft_geom::Point; fn glyphs(features: &[&str]) -> usize { let st = CharStyle { font_family: "Source Serif 4".into(), features: features.iter().map(|s| s.to_string()).collect(), ..Default::default() }; layout(FontDb::global(), &TextObject::point(Point::ZERO, "fi", st)).glyphs.len() } #[test] fn per_character_features_drive_shaping() { let with = glyphs(&[]); let without = glyphs(&["-liga"]); assert!(with < without, "the default ligature merges f+i ({with} vs {without})"); assert_eq!(OtFeatures::from_tags(["-liga", "dlig"]).to_tags(), ["-liga", "dlig"]); assert!(OtFeatures::default().to_tags().is_empty()); assert!(OtFeatures::known_tag("-onum") && !OtFeatures::known_tag("zzzz")); } }