From 5835f0a106f50dccb69307c842a03118a258c8e3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:11:10 -0400 Subject: [PATCH] Text: no Latin ligatures, and kerning only from the run's kern size (#309) PowerPoint keeps fi, ffi and Calibri's ti apart and kerns a run only when a:rPr/@kern is set and the size reaches it; DeckCraft shaped with the font's defaults (ligatures and kerning always on), so Calibri text read differently and kern="0" text came out narrower. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 27 ++++++++++++++-- crates/text/src/tests.rs | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 226d2bd..32ef2f1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -203,6 +203,8 @@ struct Style { cs: Option, /// Heading font (`+mj-…`): the theme's major font set applies. major: bool, + /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it. + kern: bool, } /// One character of a paragraph after shaping. @@ -314,6 +316,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl ea, cs, major, + kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale), } } @@ -613,7 +616,8 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa // Items: maximal ranges of one level, face, script and shaping style (case mapping, language). let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None); let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref()); - let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i)); + let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern); + let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i)); let mut a = 0; while a < n { let mut b = a + 1; @@ -629,6 +633,24 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; +/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws +/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in +/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need +/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them. +fn shaping_features(cells: &[Cell], styles: &[Style], script: Option) -> Vec { + let mut f = vec![]; + if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) { + f.extend(deckcraft_fonts::feature("-kern")); + } + let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl")); + let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F)); + if alphabetic && !emoji { + f.extend(deckcraft_fonts::feature("-liga")); + f.extend(deckcraft_fonts::feature("-clig")); + } + f +} + /// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the /// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them). #[allow(clippy::too_many_arguments)] @@ -649,7 +671,8 @@ fn shape_item( let Some(face) = faces.get(fi) else { return }; let rtl = level % 2 == 1; let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return }; - let params = ShapeParams { features: &[], rtl, script, language: lang }; + let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script); + let params = ShapeParams { features: &features, rtl, script, language: lang }; let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch }); let upem = face.upem.max(1.0); let scale_of = |cell: &Cell| { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c55f274..724e63b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -45,6 +45,75 @@ fn bullets_are_drawn_for_body_text() { assert_eq!(l.para_lines.len(), 2); } +/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`. +fn glyphs_and_width(family: &str, text: &str, kern: Option) -> (usize, f64) { + let (p, mut sh) = setup(text, 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + for r in &mut para.runs { + r.props.font = Some(family.into()); + r.props.size = Some(40.0); + r.props.kern = kern; + } + let l = lay(&p, &sh, 2000.0, 300.0); + let n = l.runs.iter().map(|r| r.glyphs.len()).sum(); + (n, l.lines[0].extent.1 - l.lines[0].extent.0) +} + +/// The first installed or bundled family of `families` for which `pick` holds. +fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option { + let db = FontDb::global(); + families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone()) +} + +const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"]; + +#[test] +fn latin_text_draws_no_ligatures() { + // PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as + // standard ligatures. + let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6; + let Some(family) = first_family(LATIN_FAMILIES, ligates) else { + eprintln!("skipped: no font with an ffi ligature"); + return; + }; + let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0)); + assert_eq!(n, 12, "{family}: one glyph per character"); +} + +#[test] +fn pairs_are_kerned_only_from_the_kern_size() { + let kerns = |f: &FontFace| { + let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::(); + w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::>()) + }; + let Some(family) = first_family(LATIN_FAMILIES, kerns) else { + eprintln!("skipped: no font that kerns AVATAR"); + return; + }; + // `kern` is the smallest size kerned; 0 is no kerning. + let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0)); + let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0)); + let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0)); + let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0)); + assert!(on < off - 2.0, "{family}: kerned {on} vs {off}"); + assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size"); + assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold"); +} + +#[test] +fn ligatures_stay_for_scripts_that_need_them() { + let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag)); + let cells = + |s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::>(); + let latin = shaping_features(&cells("office"), &[], Some(*b"Latn")); + assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern")); + // Arabic needs its ligatures; emoji sequences are joined by them. + assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga")); + assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga")); + assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga")); +} + #[test] fn only_the_first_character_of_a_bullet_is_drawn() { // "••" (seen in the wild) draws one dot in PowerPoint.