Type: Kerning has an Optical option that spaces characters by their shapes

Squashed from #4825 onto main.
This commit is contained in:
dotCooCoo
2026-11-01 01:22:04 +07:00
committed by bflatastic
parent 012af4fd49
commit 9896f633da
47 changed files with 1165 additions and 69 deletions
+277 -5
View File
@@ -1,5 +1,6 @@
//! Shaping: text runs -> positioned glyphs in points (before line breaking).
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
@@ -8,6 +9,7 @@ use unicode_script::{Script, UnicodeScript};
use unicode_segmentation::UnicodeSegmentation;
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
use kurbo::Shape;
use skrifa::MetadataProvider;
use skrifa::charmap::Charmap;
use skrifa::instance::Size;
@@ -446,6 +448,216 @@ pub(crate) fn shape_range(
{
o.sort_by_key(|g| g.byte);
}
if !feats.vertical {
optical_pairs(db, text, out.get_mut(output_start..).unwrap_or_default(), runs);
}
}
/// Line segments of a glyph outline in font units, cached for the current shaping pass.
type Profile = Vec<(f64, f64, f64, f64)>;
type CachedProfile = (Profile, (f64, f64));
fn outline_profile(db: &FontDb, g: &SGlyph, cache: &mut HashMap<(u32, u32), Option<Arc<CachedProfile>>>) -> Option<Arc<CachedProfile>> {
let key = (g.face.id(), g.gid);
cache
.entry(key)
.or_insert_with(|| {
let path = db.outline(&g.face, g.gid);
let bounds = path.bounding_box();
if path.elements().is_empty()
|| path.elements().len() > 512
|| ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 100_000.0)
{
return None;
}
let mut lines = Vec::new();
let mut previous = None;
let mut contour_start = None;
kurbo::flatten(path.iter(), 2.0, |el| match el {
kurbo::PathEl::MoveTo(p) => {
previous = Some(p);
contour_start = Some(p);
}
kurbo::PathEl::LineTo(p) => {
if let Some(a) = previous
&& lines.len() < 8192
{
lines.push((a.x, a.y, p.x, p.y));
}
previous = Some(p);
}
kurbo::PathEl::ClosePath => {
if let Some((a, b)) = previous.zip(contour_start)
&& lines.len() < 8192
{
lines.push((a.x, a.y, b.x, b.y));
}
previous = None;
contour_start = None;
}
_ => {}
});
(!lines.is_empty() && lines.len() < 8192).then(|| Arc::new((lines, (bounds.y0, bounds.y1))))
})
.clone()
}
/// The left and right ink edges where a horizontal slice crosses an outline.
fn profile_edges(lines: &[(f64, f64, f64, f64)], y: f64, sx: f64, sy: f64, dx: f64, dy: f64) -> Option<(f64, f64)> {
let mut left = f64::INFINITY;
let mut right = f64::NEG_INFINITY;
for &(x0, y0, x1, y1) in lines {
let (a, b) = (y0 * sy + dy, y1 * sy + dy);
if (a <= y && y < b) || (b <= y && y < a) {
let x = (x0 + (x1 - x0) * (y - a) / (b - a)) * sx + dx;
left = left.min(x);
right = right.max(x);
}
}
(left.is_finite() && right.is_finite()).then_some((left, right))
}
#[allow(clippy::too_many_arguments)]
fn profile_correction(
a: &[(f64, f64, f64, f64)],
b: &[(f64, f64, f64, f64)],
left: (f64, f64, f64, f64),
right: (f64, f64, f64, f64),
advance: f64,
em: f64,
upem: f64,
low: f64,
high: f64,
) -> Option<f64> {
if !low.is_finite() || !high.is_finite() || high <= low || !em.is_finite() || em <= 0.0 || !upem.is_finite() || upem <= 0.0 {
return None;
}
let mut gaps = Vec::new();
for j in 0..24 {
let y = low + (high - low) * (f64::from(j) + 0.5) / 24.0;
if let (Some((_, ar)), Some((bl, _))) =
(profile_edges(a, y, left.0, left.1, left.2, left.3), profile_edges(b, y, right.0, right.1, right.2, right.3))
{
gaps.push(advance + bl - ar);
}
}
if gaps.is_empty() {
return None;
}
gaps.sort_by(f64::total_cmp);
let gap = gaps.get(gaps.len() / 2).copied().unwrap_or(0.0);
let unit = em / upem;
let correction = ((0.08 * em - gap).clamp(-0.25 * em, 0.25 * em) / unit).round() * unit;
let correction = correction.clamp(-0.25 * em, 0.25 * em);
correction.is_finite().then_some(correction)
}
/// Move each Optical pair toward an ink gap of 0.08 em, at 24 overlapping vertical slices.
/// The median avoids a single narrow tip deciding the entire pair. Corrections are rounded to
/// one font unit and limited to 1/4 em in either direction. Missing ink gives zero correction.
fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range<usize>, &CharStyle)]) {
let mut cache = HashMap::new();
let mut corrections: HashMap<[u64; 15], Option<f64>> = HashMap::new();
let shaped_advances: Vec<f64> = glyphs.iter().map(|g| g.adv).collect();
for i in 0..glyphs.len().saturating_sub(1) {
let Some((a, b)) = glyphs.get(i).zip(glyphs.get(i + 1)) else { continue };
if a.level != b.level {
continue;
}
let Some(owner) = runs.get(a.run).map(|(_, s)| *s) else { continue };
let invisible = |ch: char| matches!(ch, SOFT_HYPHEN | '\u{2028}') || ch.is_control();
if !owner.optical_kerning
|| a.byte == b.byte
|| invisible(a.ch)
|| invisible(b.ch)
|| text.get(a.byte..b.byte).is_some_and(|between| between.chars().skip(1).any(invisible))
|| a.rotation != 0.0
|| b.rotation != 0.0
{
continue;
}
if owner.kerning.is_some() {
continue;
}
let mut start = i;
while start > 0 && glyphs[start - 1].byte == a.byte {
start -= 1;
}
let mut end = i + 1;
while end + 1 < glyphs.len() && glyphs[end + 1].byte == b.byte {
end += 1;
}
// A mark may precede its base inside either direction's shaped cluster.
let first_base = glyphs[start..=i]
.iter()
.enumerate()
.max_by(|(a, _), (b, _)| shaped_advances[start + *a].total_cmp(&shaped_advances[start + *b]))
.map_or(start, |(offset, _)| start + offset);
let second_base = glyphs[i + 1..=end]
.iter()
.enumerate()
.max_by(|(a, _), (b, _)| shaped_advances[i + 1 + *a].total_cmp(&shaped_advances[i + 1 + *b]))
.map_or(i + 1, |(offset, _)| i + 1 + offset);
let (left_index, right_index, target, cluster_start, cluster_end) =
if a.level.is_rtl() { (second_base, first_base, end, i + 1, end) } else { (first_base, second_base, i, start, i) };
let (left, right) = (&glyphs[left_index], &glyphs[right_index]);
if left.byte == right.byte
|| left.is_space()
|| right.is_space()
|| is_mark(right.ch)
|| left.inline.is_some()
|| right.inline.is_some()
|| left.ch == '\n'
|| right.ch == '\n'
{
continue;
}
let left_y = left.dy - left.bshift;
let right_y = right.dy - right.bshift;
let Some(left_style) = runs.get(left.run).map(|(_, s)| *s) else { continue };
let em = left.sx * left.face.upem / (left_style.h_scale / 100.0);
let tracking = left_style.tracking / 1000.0 * em;
let manual = left_style.kerning.unwrap_or(0.0) / 1000.0 * em;
let advance = glyphs[cluster_start..=cluster_end].iter().map(|g| g.adv).sum::<f64>() - tracking - manual;
let key = [
u64::from(left.face.id()),
u64::from(left.gid),
u64::from(right.face.id()),
u64::from(right.gid),
left.sx.to_bits(),
left.sy.to_bits(),
left.dx.to_bits(),
left_y.to_bits(),
right.sx.to_bits(),
right.sy.to_bits(),
right.dx.to_bits(),
right_y.to_bits(),
advance.to_bits(),
em.to_bits(),
left.face.upem.to_bits(),
];
let correction = *corrections.entry(key).or_insert_with(|| {
let (Some(a), Some(b)) = (outline_profile(db, left, &mut cache), outline_profile(db, right, &mut cache)) else { return None };
let low = (a.1.0 * left.sy + left_y).max(b.1.0 * right.sy + right_y);
let high = (a.1.1 * left.sy + left_y).min(b.1.1 * right.sy + right_y);
profile_correction(
&a.0,
&b.0,
(left.sx, left.sy, left.dx, left_y),
(right.sx, right.sy, right.dx, right_y),
advance,
em,
left.face.upem,
low,
high,
)
});
if let (Some(correction), Some(glyph)) = (correction, glyphs.get_mut(target))
&& !invisible(glyph.ch)
{
glyph.adv += correction;
}
}
}
/// Index in the original run list containing `byte`, limited to one coalesced shaping span.
@@ -474,6 +686,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
leading: _,
tracking,
kerning: _,
optical_kerning: _,
baseline_shift: _,
h_scale,
v_scale,
@@ -696,6 +909,15 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
};
let manual = |byte: usize| run_at(byte).1.kerning.is_some();
let mixed_kerning = boundaries().any(|b| manual(b) != manual(range.start));
let optical = |byte: usize| run_at(byte).1.optical_kerning;
let mode_boundaries: Vec<usize> = boundaries().filter(|&b| !feats.vertical && optical(b - 1) && !optical(b)).collect();
if !mode_boundaries.is_empty() {
return shape_apart(mode_boundaries, out);
}
let manual_ends: Vec<usize> = boundaries().filter(|&b| manual(b - 1) && !manual(b)).collect();
if !manual_ends.is_empty() {
return shape_apart(manual_ends, out);
}
// Shape the segment as if all of it were in `st`, with the features `more` on top.
let shape_in = |st: &CharStyle, more: &[Feature]| {
@@ -793,17 +1015,43 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
}
shaped
};
let Shaped { raw, proportional, vpal } = if mixed_kerning {
let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) };
let Shaped { raw, proportional, vpal } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
// The pair entering Optical keeps the left character's font kerning. Subsequent
// Optical pairs use outline spacing, so font pair features stop at its second character.
let optical_off = if first == range.start {
first
} else {
text.get(first..range.end).and_then(|s| s.chars().next()).map_or(range.end, |c| first + c.len_utf8())
};
let first_manual = if manual(range.start) { Some(range.start) } else { boundaries().find(|&b| manual(b)) };
if let Some(start) = first_manual.filter(|&b| b > range.start)
&& upper
&& first_char(start).to_uppercase().nth(1).is_some()
{
return shape_apart(vec![start], out);
}
let manual_off = first_manual.map_or(range.end, |start| {
if start == range.start {
start
} else {
text.get(start..range.end).and_then(|s| s.chars().next()).map_or(range.end, |c| start + c.len_utf8())
}
});
let off = optical_off.min(manual_off);
let more: Vec<Feature> = [b"kern", b"apkn", b"vkrn", b"vapk"]
.into_iter()
.filter(|_| off < range.end)
.map(|t| Feature::new(Tag::new(t), 0, off..range.end))
.collect();
shape_in(&CharStyle { kerning: None, ..run_at(range.start).1.clone() }, &more)
} else if mixed_kerning {
// A font's kerning pair applies after a character with automatic kerning and not after one
// with manual kerning. The segment is shaped apart where manual kerning ends, so each piece
// is text with automatic kerning followed by text with manual kerning. A piece is shaped
// with the font's kerning on, and off (`kern`, and the `apkn`, `vkrn` and `vapk` that
// `OtFeatures` turns off with it) from the second character with manual kerning on: a
// pair is applied only when both of its glyphs have the feature on.
let ends: Vec<usize> = boundaries().filter(|&b| manual(b - 1) && !manual(b)).collect();
if !ends.is_empty() {
return shape_apart(ends, out);
}
let first = boundaries().find(|&b| manual(b)).unwrap_or(range.end);
// A first character with manual kerning that All Caps or Small Caps sets as several letters
// (ß as SS) gives them one cluster, which a feature range cannot split, so the font's
@@ -879,3 +1127,27 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
});
}
}
#[cfg(test)]
mod optical_tests {
use super::*;
fn box_profile(left: f64, right: f64) -> Profile {
vec![(left, 0.0, left, 100.0), (right, 0.0, right, 100.0)]
}
#[test]
fn optical_side_profiles_tighten_loosen_and_stay_bounded() {
let a = box_profile(0.0, 80.0);
let b = box_profile(0.0, 20.0);
let place = (1.0, 1.0, 0.0, 0.0);
let tight = profile_correction(&a, &b, place, place, 120.0, 100.0, 1000.0, 0.0, 100.0).unwrap();
let loose = profile_correction(&a, &b, place, place, 60.0, 100.0, 1000.0, 0.0, 100.0).unwrap();
assert_eq!(tight, -25.0);
assert_eq!(loose, 25.0);
assert_eq!(profile_correction(&a, &b, place, place, 88.0, 100.0, 1000.0, 0.0, 100.0), Some(0.0));
assert_eq!(profile_correction(&a, &b, place, place, 120.0, 100.0, 1000.0, 0.0, 100.0), Some(tight));
assert_eq!(profile_correction(&a, &b, place, place, 120.0, 100.0, 1000.0, 100.0, 100.0), None);
assert_eq!(profile_correction(&[], &b, place, place, 120.0, 100.0, 1000.0, 0.0, 100.0), None);
}
}
+75
View File
@@ -412,6 +412,81 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option<Vec<u8>> {
/// The family of [`pair_kerning_font`].
pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans";
/// The family of [`no_pair_kerning_font`].
pub const NO_PAIR_KERNING_FAMILY: &str = "No Pairtest Sans";
/// A font with the bundled Source Sans 3 outlines and no `GPOS` or `kern` table.
pub fn no_pair_kerning_font() -> Option<Vec<u8>> {
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let family = NO_PAIR_KERNING_FAMILY;
let name = name_table(&[(1, family), (2, "Regular"), (4, "No Pairtest Sans Regular"), (6, "NoPairtestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !matches!(tag, t if t == Tag::new(b"name") || t == Tag::new(b"GPOS") || t == Tag::new(b"kern")) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`right_to_left_optical_font`].
pub const RIGHT_TO_LEFT_OPTICAL_FAMILY: &str = "Right-to-left Profile Sans";
/// Two Hebrew characters mapped to the bundled Source Sans 3 A and V outlines.
pub fn right_to_left_optical_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let charmap = font.charmap();
let mapping = [('א', 'A'), ('ב', 'V')];
let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32()))));
let cmap = Cmap::from_mappings(mappings.collect::<Option<Vec<_>>>()?).ok()?;
let family = RIGHT_TO_LEFT_OPTICAL_FAMILY;
let name = name_table(&[(1, family), (2, "Regular"), (4, "Right-to-left Profile Sans Regular"), (6, "RtlProfileSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if tag != Tag::new(b"GPOS") && tag != Tag::new(b"kern") && !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`soft_hyphen_outline_font`].
pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans";
/// A face that maps the invisible soft hyphen to the bundled hyphen outline.
pub fn soft_hyphen_outline_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let charmap = font.charmap();
let mappings = [('A', 'A'), ('V', 'V'), ('-', '-'), ('\u{00AD}', '-')]
.into_iter()
.map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32()))));
let cmap = Cmap::from_mappings(mappings.collect::<Option<Vec<_>>>()?).ok()?;
let family = SOFT_HYPHEN_OUTLINE_FAMILY;
let name = name_table(&[(1, family), (2, "Regular"), (4, "Soft Hyphen Profile Sans Regular"), (6, "SoftHyphenProfileSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if tag != Tag::new(b"GPOS") && tag != Tag::new(b"kern") && !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The bundled Source Sans 3 Regular as family [`PAIR_KERNING_FAMILY`], its `GPOS` replaced by two
/// `kern` pairs, A followed by V and S followed by S: each takes 60 units off the first glyph's
/// advance (the pair's first value record) and moves the second 100 units left (its second).
+261
View File
@@ -18,6 +18,267 @@ fn run(text: &str, size: f64) -> TextRun {
TextRun { text: text.into(), style: style(size), inline: None }
}
#[test]
fn optical_pairs_cross_paint_runs_and_manual_and_tracking_are_independent() {
let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, "III", st);
let advance = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let optical = advance(&t);
let mut plain = t.clone();
plain.runs[0].style.kerning = Some(0.0);
let unkerned = advance(&plain);
assert!((optical[0] - unkerned[0]).abs() > 0.1, "Optical changes the monospaced pair");
edit::style_range(&mut t.runs, 1, 2, |s| s.fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0)));
let painted = advance(&t);
assert!((painted[0] - optical[0]).abs() < 0.01, "paint does not interrupt Optical");
edit::style_range(&mut t.runs, 0, 1, |s| s.kerning = Some(0.0));
let manual = advance(&t);
assert!((manual[0] - unkerned[0]).abs() < 0.01);
assert!((manual[1] - optical[1]).abs() < 0.01, "the next pair remains Optical");
edit::style_range(&mut t.runs, 0, 3, |s| s.tracking = 100.0);
let tracked = advance(&t);
assert!((tracked[0] - manual[0] - 10.0).abs() < 0.01);
assert!((tracked[1] - manual[1] - 10.0).abs() < 0.01);
for text in ["I I", "I\u{301}"] {
let sample = TextObject::point(Point::ZERO, text, CharStyle { kerning: None, ..t.runs[0].style.clone() });
let mut zero = sample.clone();
zero.runs[0].style.kerning = Some(0.0);
assert_eq!(advance(&sample), advance(&zero), "spaces and marks inside a cluster have no Optical correction");
}
let mut vertical = TextObject::point(Point::ZERO, "II", CharStyle { kerning: None, ..t.runs[0].style.clone() });
vertical.vertical = true;
let mut vertical_zero = vertical.clone();
vertical_zero.runs[0].style.kerning = Some(0.0);
assert_eq!(advance(&vertical), advance(&vertical_zero), "vertical type keeps a neutral correction");
let rtl = TextObject::point(Point::ZERO, "אב", CharStyle { kerning: None, ..t.runs[0].style.clone() });
let rtl_advances = advance(&rtl);
assert!(rtl_advances.len() >= 2 && rtl_advances.iter().all(|v| v.is_finite()));
let mut rtl_zero = rtl.clone();
rtl_zero.runs[0].style.kerning = Some(0.0);
assert!((rtl_advances[1] - advance(&rtl_zero)[1]).abs() <= 25.0, "right-to-left Optical corrections remain bounded");
let mut rtl_manual = rtl.clone();
edit::style_range(&mut rtl_manual.runs, 0, 2, |s| s.kerning = Some(200.0));
let mut rtl_pair_zero = rtl.clone();
edit::style_range(&mut rtl_pair_zero.runs, 0, 2, |s| s.kerning = Some(0.0));
let pair_advance = |t: &TextObject| layout(db(), t).glyphs.iter().find(|g| g.byte == 0).map(|g| g.advance).unwrap();
assert!((pair_advance(&rtl_pair_zero) - pair_advance(&rtl_zero)).abs() < 0.01, "manual zero replaces the right-to-left Optical correction");
assert!((pair_advance(&rtl_manual) - pair_advance(&rtl_pair_zero) - 20.0).abs() < 0.01, "manual spacing changes the adjacent right-to-left pair");
}
#[test]
fn optical_pair_skips_an_invisible_soft_hyphen_with_an_outline() {
use crate::test_fonts::{SOFT_HYPHEN_OUTLINE_FAMILY, soft_hyphen_outline_font};
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(soft_hyphen_outline_font().unwrap()), 1);
let style = CharStyle { font_family: SOFT_HYPHEN_OUTLINE_FAMILY.into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let face = db.style_face(&style).unwrap();
assert_ne!(face.glyph_for('\u{00AD}'), 0);
assert_eq!(face.glyph_for('\u{00AD}'), face.glyph_for('-'));
let paired = layout(&db, &TextObject::point(Point::ZERO, "AV", style.clone()));
let separated = layout(&db, &TextObject::point(Point::ZERO, "A\u{00AD}V", style.clone()));
let mut manual = style;
manual.kerning = Some(0.0);
let plain = layout(&db, &TextObject::point(Point::ZERO, "A\u{00AD}V", manual));
assert!((paired.glyphs[0].advance - plain.glyphs[0].advance).abs() > 0.1, "A-V has an Optical correction");
// A keeps its uncorrected advance because neither pair across the invisible glyph is measured.
assert!((separated.glyphs[0].advance - plain.glyphs[0].advance).abs() < 0.01);
assert!(separated.glyphs.iter().filter(|g| g.byte == 1).all(|g| g.advance == 0.0), "the soft hyphen remains zero width if emitted");
let v = |lay: &TextLayout| lay.glyphs.iter().find(|g| g.byte == 3).unwrap().origin.x;
assert!((v(&separated) - v(&plain)).abs() < 0.01, "the soft hyphen adds no spacing");
}
#[test]
fn optical_pair_after_separate_combining_mark_uses_base_outline() {
let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let advances = |text: &str, manual: bool| {
let mut style = st.clone();
if manual {
style.kerning = Some(0.0);
}
layout(db(), &TextObject::point(Point::ZERO, text, style)).glyphs.iter().map(|g| (g.byte, g.advance)).collect::<Vec<_>>()
};
let plain = advances("II", false);
let plain_zero = advances("II", true);
let accented = advances("I\u{30d}I", false);
let accented_zero = advances("I\u{30d}I", true);
assert_eq!(accented.len(), 3, "the accent has its own glyph");
assert_eq!(accented[0].0, accented[1].0, "the base and mark share a cluster");
assert!((accented[0].1 - accented_zero[0].1).abs() < 0.01, "the base keeps its advance");
let correction = accented[1].1 - accented_zero[1].1;
assert!((correction - (plain[0].1 - plain_zero[0].1)).abs() < 0.01, "the marked cluster uses the base outline");
assert!(correction.abs() > 0.1, "Optical adjusts the pair after the accent");
}
#[test]
fn optical_pair_after_rtl_separate_mark_uses_base_outline() {
let st = CharStyle { font_family: "Noto Sans Arabic".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let shaped = |text: &str, manual: bool| {
let mut style = st.clone();
if manual {
style.kerning = Some(0.0);
}
layout(db(), &TextObject::point(Point::ZERO, text, style)).glyphs.iter().map(|g| (g.byte, g.advance)).collect::<Vec<_>>()
};
let plain = shaped("بب", false);
let plain_zero = shaped("بب", true);
let marked = shaped("بَب", false);
let marked_zero = shaped("بَب", true);
let marked_cluster = marked.iter().filter(|g| g.0 == 0).collect::<Vec<_>>();
assert_eq!(marked_cluster.len(), plain.iter().filter(|g| g.0 == 0).count() + 1, "the mark has its own glyph in the base cluster");
assert_eq!(marked_cluster.first().unwrap().1, 0.0, "the cluster starts with a nonspacing glyph");
assert!(marked_cluster.last().unwrap().1 > 0.0, "the base follows the mark");
let correction = marked.iter().filter(|g| g.0 == 4).map(|g| g.1).sum::<f64>() - marked_zero.iter().filter(|g| g.0 == 4).map(|g| g.1).sum::<f64>();
let plain_correction =
plain.iter().filter(|g| g.0 == 2).map(|g| g.1).sum::<f64>() - plain_zero.iter().filter(|g| g.0 == 2).map(|g| g.1).sum::<f64>();
assert!(plain_correction.abs() > 0.1, "Optical adjusts the unmarked pair");
assert!((correction - plain_correction).abs() < 0.01, "the marked cluster uses the base outline");
}
#[test]
fn recurring_optical_pairs_have_equal_corrections() {
let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let text = "II".repeat(50);
let optical = layout(db(), &TextObject::point(Point::ZERO, &text, st.clone())).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let zero = layout(db(), &TextObject::point(Point::ZERO, &text, CharStyle { kerning: Some(0.0), ..st }))
.glyphs
.iter()
.map(|g| g.advance)
.collect::<Vec<_>>();
assert_eq!(optical.len(), zero.len());
let correction = optical[0] - zero[0];
assert!(correction.abs() > 0.1);
for i in 1..optical.len() - 1 {
assert_eq!(optical[i] - zero[i], correction, "pair {i} has the same correction");
}
}
#[test]
fn metrics_pair_survives_transition_to_optical() {
let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, ..CharStyle::default() };
let mut text = TextObject::point(Point::ZERO, "AVV", st);
let advances = |text: &TextObject| layout(db(), text).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let metrics = advances(&text);
let mut unkerned = text.clone();
unkerned.runs[0].style.kerning = Some(0.0);
assert!((metrics[0] - advances(&unkerned)[0]).abs() > 0.1, "the font supplies an AV pair");
edit::style_range(&mut text.runs, 1, 3, |s| s.optical_kerning = true);
let mixed = advances(&text);
assert!((mixed[0] - metrics[0]).abs() < 0.01, "the metrics AV pair crosses the mode change");
let mut optical = TextObject::point(Point::ZERO, "VV", text.runs[1].style.clone());
let optical_pair = advances(&optical)[0];
optical.runs[0].style.kerning = Some(0.0);
assert!((mixed[1] - optical_pair).abs() < 0.01, "the first Optical V keeps its next pair");
assert!((mixed[1] - advances(&optical)[0]).abs() > 0.1, "Optical corrects the next pair");
}
#[test]
fn manual_pair_before_optical_does_not_gain_font_kerning() {
let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, ..CharStyle::default() };
let mut text = TextObject::point(Point::ZERO, "AVA", st);
edit::style_range(&mut text.runs, 1, 2, |s| s.kerning = Some(0.0));
edit::style_range(&mut text.runs, 2, 3, |s| s.optical_kerning = true);
let mixed = layout(db(), &text).glyphs[1].advance;
let mut unkerned = TextObject::point(Point::ZERO, "VA", text.runs[1].style.clone());
unkerned.runs[0].style.kerning = Some(0.0);
let manual = layout(db(), &unkerned).glyphs[0].advance;
assert!((mixed - manual).abs() < 0.01, "manual V keeps its unkerned pair with Optical A");
}
#[test]
fn optical_pair_uses_drawn_baseline() {
let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let mut text = TextObject::point(Point::ZERO, "II", st);
let first = |text: &TextObject| layout(db(), text).glyphs[0].advance;
let normal = first(&text);
edit::style_range(&mut text.runs, 1, 2, |s| s.baseline_shift = 80.0);
let shifted = first(&text);
assert!((shifted - normal).abs() > 0.1, "a displaced outline changes the Optical correction");
}
#[test]
fn optical_pair_skips_rotated_glyphs() {
let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let mut text = TextObject::point(Point::ZERO, "II", st);
let first = |text: &TextObject| layout(db(), text).glyphs[0].advance;
let unrotated = first(&text);
let mut plain = text.clone();
plain.runs[0].style.kerning = Some(0.0);
assert!((unrotated - first(&plain)).abs() > 0.1, "the unrotated pair has an Optical correction");
edit::style_range(&mut text.runs, 1, 2, |s| s.rotation = 20.0);
let rotated = first(&text);
let mut manual_zero = text.clone();
edit::style_range(&mut manual_zero.runs, 0, 1, |s| s.kerning = Some(0.0));
assert!((rotated - first(&manual_zero)).abs() < 0.01, "rotated pairs keep their uncorrected advance");
}
#[test]
fn optical_shaping_keeps_manual_value_on_first_run() {
let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, optical_kerning: true, kerning: Some(0.0), ..CharStyle::default() };
let mut text = TextObject::point(Point::ZERO, "AVA", st);
let zero = layout(db(), &text).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
edit::style_range(&mut text.runs, 0, 1, |s| s.kerning = Some(120.0));
let manual = layout(db(), &text).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
assert!((manual[0] - zero[0] - 12.0).abs() < 0.01, "the first run adds its manual value");
assert!((manual[1] - zero[1]).abs() < 0.01, "the following run keeps its advance");
}
#[test]
fn ordinary_and_vertical_segments_keep_their_spacing() {
let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, kerning: Some(100.0), ..CharStyle::default() };
let plain = TextObject::point(Point::ZERO, "AVA", st);
let advances = |text: &TextObject| layout(db(), text).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let expected = advances(&plain);
let mut split = plain.clone();
edit::style_range(&mut split.runs, 1, 2, |s| s.fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(advances(&split), expected, "a non-Optical segment keeps the same spacing across runs");
let mut vertical = plain;
vertical.vertical = true;
let expected = advances(&vertical);
edit::style_range(&mut vertical.runs, 0, 1, |s| s.kerning = Some(120.0));
let manual = advances(&vertical);
assert!((manual[0] - expected[0] - 2.0).abs() < 0.01, "vertical manual spacing changes only its own advance");
edit::style_range(&mut vertical.runs, 0, 3, |s| s.optical_kerning = true);
assert_eq!(advances(&vertical), manual, "vertical type keeps its spacing with Optical selected");
}
#[test]
fn optical_spacing_works_without_font_pair_tables() {
use crate::test_fonts::{NO_PAIR_KERNING_FAMILY, no_pair_kerning_font};
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(no_pair_kerning_font().unwrap()), 1);
let st = CharStyle { font_family: NO_PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() };
let mut text = TextObject::point(Point::ZERO, "AV", st);
let metrics = layout(&db, &text).glyphs[0].advance;
text.runs[0].style.kerning = Some(0.0);
let manual_zero = layout(&db, &text).glyphs[0].advance;
assert!((metrics - manual_zero).abs() < 0.01, "the font has no automatic pair correction");
text.runs[0].style.kerning = None;
text.runs[0].style.optical_kerning = true;
let optical = layout(&db, &text).glyphs[0].advance;
assert!((optical - manual_zero).abs() > 0.1, "Optical uses outlines without a pair table");
}
#[test]
fn manual_pair_positions_match_under_auto_and_optical_in_both_directions() {
use crate::test_fonts::{RIGHT_TO_LEFT_OPTICAL_FAMILY, right_to_left_optical_font};
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(right_to_left_optical_font().unwrap()), 1);
for (letters, family) in [("AV", "Source Sans 3"), ("אב", RIGHT_TO_LEFT_OPTICAL_FAMILY)] {
let mut text = TextObject::point(Point::ZERO, letters, CharStyle { font_family: family.into(), size: 100.0, ..CharStyle::default() });
edit::style_range(&mut text.runs, 0, letters.chars().next().unwrap().len_utf8(), |s| s.kerning = Some(200.0));
let positions = |text: &TextObject| layout(&db, text).glyphs.iter().map(|g| (g.byte, g.origin, g.advance)).collect::<Vec<_>>();
let auto = positions(&text);
edit::style_range(&mut text.runs, 0, letters.len(), |s| s.optical_kerning = true);
assert_eq!(positions(&text), auto, "{letters}: manual pair has the same positions in both modes");
}
}
fn area_path(text: &str, st: CharStyle, frame: &BezPath, justify: Justify) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, st);
t.kind = TextKind::Area { frame: PathData::from_bezpath(frame) };
+24
View File
@@ -719,5 +719,29 @@ fn proportional_metrics_kern_the_glyphs_they_respace() {
};
let (auto, manual) = (lay(true, None), lay(true, Some(0.0)));
assert!(auto.iter().zip(&manual).any(|(a, b)| (a - b).abs() > 0.01), "kerned: {auto:?} {manual:?}");
let optical = {
let s = CharStyle { font_family: "Noto Sans CJK SC".into(), optical_kerning: true, ..st(true, None) };
layout(&db, &vertical_styled("「カタカナ」とトップ、ヤッホー。", s)).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>()
};
assert!(auto.iter().zip(&optical).all(|(a, b)| (a - b).abs() < 0.01), "vertical Optical uses font metrics: {auto:?} {optical:?}");
assert!(lay(false, None).iter().all(|a| (a - 20.0).abs() < 0.01), "off: an em each, as before");
}
#[test]
fn vertical_optical_to_auto_keeps_the_fonts_pair() {
use crate::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(pair_kerning_font().unwrap());
let style = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), size: 100.0, ..CharStyle::default() };
let positions =
|t: &TextObject| layout(&db, t).glyphs.iter().map(|g| (g.advance, g.origin, g.xf.as_coeffs()[4], g.xf.as_coeffs()[5])).collect::<Vec<_>>();
let auto = vertical_styled("AV", style.clone());
let mut mixed = auto.clone();
edit::style_range(&mut mixed.runs, 0, 1, |s| s.optical_kerning = true);
let mut unkerned = auto.clone();
edit::style_range(&mut unkerned.runs, 0, 1, |s| s.kerning = Some(0.0));
let expected = positions(&auto);
assert_eq!(expected.len(), 2);
assert_ne!(expected, positions(&unkerned), "the font's A-V pair changes vertical positions");
assert_eq!(positions(&mixed), expected, "vertical Optical keeps the A-V font pair across the style boundary");
}