mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Text: Optical kerning leaves dashes and fullwidth forms alone
Two dashes end to end have no ink gap to measure, so -- and the double em dash opened up by the loosen cap; Chinese sets —— as one continuous rule. Fullwidth Latin (U+FF21–FF5A) was kernable through its Latin script and moved CJK text off its em grid. Both are now excluded from kernable(), with a unit test and a compose test that failed before.
This commit is contained in:
@@ -155,15 +155,24 @@ fn reference_gap(db: &ScopedFonts<'_>, face: &FontFace) -> Option<f64> {
|
||||
|
||||
/// 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}'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user