diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs
index a80b4dc..97a7b54 100644
--- a/CaseConverter.Test/CaseConverterTests.cs
+++ b/CaseConverter.Test/CaseConverterTests.cs
@@ -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()
{
diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs
index cc129d7..12d4d67 100644
--- a/CaseConverter/CaseConverter.cs
+++ b/CaseConverter/CaseConverter.cs
@@ -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);
+ }
+
+ ///
+ /// Applies , treating an in-word U+2019 the same as an ASCII apostrophe.
+ ///
+ /// The string to convert.
+ /// A new string in Title Case.
+ ///
+ /// keeps the letter after an ASCII ' lowercase, but
+ /// treats U+2019 as a word separator, so "don’t" would become "Don’T". Each in-word
+ /// U+2019 is swapped for ' before the call and restored at the same index afterwards, which is
+ /// safe because does not change the string's length.
+ ///
+ private static string TitleCaseKeepingTypographicApostrophes(string input)
+ {
+ char[] characters = input.ToCharArray();
+ List 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);
}
///