mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Type: Kerning has an Optical option that spaces characters by their shapes
Squashed from #4825 onto main.
This commit is contained in:
+277
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user