Files
designcraft/crates/compose/src/optical.rs
T
Abdelrehman Fouad d0120bf6a6 Text: Optical kerning leaves dashes and fullwidth forms alone
Two dashes end to end have no ink gap to measure, so -- and the double
em dash opened up by the loosen cap; Chinese sets —— as one continuous
rule. Fullwidth Latin (U+FF21–FF5A) was kernable through its Latin
script and moved CJK text off its em grid. Both are now excluded from
kernable(), with a unit test and a compose test that failed before.
2026-10-11 11:45:29 +02:00

283 lines
12 KiB
Rust

//! Optical kerning: pair spacing measured from the glyph outlines, for text whose kerning is
//! set to Optical.
//!
//! The font's own kern pairs are not used (Optical replaces Metrics, as in InDesign). Instead,
//! each glyph's ink is sampled into horizontal bands across the em, and for each pair the gap
//! between the left glyph's right edge and the right glyph's left edge is read in the bands where
//! both have ink. The pair is then moved part of the way towards the font's natural stem-to-stem
//! gap (the gap of the pair `nn`, or of `HH` then `oo` when the font lacks them), never closer
//! than a fraction of it, and never by more than a small part of the em. `nn` itself stays as the
//! font set it; diagonal pairs (`AV`, `To`) and letter-punctuation pairs (`r.`, `f,`) close up;
//! round pairs such as `oo`, and pairs that already touch, open slightly.
//!
//! Profiles are cached per (face, glyph) and the reference gap per face, so body text in Optical
//! costs one outline walk per distinct glyph.
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
use designcraft_fonts::{FontFace, ScopedFonts};
use designcraft_geom::{BezPath, PathEl, Point};
use crate::shape::Glyph;
/// Horizontal bands across the em, from `TOP` to `BOTTOM` em (outlines are y-down: negative is
/// above the baseline).
const BANDS: usize = 28;
const TOP: f64 = -1.0;
const BOTTOM: f64 = 0.3;
/// How far a pair moves towards the reference gap (1 = all the way).
const PULL: f64 = 0.6;
/// The ink gap a pair keeps at least, as a fraction of the reference gap.
const FLOOR: f64 = 0.3;
/// Tightest and loosest adjustment, in em.
const MAX_TIGHTEN: f64 = -0.12;
const MAX_LOOSEN: f64 = 0.06;
/// A glyph wider or taller than this many em is not measured.
const MAX_EXTENT: f64 = 8.0;
const PROFILE_CACHE_MAX: usize = 20_000;
/// A glyph's ink extent per band, in font units: `None` where the band holds no ink.
#[derive(Clone, Debug)]
pub struct Profile {
bands: [Option<(f64, f64)>; BANDS],
}
impl Profile {
/// Sample `path` (font units, y-down) into bands of the em.
pub fn of(path: &BezPath, upem: f64) -> Profile {
let mut bands = [None; BANDS];
if upem.is_nan() || upem.is_infinite() || upem <= 0.0 {
return Profile { bands };
}
// A glyph drawn far outside its em is no letter (a hostile font): no profile, no kerning.
let bbox = designcraft_geom::Shape::bounding_box(path);
if !(bbox.x0.is_finite() && bbox.y0.is_finite() && bbox.x1.is_finite() && bbox.y1.is_finite())
|| bbox.width() > MAX_EXTENT * upem
|| bbox.height() > MAX_EXTENT * upem
{
return Profile { bands };
}
let y0 = TOP * upem;
let h = (BOTTOM - TOP) * upem / BANDS as f64;
let mut add = |a: Point, b: Point| {
let (ya, yb) = (a.y.min(b.y), a.y.max(b.y));
let first = ((ya - y0) / h).floor().max(0.0);
let last = ((yb - y0) / h).floor().min(BANDS as f64 - 1.0);
if first > last {
return;
}
for i in first as usize..=last as usize {
let (lo, hi) = (y0 + i as f64 * h, y0 + (i + 1) as f64 * h);
// The segment is straight: its x extent within the band is at the clipped ends.
let (xa, xb) = if (b.y - a.y).abs() < 1e-9 {
(a.x, b.x)
} else {
let x_at = |y: f64| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x);
(x_at(ya.max(lo)), x_at(yb.min(hi)))
};
let (lo_x, hi_x) = (xa.min(xb), xa.max(xb));
if let Some(band) = bands.get_mut(i) {
*band = match *band {
None => Some((lo_x, hi_x)),
Some((l, r)) => Some((l.min(lo_x), r.max(hi_x))),
};
}
}
};
// Curves are flattened to a fraction of a band: finer makes no difference to the bands.
let (mut cur, mut start) = (Point::ZERO, Point::ZERO);
designcraft_geom::kurbo::flatten(path.iter(), h / 4.0, |el| match el {
PathEl::MoveTo(p) => {
cur = p;
start = p;
}
PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => {
add(cur, p);
cur = p;
}
PathEl::ClosePath => {
add(cur, start);
cur = start;
}
});
Profile { bands }
}
/// Mean and smallest ink gap between `self` (set first, advancing `adv`) and `next`, over the
/// bands where both have ink; `None` when they share fewer than two bands.
pub fn gap(&self, next: &Profile, adv: f64) -> Option<(f64, f64)> {
let gaps: Vec<f64> = self.bands.iter().zip(next.bands.iter()).filter_map(|(a, b)| Some(adv + b.as_ref()?.0 - a.as_ref()?.1)).collect();
if gaps.len() < 2 {
return None;
}
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
let min = gaps.iter().copied().fold(f64::INFINITY, f64::min);
Some((mean, min))
}
}
static PROFILES: LazyLock<RwLock<HashMap<(u32, u32), Arc<Profile>>>> = LazyLock::new(Default::default);
static REFERENCES: LazyLock<RwLock<HashMap<u32, Option<f64>>>> = LazyLock::new(Default::default);
fn profile_of(db: &ScopedFonts<'_>, face: &FontFace, gid: u32) -> Arc<Profile> {
let key = (face.id(), gid);
if let Some(p) = PROFILES.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
return p.clone();
}
let p = Arc::new(Profile::of(&db.outline(face, gid), face.upem));
let mut w = PROFILES.write().unwrap_or_else(PoisonError::into_inner);
if w.len() >= PROFILE_CACHE_MAX {
w.clear();
}
w.entry(key).or_insert(p).clone()
}
/// The font's natural gap between two straight stems, in font units: the mean ink gap of `nn`
/// (or of `HH`, then `oo`, when the font lacks them). `None` when the font has none of them.
fn reference_gap(db: &ScopedFonts<'_>, face: &FontFace) -> Option<f64> {
let id = face.id();
if let Some(r) = REFERENCES.read().unwrap_or_else(PoisonError::into_inner).get(&id) {
return *r;
}
let found = ['n', 'H', 'o'].into_iter().find_map(|c| {
let gid = face.glyph_for(c);
if gid == 0 {
return None;
}
let p = profile_of(db, face, gid);
let (mean, _) = p.gap(&p, face.advance(gid))?;
(mean > 0.0 && mean.is_finite()).then_some(mean)
});
REFERENCES.write().unwrap_or_else(PoisonError::into_inner).insert(id, found);
found
}
/// Whether `c` takes part in optical kerning: letters of the Latin, Greek and Cyrillic scripts,
/// digits and Western punctuation. Spaces, marks, joined and ideographic scripts keep their
/// font spacing, as do dashes (two bars end to end have no ink gap to measure, and `——` must
/// stay one continuous rule) and the fullwidth forms, which sit on the CJK em grid.
pub fn kernable(c: char) -> bool {
use unicode_script::{Script, UnicodeScript};
if is_dash(c) || ('\u{FF00}'..='\u{FFEF}').contains(&c) {
return false;
}
if c.is_ascii_digit() || c.is_ascii_punctuation() || ('\u{2010}'..='\u{2027}').contains(&c) {
return true;
}
c.is_alphabetic() && matches!(c.script(), Script::Latin | Script::Greek | Script::Cyrillic)
}
/// Dash punctuation (Unicode `Pd`) in the ranges optical kerning would otherwise touch.
fn is_dash(c: char) -> bool {
matches!(c, '-' | '\u{2010}'..='\u{2015}' | '\u{2E3A}' | '\u{2E3B}' | '\u{FE58}' | '\u{FF0D}')
}
/// The adjustment for a pair, in font units: how far the first glyph's advance moves.
fn adjustment(reference: f64, mean: f64, min: f64, upem: f64) -> f64 {
let mut adj = (reference - mean) * PULL;
let floor = FLOOR * reference;
if min + adj < floor {
adj = floor - min;
}
adj.clamp(MAX_TIGHTEN * upem, MAX_LOOSEN * upem)
}
/// Kern the pairs of one shaped run optically: the first glyph of each eligible pair gets its
/// advance adjusted. `tracking` is the tracking already added to each advance, in points.
pub(crate) fn kern_run(db: &ScopedFonts<'_>, run: &mut [Glyph], tracking: f64) {
let eligible = |g: &Glyph| {
g.len > 0
&& g.gid != 0
&& g.adv > 0.0
&& g.sx > 0.0
&& !g.upright
&& !g.shaping_rtl
&& g.tcy.is_none()
&& kernable(g.ch)
&& !g.face.glyph_is_mark(g.gid)
};
for i in 0..run.len().saturating_sub(1) {
let Some((a, b)) = run.get(i).zip(run.get(i + 1)) else { break };
if !eligible(a) || !eligible(b) || a.face.id() != b.face.id() {
continue;
}
let face: &FontFace = &a.face;
let Some(reference) = reference_gap(db, face) else { continue };
let (pa, pb) = (profile_of(db, face, a.gid), profile_of(db, face, b.gid));
// The pair's distance as shaped (features such as `palt` change it), without tracking,
// plus the glyphs' own x offsets; in font units.
let adv = (a.adv - tracking + b.dx - a.dx) / a.sx;
let Some((mean, min)) = pa.gap(&pb, adv) else { continue };
let adj = adjustment(reference, mean, min, face.upem);
if adj.is_finite()
&& let Some(g) = run.get_mut(i)
{
g.adv += adj * g.sx;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_geom::Rect;
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
designcraft_geom::Shape::to_path(&Rect::new(x0, y0, x1, y1), 0.1)
}
#[test]
fn profile_reads_a_stem() {
// A stem from x 100..200, baseline to x-height (y -500..0), in a 1000 upem.
let p = Profile::of(&rect(100.0, -500.0, 200.0, 0.0), 1000.0);
let inked: Vec<_> = p.bands.iter().flatten().collect();
assert!(inked.len() >= 9 && inked.len() <= 12, "{} bands", inked.len());
assert!(inked.iter().all(|(l, r)| (*l - 100.0).abs() < 1e-9 && (*r - 200.0).abs() < 1e-9));
// Two such stems 300 apart: the gap is 200 in every band.
let (mean, min) = p.gap(&p, 300.0).unwrap();
assert!((mean - 200.0).abs() < 1e-9 && (min - 200.0).abs() < 1e-9);
}
#[test]
fn no_shared_band_no_gap() {
let high = Profile::of(&rect(0.0, -900.0, 100.0, -700.0), 1000.0);
let low = Profile::of(&rect(0.0, -100.0, 100.0, 0.0), 1000.0);
assert!(high.gap(&low, 200.0).is_none());
}
#[test]
fn adjustment_is_bounded_and_keeps_a_floor() {
// Reference 200: a pair already at 200 doesn't move.
assert!(adjustment(200.0, 200.0, 200.0, 1000.0).abs() < 1e-9);
// A wide pair tightens by PULL of the excess, up to the cap.
assert!((adjustment(200.0, 300.0, 300.0, 1000.0) + 60.0).abs() < 1e-9);
assert!((adjustment(200.0, 1000.0, 1000.0, 1000.0) + 120.0).abs() < 1e-9);
// Tightening never takes the smallest gap under the floor.
assert!((adjustment(200.0, 300.0, 70.0, 1000.0) + 10.0).abs() < 1e-9);
// A touching pair opens, up to the cap.
assert!((adjustment(200.0, 100.0, -50.0, 1000.0) - 60.0).abs() < 1e-9);
}
#[test]
fn kernable_characters() {
assert!(kernable('A') && kernable('é') && kernable('Ж') && kernable('Ω') && kernable('7') && kernable('.') && kernable('“'));
assert!(!kernable(' ') && !kernable('\t') && !kernable('漢') && !kernable('ب') && !kernable('\u{301}'));
}
#[test]
fn dashes_and_fullwidth_forms_keep_their_font_spacing() {
// `--` and `——` have no ink gap to measure and would otherwise open up; `——` is one
// continuous dash in Chinese text.
for c in ['-', '\u{2010}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2E3A}', '\u{FF0D}'] {
assert!(!kernable(c), "{c:?} is a dash");
}
// Fullwidth Latin letters, digits and punctuation sit on the CJK em grid.
for c in ['\u{FF21}', '\u{FF41}', '\u{FF5A}', '\u{FF11}', '\u{FF0C}'] {
assert!(!kernable(c), "{c:?} is a fullwidth form");
}
// Hyphenated and dashed words still kern on their letters.
assert!(kernable('A') && kernable('.') && kernable('\u{2019}'));
}
}