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));