diff --git a/Extensions.Test/StringExtensionsTests.cs b/Extensions.Test/StringExtensionsTests.cs index 57b8468..bea02fa 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)]; + Assert.AreSequenceEqual(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); }