From 0cc88a962998d6c64af9c7b3781bee6342b5e112 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:37:37 +0000 Subject: [PATCH 1/2] Keep a hard-broken hyphen on the line with the rest of its word [patch] When a chunk like "self-" was exactly one character too wide, the hard break left only its trailing hyphen on the new line, and the next chunk of the word then broke before itself, so "self-explanatory" at width 4 wrapped to "self" / "-" / "expl" / ... A line left holding only that hyphen run now takes the next chunk of the same word and hard-breaks from there: "self" / "-exp" / "lana" / "tory". Breaks at spaces and soft hyphens, and the width-1 output, are unchanged. Fixes ktsu-dev/Extensions#182 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5jAJ5C3wivQLYNkMm --- Extensions.Test/StringExtensionsTests.cs | 20 +++++++++ Extensions/StringExtensions.cs | 56 +++++++++++++++++++----- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index bb90006..cb55ac4 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -578,6 +578,26 @@ public void NominalWordWrapPrefersHyphenBoundaryOverHardBreak() CollectionAssert.AreEqual(new List { "foobar-", "bazqux" }, result); } + [TestMethod] + [DataRow("self-explanatory", 4f, new[] { "self", "-exp", "lana", "tory" })] + [DataRow("abcde-fghij", 5f, new[] { "abcde", "-fghi", "j" })] + [DataRow("well-known e-mail", 4f, new[] { "well", "-kno", "wn", "e-", "mail" })] + public void NominalWordWrapNeverLeavesALineOfOnlyHyphens(string input, float maxChars, string[] expected) + { + // A hard break that cuts only the trailing hyphen off a chunk keeps the next chunk of the word on the hyphen's line (#182). + List result = [.. input.NominalWordWrap(maxChars, 1f)]; + CollectionAssert.AreEqual(expected, result); + Assert.IsFalse(result.Any(line => line.Length > 0 && line.Trim('-').Length == 0), "No line should hold only hyphens."); + Assert.IsFalse(result.Any(line => line.Length > maxChars), "No line should be wider than the width."); + } + + [TestMethod] + public void NominalWordWrapAtWidthOneKeepsTheHyphenOnItsOwnLine() + { + List result = [.. "x-ray".NominalWordWrap(1f, 1f)]; + CollectionAssert.AreEqual(new List { "x", "-", "r", "a", "y" }, result); + } + [TestMethod] public void NominalWordWrapDoesNotBreakOnUnderscores() { diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index e42e345..ca59964 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -292,10 +292,24 @@ private static IEnumerable NominalWordWrapIterator(string text, int maxC // The atoms on the current line after its first, with the line length before each (and its separator), so a // break that would not leave room for a rendered hyphen can fall back to an earlier boundary. List<(int Index, int StartLength)> lineAtoms = []; + + // Whether the current line is only the hyphen run a hard break cut off the end of a chunk ("self-" at width 4 + // leaves "-"). The boundary after it is not taken, so the line never ends up holding nothing but hyphens. + bool lineIsHyphenRemainder = false; for (int i = 0, next; i < atoms.Count; i = next) { next = i + 1; (string atomText, bool glueSpace, bool hyphenBefore, bool canBreak) = atoms[i]; + bool joinRemainder = lineIsHyphenRemainder && !glueSpace; + lineIsHyphenRemainder = false; + if (joinRemainder) + { + // The next chunk of the same word continues on the hyphen's line, and is hard-broken from there as usual. + atomText = line.ToString() + atomText; + line.Clear(); + lineAtoms.Clear(); + } + bool atLineStart = line.Length == 0; int separatorLength = (!atLineStart && glueSpace) ? 1 : 0; int projectedLength = line.Length + separatorLength + atomText.Length; @@ -347,21 +361,14 @@ private static IEnumerable NominalWordWrapIterator(string text, int maxC } // At the start of a fresh line: place the atom, hard-breaking it if it still cannot fit. - string remaining = atomText; - while (remaining.Length > maxCharsPerLine) + List fullLines = HardBreak(atomText, maxCharsPerLine, out string remaining); + foreach (string fullLine in fullLines) { - 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); + yield return fullLine; } line.Append(remaining); + lineIsHyphenRemainder = fullLines.Count > 0 && remaining.Length > 0 && remaining.Trim('-').Length == 0; } if (line.Length > 0) @@ -371,6 +378,33 @@ private static IEnumerable NominalWordWrapIterator(string text, int maxC } } + /// + /// Cuts text that is wider than a line into full lines, never splitting a surrogate pair. + /// + /// The text to place at the start of a line. + /// The line width in characters. + /// The text left over for the last, partly filled line. + /// The full lines cut from the start of the text, in order. + private static List HardBreak(string text, int maxCharsPerLine, out string remaining) + { + List fullLines = []; + remaining = text; + while (remaining.Length > 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; + } + + fullLines.Add(remaining.Substring(0, cut)); + remaining = remaining.Substring(cut); + } + + return fullLines; + } + private static List<(string Text, bool GlueSpace, bool HyphenBefore, bool CanBreak)> BuildAtoms(string segment) { List<(string Text, bool GlueSpace, bool HyphenBefore, bool CanBreak)> atoms = []; From 9971a489f4afed5bb3322a34d3a870dec5dd024a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:40:32 +0000 Subject: [PATCH 2/2] Drop a redundant ToString in the hyphen-remainder join Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5jAJ5C3wivQLYNkMm --- Extensions/StringExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index ca59964..f834384 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -305,7 +305,7 @@ private static IEnumerable NominalWordWrapIterator(string text, int maxC if (joinRemainder) { // The next chunk of the same word continues on the hyphen's line, and is hard-broken from there as usual. - atomText = line.ToString() + atomText; + atomText = line + atomText; line.Clear(); lineAtoms.Clear(); }