From 64f7538bb2866cf1f2e8ddfa74a4b23cea011aff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:46:21 +0000 Subject: [PATCH] Break NominalWordWrap at every Unicode mandatory line break [patch] Split segments on vertical tab, form feed, NEL (U+0085), line separator (U+2028) and paragraph separator (U+2029) as well as LF, so lines they separate stay separate and consecutive ones keep a blank line, as \n\n does. Fixes #184 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW63GiadYdzxagcfgyE --- Extensions.Test/StringExtensionsTests.cs | 26 ++++++++++++++++++++++++ Extensions/StringExtensions.cs | 12 +++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index bb90006..a3a06ae 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -406,6 +406,32 @@ public void NominalWordWrapPreservesBlankLines() CollectionAssert.AreEqual(new List { "hello", "", "world" }, result); } + [TestMethod] + [DataRow('\v')] + [DataRow('\f')] + [DataRow('\u0085')] + [DataRow('\u2028')] + [DataRow('\u2029')] + public void NominalWordWrapHonorsUnicodeMandatoryBreaks(char separator) + { + string input = "Line one" + separator + "Line two"; + List result = [.. input.NominalWordWrap(80f, 1f)]; + CollectionAssert.AreEqual(new List { "Line one", "Line two" }, result); + } + + [TestMethod] + [DataRow('\v')] + [DataRow('\f')] + [DataRow('\u0085')] + [DataRow('\u2028')] + [DataRow('\u2029')] + public void NominalWordWrapPreservesBlankLinesBetweenUnicodeMandatoryBreaks(char separator) + { + string input = "a" + separator + separator + "b"; + List result = [.. input.NominalWordWrap(80f, 1f)]; + CollectionAssert.AreEqual(new List { "a", "", "b" }, result); + } + [TestMethod] public void NominalWordWrapHonorsWindowsLineBreaks() { diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index e42e345..d050d03 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -223,7 +223,8 @@ public static string NormalizeLineEndings(this string s, LineEndingStyle style) /// The nominal (assumed uniform) width of a single glyph, in the same units as . /// /// The wrapped lines. Existing line breaks (Unix, Windows, or Mac) are honored as forced breaks and blank lines are - /// preserved. Runs of whitespace within a line are collapsed to a single space. The no-break spaces U+00A0, U+2007 and + /// preserved, as are the other mandatory breaks: vertical tab, form feed, next line (U+0085), line separator (U+2028) + /// and paragraph separator (U+2029). Runs of whitespace within a line are collapsed to a single space. The no-break spaces U+00A0, U+2007 and /// U+202F are not break points and are kept unchanged. A break may also occur after a visible /// 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. A soft hyphen is only broken at when its rendered hyphen fits on the @@ -271,10 +272,17 @@ public static IEnumerable NominalWordWrap(this string text, float wrapWi /// private const char SoftHyphen = '­'; + /// + /// The characters that force a line break (Unicode line-break classes BK and NL in UAX #14) once CR and CRLF have been + /// normalized to LF: line feed, vertical tab, form feed, next line (U+0085), line separator (U+2028) and paragraph + /// separator (U+2029). + /// + private static readonly char[] MandatoryLineBreaks = ['\n', '\v', '\f', '\u0085', '\u2028', '\u2029']; + private static IEnumerable NominalWordWrapIterator(string text, int maxCharsPerLine) { string normalized = text.NormalizeLineEndings(LineEndingStyle.Unix); - string[] segments = normalized.Split('\n'); + string[] segments = normalized.Split(MandatoryLineBreaks); foreach (string segment in segments) {