From ee5e2db9c4af1ccf1ad9df41c117373fe4b4a4b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:36:55 -0800 Subject: [PATCH] Type: Optical kerning keeps the space Tsume takes out Tsume narrows a cluster before Optical kerning measures its pairs, so Optical measured the narrowed gap and gave part of the space back. Optical now measures each pair as it would be without the Tsume, as it already does for Insert Aki, and the Tsume stays on top. Tsume records what it took on the glyph for this. --- crates/text/src/layout.rs | 8 ++++++ crates/text/src/layout/wrapping_tests.rs | 2 ++ crates/text/src/shape.rs | 33 ++++++++++++++++------ crates/text/src/tests/harakat.rs | 36 ++++++++++++++++++++++++ crates/text/src/tests/tsume.rs | 19 +++++++++++++ 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index a7d7d1d3f3..d7669f2fe2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -336,6 +336,8 @@ impl Ctx<'_> { tab.kern_shift = 0.0; tab.aki_shift = 0.0; tab.aki = 0.0; + tab.tsume_shift = 0.0; + tab.tsume = 0.0; tab.tcy = None; tab.wari = None; tab.vpal = None; @@ -653,6 +655,8 @@ impl Ctx<'_> { k.kern_shift = 0.0; k.aki_shift = 0.0; k.aki = 0.0; + k.tsume_shift = 0.0; + k.tsume = 0.0; k.len = 0; k.lead = 0.0; k.ch = kashida::TATWEEL; @@ -713,6 +717,8 @@ mod emit_kashida_mark_last { kern_shift: 0.0, aki_shift: 0.0, aki: 0.0, + tsume_shift: 0.0, + tsume: 0.0, sx: face.upem.recip() * 48.0, sy: face.upem.recip() * 48.0, bshift: 0.0, @@ -779,6 +785,8 @@ mod emit_kashida_mark_last { kern_shift: 0.0, aki_shift: 0.0, aki: 0.0, + tsume_shift: 0.0, + tsume: 0.0, sx: face.upem.recip() * 48.0, sy: face.upem.recip() * 48.0, bshift: 0.0, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 1878ce962d..2116070dd0 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -22,6 +22,8 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { kern_shift: 0.0, aki_shift: 0.0, aki: 0.0, + tsume_shift: 0.0, + tsume: 0.0, sx: 1.0, sy: 1.0, bshift: 0.0, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index befcbed41f..a6d68ece8d 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -49,6 +49,12 @@ pub(crate) struct SGlyph { /// The Insert Aki Left and Right `adv` holds, in points: on the last glyph of its cluster, 0 /// on the others. Justification does not stretch or narrow it. pub aki: f64, + /// The space Tsume ([`tighten`]) took before the cluster's ink, in points, on every glyph of + /// its cluster: `dx` is that much smaller. + pub tsume_shift: f64, + /// The space Tsume took out of `adv`, in points: on the last glyph of its cluster, 0 on the + /// others. + pub tsume: f64, /// Outline scale (font units -> points). pub sx: f64, pub sy: f64, @@ -232,6 +238,8 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { h.kern_shift = 0.0; h.aki_shift = 0.0; h.aki = 0.0; + h.tsume_shift = 0.0; + h.tsume = 0.0; h.byte = g.byte + g.len; h.len = 0; h.ch = '-'; @@ -838,8 +846,8 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range< let mut cache = HashMap::new(); let mut corrections: HashMap<[u64; 15], Option> = HashMap::new(); let mut references: HashMap> = HashMap::new(); - // The Insert Aki a cluster's last glyph holds does not make a mark its base. - let shaped_advances: Vec = glyphs.iter().map(|g| g.adv - g.aki).collect(); + // The Insert Aki and Tsume a cluster's last glyph holds do not make a mark its base. + let shaped_advances: Vec = glyphs.iter().map(|g| g.adv - g.aki + g.tsume).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 { @@ -897,10 +905,10 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range< continue; } // Neither glyph is rotated, so a right-to-left manual kerning shift adds to the offset. - // Insert Aki is measured as no space: the pair is spaced as it would be without it, and - // the Aki stays on top of that. - let left_x = left.dx + left.kern_shift - left.aki_shift; - let right_x = right.dx + right.kern_shift - right.aki_shift; + // Insert Aki is measured as no space, and Tsume as taking none: the pair is spaced as it + // would be without them, and they stay on top of that. + let left_x = left.dx + left.kern_shift - left.aki_shift + left.tsume_shift; + let right_x = right.dx + right.kern_shift - right.aki_shift + right.tsume_shift; 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 }; @@ -909,7 +917,7 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range< let tracking = left_style.tracking / 1000.0 * em * spacing_scale(left_style, false); // A manual kerning value on the left glyph is part of its advance and, right to left, of // its offset too (it spaces the left glyph from its left neighbor): it is not taken off. - let advance = glyphs[cluster_start..=cluster_end].iter().map(|g| g.adv - g.aki).sum::() - tracking; + let advance = glyphs[cluster_start..=cluster_end].iter().map(|g| g.adv - g.aki + g.tsume).sum::() - tracking; let key = [ u64::from(left.face.id()), u64::from(left.gid), @@ -1276,6 +1284,8 @@ fn inline_glyph( kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before } + aki_shift, aki_shift, aki: if adv.is_finite() { aki_left + aki_right } else { 0.0 }, + tsume_shift: 0.0, + tsume: 0.0, // Not used to draw (the art has its own transform), but they make the glyph's em box // its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a // character of its run's size, whatever its scale (see `layout::glyph_em`). @@ -1784,6 +1794,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe kern_shift, aki_shift, aki: aki_in_adv, + tsume_shift: 0.0, + tsume: 0.0, sx: k * hs, sy: k * vs, bshift: gst.baseline_shift + pos_shift, @@ -1822,8 +1834,9 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe /// drawn along the line, the last one with `own` as its width before the tracking, kerning and /// Insert Aki its advance holds. `tsume` percent of the blank space between the cluster's own width /// and its ink, on each side, comes out of the cluster's advance, and its glyphs are drawn the -/// left side's share earlier. A cluster without ink, such as a space, or with a width that is not -/// positive, keeps its place and width. +/// left side's share earlier ([`SGlyph::tsume`] and [`SGlyph::tsume_shift`] hold both, for Optical +/// kerning to measure the pair without them). A cluster without ink, such as a space, or with a +/// width that is not positive, keeps its place and width. fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) { let Some(last) = glyphs.len().checked_sub(1) else { return }; let share = if tsume.is_nan() { 0.0 } else { tsume.clamp(0.0, MAX_TSUME) / 100.0 }; @@ -1844,9 +1857,11 @@ fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) { let right = (pen - hi).clamp(0.0, pen) * share; for g in glyphs.iter_mut() { g.dx -= left; + g.tsume_shift = left; } if let Some(g) = glyphs.get_mut(last) { g.adv -= left + right; + g.tsume = left + right; } } diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index ea84bcf346..36afafdc4c 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -381,6 +381,42 @@ fn check_spaced_harakat(db: &FontDb, family: &str) { assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b)); } +/// Right to left, with harakat coloured apart, Optical kerning keeps the Tsume of each letter's +/// cluster as it does in "HOH": the glyphs move as far with Optical kerning as without it. +#[test] +fn optical_kerning_keeps_the_tsume_of_a_letter_with_coloured_harakat() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1); + check_optical_tsume(&db, crate::test_fonts::lam_alef_family(b"rlig")); + if let Some(face) = arabic_face("optical_kerning_keeps_the_tsume_of_a_letter_with_coloured_harakat") { + check_optical_tsume(FontDb::global(), &face.family); + } +} + +fn check_optical_tsume(db: &FontDb, family: &str) { + const WORD: &str = "بَبَ"; // ب 0, fatha 2, ب 4, fatha 6 + let set = |optical: bool, letter: Option| { + let mut t = text_with_harakat(WORD, family, Some(red())); + crate::edit::style_range(&mut t.runs, 0, WORD.len(), |st| st.optical_kerning = optical); + if let Some(at) = letter { + crate::edit::style_range(&mut t.runs, at, at + 2, |st| st.tsume = 100.0); + } + t + }; + let xs = |t: &TextObject| layout(db, t).glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect::>(); + assert!(xs(&set(true, None)).iter().zip(xs(&set(false, None))).any(|(a, b)| (a - b).abs() > 1e-6), "{family}: Optical moves the pair"); + // Each letter in turn: the first one's cluster ends the pair, the second one's starts it. + for letter in [0, 4] { + let moved = |optical: bool| xs(&set(optical, Some(letter))).iter().zip(xs(&set(optical, None))).map(|(a, b)| a - b).collect::>(); + let (without, with_optical) = (moved(false), moved(true)); + assert!(without.iter().any(|m| m.abs() > 1e-3), "{family} {letter}: Tsume moves the glyphs: {without:?}"); + assert!( + without.len() == with_optical.len() && without.iter().zip(&with_optical).all(|(a, b)| (a - b).abs() < 1e-6), + "{family} {letter}: {without:?} {with_optical:?}" + ); + } +} + #[test] fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and diff --git a/crates/text/src/tests/tsume.rs b/crates/text/src/tests/tsume.rs index 72f0d69f19..bd2d7d9ccc 100644 --- a/crates/text/src/tests/tsume.rs +++ b/crates/text/src/tests/tsume.rs @@ -112,6 +112,25 @@ fn the_font_kerning_is_added_after_the_tsume() { assert!(near(&before_kerned, &before_h) && before_h[0] < -10.0, "{before_kerned:?} {before_h:?}"); } +#[test] +fn optical_kerning_keeps_the_tsume() { + // Optical kerning spaces each pair as without the Tsume, and the Tsume stays on top: the + // characters move back as far with Optical kerning as without it. + let plain = point("HOH", style(100.0)); + let optical = point("HOH", CharStyle { optical_kerning: true, ..style(100.0) }); + let xs = |t: &TextObject| { + let l = layout(db(), t); + [0, 1, 2].map(|i| drawn_x(&l, i)) + }; + // The test font's Optical spacing differs from its own for these pairs. + assert!(!near(&xs(&optical), &xs(&plain)), "{:?} {:?}", xs(&optical), xs(&plain)); + for at in [0, 1, 2] { + let without = moves(&plain, &with(&plain, at, |s| s.tsume = 100.0), &[0, 1, 2]); + let with_optical = moves(&optical, &with(&optical, at, |s| s.tsume = 100.0), &[0, 1, 2]); + assert!(without.iter().any(|m| *m < -1.0) && near(&with_optical, &without), "{at}: {without:?} {with_optical:?}"); + } +} + #[test] fn a_letter_with_its_mark_takes_the_tsume_of_the_letter() { let plain = point("H\u{301}O", style(100.0));