Merge pull request #1053 from Silvertree2010/fix/h1274-title-case-leaves-a-word-lowerca

Capitalise title-case word after an opening apostrophe
This commit is contained in:
BFlat
2026-10-27 00:13:01 +07:00
committed by GitHub
4 changed files with 73 additions and 6 deletions
+41 -3
View File
@@ -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<char>) -> 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<char>, prev: Option<char>) -> 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<char>, 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");
}
}
+4 -2
View File
@@ -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, "<tspan x=\"{}\" y=\"{}\">{}</tspan>", num(x), num(y), esc(&text));
}
+3 -1
View File
@@ -1112,6 +1112,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, 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<Face>, 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)];
+25
View File
@@ -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<usize>| {
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<char> = s.chars().collect();
(0..chars.len()).map(|i| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid)).collect::<Vec<_>>()
};
let expect = |s: &str, want: &str| {
let got: Vec<Option<u32>> = 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() {