diff --git a/src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs b/src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs index c3196897f3bcef..f527e96292584e 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Globalization/TextInfo.cs @@ -625,8 +625,11 @@ public string ToTitleCase(string str) StringBuilder result = new StringBuilder(); string? lowercaseData = null; - // Store if the current culture is Dutch (special case) - bool isDutchCulture = CultureName.StartsWith("nl-", StringComparison.OrdinalIgnoreCase); + // Store if the current culture is Dutch (special case). This covers both the + // neutral culture ("nl") and any specific Dutch culture ("nl-NL", "nl-BE", etc.). + string cultureName = CultureName; + bool isDutchCulture = cultureName.StartsWith("nl", StringComparison.OrdinalIgnoreCase) && + (cultureName.Length == 2 || cultureName[2] == '-'); for (int i = 0; i < str.Length; i++) { @@ -666,7 +669,7 @@ public string ToTitleCase(string str) } i += charLen; } - else if (str[i] == '\'') + else if (IsApostrophe(str[i])) { i++; if (hasLowerCase) @@ -852,6 +855,18 @@ private static bool IsWordSeparator(UnicodeCategory category) return (c_wordSeparatorMask & (1 << (int)category)) != 0; } + // Characters treated as an apostrophe within a word (e.g. contractions such as + // "can't" or possessives such as "Grandma's"), so a following letter is not treated + // as the start of a new word during titlecasing: + // U+0027 APOSTROPHE + // U+2019 RIGHT SINGLE QUOTATION MARK (the typographic curly apostrophe) + // U+2018 LEFT SINGLE QUOTATION MARK + // U+FF07 FULLWIDTH APOSTROPHE + private static bool IsApostrophe(char c) + { + return c is '\'' or '\u2019' or '\u2018' or '\uFF07'; + } + private static bool IsLetterCategory(UnicodeCategory uc) { return uc == UnicodeCategory.UppercaseLetter diff --git a/src/libraries/System.Runtime/tests/System.Globalization.Tests/System/Globalization/TextInfoTests.cs b/src/libraries/System.Runtime/tests/System.Globalization.Tests/System/Globalization/TextInfoTests.cs index 992beda98fc5ae..1b2703549f0436 100644 --- a/src/libraries/System.Runtime/tests/System.Globalization.Tests/System/Globalization/TextInfoTests.cs +++ b/src/libraries/System.Runtime/tests/System.Globalization.Tests/System/Globalization/TextInfoTests.cs @@ -61,9 +61,29 @@ public void ToTitleCaseTest() AssertExtensions.Throws("str", () => ti.ToTitleCase(null)); } + [Theory] + // ASCII apostrophe (U+0027) keeps a contraction/possessive as a single word. + [InlineData("Grandma's pictures", "Grandma's Pictures")] + [InlineData("can't stop", "Can't Stop")] + // U+2019 RIGHT SINGLE QUOTATION MARK (typographic curly apostrophe). + [InlineData("Grandma\u2019s pictures", "Grandma\u2019s Pictures")] + [InlineData("can\u2019t stop", "Can\u2019t Stop")] + // U+2018 LEFT SINGLE QUOTATION MARK and U+FF07 FULLWIDTH APOSTROPHE. + [InlineData("Grandma\u2018s pictures", "Grandma\u2018s Pictures")] + [InlineData("Grandma\uFF07s pictures", "Grandma\uFF07s Pictures")] + // A genuine separator still ends the word and titlecases what follows. + [InlineData("Grandma-s pictures", "Grandma-S Pictures")] + public void ToTitleCase_Apostrophe(string input, string expected) + { + TextInfo ti = CultureInfo.GetCultureInfo("en-US").TextInfo; + Assert.Equal(expected, ti.ToTitleCase(input)); + } + public static IEnumerable DutchTitleCaseInfo_TestData() { yield return new object[] { "nl-NL", "IJ IJ IJ IJ", "ij iJ Ij IJ" }; + yield return new object[] { "nl", "IJ IJ IJ IJ", "ij iJ Ij IJ" }; + yield return new object[] { "nl", "De IJsvogel", "de ijsvogel" }; yield return new object[] { "nl-be", "IJzeren Eigenschappen", "ijzeren eigenschappen" }; yield return new object[] { "NL-NL", "Lake IJssel", "lake iJssel" }; yield return new object[] { "NL-BE", "Boba N' IJango Fett PEW PEW", "Boba n' Ijango fett PEW PEW" };