Type: Optical kerning spaces pairs by the font's own stem spacing (#5336)

Optical moved every pair all the way to a fixed 0.08 em ink gap, tighter
than most text faces' own stem spacing, so ordinary text was squeezed
(at 100 pt, Source Sans 3 `nn` closed from 54.7 to 46.9 pt).

Each pair now moves 0.6 of the way toward the face's reference gap (the
median ink gap of `nn`, else `HH` or `oo`; 0.08 em when it has none),
keeps at least 0.3 of it at its narrowest, and changes by at most
0.12 em tighter or 0.06 em looser. Ideographs, kana, hangul, fullwidth
and CJK punctuation forms and dashes keep the font's spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ruchirguitar
2026-11-05 03:04:51 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 84c197cfbe
commit b87fddba11
5 changed files with 196 additions and 39 deletions
+125 -32
View File
@@ -475,12 +475,17 @@ pub(crate) fn shape_range(
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);
fn outline_profile(
db: &FontDb,
face: &FontFace,
gid: u32,
cache: &mut HashMap<(u32, u32), Option<Arc<CachedProfile>>>,
) -> Option<Arc<CachedProfile>> {
let key = (face.id(), gid);
cache
.entry(key)
.or_insert_with(|| {
let path = db.outline(&g.face, g.gid);
let path = db.outline(face, gid);
let bounds = path.bounding_box();
if path.elements().is_empty()
|| path.elements().len() > 512
@@ -535,6 +540,65 @@ fn profile_edges(lines: &[(f64, f64, f64, f64)], y: f64, sx: f64, sy: f64, dx: f
(left.is_finite() && right.is_finite()).then_some((left, right))
}
/// The ink gaps between glyph outline `a` and glyph outline `b` placed `advance` after it, at 24
/// heights between `low` and `high` where both have ink, sorted. Each outline is placed by its
/// (scale x, scale y, offset x, offset y).
fn ink_gaps(
a: &[(f64, f64, f64, f64)],
b: &[(f64, f64, f64, f64)],
left: (f64, f64, f64, f64),
right: (f64, f64, f64, f64),
advance: f64,
low: f64,
high: f64,
) -> Vec<f64> {
let mut gaps = Vec::new();
if !low.is_finite() || !high.is_finite() || high <= low || !advance.is_finite() {
return gaps;
}
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))
{
let gap = advance + bl - ar;
if gap.is_finite() {
gaps.push(gap);
}
}
}
gaps.sort_by(f64::total_cmp);
gaps
}
/// How far an Optical pair moves toward the reference gap (1 = all the way).
const OPTICAL_PULL: f64 = 0.6;
/// The narrowest ink gap an Optical correction leaves, as a fraction of the reference gap.
const OPTICAL_FLOOR: f64 = 0.3;
/// The most an Optical correction tightens and loosens a pair, in em.
const OPTICAL_MAX_TIGHTEN: f64 = 0.12;
const OPTICAL_MAX_LOOSEN: f64 = 0.06;
/// The reference gap of a face that has none of the reference pairs, in em.
const OPTICAL_FALLBACK_GAP: f64 = 0.08;
/// The face's own spacing between two straight stems, in font units: the median ink gap of `nn`,
/// or of `HH`, then `oo`, when the face doesn't map the letter. `None` when it has none of them.
fn reference_gap(db: &FontDb, face: &FontFace, cache: &mut HashMap<(u32, u32), Option<Arc<CachedProfile>>>) -> Option<f64> {
let upem = face.units_per_em();
['n', 'H', 'o'].into_iter().filter(|&c| face.covers(c)).find_map(|c| {
let gid = face.glyph_for(c);
let profile = outline_profile(db, face, gid, cache)?;
let place = (1.0, 1.0, 0.0, 0.0);
let gaps = ink_gaps(&profile.0, &profile.0, place, place, face.advance(gid), profile.1.0, profile.1.1);
let gap = gaps.get(gaps.len() / 2).copied()?;
(gap.is_finite() && gap > 0.0 && gap < upem).then_some(gap)
})
}
/// The correction to an Optical pair's advance: the pair's median ink gap moves part of the way
/// ([`OPTICAL_PULL`]) toward `target`, the face's reference gap in the drawn size, its narrowest
/// gap stays at least [`OPTICAL_FLOOR`] of `target` where the pair allows it, and the change is
/// rounded to one font unit and limited to [`OPTICAL_MAX_TIGHTEN`] and [`OPTICAL_MAX_LOOSEN`].
#[allow(clippy::too_many_arguments)]
fn profile_correction(
a: &[(f64, f64, f64, f64)],
@@ -542,40 +606,39 @@ fn profile_correction(
left: (f64, f64, f64, f64),
right: (f64, f64, f64, f64),
advance: f64,
target: 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 {
if !em.is_finite() || em <= 0.0 || !upem.is_finite() || upem <= 0.0 || !target.is_finite() || target <= 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 gaps = ink_gaps(a, b, left, right, advance, low, high);
let (Some(&narrowest), Some(&gap)) = (gaps.first(), gaps.get(gaps.len() / 2)) else { return None };
let pulled = OPTICAL_PULL * (target - gap);
let correction = pulled.max(OPTICAL_FLOOR * target - narrowest).clamp(-OPTICAL_MAX_TIGHTEN * em, OPTICAL_MAX_LOOSEN * em);
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);
let correction = ((correction / unit).round() * unit).clamp(-OPTICAL_MAX_TIGHTEN * em, OPTICAL_MAX_LOOSEN * 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.
/// Does `c` take part in Optical spacing? Ideographs, kana, hangul and the fullwidth and CJK
/// punctuation forms sit on the em grid and dashes join end to end: they keep the font's spacing.
fn optical_spaces(c: char) -> bool {
!matches!(c.script(), Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul | Script::Bopomofo | Script::Yi)
&& !matches!(c, '\u{3000}'..='\u{303F}' | '\u{3200}'..='\u{33FF}' | '\u{FE30}'..='\u{FE4F}' | '\u{FF00}'..='\u{FFEF}')
&& !matches!(c, '-' | '\u{2010}'..='\u{2015}' | '\u{2E3A}' | '\u{2E3B}' | '\u{FE58}' | '\u{FE63}' | '\u{FF0D}')
}
/// Move each Optical pair part of the way toward the face's own stem spacing (see
/// [`reference_gap`] and [`profile_correction`]), measured at 24 overlapping vertical slices. The
/// median avoids a single narrow tip deciding the entire pair. 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 mut references: HashMap<u32, 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 };
@@ -628,6 +691,8 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range<
|| right.inline.is_some()
|| left.ch == '\n'
|| right.ch == '\n'
|| !optical_spaces(left.ch)
|| !optical_spaces(right.ch)
{
continue;
}
@@ -660,7 +725,12 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range<
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 reference = *references.entry(left.face.id()).or_insert_with(|| reference_gap(db, &left.face, &mut cache));
let reference = reference.map_or(OPTICAL_FALLBACK_GAP * em, |gap| gap * left.sx);
let (Some(a), Some(b)) = (outline_profile(db, &left.face, left.gid, &mut cache), outline_profile(db, &right.face, right.gid, &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(
@@ -669,6 +739,7 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range<
(left.sx, left.sy, left_x, left_y),
(right.sx, right.sy, right_x, right_y),
advance,
reference,
em,
left.face.upem,
low,
@@ -1270,16 +1341,38 @@ mod optical_tests {
#[test]
fn optical_side_profiles_tighten_loosen_and_stay_bounded() {
// 100 pt in a 1000-unit font: one font unit is 0.1 pt; the reference gap is 8 pt.
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);
let correct = |a: &[(f64, f64, f64, f64)], advance: f64, target: f64| {
profile_correction(a, &b, place, place, advance, target, 100.0, 1000.0, 0.0, 100.0)
};
assert_eq!(correct(&a, 120.0, 8.0), Some(-12.0), "a 40 pt gap tightens by at most 0.12 em");
assert_eq!(correct(&a, 60.0, 8.0), Some(6.0), "overlapping ink loosens by at most 0.06 em");
assert_eq!(correct(&a, 88.0, 8.0), Some(0.0), "the reference gap itself is left alone");
assert!((correct(&a, 98.0, 8.0).unwrap() - -6.0).abs() < 1e-9, "a pair moves 0.6 of the way toward the reference");
assert!((correct(&a, 84.0, 8.0).unwrap() - 2.4).abs() < 1e-9, "a tight pair opens 0.6 of the way");
assert_eq!(correct(&a, 88.0, 0.0), None);
assert_eq!(profile_correction(&a, &b, place, place, 120.0, 8.0, 100.0, 1000.0, 100.0, 100.0), None);
assert_eq!(profile_correction(&[], &b, place, place, 120.0, 8.0, 100.0, 1000.0, 0.0, 100.0), None);
// A right side slanting from x 60 at the bottom to x 100 at the top: the median gap is
// near the 20 pt reference, but the top nearly touches. The floor keeps 0.3 of it.
let slanted = vec![(0.0, 0.0, 0.0, 100.0), (60.0, 0.0, 100.0, 100.0)];
let correction = correct(&slanted, 100.0, 20.0).unwrap();
let narrowest = ink_gaps(&slanted, &b, place, place, 100.0, 0.0, 100.0)[0];
assert!(correction > 1.0, "the narrowest gap opens: {correction}");
assert!(narrowest + correction >= 0.3 * 20.0 - 0.1, "{narrowest} + {correction}");
}
#[test]
fn ideographs_kana_fullwidth_forms_and_dashes_keep_the_font_spacing() {
for c in ['日', 'カ', 'な', '한', '、', 'W', '-', '\u{2014}', '\u{FF0D}'] {
assert!(!optical_spaces(c), "{c:?}");
}
for c in ['A', 'v', '7', '.', 'Ω', 'Ж', 'א', 'ب', 'क'] {
assert!(optical_spaces(c), "{c:?}");
}
}
}
+18 -4
View File
@@ -830,16 +830,30 @@ 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>> {
remapped_source_sans(RIGHT_TO_LEFT_OPTICAL_FAMILY, "RtlProfileSans-Regular", &[('א', 'A'), ('ב', 'V')])
}
/// The family of [`ideograph_optical_font`].
pub const IDEOGRAPH_OPTICAL_FAMILY: &str = "Ideograph Profile Sans";
/// Two ideographs, two kana and a fullwidth letter mapped to the bundled Source Sans 3 A, V, T, o
/// and W outlines: shapes Optical would space, on characters that keep the font's spacing.
pub fn ideograph_optical_font() -> Option<Vec<u8>> {
remapped_source_sans(IDEOGRAPH_OPTICAL_FAMILY, "IdeographProfileSans-Regular", &[('日', 'A'), ('本', 'V'), ('カ', 'T'), ('な', 'o'), ('W', 'W')])
}
/// A face named `family` that maps only the characters of `mapping` to the bundled Source Sans 3
/// outlines of their letters, with no `GPOS` or `kern` table.
fn remapped_source_sans(family: &str, postscript: &str, mapping: &[(char, char)]) -> 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 mappings = mapping.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 full = format!("{family} Regular");
let name = name_table(&[(1, family), (2, "Regular"), (4, full.as_str()), (6, postscript)])?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
+48
View File
@@ -229,6 +229,54 @@ fn optical_pair_after_rtl_separate_mark_uses_base_outline() {
assert!((correction - plain_correction).abs() < 0.01, "the marked cluster uses the base outline");
}
/// Optical spaces pairs by the face's own stem spacing (#5336): straight pairs like `nn` keep the
/// font's spacing, diagonal pairs close up, and a line of ordinary text is no narrower than it
/// is with the font's spacing and no kerning.
#[test]
fn optical_keeps_the_fonts_stem_spacing_and_closes_diagonal_pairs() {
let st = CharStyle { font_family: "Source Sans 3".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let advances = |text: &str, kerning: Option<f64>| {
layout(db(), &TextObject::point(Point::ZERO, text, CharStyle { kerning, ..st.clone() })).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>()
};
let width = |text: &str, kerning: Option<f64>| advances(text, kerning).iter().sum::<f64>();
let (optical, zero) = (advances("nn", None), advances("nn", Some(0.0)));
assert!((optical[0] - zero[0]).abs() <= 0.11, "nn, the reference, keeps the font's spacing: {optical:?} {zero:?}");
for pair in ["HH", "ll", "nl"] {
let (optical, zero) = (advances(pair, None), advances(pair, Some(0.0)));
assert!((optical[0] - zero[0]).abs() <= 3.0, "{pair} stays within 0.03 em of the font's spacing: {optical:?} {zero:?}");
}
for pair in ["AV", "To", "Yo"] {
let (optical, zero) = (advances(pair, None), advances(pair, Some(0.0)));
assert!(optical[0] < zero[0] - 1.0, "{pair} closes up: {optical:?} {zero:?}");
assert!(optical[0] >= zero[0] - 12.0 - 0.01, "{pair} tightens by at most 0.12 em: {optical:?} {zero:?}");
}
for text in ["minimum", "Hamburgefonstiv", "illumination"] {
let (optical, zero) = (width(text, None), width(text, Some(0.0)));
assert!(optical >= zero * 0.97, "{text} is not squeezed: {optical} against {zero}");
}
}
/// Ideographs, kana and fullwidth forms keep the font's spacing under Optical, even where their
/// outlines would be spaced (the test face draws them with Latin outlines).
#[test]
fn optical_leaves_ideographs_kana_and_fullwidth_forms_alone() {
use crate::test_fonts::{IDEOGRAPH_OPTICAL_FAMILY, ideograph_optical_font};
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(ideograph_optical_font().unwrap()), 1);
let st = CharStyle { font_family: IDEOGRAPH_OPTICAL_FAMILY.into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
let advances = |kerning: Option<f64>| {
layout(&db, &TextObject::point(Point::ZERO, "日本カなW日", CharStyle { kerning, ..st.clone() }))
.glyphs
.iter()
.map(|g| g.advance)
.collect::<Vec<_>>()
};
let (optical, zero) = (advances(None), advances(Some(0.0)));
assert_eq!(optical.len(), 6);
assert_eq!(optical, zero, "no ideograph, kana or fullwidth pair is corrected");
}
#[test]
fn recurring_optical_pairs_have_equal_corrections() {
let st = CharStyle { font_family: "JetBrains Mono".into(), size: 100.0, optical_kerning: true, ..CharStyle::default() };
+2 -1
View File
@@ -5436,7 +5436,8 @@ the paragraph's text now starts; when the text before `at` starts no list, nothi
`text.setRangeStyle {id, start, end, kerning}` (a range) and `text.setFormat {ids?, kerning}`
(the selected type) accept `kerning: "auto" | "optical" | number` (1/1000 em). Auto uses the font's
pair metrics; Optical spaces horizontal pairs from their glyph shapes. Vertical text uses font
pair metrics; Optical spaces horizontal pairs from their glyph shapes, moving each toward the font's own
stem spacing; ideographs, kana, fullwidth forms and dashes keep the font's spacing. Vertical text uses font
metrics in either mode. A number sets a manual pair value,
replacing that pair's automatic correction while tracking remains additive. Choosing `"optical"` or
`"auto"` clears existing manual pair values in the text it applies to. New text and files without an Optical setting use Auto. The Character
+3 -2
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Type-range Copy keeps formatting on canvas Paste after editing; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -23,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. |
| 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. |
| 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. |
| 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset, as the public page lists the pane's options for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. |
@@ -918,7 +919,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`.
- **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`.
- Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291).
- **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`.
- **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`.
- **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`.
- **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`.
- **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`.