diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index fbc7678..f138edf 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -449,6 +449,24 @@ public void NominalWordWrapClampsToAtLeastOneCharacterPerLine() CollectionAssert.AreEqual(new List { "a", "b" }, result); } + [TestMethod] + public void NominalWordWrapHardBreakDoesNotSplitSurrogatePairs() + { + // Each 😀 is a surrogate pair: two UTF-16 code units. + List emoji = [.. "😀😀😀".NominalWordWrap(3f, 1f)]; + Assert.AreSequenceEqual(new List { "😀", "😀", "😀" }, emoji); + + List mixed = [.. "ab😀cd".NominalWordWrap(3f, 1f)]; + Assert.AreSequenceEqual(new List { "ab", "😀c", "d" }, mixed); + } + + [TestMethod] + public void NominalWordWrapLetsASurrogatePairOverhangAtWidthOne() + { + List result = [.. "a😀b".NominalWordWrap(1f, 1f)]; + Assert.AreSequenceEqual(new List { "a", "😀", "b" }, result); + } + [TestMethod] public void NominalWordWrapThrowsArgumentNullExceptionWhenTextIsNull() { diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index f1ea5b2..2c9f3d8 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -227,7 +227,8 @@ public static string NormalizeLineEndings(this string s, LineEndingStyle style) /// hyphen (the hyphen stays on the upper line) or at a soft hyphen (­), which renders as a hyphen only when /// a break lands there and is otherwise removed. Words that still cannot fit are hard-broken as a last resort so that /// no line exceeds the computed width, except that honoring a soft hyphen at a line boundary may add a single - /// overhanging character. + /// overhanging character. A hard break never splits a surrogate pair, so at a width of one character a line may + /// hold a whole pair (two UTF-16 code units). /// /// Thrown when is null. /// Thrown when or is not greater than zero. @@ -309,8 +310,15 @@ private static IEnumerable NominalWordWrapIterator(string text, int maxC string remaining = atomText; while (remaining.Length > maxCharsPerLine) { - yield return remaining.Substring(0, maxCharsPerLine); - remaining = remaining.Substring(maxCharsPerLine); + int cut = maxCharsPerLine; + if (char.IsHighSurrogate(remaining[cut - 1]) && char.IsLowSurrogate(remaining[cut])) + { + // Never split a surrogate pair. At a width of one, let the pair overhang instead. + cut = cut > 1 ? cut - 1 : 2; + } + + yield return remaining.Substring(0, cut); + remaining = remaining.Substring(cut); } line.Append(remaining);