Files
vectorcraft/crates/text/src/features.rs
T
imachusandClaude Opus 5.5 621e1a6640 Type: Proportional Metrics — full-width glyphs on the font's proportional widths, palt (#966)
A character attribute (Character panel menu with the East Asian options;
`text.setFormat` / `text.setRangeStyle {proportionalMetrics}`), saved only
when on. In horizontal type it applies `palt`; vertical type sets as before
until the vertical shaping change (`vpal`) follows.

Line-end Punctuation Half Width takes nothing more off the punctuation
`palt` re-spaced (the flush opening bracket, consecutive punctuation, the
line-end half width): the segment is shaped once more without `palt`, and
the glyphs whose advance differs are marked. Punctuation the font leaves
full width is trimmed as before. SVG writes `"palt" 1` with the
character's other features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:00:35 +07:00

228 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Item = &'a str>) -> 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<String> {
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<Item = &'a str>) -> 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<Feature> {
// 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));
}
if self.proportional(st) {
v.push(f(b"palt", true));
}
v
}
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
st.proportional_metrics && !self.vertical
}
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
/// [`Self::resolve`] without `palt`.
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
let mut v = self.resolve(st);
v.retain(|x| x.tag != Tag::new(b"palt"));
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<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::*;
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"));
}
}