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); } ///