From 22a899f8945801693f006070cce5a0aa0b34ab4c Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 9 Jul 2026 00:07:03 +0900 Subject: [PATCH] Titlecase the first-of-word code point without leading adjustment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit case::{title,capitalize}_wtf8 pass a single first-of-word code point to TitlecaseMapper::titlecase_segment. Under the default Auto leading adjustment the mapper looks for a Letter/Number/Symbol/Private_Use head and skips anything else, dropping the titlecase mapping of cased marks such as U+0345 (COMBINING GREEK YPOGEGRAMMENI) -> U+0399: 'ͅ'.title() returned 'ͅ' instead of 'Ι'. Use LeadingAdjustment::None so the given code point is titlecased directly, matching CPython str.title and str.capitalize. Assisted-by: Claude --- crates/unicode/src/case.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/unicode/src/case.rs b/crates/unicode/src/case.rs index 0d74133399a..c563db09339 100644 --- a/crates/unicode/src/case.rs +++ b/crates/unicode/src/case.rs @@ -19,6 +19,7 @@ use alloc::{ vec::Vec, }; +use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions}; use icu_casemap::{CaseMapper, TitlecaseMapper}; use icu_locale::LanguageIdentifier; use icu_properties::props::{ @@ -236,8 +237,16 @@ fn titlecase_string(s: &str, out: &mut FmtWriter<'_>) { } fn titlecase_segment(s: &str, out: &mut FmtWriter<'_>) { + // Callers pass a single first-of-word code point, which Python titlecases + // unconditionally (applying its titlecase mapping). The default `Auto` + // leading adjustment looks for a head in Letter/Number/Symbol/Private_Use + // and skips anything else, dropping the titlecase mapping of cased marks + // such as U+0345 (`ͅ`, general category Mn) -> U+0399 (`Ι`). `None` + // titlecases the code point as given. + let mut options = TitlecaseOptions::default(); + options.leading_adjustment = Some(LeadingAdjustment::None); TitlecaseMapper::new() - .titlecase_segment(s, &LanguageIdentifier::UNKNOWN, Default::default()) + .titlecase_segment(s, &LanguageIdentifier::UNKNOWN, options) .write_to(out) .expect("writing to an in-memory buffer cannot fail"); } @@ -353,6 +362,23 @@ mod tests { assert_eq!(swapcase_str("Hello"), "hELLO"); } + #[test] + fn titlecase_first_of_word_takes_titlecase_mapping() { + // A leading cased combining mark still takes its titlecase mapping: + // U+0345 (ͅ, general category Mn) titlecases to U+0399 (Ι), even though + // it is not a Letter/Number/Symbol head. + assert_eq!(title_str("\u{0345}"), "\u{0399}"); + assert_eq!(capitalize_str("\u{0345}"), "\u{0399}"); + assert_eq!(title_str("\u{0345}a"), "\u{0399}a"); + // Full (one-to-many) titlecase mappings still apply to the first + // character of each word. + // cspell:ignore finnish NNISH dzungla Dzungla ßhello Sshello + assert_eq!(capitalize_str("finnish"), "Finnish"); + assert_eq!(title_str("fiNNISH"), "Finnish"); + assert_eq!(capitalize_str("dzungla"), "Dzungla"); + assert_eq!(capitalize_str("ßhello"), "Sshello"); + } + #[test] fn wtf8_passes_surrogates_through() { let mut buf = Wtf8Buf::from("ab cd");