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
12 changes: 12 additions & 0 deletions CaseConverter.Test/CaseConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ public void ToTitleCaseShouldKeepAnApostropheInsideItsWord()
Assert.AreEqual("Don't Stop", "don't stop".ToTitleCase());
}

// TextInfo.ToTitleCase only keeps the letter after an ASCII apostrophe lowercase; it treated the
// typographic apostrophe U+2019 as a separator and gave "Don’T Stop".

[TestMethod]
[DataRow("don’t stop", "Don’t Stop")]
[DataRow("it’s fine", "It’s Fine")]
[DataRow("’quoted’ word", "’Quoted’ Word")]
public void ToTitleCaseShouldKeepATypographicApostropheInsideItsWord(string input, string expected)
{
Assert.AreEqual(expected, input.ToTitleCase());
}

[TestMethod]
public void ToTitleCaseShouldTreatAnUnderscoreAsAWordSeparator()
{
Expand Down
48 changes: 47 additions & 1 deletion CaseConverter/CaseConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,53 @@ public static string ToTitleCase(this string input)
// otherwise the same word converts two different ways depending on its neighbours.
output = LowercaseAllCapsWords(output);

return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(output);
return TitleCaseKeepingTypographicApostrophes(output);
}

/// <summary>
/// Applies <see cref="TextInfo.ToTitleCase(string)"/>, treating an in-word U+2019 the same as an ASCII apostrophe.
/// </summary>
/// <param name="input">The string to convert.</param>
/// <returns>A new string in Title Case.</returns>
/// <remarks>
/// <see cref="TextInfo.ToTitleCase(string)"/> keeps the letter after an ASCII <c>'</c> lowercase, but
/// treats U+2019 as a word separator, so <c>"don’t"</c> would become <c>"Don’T"</c>. Each in-word
/// U+2019 is swapped for <c>'</c> before the call and restored at the same index afterwards, which is
/// safe because <see cref="TextInfo.ToTitleCase(string)"/> does not change the string's length.
/// </remarks>
private static string TitleCaseKeepingTypographicApostrophes(string input)
{
char[] characters = input.ToCharArray();
List<int> typographicApostrophes = [];
int previousStart = -1;

for (int i = 0; i < input.Length;)
{
int nextStart = i + CodePointLength(input, i);

if (input[i] == '’' && IsApostropheWithinWord(input, previousStart, i, nextStart))
{
characters[i] = '\'';
typographicApostrophes.Add(i);
}

previousStart = i;
i = nextStart;
}

if (typographicApostrophes.Count == 0)
{
return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(input);
}

char[] output = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(new string(characters)).ToCharArray();

foreach (int index in typographicApostrophes)
{
output[index] = '’';
}

return new string(output);
}

/// <summary>
Expand Down
Loading