diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8c739920..a000447f 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -120,23 +120,40 @@ impl TextCase { } /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises - /// the first letter of each word even when a word spans several styled runs). + /// the first letter of each word even when a word spans several styled runs). An apostrophe + /// as `prev` is taken to continue a word. pub fn apply_after(self, s: &str, prev: Option) -> String { + self.apply_impl(s, prev, prev.is_some_and(is_word_char)) + } + + /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe + /// only continues a word when a letter or digit precedes it. + pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { + let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); + self.apply_impl(s, prev, continues) + } + + /// `in_word` says whether `prev` continues a word (rather than a new word starting here). + fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { match self { TextCase::Original | TextCase::SmallCaps => s.to_string(), TextCase::Upper => s.to_uppercase(), TextCase::Lower => s.to_lowercase(), TextCase::Title => { let mut out = String::with_capacity(s.len()); - let mut start = prev.is_none_or(|c| !is_word_char(c)); + let mut start = !in_word; + let mut last = prev; for ch in s.chars() { if start && ch.is_alphanumeric() { out.extend(ch.to_uppercase()); start = false; } else { out.push(ch); - start = !is_word_char(ch); + // An apostrophe only continues a word right after a letter or digit. + let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric); + start = !is_word_char(ch) || opening_apostrophe; } + last = Some(ch); } out } @@ -880,3 +897,24 @@ mod case_tests { assert_eq!(t.para_list(0), ParaList::default()); } } + +#[cfg(test)] +mod title_case_tests { + use super::TextCase; + + #[test] + fn title_case_after_opening_apostrophe() { + assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); + assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); + assert_eq!(TextCase::Title.apply("don't"), "Don't"); + assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); + } + + #[test] + fn title_case_between_runs_sees_char_before_apostrophe() { + assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); + assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); + } +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 449426d2..233da218 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -626,7 +626,8 @@ impl W<'_> { let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len()); let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect(); let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied(); - let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); + let before = start.checked_sub(2).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_between(text.trim_end_matches(['\n', '\r']), before, prev); let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) { Some(fills) => { let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into()); @@ -642,7 +643,8 @@ impl W<'_> { } let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect(); let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied(); - let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); + let before = line.char_start.checked_sub(2).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_between(text.trim_end_matches(['\n', '\r']), before, prev); let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y)); let _ = write!(spans, "{}", num(x), num(y), esc(&text)); } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 77d42804..ca5f0362 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1112,6 +1112,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe // a word split across styled runs is capitalised once) and remember synthesised small caps. let mut mapped: Vec<(char, usize, bool)> = Vec::with_capacity(end.saturating_sub(start)); let mut prev = start.checked_sub(1).and_then(|i| chars.get(i)).copied(); + let mut before = start.checked_sub(2).and_then(|i| chars.get(i)).copied(); for (i, c) in chars.get(start..end).unwrap_or(&[]).iter().enumerate() { let src = start + i; if small_caps && !native_smcp && c.is_lowercase() { @@ -1122,10 +1123,11 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe mapped.push((lower_sigma(chars, src), src, false)); } else { let mut buf = [0u8; 4]; - for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() { + for u in style.case.apply_between(c.encode_utf8(&mut buf), before, prev).chars() { mapped.push((u, src, false)); } } + before = prev; prev = Some(*c); } let mut small = vec![false; end.saturating_sub(start)]; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 09accbd4..e643ccae 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -170,6 +170,31 @@ fn title_case_across_runs() { assert_eq!(gid_at(6), Some(face.glyph_for('W'))); } +#[test] +fn title_case_after_opening_apostrophe_on_canvas() { + let Some(face) = inter() else { return }; + let glyph_chars = |s: &str, split: Option| { + let mut t = TextData::new(s); + t.style.case = figcraft_doc::TextCase::Title; + if let Some(n) = split { + t.restyle(0, n, |st| st.font_style = "Bold".into()); + } + let l = layout(&t, None, None); + let chars: Vec = s.chars().collect(); + (0..chars.len()).map(|i| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid)).collect::>() + }; + let expect = |s: &str, want: &str| { + let got: Vec> = want.chars().map(|c| Some(face.glyph_for(c))).collect(); + for split in [None, Some(1), Some(2)] { + assert_eq!(glyph_chars(s, split), got, "{s:?} split {split:?}"); + } + }; + expect("'hello world'", "'Hello World'"); + expect("say 'hi'", "Say 'Hi'"); + expect("don't", "Don't"); + expect("\u{2019}hello", "\u{2019}Hello"); +} + #[test] fn lists_hang_and_number() { if !have_fonts() {