From 4b58652c3e6b9c6494bc90443454dfa9d328be34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:29:26 +0000 Subject: [PATCH 1/2] fix: keep surrogate pairs whole when NominalWordWrap hard-breaks a word [patch] The hard-break loop cut every maxCharsPerLine UTF-16 code units, so a cut could land between the halves of a non-BMP character and leave both lines with a lone surrogate. The cut now steps back one code unit when it would split a pair, and at a width of one lets the pair overhang instead. Fixes #142 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwgZGoivWyqL4pWDsenTPm --- Extensions.Test/StringExtensionsTests.cs | 18 ++++++++++++++++++ Extensions/StringExtensions.cs | 14 +++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index fbc7678..076da05 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)]; + CollectionAssert.AreEqual(new List { "😀", "😀", "😀" }, emoji); + + List mixed = [.. "ab😀cd".NominalWordWrap(3f, 1f)]; + CollectionAssert.AreEqual(new List { "ab", "😀c", "d" }, mixed); + } + + [TestMethod] + public void NominalWordWrapLetsASurrogatePairOverhangAtWidthOne() + { + List result = [.. "a😀b".NominalWordWrap(1f, 1f)]; + CollectionAssert.AreEqual(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); From be88199051c8416cd231578f5570dac5b312f8e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:41:24 +0000 Subject: [PATCH 2/2] test: use Assert.AreSequenceEqual in the surrogate-pair wrap tests (MSTEST0068) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwgZGoivWyqL4pWDsenTPm --- Extensions.Test/StringExtensionsTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index 076da05..f138edf 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -454,17 +454,17 @@ public void NominalWordWrapHardBreakDoesNotSplitSurrogatePairs() { // Each 😀 is a surrogate pair: two UTF-16 code units. List emoji = [.. "😀😀😀".NominalWordWrap(3f, 1f)]; - CollectionAssert.AreEqual(new List { "😀", "😀", "😀" }, emoji); + Assert.AreSequenceEqual(new List { "😀", "😀", "😀" }, emoji); List mixed = [.. "ab😀cd".NominalWordWrap(3f, 1f)]; - CollectionAssert.AreEqual(new List { "ab", "😀c", "d" }, mixed); + Assert.AreSequenceEqual(new List { "ab", "😀c", "d" }, mixed); } [TestMethod] public void NominalWordWrapLetsASurrogatePairOverhangAtWidthOne() { List result = [.. "a😀b".NominalWordWrap(1f, 1f)]; - CollectionAssert.AreEqual(new List { "a", "😀", "b" }, result); + Assert.AreSequenceEqual(new List { "a", "😀", "b" }, result); } [TestMethod]