Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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++)
{
Expand Down Expand Up @@ -666,7 +669,7 @@ public string ToTitleCase(string str)
}
i += charLen;
}
else if (str[i] == '\'')
else if (IsApostrophe(str[i]))
{
i++;
if (hasLowerCase)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,29 @@ public void ToTitleCaseTest()
AssertExtensions.Throws<ArgumentNullException>("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<object[]> 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" };
Expand Down
Loading