mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
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:
+41
-3
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user