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..f834384 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 + 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 = [];