From 8832f19233f1a0dc8cda96f29d7cd53de2de466a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:33:42 +0000 Subject: [PATCH 1/2] Stop NominalWordWrap wrapping one character early at exact widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line length is floor(wrapWidth / glyphWidth) in double. Glyph widths such as 0.7f are not exact in binary, so a width of exactly n glyphs often divides to n - ε and floors to n - 1: "hello world" at 11 × 0.7 wrapped onto two lines. About 45% of exact multiples were one short for every glyph width tried. A relative tolerance of 1e-6 is added before flooring. It absorbs the representation error without rounding up a width that is half a glyph short. The int.MaxValue clamp is unchanged. Fixes #171 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TUnnjk1kZxBU7o2kbNsSaP --- Extensions.Test/StringExtensionsTests.cs | 38 ++++++++++++++++++++++++ Extensions/StringExtensions.cs | 7 ++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index 57b8468..2bc9b23 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -449,6 +449,44 @@ public void NominalWordWrapClampsToAtLeastOneCharacterPerLine() CollectionAssert.AreEqual(new List { "a", "b" }, result); } + [TestMethod] + [DataRow(0.1f)] + [DataRow(0.7f)] + [DataRow(6.6f)] + [DataRow(7.2f)] + [DataRow(13.3f)] + public void NominalWordWrapFitsExactlyNGlyphsInAWidthOfNGlyphs(float nominalGlyphWidth) + { + string input = new('a', 201); + + for (int n = 1; n <= 200; n++) + { + string firstLine = input.NominalWordWrap(n * nominalGlyphWidth, nominalGlyphWidth).First(); + Assert.AreEqual(n, firstLine.Length, $"A width of {n} glyphs of {nominalGlyphWidth} should hold {n} characters."); + } + } + + [TestMethod] + public void NominalWordWrapKeepsAWordThatExactlyFillsTheWidthOnOneLine() + { + List result = [.. "hello world".NominalWordWrap(11 * 0.7f, 0.7f)]; + CollectionAssert.AreEqual(new List { "hello world" }, result); + } + + [TestMethod] + [DataRow(0.7f)] + [DataRow(6.6f)] + public void NominalWordWrapDoesNotRoundUpAWidthHalfAGlyphShort(float nominalGlyphWidth) + { + string input = new('a', 201); + + for (int n = 1; n < 200; n++) + { + string firstLine = input.NominalWordWrap((n + 0.5f) * nominalGlyphWidth, nominalGlyphWidth).First(); + Assert.AreEqual(n, firstLine.Length, $"A width of {n + 0.5f} glyphs of {nominalGlyphWidth} should hold {n} characters."); + } + } + [TestMethod] public void NominalWordWrapHardBreakDoesNotSplitSurrogatePairs() { diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index 9ecb5f8..2f53e6d 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -253,9 +253,14 @@ public static IEnumerable NominalWordWrap(this string text, float wrapWi return []; } + // A width of exactly n glyphs often divides to n - ε, because values like 0.7f are not exact in binary, so + // flooring the raw quotient wraps one character early. A small relative tolerance absorbs that error without + // rounding up a width that falls genuinely short of the next glyph. + double raw = (double)wrapWidth / nominalGlyphWidth; + // Clamp before casting: float-to-int conversion does not saturate before .NET 9, so an infinite or huge ratio // would otherwise become int.MinValue and wrap one character per line. - double ratio = Math.Floor((double)wrapWidth / nominalGlyphWidth); + double ratio = Math.Floor(raw + (raw * 1e-6)); int maxCharsPerLine = ratio >= int.MaxValue ? int.MaxValue : Math.Max(1, (int)ratio); return NominalWordWrapIterator(text, maxCharsPerLine); } From ee2db1c6efb3905bfedbcec36ae005b3ffa8b361 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:46:59 +0000 Subject: [PATCH 2/2] Use Assert.AreSequenceEqual in the exact-fill word-wrap test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TUnnjk1kZxBU7o2kbNsSaP --- Extensions.Test/StringExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index 2bc9b23..bea02fa 100644 --- a/Extensions.Test/StringExtensionsTests.cs +++ b/Extensions.Test/StringExtensionsTests.cs @@ -470,7 +470,7 @@ public void NominalWordWrapFitsExactlyNGlyphsInAWidthOfNGlyphs(float nominalGlyp public void NominalWordWrapKeepsAWordThatExactlyFillsTheWidthOnOneLine() { List result = [.. "hello world".NominalWordWrap(11 * 0.7f, 0.7f)]; - CollectionAssert.AreEqual(new List { "hello world" }, result); + Assert.AreSequenceEqual(new List { "hello world" }, result); } [TestMethod]