From d0120bf6a642beb92e31e026fc8a9bc64778bc13 Mon Sep 17 00:00:00 2001 From: Abdelrehman Fouad <97409061+MaridWSH@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:45:12 +0200 Subject: [PATCH] Text: Optical kerning leaves dashes and fullwidth forms alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dashes end to end have no ink gap to measure, so -- and the double em dash opened up by the loosen cap; Chinese sets —— as one continuous rule. Fullwidth Latin (U+FF21–FF5A) was kernable through its Latin script and moved CJK text off its em grid. Both are now excluded from kernable(), with a unit test and a compose test that failed before. --- crates/compose/src/optical.rs | 26 +++++++++++++++++++++++++- crates/compose/src/tests.rs | 15 +++++++++++++++ docs/typography-parity.md | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/optical.rs b/crates/compose/src/optical.rs index bd697c0dd..b7e4b8633 100644 --- a/crates/compose/src/optical.rs +++ b/crates/compose/src/optical.rs @@ -155,15 +155,24 @@ fn reference_gap(db: &ScopedFonts<'_>, face: &FontFace) -> Option { /// Whether `c` takes part in optical kerning: letters of the Latin, Greek and Cyrillic scripts, /// digits and Western punctuation. Spaces, marks, joined and ideographic scripts keep their -/// font spacing. +/// font spacing, as do dashes (two bars end to end have no ink gap to measure, and `——` must +/// stay one continuous rule) and the fullwidth forms, which sit on the CJK em grid. pub fn kernable(c: char) -> bool { use unicode_script::{Script, UnicodeScript}; + if is_dash(c) || ('\u{FF00}'..='\u{FFEF}').contains(&c) { + return false; + } if c.is_ascii_digit() || c.is_ascii_punctuation() || ('\u{2010}'..='\u{2027}').contains(&c) { return true; } c.is_alphabetic() && matches!(c.script(), Script::Latin | Script::Greek | Script::Cyrillic) } +/// Dash punctuation (Unicode `Pd`) in the ranges optical kerning would otherwise touch. +fn is_dash(c: char) -> bool { + matches!(c, '-' | '\u{2010}'..='\u{2015}' | '\u{2E3A}' | '\u{2E3B}' | '\u{FE58}' | '\u{FF0D}') +} + /// The adjustment for a pair, in font units: how far the first glyph's advance moves. fn adjustment(reference: f64, mean: f64, min: f64, upem: f64) -> f64 { let mut adj = (reference - mean) * PULL; @@ -255,4 +264,19 @@ mod tests { assert!(kernable('A') && kernable('é') && kernable('Ж') && kernable('Ω') && kernable('7') && kernable('.') && kernable('“')); assert!(!kernable(' ') && !kernable('\t') && !kernable('漢') && !kernable('ب') && !kernable('\u{301}')); } + + #[test] + fn dashes_and_fullwidth_forms_keep_their_font_spacing() { + // `--` and `——` have no ink gap to measure and would otherwise open up; `——` is one + // continuous dash in Chinese text. + for c in ['-', '\u{2010}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2E3A}', '\u{FF0D}'] { + assert!(!kernable(c), "{c:?} is a dash"); + } + // Fullwidth Latin letters, digits and punctuation sit on the CJK em grid. + for c in ['\u{FF21}', '\u{FF41}', '\u{FF5A}', '\u{FF11}', '\u{FF0C}'] { + assert!(!kernable(c), "{c:?} is a fullwidth form"); + } + // Hyphenated and dashed words still kern on their letters. + assert!(kernable('A') && kernable('.') && kernable('\u{2019}')); + } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 664329a07..339c22477 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -312,6 +312,21 @@ fn optical_kerning_is_bounded_and_skips_spaces() { assert!((none[0].1 - optical[0].1).abs() < 1e-9); } +#[test] +fn optical_kerning_leaves_dash_pairs_alone() { + use designcraft_doc::Kerning; + // Two bars end to end have a near-zero ink gap; pulling them towards the stem gap would + // break `--` and the double em dash `——` that Chinese sets as one continuous rule. + for text in ["--", "\u{2014}\u{2014}", "a--b", "\u{2013}\u{2013}"] { + let (none_w, none) = set_with_kerning(text, Kerning::None); + let (optical_w, optical) = set_with_kerning(text, Kerning::Optical); + assert!((none_w - optical_w).abs() < 1e-9, "{text:?}: optical {optical_w:.3} vs none {none_w:.3}"); + for ((c, a), (_, b)) in none.iter().zip(&optical) { + assert!((a - b).abs() < 1e-9, "{text:?} {c:?}: {a:.3} vs {b:.3}"); + } + } +} + #[test] fn hyphen_limit_is_a_hard_constraint() { let text = CORPUS.join(" "); diff --git a/docs/typography-parity.md b/docs/typography-parity.md index a3afae27a..cd17dd7f5 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -14,7 +14,7 @@ against InDesign is **unmeasured**, which is the main gap. ## Fidelity: how we'd know There is no oracle yet. What exists: unit tests of break points and invariants in -`crates/compose/src/tests.rs` (170 tests in the crate), IDML files built by DesignCraft and opened +`crates/compose/src/tests.rs` (172 tests in the crate), IDML files built by DesignCraft and opened in InDesign 2026 by hand, and user reports comparing the two (#179, #180, #181, #282–#295). **Needed (gap 4 in [gaps.md](gaps.md)):** a set of synthetic stories (our own text and fonts: