From 9e2f7b96dc0d066341b09b68b690d45c49ac2919 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:52:36 -0400 Subject: [PATCH] =?UTF-8?q?Text:=20small=20capitals=20draw=20at=20their=20?= =?UTF-8?q?size,=20and=20all=20caps=20keep=20characters=20like=20"=C3=9F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small capitals were drawn at the full size of the run's first character: glyph runs grouped cells by style and face only, so "HxHx" drew every letter at the capital's size on the small capitals' narrower advances (letters overlapped). A lowercase letter in small caps now starts a run of its own at 80 % (PowerPoint's small capital "X" is 0.81 of the capital "H" in Arial). All caps and small caps mapped "ß" to "S" (the first character of "SS"); PowerPoint leaves a character whose capital is a sequence as it is ("ß", "fi", "ʼn"), and maps the rest one to one. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 18 +++++++++++++++--- crates/text/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 007f63e2..48a466f8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -647,6 +647,17 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa cells } +/// A character in capitals (`cap="all"`/`"small"`): its uppercase when that is one character. +/// PowerPoint leaves characters whose capital is a sequence as they are: "ß" stays "ß" (not "SS"), +/// "fi" and "ʼn" stay. +fn caps_char(ch: char) -> char { + let mut up = ch.to_uppercase(); + match (up.next(), up.next()) { + (Some(u), None) => u, + _ => ch, + } +} + /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; @@ -690,7 +701,7 @@ fn shape_item( let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return }; 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 glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { caps_char(ch) } else { ch }); let upem = face.upem.max(1.0); let scale_of = |cell: &Cell| { let st = styles.get(cell.style); @@ -1420,7 +1431,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } // Group visually consecutive cells by (style, face). let mut cur: Option = None; - let mut cur_key = (usize::MAX, usize::MAX); + let mut cur_key = (usize::MAX, usize::MAX, false); for &k in &place.order { let ci = line.start + k; let Some(cell) = p.cells.get(ci) else { continue }; @@ -1429,7 +1440,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let base_y = line.baseline - st.baseline * st.size; let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; - let key = (cell.style, cell.face); + // Small capitals are drawn smaller than the capitals beside them: a run of their own. + let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase()); if key != cur_key { if let Some(r) = cur.take() { out.runs.push(r); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 240dd47a..12d80dc4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -839,3 +839,35 @@ fn symbol_font_characters_draw_in_the_sym_font() { // The letters around it keep the run's font. assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}"); } + +#[test] +fn small_capitals_draw_smaller_than_the_capitals_beside_them() { + let (p, mut sh) = setup("HxHx", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.caps = Some(Caps::Small); + para.runs[0].props.size = Some(40.0); + let l = lay(&p, &sh, 2000.0, 300.0); + let sizes: Vec<(usize, f64)> = l.runs.iter().map(|r| (r.glyphs.len(), r.size)).collect(); + // H at 40 pt, x as a capital at 80 % (four runs: the size changes at every letter). + assert_eq!(sizes, vec![(1, 40.0), (1, 32.0), (1, 40.0), (1, 32.0)]); +} + +#[test] +fn capitals_keep_characters_whose_capital_is_a_sequence() { + assert_eq!(caps_char('a'), 'A'); + assert_eq!(caps_char('dž'), 'DŽ'); + // PowerPoint draws "ß", "fi" and "ʼn" unchanged in all caps. + assert_eq!(caps_char('ß'), 'ß'); + assert_eq!(caps_char('\u{FB01}'), '\u{FB01}'); + assert_eq!(caps_char('ʼn'), 'ʼn'); + let (p, mut sh) = setup("aß", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.runs[0].props.caps = Some(Caps::All); + let l = lay(&p, &sh, 2000.0, 300.0); + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + let face = &r.face; + if face.covers('ß') { + assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S"); + } +}