From ddeceb17b34f05e599170930ca7dcfe3f96bb97e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:36:45 +0000 Subject: [PATCH 1/2] Capitalise title-case word after an opening apostrophe Found while testing crates/doc/src/text.rs:138 (TextCase::apply_after; is_word_char at :153 counts ' and U+2019 as word characters) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/text.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 458426ad..15e284bc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -129,14 +129,17 @@ impl TextCase { TextCase::Title => { let mut out = String::with_capacity(s.len()); let mut start = prev.is_none_or(|c| !is_word_char(c)); + let mut before = 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. + start = !is_word_char(ch) || (!ch.is_alphanumeric() && !before.is_some_and(|c| c.is_alphanumeric())); } + before = Some(ch); } out } @@ -831,3 +834,16 @@ 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"); + } +} From 8b94da604a255ce768c5c52107d2103ad6c9eddc Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:46:51 +0200 Subject: [PATCH 2/2] fix: use apostrophe-aware title case on the canvas text path Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/text.rs | 32 +++++++++++++++++++++++++++----- crates/svg/src/lib.rs | 6 ++++-- crates/text/src/layout.rs | 4 +++- crates/text/src/tests.rs | 25 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 15e284bc..fe71bb5b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -120,16 +120,29 @@ 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 before = prev; + let mut start = !in_word; + let mut last = prev; for ch in s.chars() { if start && ch.is_alphanumeric() { out.extend(ch.to_uppercase()); @@ -137,9 +150,10 @@ impl TextCase { } else { out.push(ch); // An apostrophe only continues a word right after a letter or digit. - start = !is_word_char(ch) || (!ch.is_alphanumeric() && !before.is_some_and(|c| c.is_alphanumeric())); + let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric); + start = !is_word_char(ch) || opening_apostrophe; } - before = Some(ch); + last = Some(ch); } out } @@ -846,4 +860,12 @@ mod title_case_tests { 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 55176aaf..908b82ea 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -587,7 +587,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()); @@ -603,7 +604,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 26ae3d33..9ad5a373 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1059,6 +1059,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() { @@ -1067,10 +1068,11 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe } } 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 ebf5889c..3524c51b 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() {