From ba825420809cb9534e0962ccd1e15201594aedbc Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 18 Dec 2024 23:04:27 +1000
Subject: [PATCH 01/11] Refactor line breaking

---
 src/SixLabors.Fonts/TextLayout.cs             | 98 +++++--------------
 .../SixLabors.Fonts.Tests/Issues/Issues_33.cs |  4 +-
 .../Issues/Issues_431.cs                      |  4 +-
 .../Issues/Issues_434.cs                      | 31 ++++++
 .../SixLabors.Fonts.Tests/TextLayoutTests.cs  | 33 +++++++
 5 files changed, 91 insertions(+), 79 deletions(-)
 create mode 100644 tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs

diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index 976b00ff..9085bd58 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -910,6 +910,7 @@ private static TextBox BreakLines(
         }
 
         int lineBreakIndex = 0;
+        int maxLineBreakIndex = lineBreaks.Count - 1;
         LineBreak lastLineBreak = lineBreaks[lineBreakIndex];
         LineBreak currentLineBreak = lineBreaks[lineBreakIndex];
         int graphemeIndex;
@@ -1089,7 +1090,6 @@ VerticalOrientationType.Rotate or
                 {
                     float scaleAX = pointSize / glyph.ScaleFactor.X;
                     glyphAdvance *= scaleAX;
-
                     for (int i = 0; i < decomposedAdvances.Length; i++)
                     {
                         decomposedAdvances[i] *= scaleAX;
@@ -1106,71 +1106,43 @@ VerticalOrientationType.Rotate or
                 }
 
                 // Should we start a new line?
-                bool requiredBreak = false;
-                if (graphemeCodePointIndex == 0)
+                if (graphemeCodePointIndex == 0 && textLine.Count > 0)
                 {
-                    // Mandatory wrap at index.
-                    if (currentLineBreak.PositionWrap == codePointIndex && currentLineBreak.Required)
+                    if (codePointIndex == currentLineBreak.PositionWrap && currentLineBreak.Required)
                     {
+                        // Mandatory line break at index.
                         textLines.Add(textLine.Finalize());
                         glyphCount += textLine.Count;
                         textLine = new();
                         lineAdvance = 0;
-                        requiredBreak = true;
                     }
                     else if (shouldWrap && lineAdvance + glyphAdvance >= wrappingLength)
                     {
-                        // Forced wordbreak
-                        if (breakAll && textLine.Count > 0)
+                        if (breakAll)
                         {
+                            // Insert a forced break at this index.
                             textLines.Add(textLine.Finalize());
                             glyphCount += textLine.Count;
                             textLine = new();
                             lineAdvance = 0;
                         }
-                        else if (currentLineBreak.PositionMeasure == codePointIndex)
-                        {
-                            // Exact length match. Check for CJK
-                            if (keepAll)
-                            {
-                                TextLine split = textLine.SplitAt(lastLineBreak, keepAll);
-                                if (split != textLine)
-                                {
-                                    textLines.Add(textLine.Finalize());
-                                    textLine = split;
-                                    lineAdvance = split.ScaledLineAdvance;
-                                }
-                            }
-                            else if (textLine.Count > 0)
-                            {
-                                textLines.Add(textLine.Finalize());
-                                glyphCount += textLine.Count;
-                                textLine = new();
-                                lineAdvance = 0;
-                            }
-                        }
-                        else if (currentLineBreak.PositionWrap == codePointIndex)
+                        else if (codePointIndex == currentLineBreak.PositionWrap)
                         {
-                            // Exact length match. Check for CJK
-                            TextLine split = textLine.SplitAt(currentLineBreak, keepAll);
+                            // Split the line at the last line break.
+                            // CJK characters will not be split if 'keepAll' is true.
+                            TextLine split = textLine.SplitAt(lastLineBreak, keepAll);
                             if (split != textLine)
                             {
                                 textLines.Add(textLine.Finalize());
                                 textLine = split;
                                 lineAdvance = split.ScaledLineAdvance;
                             }
-                            else if (textLine.Count > 0)
-                            {
-                                textLines.Add(textLine.Finalize());
-                                textLine = new();
-                                lineAdvance = 0;
-                            }
                         }
-                        else if (lastLineBreak.PositionWrap < codePointIndex && !CodePoint.IsWhiteSpace(codePoint))
+                        else if (breakWord)
                         {
-                            // Split the current text line into two at the last wrapping point if the current glyph
-                            // does not represent whitespace. Whitespace characters will be correctly trimmed at the
-                            // next iteration.
+                            // We have to do more work here and check each exceeding codepoint.
+                            // If we can split the line at the last line break, use that, otherwise
+                            // we have to insert a break at the current index.
                             TextLine split = textLine.SplitAt(lastLineBreak, keepAll);
                             if (split != textLine)
                             {
@@ -1178,54 +1150,26 @@ VerticalOrientationType.Rotate or
                                 textLine = split;
                                 lineAdvance = split.ScaledLineAdvance;
                             }
-                            else if (breakWord && textLine.Count > 0)
+                            else
                             {
+                                // Insert a forced break at this index.
                                 textLines.Add(textLine.Finalize());
                                 glyphCount += textLine.Count;
                                 textLine = new();
                                 lineAdvance = 0;
                             }
                         }
-                        else if (breakWord && textLine.Count > 0)
-                        {
-                            textLines.Add(textLine.Finalize());
-                            glyphCount += textLine.Count;
-                            textLine = new();
-                            lineAdvance = 0;
-                        }
                     }
                 }
 
                 // Find the next line break.
-                if (currentLineBreak.PositionWrap == codePointIndex)
+                if (lineBreakIndex < maxLineBreakIndex &&
+                    (currentLineBreak.PositionWrap == codePointIndex))
                 {
                     lastLineBreak = currentLineBreak;
                     currentLineBreak = lineBreaks[++lineBreakIndex];
                 }
 
-                // Do not start a line following a break with breaking whitespace
-                // unless the break was required.
-                if (textLine.Count == 0
-                    && textLines.Count > 0
-                    && !requiredBreak
-                    && CodePoint.IsWhiteSpace(codePoint)
-                    && !CodePoint.IsNonBreakingSpace(codePoint)
-                    && !CodePoint.IsTabulation(codePoint)
-                    && !CodePoint.IsNewLine(codePoint))
-                {
-                    codePointIndex++;
-                    graphemeCodePointIndex++;
-                    continue;
-                }
-
-                if (textLine.Count > 0 && CodePoint.IsNewLine(codePoint))
-                {
-                    // Do not add new lines unless at position zero.
-                    codePointIndex++;
-                    graphemeCodePointIndex++;
-                    continue;
-                }
-
                 // For non-decomposed glyphs the length is always 1.
                 for (int i = 0; i < decomposedAdvances.Length; i++)
                 {
@@ -1467,7 +1411,11 @@ private void TrimTrailingWhitespaceAndRecalculateMetrics()
             this.ScaledMaxLineHeight = lineHeight;
         }
 
-        public TextLine Finalize() => this.BidiReOrder();
+        public TextLine Finalize()
+        {
+            this.TrimTrailingWhitespaceAndRecalculateMetrics();
+            return this.BidiReOrder();
+        }
 
         public void Justify(TextOptions options)
         {
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs
index c5c40b5a..6e7f5d14 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs
@@ -13,8 +13,8 @@ public class Issues_33
     [InlineData("\n\tHelloworld", 310, 10)]
     [InlineData("\tHelloworld", 310, 10)]
     [InlineData("  Helloworld", 340, 10)]
-    [InlineData("Hell owor ld\t", 390, 10)]
-    [InlineData("Helloworld  ", 360, 10)]
+    [InlineData("Hell owor ld\t", 340, 10)]
+    [InlineData("Helloworld  ", 280, 10)]
     public void WhiteSpaceAtStartOfLineNotMeasured(string text, float width, float height)
     {
         Font font = CreateFont(text);
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
index ec47bbd4..5b3bcc6f 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
@@ -22,10 +22,10 @@ public void ShouldNotInsertExtraLineBreaks()
             };
 
             int lineCount = TextMeasurer.CountLines(text, options);
-            Assert.Equal(4, lineCount);
+            Assert.Equal(3, lineCount);
 
             IReadOnlyList<GlyphLayout> layout = TextLayout.GenerateLayout(text, options);
-            Assert.Equal(46, layout.Count);
+            Assert.Equal(47, layout.Count);
         }
     }
 }
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs
new file mode 100644
index 00000000..e0a0f4ea
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+
+namespace SixLabors.Fonts.Tests.Issues;
+
+public class Issues_434
+{
+    [Theory]
+    [InlineData("- Lorem ipsullll\n\ndolor sit amet\n-consectetur elit", 3)]
+    [InlineData("- Lorem ipsullll\n\n\ndolor sit amet\n-consectetur elit", 3)]
+    public void ShouldNotInsertExtraLineBreaks(string text, int expectedLineCount)
+    {
+        if (SystemFonts.TryGet("Arial", out FontFamily family))
+        {
+            Font font = family.CreateFont(60);
+            TextOptions options = new(font)
+            {
+                Origin = new Vector2(50, 20),
+                WrappingLength = 400,
+            };
+
+            int lineCount = TextMeasurer.CountLines(text, options);
+            Assert.Equal(expectedLineCount, lineCount);
+
+            IReadOnlyList<GlyphLayout> layout = TextLayout.GenerateLayout(text, options);
+            Assert.Equal(47, layout.Count);
+        }
+    }
+}
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
index dff3522a..51a86826 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
@@ -373,6 +373,39 @@ public void MeasureTextWordWrappingVerticalMixedLeftRight(string text, float hei
     }
 
 #if OS_WINDOWS
+    [Theory]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.Standard, 100, 870)]
+    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakAll, 120, 399)]
+    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakWord, 120, 400)]
+    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.KeepAll, 60, 699)]
+    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.Standard, 101, 870)]
+    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakAll, 121, 399)]
+    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakWord, 121, 400)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.KeepAll, 61, 699)]
+    public void MeasureTextWordBreakMatchesMDN(string text, LayoutMode layoutMode, WordBreaking wordBreaking, float height, float width)
+    {
+        // Testing using Windows only to ensure that actual glyphs are rendered
+        // against known physically tested values.
+        FontFamily arial = SystemFonts.Get("Arial");
+        FontFamily jhengHei = SystemFonts.Get("Microsoft JhengHei");
+
+        Font font = arial.CreateFont(16);
+        FontRectangle size = TextMeasurer.MeasureAdvance(
+            text,
+            new TextOptions(font)
+            {
+                Dpi = 96,
+                WrappingLength = 238,
+                LayoutMode = layoutMode,
+                WordBreaking = wordBreaking,
+                FallbackFontFamilies = new[] { jhengHei }
+            });
+
+        Assert.Equal(width, size.Width, 4F);
+        Assert.Equal(height, size.Height, 4F);
+    }
+
+
     [Theory]
     [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.Standard, 100, 870)]
     [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakAll, 120, 399)]

From a52a7a54af5e96320b7bcc0bba60a735344497ea Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Thu, 19 Dec 2024 21:52:20 +1000
Subject: [PATCH 02/11] Move line breaking after bidi

---
 src/SixLabors.Fonts/TextLayout.cs             | 296 ++++++++++--------
 ...Length_wrappingLength_100-usedLines_7_.png |   3 +
 ...Length_wrappingLength_200-usedLines_6_.png |   3 +
 ...gLength_wrappingLength_25-usedLines_7_.png |   3 +
 ...gLength_wrappingLength_50-usedLines_7_.png |   3 +
 .../ImageComparison/ExactImageComparer.cs     |  58 ++++
 ...ImageDifferenceIsOverThresholdException.cs |  63 ++++
 .../ImageDimensionsMismatchException.cs       |  20 ++
 .../Exceptions/ImagesSimilarityException.cs   |  14 +
 .../ImageComparison/ImageComparer.cs          | 177 +++++++++++
 .../ImageComparison/ImageSimilarityReport.cs  | 110 +++++++
 .../ImageComparison/PixelDifference.cs        |  47 +++
 .../ImageComparison/TestImageExtensions.cs    |  82 +++++
 .../ImageComparison/TolerantImageComparer.cs  | 118 +++++++
 .../SixLabors.Fonts.Tests.csproj              |   1 +
 .../SixLabors.Fonts.Tests/TestEnvironment.cs  |  53 +++-
 .../SixLabors.Fonts.Tests/TextLayoutTests.cs  |  30 +-
 17 files changed, 930 insertions(+), 151 deletions(-)
 create mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png
 create mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png
 create mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png
 create mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/ExactImageComparer.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDifferenceIsOverThresholdException.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDimensionsMismatchException.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImagesSimilarityException.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/ImageComparer.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/ImageSimilarityReport.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/PixelDifference.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
 create mode 100644 tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs

diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index 9085bd58..0274e2e8 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -901,24 +901,11 @@ private static TextBox BreakLines(
         bool isVerticalLayout = layoutMode.IsVertical();
         bool isVerticalMixedLayout = layoutMode.IsVerticalMixed();
 
-        // Calculate the position of potential line breaks.
-        LineBreakEnumerator lineBreakEnumerator = new(text);
-        List<LineBreak> lineBreaks = new();
-        while (lineBreakEnumerator.MoveNext())
-        {
-            lineBreaks.Add(lineBreakEnumerator.Current);
-        }
-
-        int lineBreakIndex = 0;
-        int maxLineBreakIndex = lineBreaks.Count - 1;
-        LineBreak lastLineBreak = lineBreaks[lineBreakIndex];
-        LineBreak currentLineBreak = lineBreaks[lineBreakIndex];
         int graphemeIndex;
         int codePointIndex = 0;
         float lineAdvance = 0;
         List<TextLine> textLines = new();
         TextLine textLine = new();
-        int glyphCount = 0;
         int stringIndex = 0;
 
         // No glyph should contain more than 64 metrics.
@@ -1105,71 +1092,6 @@ VerticalOrientationType.Rotate or
                     }
                 }
 
-                // Should we start a new line?
-                if (graphemeCodePointIndex == 0 && textLine.Count > 0)
-                {
-                    if (codePointIndex == currentLineBreak.PositionWrap && currentLineBreak.Required)
-                    {
-                        // Mandatory line break at index.
-                        textLines.Add(textLine.Finalize());
-                        glyphCount += textLine.Count;
-                        textLine = new();
-                        lineAdvance = 0;
-                    }
-                    else if (shouldWrap && lineAdvance + glyphAdvance >= wrappingLength)
-                    {
-                        if (breakAll)
-                        {
-                            // Insert a forced break at this index.
-                            textLines.Add(textLine.Finalize());
-                            glyphCount += textLine.Count;
-                            textLine = new();
-                            lineAdvance = 0;
-                        }
-                        else if (codePointIndex == currentLineBreak.PositionWrap)
-                        {
-                            // Split the line at the last line break.
-                            // CJK characters will not be split if 'keepAll' is true.
-                            TextLine split = textLine.SplitAt(lastLineBreak, keepAll);
-                            if (split != textLine)
-                            {
-                                textLines.Add(textLine.Finalize());
-                                textLine = split;
-                                lineAdvance = split.ScaledLineAdvance;
-                            }
-                        }
-                        else if (breakWord)
-                        {
-                            // We have to do more work here and check each exceeding codepoint.
-                            // If we can split the line at the last line break, use that, otherwise
-                            // we have to insert a break at the current index.
-                            TextLine split = textLine.SplitAt(lastLineBreak, keepAll);
-                            if (split != textLine)
-                            {
-                                textLines.Add(textLine.Finalize());
-                                textLine = split;
-                                lineAdvance = split.ScaledLineAdvance;
-                            }
-                            else
-                            {
-                                // Insert a forced break at this index.
-                                textLines.Add(textLine.Finalize());
-                                glyphCount += textLine.Count;
-                                textLine = new();
-                                lineAdvance = 0;
-                            }
-                        }
-                    }
-                }
-
-                // Find the next line break.
-                if (lineBreakIndex < maxLineBreakIndex &&
-                    (currentLineBreak.PositionWrap == codePointIndex))
-                {
-                    lastLineBreak = currentLineBreak;
-                    currentLineBreak = lineBreaks[++lineBreakIndex];
-                }
-
                 // For non-decomposed glyphs the length is always 1.
                 for (int i = 0; i < decomposedAdvances.Length; i++)
                 {
@@ -1203,6 +1125,7 @@ VerticalOrientationType.Rotate or
                         bidiRuns[bidiMap[codePointIndex]],
                         graphemeIndex,
                         codePointIndex,
+                        graphemeCodePointIndex,
                         shouldRotate || shouldOffset,
                         isDecomposed,
                         stringIndex);
@@ -1215,6 +1138,93 @@ VerticalOrientationType.Rotate or
             stringIndex += graphemeEnumerator.Current.Length;
         }
 
+        // Resolve the bidi order for the line.
+        // This reorders the glyphs in the line to match the visual order.
+        textLine.BidiReOrder();
+
+        // Now we need to loop through our reordered line and split it at any line breaks.
+        //
+        // First calculate the position of potential line breaks.
+        LineBreakEnumerator lineBreakEnumerator = new(text);
+        List<LineBreak> lineBreaks = new();
+        while (lineBreakEnumerator.MoveNext())
+        {
+            lineBreaks.Add(lineBreakEnumerator.Current);
+        }
+
+        // Then split the line at the line breaks.
+        int lineBreakIndex = 0;
+        int maxLineBreakIndex = lineBreaks.Count - 1;
+        LineBreak lastLineBreak = lineBreaks[lineBreakIndex];
+        LineBreak currentLineBreak = lineBreaks[lineBreakIndex];
+
+        lineAdvance = 0;
+        for (int i = 0; i < textLine.Count; i++)
+        {
+            int max = textLine.Count - 1;
+            TextLine.GlyphLayoutData glyph = textLine[i];
+            codePointIndex = glyph.CodePointIndex;
+            int graphemeCodePointIndex = glyph.GraphemeCodePointIndex;
+            float glyphAdvance = glyph.ScaledAdvance;
+            lineAdvance += glyphAdvance;
+
+            if (graphemeCodePointIndex == 0 && textLine.Count > 0)
+            {
+                if (codePointIndex == currentLineBreak.PositionWrap && currentLineBreak.Required)
+                {
+                    // Mandatory line break at index.
+                    TextLine remaining = textLine.SplitAt(i);
+                    textLines.Add(textLine.Finalize());
+                    textLine = remaining;
+                    i = 0;
+                    lineAdvance = 0;
+                }
+                else if (shouldWrap && lineAdvance + glyphAdvance >= wrappingLength)
+                {
+                    if (breakAll)
+                    {
+                        // Insert a forced break at this index.
+                        TextLine remaining = textLine.SplitAt(i);
+                        textLines.Add(textLine.Finalize());
+                        textLine = remaining;
+                        i = 0;
+                        lineAdvance = 0;
+                    }
+                    else if (codePointIndex == currentLineBreak.PositionWrap || i == max)
+                    {
+                        // If we are at the position wrap we can break here.
+                        // Split the line at the last line break.
+                        // CJK characters will not be split if 'keepAll' is true.
+                        TextLine remaining = textLine.SplitAt(lastLineBreak, keepAll);
+                        if (remaining != textLine)
+                        {
+                            textLines.Add(textLine.Finalize());
+                            textLine = remaining;
+                            i = 0;
+                            lineAdvance = 0;
+                        }
+                    }
+                    else if (breakWord)
+                    {
+                        // Insert a forced break at this index.
+                        TextLine remaining = textLine.SplitAt(i);
+                        textLines.Add(textLine.Finalize());
+                        textLine = remaining;
+                        i = 0;
+                        lineAdvance = 0;
+                    }
+                }
+            }
+
+            // Find the next line break.
+            if (lineBreakIndex < maxLineBreakIndex &&
+                (currentLineBreak.PositionWrap == codePointIndex))
+            {
+                lastLineBreak = currentLineBreak;
+                currentLineBreak = lineBreaks[++lineBreakIndex];
+            }
+        }
+
         // Add the final line.
         if (textLine.Count > 0)
         {
@@ -1245,7 +1255,11 @@ public float ScaledMaxAdvance()
 
     internal sealed class TextLine
     {
-        private readonly List<GlyphLayoutData> data = new();
+        private readonly List<GlyphLayoutData> data;
+
+        public TextLine() => this.data = new(16);
+
+        public TextLine(int capacity) => this.data = new(capacity);
 
         public int Count => this.data.Count;
 
@@ -1268,7 +1282,8 @@ public void Add(
             float scaledDescender,
             BidiRun bidiRun,
             int graphemeIndex,
-            int offset,
+            int codePointIndex,
+            int graphemeCodePointIndex,
             bool isTransformed,
             bool isDecomposed,
             int stringIndex)
@@ -1289,12 +1304,24 @@ public void Add(
                 scaledDescender,
                 bidiRun,
                 graphemeIndex,
-                offset,
+                codePointIndex,
+                graphemeCodePointIndex,
                 isTransformed,
                 isDecomposed,
                 stringIndex));
         }
 
+        public TextLine SplitAt(int index)
+        {
+            TextLine result = new();
+            result.data.AddRange(this.data.GetRange(index, this.data.Count - index));
+            RecalculateLineMetrics(result);
+
+            this.data.RemoveRange(index, this.data.Count - index);
+            RecalculateLineMetrics(this);
+            return result;
+        }
+
         public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
         {
             int index = this.data.Count;
@@ -1302,8 +1329,7 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
             while (index > 0)
             {
                 glyphWrap = this.data[--index];
-
-                if (glyphWrap.Offset == lineBreak.PositionWrap)
+                if (glyphWrap.CodePointIndex == lineBreak.PositionWrap)
                 {
                     break;
                 }
@@ -1313,13 +1339,13 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
             {
                 // Now trim trailing whitespace from this line in the case of an exact
                 // length line break (non CJK)
-                this.TrimTrailingWhitespaceAndRecalculateMetrics();
+                RecalculateLineMetrics(this);
                 return this;
             }
 
             // Word breaks should not be used for Chinese/Japanese/Korean (CJK) text
             // when word-breaking mode is keep-all.
-            if (keepAll && UnicodeUtility.IsCJKCodePoint((uint)glyphWrap.CodePoint.Value))
+            if (!lineBreak.Required && keepAll && UnicodeUtility.IsCJKCodePoint((uint)glyphWrap.CodePoint.Value))
             {
                 // Loop through previous glyphs to see if there is
                 // a non CJK codepoint we can break at.
@@ -1337,48 +1363,35 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
                 {
                     // Now trim trailing whitespace from this line in the case of an exact
                     // length line break (non CJK)
-                    this.TrimTrailingWhitespaceAndRecalculateMetrics();
+                    RecalculateLineMetrics(this);
                     return this;
                 }
             }
 
             // Create a new line ensuring we capture the initial metrics.
-            TextLine result = new();
-            result.data.AddRange(this.data.GetRange(index, this.data.Count - index));
-
-            float advance = 0;
-            float ascender = 0;
-            float descender = 0;
-            float lineHeight = 0;
-            for (int i = 0; i < result.data.Count; i++)
-            {
-                GlyphLayoutData glyph = result.data[i];
-                advance += glyph.ScaledAdvance;
-                ascender = MathF.Max(ascender, glyph.ScaledAscender);
-                descender = MathF.Max(descender, glyph.ScaledDescender);
-                lineHeight = MathF.Max(lineHeight, glyph.ScaledLineHeight);
-            }
-
-            result.ScaledLineAdvance = advance;
-            result.ScaledMaxAscender = ascender;
-            result.ScaledMaxDescender = descender;
-            result.ScaledMaxLineHeight = lineHeight;
+            int count = this.data.Count - index;
+            TextLine result = new(count);
+            result.data.AddRange(this.data.GetRange(index, count));
+            RecalculateLineMetrics(result);
 
             // Remove those items from this line.
-            this.data.RemoveRange(index, this.data.Count - index);
+            this.data.RemoveRange(index, count);
 
             // Now trim trailing whitespace from this line.
-            this.TrimTrailingWhitespaceAndRecalculateMetrics();
+            RecalculateLineMetrics(this);
+            // this.TrimTrailingWhitespaceAndRecalculateMetrics();
 
             return result;
         }
 
-        private void TrimTrailingWhitespaceAndRecalculateMetrics()
+        private TextLine TrimTrailingWhitespaceAndRecalculateMetrics()
         {
             int index = this.data.Count;
             while (index > 0)
             {
-                if (!CodePoint.IsWhiteSpace(this.data[index - 1].CodePoint))
+                // Trim trailing breaking whitespace.
+                CodePoint point = this.data[index - 1].CodePoint;
+                if (!CodePoint.IsWhiteSpace(point) || CodePoint.IsNonBreakingSpace(point))
                 {
                     break;
                 }
@@ -1391,31 +1404,12 @@ private void TrimTrailingWhitespaceAndRecalculateMetrics()
                 this.data.RemoveRange(index, this.data.Count - index);
             }
 
-            // Lastly recalculate this line metrics.
-            float advance = 0;
-            float ascender = 0;
-            float descender = 0;
-            float lineHeight = 0;
-            for (int i = 0; i < this.data.Count; i++)
-            {
-                GlyphLayoutData glyph = this.data[i];
-                advance += glyph.ScaledAdvance;
-                ascender = MathF.Max(ascender, glyph.ScaledAscender);
-                descender = MathF.Max(descender, glyph.ScaledDescender);
-                lineHeight = MathF.Max(lineHeight, glyph.ScaledLineHeight);
-            }
-
-            this.ScaledLineAdvance = advance;
-            this.ScaledMaxAscender = ascender;
-            this.ScaledMaxDescender = descender;
-            this.ScaledMaxLineHeight = lineHeight;
+            RecalculateLineMetrics(this);
+            return this;
         }
 
         public TextLine Finalize()
-        {
-            this.TrimTrailingWhitespaceAndRecalculateMetrics();
-            return this.BidiReOrder();
-        }
+            => this.TrimTrailingWhitespaceAndRecalculateMetrics();
 
         public void Justify(TextOptions options)
         {
@@ -1489,7 +1483,7 @@ public void Justify(TextOptions options)
             }
         }
 
-        private TextLine BidiReOrder()
+        public void BidiReOrder()
         {
             // Build up the collection of ordered runs.
             BidiRun run = this.data[0].BidiRun;
@@ -1539,7 +1533,7 @@ private TextLine BidiReOrder()
             if (max == 0 || (min == max && (max & 1) == 0))
             {
                 // Nothing to reverse.
-                return this;
+                return;
             }
 
             // Now apply the reversal and replace the original contents.
@@ -1567,8 +1561,28 @@ private TextLine BidiReOrder()
                 this.data.AddRange(current.AsSlice());
                 current = current.Next;
             }
+        }
 
-            return this;
+        private static void RecalculateLineMetrics(TextLine textLine)
+        {
+            // Lastly recalculate this line metrics.
+            float advance = 0;
+            float ascender = 0;
+            float descender = 0;
+            float lineHeight = 0;
+            for (int i = 0; i < textLine.Count; i++)
+            {
+                GlyphLayoutData glyph = textLine[i];
+                advance += glyph.ScaledAdvance;
+                ascender = MathF.Max(ascender, glyph.ScaledAscender);
+                descender = MathF.Max(descender, glyph.ScaledDescender);
+                lineHeight = MathF.Max(lineHeight, glyph.ScaledLineHeight);
+            }
+
+            textLine.ScaledLineAdvance = advance;
+            textLine.ScaledMaxAscender = ascender;
+            textLine.ScaledMaxDescender = descender;
+            textLine.ScaledMaxLineHeight = lineHeight;
         }
 
         /// <summary>
@@ -1644,7 +1658,8 @@ public GlyphLayoutData(
                 float scaledDescender,
                 BidiRun bidiRun,
                 int graphemeIndex,
-                int offset,
+                int codePointIndex,
+                int graphemeCodePointIndex,
                 bool isTransformed,
                 bool isDecomposed,
                 int stringIndex)
@@ -1657,7 +1672,8 @@ public GlyphLayoutData(
                 this.ScaledDescender = scaledDescender;
                 this.BidiRun = bidiRun;
                 this.GraphemeIndex = graphemeIndex;
-                this.Offset = offset;
+                this.CodePointIndex = codePointIndex;
+                this.GraphemeCodePointIndex = graphemeCodePointIndex;
                 this.IsTransformed = isTransformed;
                 this.IsDecomposed = isDecomposed;
                 this.StringIndex = stringIndex;
@@ -1683,7 +1699,9 @@ public GlyphLayoutData(
 
             public int GraphemeIndex { get; }
 
-            public int Offset { get; }
+            public int GraphemeCodePointIndex { get; }
+
+            public int CodePointIndex { get; }
 
             public bool IsTransformed { get; }
 
@@ -1694,7 +1712,7 @@ public GlyphLayoutData(
             public readonly bool IsNewLine => CodePoint.IsNewLine(this.CodePoint);
 
             private readonly string DebuggerDisplay => FormattableString
-                .Invariant($"{this.CodePoint.ToDebuggerDisplay()} : {this.TextDirection} : {this.Offset}, level: {this.BidiRun.Level}");
+                .Invariant($"{this.CodePoint.ToDebuggerDisplay()} : {this.TextDirection} : {this.CodePointIndex}, level: {this.BidiRun.Level}");
         }
 
         private sealed class OrderedBidiRun
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png
new file mode 100644
index 00000000..d30b1f24
--- /dev/null
+++ b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c09ad5c85f708f5cd2135a52b13aa248a130abbc7fe8f0449b44d62f9d360384
+size 4505
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png
new file mode 100644
index 00000000..60fccee3
--- /dev/null
+++ b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5d54e2b3f85b01ee45f25e53c8f97e80ca9d7655ff6b8aa643664912812a3976
+size 4521
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png
new file mode 100644
index 00000000..7407b0cf
--- /dev/null
+++ b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1161af3a0ffc7d4835e58bcfa064713fa06971747414a144c3b979fdabf1bbdd
+size 4855
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png
new file mode 100644
index 00000000..4ec38250
--- /dev/null
+++ b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8a11863fa11c93d158560fadddcb4768047c5c7f0069054cbf9392b5dc234759
+size 4853
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/ExactImageComparer.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/ExactImageComparer.cs
new file mode 100644
index 00000000..f321e849
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/ExactImageComparer.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Memory;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public class ExactImageComparer : ImageComparer
+{
+    public static ExactImageComparer Instance { get; } = new ExactImageComparer();
+
+    public override ImageSimilarityReport<TPixelA, TPixelB> CompareImagesOrFrames<TPixelA, TPixelB>(
+        int index,
+        ImageFrame<TPixelA> expected,
+        ImageFrame<TPixelB> actual)
+    {
+        if (expected.Size != actual.Size)
+        {
+            throw new InvalidOperationException("Calling ImageComparer is invalid when dimensions mismatch!");
+        }
+
+        int width = actual.Width;
+
+        // TODO: Comparing through Rgba64 may not be robust enough because of the existence of super high precision pixel types.
+        Rgba64[] aBuffer = new Rgba64[width];
+        Rgba64[] bBuffer = new Rgba64[width];
+
+        List<PixelDifference> differences = new();
+        Configuration configuration = expected.Configuration;
+        Buffer2D<TPixelA> expectedBuffer = expected.PixelBuffer;
+        Buffer2D<TPixelB> actualBuffer = actual.PixelBuffer;
+
+        for (int y = 0; y < actual.Height; y++)
+        {
+            Span<TPixelA> aSpan = expectedBuffer.DangerousGetRowSpan(y);
+            Span<TPixelB> bSpan = actualBuffer.DangerousGetRowSpan(y);
+
+            PixelOperations<TPixelA>.Instance.ToRgba64(configuration, aSpan, aBuffer);
+            PixelOperations<TPixelB>.Instance.ToRgba64(configuration, bSpan, bBuffer);
+
+            for (int x = 0; x < width; x++)
+            {
+                Rgba64 aPixel = aBuffer[x];
+                Rgba64 bPixel = bBuffer[x];
+
+                if (aPixel != bPixel)
+                {
+                    PixelDifference diff = new(new Point(x, y), aPixel, bPixel);
+                    differences.Add(diff);
+                }
+            }
+        }
+
+        return new ImageSimilarityReport<TPixelA, TPixelB>(index, expected, actual, differences);
+    }
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDifferenceIsOverThresholdException.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDifferenceIsOverThresholdException.cs
new file mode 100644
index 00000000..a3253a8c
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDifferenceIsOverThresholdException.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Globalization;
+using System.Text;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public class ImageDifferenceIsOverThresholdException : ImagesSimilarityException
+{
+    public ImageSimilarityReport[] Reports { get; }
+
+    public ImageDifferenceIsOverThresholdException(params ImageSimilarityReport[] reports)
+        : base("Image difference is over threshold!" + FormatReports(reports))
+        => this.Reports = reports.ToArray();
+
+    private static string FormatReports(IEnumerable<ImageSimilarityReport> reports)
+    {
+        StringBuilder sb = new();
+
+        sb.Append(Environment.NewLine);
+        sb.AppendFormat(CultureInfo.InvariantCulture, "Test Environment OS : {0}", GetEnvironmentName());
+        sb.Append(Environment.NewLine);
+
+        sb.AppendFormat(CultureInfo.InvariantCulture, "Test Environment is CI : {0}", TestEnvironment.RunsOnCI);
+        sb.Append(Environment.NewLine);
+
+        sb.AppendFormat(CultureInfo.InvariantCulture, "Test Environment OS Architecture : {0}", TestEnvironment.OSArchitecture);
+        sb.Append(Environment.NewLine);
+
+        sb.AppendFormat(CultureInfo.InvariantCulture, "Test Environment Process Architecture : {0}", TestEnvironment.ProcessArchitecture);
+        sb.Append(Environment.NewLine);
+
+        foreach (ImageSimilarityReport r in reports)
+        {
+            sb.AppendFormat(CultureInfo.InvariantCulture, "Report ImageFrame {0}: ", r.Index)
+              .Append(r)
+              .Append(Environment.NewLine);
+        }
+
+        return sb.ToString();
+    }
+
+    private static string GetEnvironmentName()
+    {
+        if (TestEnvironment.IsMacOS)
+        {
+            return "MacOS";
+        }
+
+        if (TestEnvironment.IsLinux)
+        {
+            return "Linux";
+        }
+
+        if (TestEnvironment.IsWindows)
+        {
+            return "Windows";
+        }
+
+        return "Unknown";
+    }
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDimensionsMismatchException.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDimensionsMismatchException.cs
new file mode 100644
index 00000000..9cdd5e0e
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImageDimensionsMismatchException.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public class ImageDimensionsMismatchException : ImagesSimilarityException
+{
+    public ImageDimensionsMismatchException(Size expectedSize, Size actualSize)
+        : base($"The image dimensions {actualSize} do not match the expected {expectedSize}!")
+    {
+        this.ExpectedSize = expectedSize;
+        this.ActualSize = actualSize;
+    }
+
+    public Size ExpectedSize { get; }
+
+    public Size ActualSize { get; }
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImagesSimilarityException.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImagesSimilarityException.cs
new file mode 100644
index 00000000..652ce3ef
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/Exceptions/ImagesSimilarityException.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+using System;
+
+public class ImagesSimilarityException : Exception
+{
+    public ImagesSimilarityException(string message)
+        : base(message)
+    {
+    }
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/ImageComparer.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/ImageComparer.cs
new file mode 100644
index 00000000..ae3f6883
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/ImageComparer.cs
@@ -0,0 +1,177 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public abstract class ImageComparer
+{
+    public static ImageComparer Exact { get; } = Tolerant(0, 0);
+
+    /// <summary>
+    /// Returns an instance of <see cref="TolerantImageComparer"/>.
+    /// Individual Manhattan pixel difference is only added to total image difference when the individual difference is over 'perPixelManhattanThreshold'.
+    /// </summary>
+    /// <param name="imageThreshold">The maximal tolerated difference represented by a value between 0 and 1.</param>
+    /// <param name="perPixelManhattanThreshold">Gets the threshold of the individual pixels before they accumulate towards the overall difference.</param>
+    /// <returns>A ImageComparer instance.</returns>
+    public static ImageComparer Tolerant(
+        float imageThreshold = TolerantImageComparer.DefaultImageThreshold,
+        int perPixelManhattanThreshold = 0) =>
+        new TolerantImageComparer(imageThreshold, perPixelManhattanThreshold);
+
+    /// <summary>
+    /// Returns Tolerant(imageThresholdInPercent/100)
+    /// </summary>
+    /// <param name="imageThresholdInPercent">The maximal tolerated difference represented by a value between 0 and 100.</param>
+    /// <param name="perPixelManhattanThreshold">Gets the threshold of the individual pixels before they accumulate towards the overall difference.</param>
+    /// <returns>A ImageComparer instance.</returns>
+    public static ImageComparer TolerantPercentage(float imageThresholdInPercent, int perPixelManhattanThreshold = 0)
+        => Tolerant(imageThresholdInPercent / 100F, perPixelManhattanThreshold);
+
+    public abstract ImageSimilarityReport<TPixelA, TPixelB> CompareImagesOrFrames<TPixelA, TPixelB>(
+        int index,
+        ImageFrame<TPixelA> expected,
+        ImageFrame<TPixelB> actual)
+        where TPixelA : unmanaged, IPixel<TPixelA>
+        where TPixelB : unmanaged, IPixel<TPixelB>;
+}
+
+public static class ImageComparerExtensions
+{
+    public static ImageSimilarityReport<TPixelA, TPixelB> CompareImagesOrFrames<TPixelA, TPixelB>(
+        this ImageComparer comparer,
+        Image<TPixelA> expected,
+        Image<TPixelB> actual)
+        where TPixelA : unmanaged, IPixel<TPixelA>
+        where TPixelB : unmanaged, IPixel<TPixelB> => comparer.CompareImagesOrFrames(0, expected.Frames.RootFrame, actual.Frames.RootFrame);
+
+    public static IEnumerable<ImageSimilarityReport<TPixelA, TPixelB>> CompareImages<TPixelA, TPixelB>(
+        this ImageComparer comparer,
+        Image<TPixelA> expected,
+        Image<TPixelB> actual,
+        Func<int, int, bool> predicate = null)
+        where TPixelA : unmanaged, IPixel<TPixelA>
+        where TPixelB : unmanaged, IPixel<TPixelB>
+    {
+        List<ImageSimilarityReport<TPixelA, TPixelB>> result = new();
+
+        int expectedFrameCount = actual.Frames.Count;
+        if (predicate != null)
+        {
+            expectedFrameCount = 0;
+            for (int i = 0; i < actual.Frames.Count; i++)
+            {
+                if (predicate(i, actual.Frames.Count))
+                {
+                    expectedFrameCount++;
+                }
+            }
+        }
+
+        if (expected.Frames.Count != expectedFrameCount)
+        {
+            throw new ImagesSimilarityException("Frame count does not match!");
+        }
+
+        for (int i = 0; i < expected.Frames.Count; i++)
+        {
+            if (predicate != null && !predicate(i, expected.Frames.Count))
+            {
+                continue;
+            }
+
+            ImageSimilarityReport<TPixelA, TPixelB> report = comparer.CompareImagesOrFrames(i, expected.Frames[i], actual.Frames[i]);
+            if (!report.IsEmpty)
+            {
+                result.Add(report);
+            }
+        }
+
+        return result;
+    }
+
+    public static void VerifySimilarity<TPixelA, TPixelB>(
+        this ImageComparer comparer,
+        Image<TPixelA> expected,
+        Image<TPixelB> actual,
+        Func<int, int, bool> predicate = null)
+        where TPixelA : unmanaged, IPixel<TPixelA>
+        where TPixelB : unmanaged, IPixel<TPixelB>
+    {
+        if (expected.Size != actual.Size)
+        {
+            throw new ImageDimensionsMismatchException(expected.Size, actual.Size);
+        }
+
+        int expectedFrameCount = actual.Frames.Count;
+        if (predicate != null)
+        {
+            expectedFrameCount = 0;
+            for (int i = 0; i < actual.Frames.Count; i++)
+            {
+                if (predicate(i, actual.Frames.Count))
+                {
+                    expectedFrameCount++;
+                }
+            }
+        }
+
+        if (expected.Frames.Count != expectedFrameCount)
+        {
+            throw new ImagesSimilarityException("Image frame count does not match!");
+        }
+
+        IEnumerable<ImageSimilarityReport> reports = comparer.CompareImages(expected, actual, predicate);
+        if (reports.Any())
+        {
+            throw new ImageDifferenceIsOverThresholdException(reports.ToArray());
+        }
+    }
+
+    public static void VerifySimilarityIgnoreRegion<TPixelA, TPixelB>(
+        this ImageComparer comparer,
+        Image<TPixelA> expected,
+        Image<TPixelB> actual,
+        Rectangle ignoredRegion)
+        where TPixelA : unmanaged, IPixel<TPixelA>
+        where TPixelB : unmanaged, IPixel<TPixelB>
+    {
+        if (expected.Size != actual.Size)
+        {
+            throw new ImageDimensionsMismatchException(expected.Size, actual.Size);
+        }
+
+        if (expected.Frames.Count != actual.Frames.Count)
+        {
+            throw new ImagesSimilarityException("Image frame count does not match!");
+        }
+
+        IEnumerable<ImageSimilarityReport<TPixelA, TPixelB>> reports = comparer.CompareImages(expected, actual);
+        if (reports.Any())
+        {
+            List<ImageSimilarityReport<TPixelA, TPixelB>> cleanedReports = new(reports.Count());
+            foreach (ImageSimilarityReport<TPixelA, TPixelB> r in reports)
+            {
+                IEnumerable<PixelDifference> outsideChanges = r.Differences.Where(
+                    x =>
+                    !(ignoredRegion.X <= x.Position.X
+                    && x.Position.X <= ignoredRegion.Right
+                    && ignoredRegion.Y <= x.Position.Y
+                    && x.Position.Y <= ignoredRegion.Bottom));
+
+                if (outsideChanges.Any())
+                {
+                    cleanedReports.Add(new ImageSimilarityReport<TPixelA, TPixelB>(r.Index, r.ExpectedImage, r.ActualImage, outsideChanges, null));
+                }
+            }
+
+            if (cleanedReports.Count > 0)
+            {
+                throw new ImageDifferenceIsOverThresholdException(cleanedReports.ToArray());
+            }
+        }
+    }
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/ImageSimilarityReport.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/ImageSimilarityReport.cs
new file mode 100644
index 00000000..9ad00120
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/ImageSimilarityReport.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Globalization;
+using System.Text;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public class ImageSimilarityReport
+{
+    protected ImageSimilarityReport(
+        int index,
+        object expectedImage,
+        object actualImage,
+        IEnumerable<PixelDifference> differences,
+        float? totalNormalizedDifference = null)
+    {
+        this.Index = index;
+        this.ExpectedImage = expectedImage;
+        this.ActualImage = actualImage;
+        this.TotalNormalizedDifference = totalNormalizedDifference;
+        this.Differences = differences.ToArray();
+    }
+
+    public int Index { get; }
+
+    public object ExpectedImage { get; }
+
+    public object ActualImage { get; }
+
+    // TODO: This should not be a nullable value!
+    public float? TotalNormalizedDifference { get; }
+
+    public string DifferencePercentageString
+    {
+        get
+        {
+            if (!this.TotalNormalizedDifference.HasValue)
+            {
+                return "?";
+            }
+            else if (this.TotalNormalizedDifference == 0)
+            {
+                return "0%";
+            }
+            else
+            {
+                return $"{this.TotalNormalizedDifference.Value * 100:0.0000}%";
+            }
+        }
+    }
+
+    public PixelDifference[] Differences { get; }
+
+    public bool IsEmpty => this.Differences.Length == 0;
+
+    public override string ToString() => this.IsEmpty ? "[SimilarImages]" : this.PrintDifference();
+
+    private string PrintDifference()
+    {
+        StringBuilder sb = new();
+        if (this.TotalNormalizedDifference.HasValue)
+        {
+            sb.AppendLine()
+               .AppendLine(CultureInfo.InvariantCulture, $"Total difference: {this.DifferencePercentageString}");
+        }
+
+        int max = Math.Min(5, this.Differences.Length);
+
+        for (int i = 0; i < max; i++)
+        {
+            sb.Append(this.Differences[i]);
+            if (i < max - 1)
+            {
+                sb.AppendFormat(CultureInfo.InvariantCulture, ";{0}", Environment.NewLine);
+            }
+        }
+
+        if (this.Differences.Length >= 5)
+        {
+            sb.Append("...");
+        }
+
+        return sb.ToString();
+    }
+}
+
+public class ImageSimilarityReport<TPixelA, TPixelB> : ImageSimilarityReport
+    where TPixelA : unmanaged, IPixel<TPixelA>
+    where TPixelB : unmanaged, IPixel<TPixelB>
+{
+    public ImageSimilarityReport(
+        int index,
+        ImageFrame<TPixelA> expectedImage,
+        ImageFrame<TPixelB> actualImage,
+        IEnumerable<PixelDifference> differences,
+        float? totalNormalizedDifference = null)
+        : base(index, expectedImage, actualImage, differences, totalNormalizedDifference)
+    {
+    }
+
+    public static ImageSimilarityReport<TPixelA, TPixelB> Empty =>
+        new(0, null, null, Enumerable.Empty<PixelDifference>(), 0f);
+
+    public new ImageFrame<TPixelA> ExpectedImage => (ImageFrame<TPixelA>)base.ExpectedImage;
+
+    public new ImageFrame<TPixelB> ActualImage => (ImageFrame<TPixelB>)base.ActualImage;
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/PixelDifference.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/PixelDifference.cs
new file mode 100644
index 00000000..309790e6
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/PixelDifference.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public readonly struct PixelDifference
+{
+    public PixelDifference(
+        Point position,
+        int redDifference,
+        int greenDifference,
+        int blueDifference,
+        int alphaDifference)
+    {
+        this.Position = position;
+        this.RedDifference = redDifference;
+        this.GreenDifference = greenDifference;
+        this.BlueDifference = blueDifference;
+        this.AlphaDifference = alphaDifference;
+    }
+
+    public PixelDifference(Point position, Rgba64 expected, Rgba64 actual)
+        : this(
+            position,
+            actual.R - expected.R,
+            actual.G - expected.G,
+            actual.B - expected.B,
+            actual.A - expected.A)
+    {
+    }
+
+    public Point Position { get; }
+
+    public int RedDifference { get; }
+
+    public int GreenDifference { get; }
+
+    public int BlueDifference { get; }
+
+    public int AlphaDifference { get; }
+
+    public override string ToString() =>
+        $"[Δ({this.RedDifference},{this.GreenDifference},{this.BlueDifference},{this.AlphaDifference}) @ ({this.Position.X},{this.Position.Y})]";
+}
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
new file mode 100644
index 00000000..bfc9d16c
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using SixLabors.Fonts.Tests.ImageComparison;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.Fonts.Tests.TestUtilities;
+
+public static class TestImageExtensions
+{
+    public static string DebugSave(
+        this Image image,
+        string extension = null,
+        [CallerMemberName] string test = "",
+        object properties = null)
+    {
+        string outputDirectory = TestEnvironment.ActualOutputDirectoryFullPath;
+        if (!Directory.Exists(outputDirectory))
+        {
+            Directory.CreateDirectory(outputDirectory);
+        }
+
+        string path = Path.Combine(outputDirectory, $"{test}{FormatTestDetails(properties)}.{extension ?? "png"}");
+        image.Save(path);
+
+        return path;
+    }
+
+    public static void CompareToReference<TPixel>(
+        this Image<TPixel> image,
+        float percentageTolerance = 0F,
+        string extension = null,
+        [CallerMemberName] string test = "",
+        object properties = null)
+        where TPixel : unmanaged, IPixel<TPixel>
+    {
+        string path = image.DebugSave(extension, test, properties: properties);
+        string referencePath = path.Replace(TestEnvironment.ActualOutputDirectoryFullPath, TestEnvironment.ReferenceOutputDirectoryFullPath);
+
+        if (!File.Exists(referencePath))
+        {
+            throw new FileNotFoundException($"The reference image file was not found: {referencePath}");
+        }
+
+        using Image<Rgba64> expected = Image.Load<Rgba64>(referencePath);
+        TolerantImageComparer comparer = new(percentageTolerance / 100F);
+        ImageSimilarityReport report = comparer.CompareImagesOrFrames(expected, image);
+
+        if (!report.IsEmpty)
+        {
+            throw new ImageDifferenceIsOverThresholdException(report);
+        }
+    }
+
+    private static string FormatTestDetails(object properties)
+    {
+        if (properties is null)
+        {
+            return "-";
+        }
+
+        if (properties is FormattableString fs)
+        {
+            return FormattableString.Invariant(fs);
+        }
+        else if (properties is string s)
+        {
+            return FormattableString.Invariant($"-{s}-");
+        }
+
+        IEnumerable<PropertyInfo> runtimeProperties = properties.GetType().GetRuntimeProperties();
+
+        return FormattableString.Invariant($"_{string.Join(
+            "-",
+            runtimeProperties.ToDictionary(x => x.Name, x => x.GetValue(properties))
+                .Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
+    }
+}
+
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs
new file mode 100644
index 00000000..58ae66e5
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Memory;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.Fonts.Tests.ImageComparison;
+
+public class TolerantImageComparer : ImageComparer
+{
+    // 1% of all pixels in a 100*100 pixel area are allowed to have a difference of 1 unit
+    // 257 = (1 / 255) * 65535.
+    public const float DefaultImageThreshold = 257F / (100 * 100 * 65535);
+
+    /// <summary>
+    /// Individual Manhattan pixel difference is only added to total image difference when the individual difference is over 'perPixelManhattanThreshold'.
+    /// </summary>
+    /// <param name="imageThreshold">The maximal tolerated difference represented by a value between 0.0 and 1.0 scaled to 0 and 65535.</param>
+    /// <param name="perPixelManhattanThreshold">Gets the threshold of the individual pixels before they accumulate towards the overall difference.</param>
+    public TolerantImageComparer(float imageThreshold, int perPixelManhattanThreshold = 0)
+    {
+        Guard.MustBeGreaterThanOrEqualTo(imageThreshold, 0, nameof(imageThreshold));
+
+        this.ImageThreshold = imageThreshold;
+        this.PerPixelManhattanThreshold = perPixelManhattanThreshold;
+    }
+
+    /// <summary>
+    /// <para>
+    /// Gets the maximal tolerated difference represented by a value between 0.0 and 1.0 scaled to 0 and 65535.
+    /// Examples of percentage differences on a single pixel:
+    /// 1. PixelA = (65535,65535,65535,0) PixelB =(0,0,0,65535) leads to 100% difference on a single pixel
+    /// 2. PixelA = (65535,65535,65535,0) PixelB =(65535,65535,65535,65535) leads to 25% difference on a single pixel
+    /// 3. PixelA = (65535,65535,65535,0) PixelB =(32767,32767,32767,32767) leads to 50% difference on a single pixel
+    /// </para>
+    /// <para>
+    /// The total differences is the sum of all pixel differences normalized by image dimensions!
+    /// The individual distances are calculated using the Manhattan function:
+    /// <see>
+    ///     <cref>https://en.wikipedia.org/wiki/Taxicab_geometry</cref>
+    /// </see>
+    /// ImageThresholdInPercent = 1/255 =  257/65535 means that we allow one unit difference per channel on a 1x1 image
+    /// ImageThresholdInPercent = 1/(100*100*255) = 257/(100*100*65535) means that we allow only one unit difference per channel on a 100x100 image
+    /// </para>
+    /// </summary>
+    public float ImageThreshold { get; }
+
+    /// <summary>
+    /// Gets the threshold of the individual pixels before they accumulate towards the overall difference.
+    /// For an individual <see cref="Rgba64"/> pixel pair the value is the Manhattan distance of pixels:
+    /// <see>
+    ///     <cref>https://en.wikipedia.org/wiki/Taxicab_geometry</cref>
+    /// </see>
+    /// </summary>
+    public int PerPixelManhattanThreshold { get; }
+
+    public override ImageSimilarityReport<TPixelA, TPixelB> CompareImagesOrFrames<TPixelA, TPixelB>(int index, ImageFrame<TPixelA> expected, ImageFrame<TPixelB> actual)
+    {
+        if (expected.Size != actual.Size)
+        {
+            throw new InvalidOperationException("Calling ImageComparer is invalid when dimensions mismatch!");
+        }
+
+        int width = actual.Width;
+
+        // TODO: Comparing through Rgba64 may not robust enough because of the existence of super high precision pixel types.
+        Rgba64[] aBuffer = new Rgba64[width];
+        Rgba64[] bBuffer = new Rgba64[width];
+
+        float totalDifference = 0F;
+
+        List<PixelDifference> differences = new();
+        Configuration configuration = expected.Configuration;
+        Buffer2D<TPixelA> expectedBuffer = expected.PixelBuffer;
+        Buffer2D<TPixelB> actualBuffer = actual.PixelBuffer;
+
+        for (int y = 0; y < actual.Height; y++)
+        {
+            Span<TPixelA> aSpan = expectedBuffer.DangerousGetRowSpan(y);
+            Span<TPixelB> bSpan = actualBuffer.DangerousGetRowSpan(y);
+
+            PixelOperations<TPixelA>.Instance.ToRgba64(configuration, aSpan, aBuffer);
+            PixelOperations<TPixelB>.Instance.ToRgba64(configuration, bSpan, bBuffer);
+
+            for (int x = 0; x < width; x++)
+            {
+                int d = GetManhattanDistanceInRgbaSpace(ref aBuffer[x], ref bBuffer[x]);
+
+                if (d > this.PerPixelManhattanThreshold)
+                {
+                    PixelDifference diff = new(new Point(x, y), aBuffer[x], bBuffer[x]);
+                    differences.Add(diff);
+
+                    totalDifference += d;
+                }
+            }
+        }
+
+        float normalizedDifference = totalDifference / (actual.Width * (float)actual.Height);
+        normalizedDifference /= 4F * 65535F;
+
+        if (normalizedDifference > this.ImageThreshold)
+        {
+            return new ImageSimilarityReport<TPixelA, TPixelB>(index, expected, actual, differences, normalizedDifference);
+        }
+
+        return ImageSimilarityReport<TPixelA, TPixelB>.Empty;
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static int GetManhattanDistanceInRgbaSpace(ref Rgba64 a, ref Rgba64 b)
+        => Diff(a.R, b.R) + Diff(a.G, b.G) + Diff(a.B, b.B) + Diff(a.A, b.A);
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static int Diff(ushort a, ushort b) => Math.Abs(a - b);
+}
diff --git a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
index 2014f031..3114ff53 100644
--- a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
+++ b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
@@ -35,6 +35,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4" />
     <PackageReference Include="Pegasus" Version="4.1.0" PrivateAssets="all" />    
     <Compile Include="..\..\src\UnicodeTrieGenerator\StateAutomation\DeterministicFiniteAutomata.cs" Link="Unicode\StateAutomation\DeterministicFiniteAutomata.cs" />
     <Compile Include="..\..\src\UnicodeTrieGenerator\StateAutomation\Compile.cs" Link="Unicode\StateAutomation\Compile.cs" />
diff --git a/tests/SixLabors.Fonts.Tests/TestEnvironment.cs b/tests/SixLabors.Fonts.Tests/TestEnvironment.cs
index 84a931a0..a4531530 100644
--- a/tests/SixLabors.Fonts.Tests/TestEnvironment.cs
+++ b/tests/SixLabors.Fonts.Tests/TestEnvironment.cs
@@ -2,13 +2,20 @@
 // Licensed under the Six Labors Split License.
 
 using System.Reflection;
+using System.Runtime.InteropServices;
 
 namespace SixLabors.Fonts.Tests;
 
 internal static class TestEnvironment
 {
+    private static readonly FileInfo TestAssemblyFile = new(typeof(TestEnvironment).GetTypeInfo().Assembly.Location);
+
     private const string SixLaborsSolutionFileName = "SixLabors.Fonts.sln";
 
+    private const string ActualOutputDirectoryRelativePath = @"tests\Images\ActualOutput";
+
+    private const string ReferenceOutputDirectoryRelativePath = @"tests\Images\ReferenceOutput";
+
     private const string UnicodeTestDataRelativePath = @"tests\UnicodeTestData\";
 
     private static readonly Lazy<string> SolutionDirectoryFullPathLazy = new(GetSolutionDirectoryFullPathImpl);
@@ -20,15 +27,43 @@ internal static class TestEnvironment
     /// </summary>
     internal static string UnicodeTestDataFullPath => GetFullPath(UnicodeTestDataRelativePath);
 
-    private static string GetSolutionDirectoryFullPathImpl()
-    {
-        string assemblyLocation = Path.GetDirectoryName(new Uri(typeof(TestEnvironment).GetTypeInfo().Assembly.CodeBase).LocalPath);
+    /// <summary>
+    /// Gets the correct full path to the Actual Output directory. (To be written to by the test cases.)
+    /// </summary>
+    internal static string ActualOutputDirectoryFullPath => GetFullPath(ActualOutputDirectoryRelativePath);
+
+    /// <summary>
+    /// Gets the correct full path to the Expected Output directory. (To compare the test results to.)
+    /// </summary>
+    internal static string ReferenceOutputDirectoryFullPath => GetFullPath(ReferenceOutputDirectoryRelativePath);
+
+    internal static bool IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
 
-        var assemblyFile = new FileInfo(assemblyLocation);
+    internal static bool IsMacOS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
 
-        DirectoryInfo directory = assemblyFile.Directory;
+    internal static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+    internal static bool Is64BitProcess => Environment.Is64BitProcess;
+
+    internal static Architecture OSArchitecture => RuntimeInformation.OSArchitecture;
+
+    internal static Architecture ProcessArchitecture => RuntimeInformation.ProcessArchitecture;
+
+
+    /// <summary>
+    /// Gets a value indicating whether test execution runs on CI.
+    /// </summary>
+#if ENV_CI
+    internal static bool RunsOnCI => true;
+#else
+    internal static bool RunsOnCI => false;
+#endif
+
+    private static string GetSolutionDirectoryFullPathImpl()
+    {
+        DirectoryInfo directory = TestAssemblyFile.Directory;
 
-        while (!directory.EnumerateFiles(SixLaborsSolutionFileName).Any())
+        while (directory?.EnumerateFiles(SixLaborsSolutionFileName).Any() == false)
         {
             try
             {
@@ -36,14 +71,14 @@ private static string GetSolutionDirectoryFullPathImpl()
             }
             catch (Exception ex)
             {
-                throw new Exception(
-                    $"Unable to find SixLabors solution directory from {assemblyLocation} because of {ex.GetType().Name}!",
+                throw new DirectoryNotFoundException(
+                    $"Unable to find  solution directory from {TestAssemblyFile} because of {ex.GetType().Name}!",
                     ex);
             }
 
             if (directory == null)
             {
-                throw new Exception($"Unable to find SixLabors solution directory from {assemblyLocation}!");
+                throw new DirectoryNotFoundException($"Unable to find  solution directory from {TestAssemblyFile}!");
             }
         }
 
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
index 51a86826..497d7836 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
@@ -4,7 +4,12 @@
 using System.Globalization;
 using System.Numerics;
 using SixLabors.Fonts.Tests.Fakes;
+using SixLabors.Fonts.Tests.TestUtilities;
 using SixLabors.Fonts.Unicode;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
 
 namespace SixLabors.Fonts.Tests;
 
@@ -531,10 +536,24 @@ public void CountLinesWithSpan()
     [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 200, 6)]
     public void CountLinesWrappingLength(string text, int wrappingLength, int usedLines)
     {
-        Font font = CreateFont(text);
-        int count = TextMeasurer.CountLines(text, new TextOptions(font) { Dpi = font.FontMetrics.ScaleFactor, WrappingLength = wrappingLength });
+        Font font = CreateRenderingFont();
+        RichTextOptions options = new(font)
+        {
+            // Dpi = font.FontMetrics.ScaleFactor,
+            WrappingLength = wrappingLength
+        };
 
-        Assert.Equal(usedLines, count);
+        int count = TextMeasurer.CountLines(text, options);
+
+        // Assert.Equal(usedLines, count);
+        FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
+        int width = (int)Math.Ceiling(advance.Width);
+        int height = (int)Math.Ceiling(advance.Height);
+
+        using Image<Rgba32> img = new(Math.Max(wrappingLength + 1, width), height, Color.White);
+        img.Mutate(x => x.DrawLine(Color.Red, 1, new(wrappingLength, 0), new(wrappingLength, height)));
+        img.Mutate(ctx => ctx.DrawText(options, text, Color.Black));
+        img.DebugSave(properties: new { wrappingLength, usedLines });
     }
 
     [Fact]
@@ -1351,6 +1370,11 @@ public FontRectangle BenchmarkTest()
     private static readonly Font Arial = SystemFonts.CreateFont("Arial", 12);
 #endif
 
+    public static Font CreateRenderingFont()
+    {
+        return new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(12);
+    }
+
     public static Font CreateFont(string text)
     {
         var fc = (IFontMetricsCollection)new FontCollection();

From 7236a639c60b405360c7c080ce8ac15d1bd1f950 Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Tue, 7 Jan 2025 19:42:55 +1000
Subject: [PATCH 03/11] Fix issues and add visual references

---
 src/SixLabors.Fonts/TextLayout.cs             | 166 ++++++----
 ...Length_wrappingLength_100-usedLines_7_.png |   3 -
 ...Length_wrappingLength_200-usedLines_6_.png |   3 -
 ...gLength_wrappingLength_25-usedLines_7_.png |   3 -
 ...gLength_wrappingLength_50-usedLines_7_.png |   3 -
 .../CountLinesWrappingLength_100-4.png        |   3 +
 .../CountLinesWrappingLength_200-3.png        |   3 +
 .../CountLinesWrappingLength_25-6.png         |   3 +
 .../CountLinesWrappingLength_50-5.png         |   3 +
 ...zontalBottomTop-wordBreaking_BreakAll_.png |   3 +
 ...ontalBottomTop-wordBreaking_BreakWord_.png |   3 +
 ...izontalBottomTop-wordBreaking_KeepAll_.png |   3 +
 ...zontalBottomTop-wordBreaking_Standard_.png |   3 +
 ...zontalTopBottom-wordBreaking_BreakAll_.png |   3 +
 ...ontalTopBottom-wordBreaking_BreakWord_.png |   3 +
 ...izontalTopBottom-wordBreaking_KeepAll_.png |   3 +
 ...zontalTopBottom-wordBreaking_Standard_.png |   3 +
 ...zontalBottomTop-wordBreaking_BreakAll_.png |   3 +
 ...ontalBottomTop-wordBreaking_BreakWord_.png |   3 +
 ...izontalBottomTop-wordBreaking_KeepAll_.png |   3 +
 ...zontalBottomTop-wordBreaking_Standard_.png |   3 +
 ...zontalTopBottom-wordBreaking_BreakAll_.png |   3 +
 ...ontalTopBottom-wordBreaking_BreakWord_.png |   3 +
 ...izontalTopBottom-wordBreaking_KeepAll_.png |   3 +
 ...zontalTopBottom-wordBreaking_Standard_.png |   3 +
 ...BottomTop_350-_height_10-width_87.125_.png |   3 +
 ...omTop_350-_height_11.438-width_279.13_.png |   3 +
 ...omTop_350-_height_62.625-width_318.86_.png |   3 +
 ...TopBottom_350-_height_10-width_87.125_.png |   3 +
 ...ottom_350-_height_11.438-width_279.13_.png |   3 +
 ...ottom_350-_height_62.625-width_318.86_.png |   3 +
 ...LeftRight_350-_height_171.25-width_10_.png |   3 +
 ...Right_350-_height_267.25-width_23.875_.png |   3 +
 ...ight_350-_height_318.563-width_62.813_.png |   3 +
 ...ight_350-_height_279.125-width_11.438_.png |   3 +
 ...ight_350-_height_318.563-width_62.813_.png |   3 +
 ...LeftRight_350-_height_87.125-width_10_.png |   3 +
 ...RightLeft_350-_height_171.25-width_10_.png |   3 +
 ...tLeft_350-_height_267.25-width_23.875_.png |   3 +
 ...Left_350-_height_318.563-width_62.813_.png |   3 +
 .../ShouldInsertExtraLineBreaksA_400-4.png    |   3 +
 .../ShouldInsertExtraLineBreaksB_400-4.png    |   3 +
 ...MatchBrowserBreak__WrappingLength_372_.png |   3 +
 ...rtExtraLineBreaks__WrappingLength_400_.png |   3 +
 ...ight-TextJustification_InterCharacter_.png |   3 +
 ...Left-TextJustification_InterCharacter_.png |   3 +
 ...ight-TextJustification_InterCharacter_.png |   3 +
 ...Left-TextJustification_InterCharacter_.png |   3 +
 ...ftToRight-TextJustification_InterWord_.png |   3 +
 ...ghtToLeft-TextJustification_InterWord_.png |   3 +
 ...ftToRight-TextJustification_InterWord_.png |   3 +
 ...ghtToLeft-TextJustification_InterWord_.png |   3 +
 .../ImageComparison/TestImageExtensions.cs    |  33 +-
 .../ImageComparison/TolerantImageComparer.cs  |   2 +-
 .../Issues/Issues_367.cs                      |   2 +
 .../Issues/Issues_431.cs                      |   6 +-
 .../Issues/Issues_434.cs                      |  36 ++-
 .../SixLabors.Fonts.Tests.csproj              |  21 +-
 .../TextLayoutTestUtilities.cs                | 116 +++++++
 .../SixLabors.Fonts.Tests/TextLayoutTests.cs  | 300 +++++++++---------
 60 files changed, 605 insertions(+), 230 deletions(-)
 delete mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png
 delete mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png
 delete mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png
 delete mode 100644 tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png
 create mode 100644 tests/Images/ReferenceOutput/CountLinesWrappingLength_100-4.png
 create mode 100644 tests/Images/ReferenceOutput/CountLinesWrappingLength_200-3.png
 create mode 100644 tests/Images/ReferenceOutput/CountLinesWrappingLength_25-6.png
 create mode 100644 tests/Images/ReferenceOutput/CountLinesWrappingLength_50-5.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_10-width_87.125_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_11.438-width_279.13_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_62.625-width_318.86_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_10-width_87.125_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_11.438-width_279.13_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_62.625-width_318.86_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_171.25-width_10_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_267.25-width_23.875_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_318.563-width_62.813_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_318.563-width_62.813_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_171.25-width_10_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_267.25-width_23.875_.png
 create mode 100644 tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_318.563-width_62.813_.png
 create mode 100644 tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksA_400-4.png
 create mode 100644 tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksB_400-4.png
 create mode 100644 tests/Images/ReferenceOutput/ShouldMatchBrowserBreak__WrappingLength_372_.png
 create mode 100644 tests/Images/ReferenceOutput/ShouldNotInsertExtraLineBreaks__WrappingLength_400_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png
 create mode 100644 tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png
 create mode 100644 tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs

diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index 0274e2e8..46137fcb 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -695,10 +695,11 @@ private static IEnumerable<GlyphLayout> LayoutLineVerticalMixed(
 
                     // Adjust the horizontal offset further by considering the descender differences:
                     // - Subtract the current glyph's descender (data.ScaledDescender) to align it properly.
-                    float descenderDelta = (Math.Abs(textLine.ScaledMaxDescender) - Math.Abs(data.ScaledDescender)) * .5F;
+                    float descenderAbs = Math.Abs(data.ScaledDescender);
+                    float descenderDelta = (Math.Abs(textLine.ScaledMaxDescender) - descenderAbs) * .5F;
 
                     // Final horizontal center offset combines the baseline and descender adjustments.
-                    float centerOffsetX = (baselineDelta - data.ScaledDescender) + descenderDelta;
+                    float centerOffsetX = baselineDelta + descenderAbs + descenderDelta;
 
                     glyphs.Add(new GlyphLayout(
                         new Glyph(metric, data.PointSize),
@@ -1138,12 +1139,7 @@ VerticalOrientationType.Rotate or
             stringIndex += graphemeEnumerator.Current.Length;
         }
 
-        // Resolve the bidi order for the line.
-        // This reorders the glyphs in the line to match the visual order.
-        textLine.BidiReOrder();
-
-        // Now we need to loop through our reordered line and split it at any line breaks.
-        //
+        // Now we need to loop through our line and split it at any line breaks.
         // First calculate the position of potential line breaks.
         LineBreakEnumerator lineBreakEnumerator = new(text);
         List<LineBreak> lineBreaks = new();
@@ -1174,44 +1170,69 @@ VerticalOrientationType.Rotate or
                 {
                     // Mandatory line break at index.
                     TextLine remaining = textLine.SplitAt(i);
-                    textLines.Add(textLine.Finalize());
+                    textLines.Add(textLine.Finalize(options));
                     textLine = remaining;
                     i = 0;
                     lineAdvance = 0;
                 }
-                else if (shouldWrap && lineAdvance + glyphAdvance >= wrappingLength)
+                else if (shouldWrap)
                 {
-                    if (breakAll)
-                    {
-                        // Insert a forced break at this index.
-                        TextLine remaining = textLine.SplitAt(i);
-                        textLines.Add(textLine.Finalize());
-                        textLine = remaining;
-                        i = 0;
-                        lineAdvance = 0;
-                    }
-                    else if (codePointIndex == currentLineBreak.PositionWrap || i == max)
+                    float currentAdvance = lineAdvance + glyphAdvance;
+                    if (currentAdvance >= wrappingLength)
                     {
-                        // If we are at the position wrap we can break here.
-                        // Split the line at the last line break.
-                        // CJK characters will not be split if 'keepAll' is true.
-                        TextLine remaining = textLine.SplitAt(lastLineBreak, keepAll);
-                        if (remaining != textLine)
+                        if (breakAll)
                         {
-                            textLines.Add(textLine.Finalize());
+                            // Insert a forced break at this index.
+                            TextLine remaining = textLine.SplitAt(i);
+                            textLines.Add(textLine.Finalize(options));
                             textLine = remaining;
                             i = 0;
                             lineAdvance = 0;
                         }
-                    }
-                    else if (breakWord)
-                    {
-                        // Insert a forced break at this index.
-                        TextLine remaining = textLine.SplitAt(i);
-                        textLines.Add(textLine.Finalize());
-                        textLine = remaining;
-                        i = 0;
-                        lineAdvance = 0;
+                        else if (codePointIndex == currentLineBreak.PositionWrap || i == max)
+                        {
+                            LineBreak lineBreak = currentAdvance == wrappingLength
+                                ? currentLineBreak
+                                : lastLineBreak;
+
+                            if (i > 0)
+                            {
+                                // If the current break is a space, and the line minus the space
+                                // is less than the wrapping length, we can break using the current break.
+                                float positionAdvance = lineAdvance;
+                                TextLine.GlyphLayoutData lastGlyph = textLine[i - 1];
+                                if (CodePoint.IsWhiteSpace(lastGlyph.CodePoint))
+                                {
+                                    positionAdvance -= lastGlyph.ScaledAdvance;
+                                    if (positionAdvance <= wrappingLength)
+                                    {
+                                        lineBreak = currentLineBreak;
+                                    }
+                                }
+                            }
+
+                            // If we are at the position wrap we can break here.
+                            // Split the line at the appropriate break.
+                            // CJK characters will not be split if 'keepAll' is true.
+                            TextLine remaining = textLine.SplitAt(lineBreak, keepAll);
+
+                            if (remaining != textLine)
+                            {
+                                if (breakWord)
+                                {
+                                    // If the line is too long, insert a forced line break.
+                                    if (textLine.ScaledLineAdvance > wrappingLength)
+                                    {
+                                        remaining.InsertAt(0, textLine.SplitAt(wrappingLength));
+                                    }
+                                }
+
+                                textLines.Add(textLine.Finalize(options));
+                                textLine = remaining;
+                                i = 0;
+                                lineAdvance = 0;
+                            }
+                        }
                     }
                 }
             }
@@ -1228,27 +1249,23 @@ VerticalOrientationType.Rotate or
         // Add the final line.
         if (textLine.Count > 0)
         {
-            textLines.Add(textLine.Finalize());
+            textLines.Add(textLine.Finalize(options));
         }
 
-        return new TextBox(options, textLines);
+        return new TextBox(textLines);
     }
 
     internal sealed class TextBox
     {
-        public TextBox(TextOptions options, IReadOnlyList<TextLine> textLines)
-        {
-            this.TextLines = textLines;
-            for (int i = 0; i < this.TextLines.Count - 1; i++)
-            {
-                this.TextLines[i].Justify(options);
-            }
-        }
+        private float? scaledMaxAdvance;
+
+        public TextBox(IReadOnlyList<TextLine> textLines)
+            => this.TextLines = textLines;
 
         public IReadOnlyList<TextLine> TextLines { get; }
 
         public float ScaledMaxAdvance()
-            => this.TextLines.Max(x => x.ScaledLineAdvance);
+            => this.scaledMaxAdvance ??= this.TextLines.Max(x => x.ScaledLineAdvance);
 
         public TextDirection TextDirection() => this.TextLines[0][0].TextDirection;
     }
@@ -1311,8 +1328,20 @@ public void Add(
                 stringIndex));
         }
 
+        public TextLine InsertAt(int index, TextLine textLine)
+        {
+            this.data.InsertRange(index, textLine.data);
+            RecalculateLineMetrics(this);
+            return this;
+        }
+
         public TextLine SplitAt(int index)
         {
+            if (index == 0 || index >= this.Count)
+            {
+                return this;
+            }
+
             TextLine result = new();
             result.data.AddRange(this.data.GetRange(index, this.data.Count - index));
             RecalculateLineMetrics(result);
@@ -1322,6 +1351,28 @@ public TextLine SplitAt(int index)
             return result;
         }
 
+        public TextLine SplitAt(float length)
+        {
+            TextLine result = new();
+            float advance = 0;
+            for (int i = 0; i < this.data.Count; i++)
+            {
+                GlyphLayoutData glyph = this.data[i];
+                advance += glyph.ScaledAdvance;
+                if (advance >= length)
+                {
+                    result.data.AddRange(this.data.GetRange(i, this.data.Count - i));
+                    RecalculateLineMetrics(result);
+
+                    this.data.RemoveRange(i, this.data.Count - i);
+                    RecalculateLineMetrics(this);
+                    return result;
+                }
+            }
+
+            return this;
+        }
+
         public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
         {
             int index = this.data.Count;
@@ -1337,9 +1388,6 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
 
             if (index == 0)
             {
-                // Now trim trailing whitespace from this line in the case of an exact
-                // length line break (non CJK)
-                RecalculateLineMetrics(this);
                 return this;
             }
 
@@ -1361,9 +1409,6 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
 
                 if (index == 0)
                 {
-                    // Now trim trailing whitespace from this line in the case of an exact
-                    // length line break (non CJK)
-                    RecalculateLineMetrics(this);
                     return this;
                 }
             }
@@ -1376,15 +1421,11 @@ public TextLine SplitAt(LineBreak lineBreak, bool keepAll)
 
             // Remove those items from this line.
             this.data.RemoveRange(index, count);
-
-            // Now trim trailing whitespace from this line.
             RecalculateLineMetrics(this);
-            // this.TrimTrailingWhitespaceAndRecalculateMetrics();
-
             return result;
         }
 
-        private TextLine TrimTrailingWhitespaceAndRecalculateMetrics()
+        private void TrimTrailingWhitespace()
         {
             int index = this.data.Count;
             while (index > 0)
@@ -1403,14 +1444,19 @@ private TextLine TrimTrailingWhitespaceAndRecalculateMetrics()
             {
                 this.data.RemoveRange(index, this.data.Count - index);
             }
+        }
+
+        public TextLine Finalize(TextOptions options)
+        {
+            this.TrimTrailingWhitespace();
+            this.BidiReOrder();
+            RecalculateLineMetrics(this);
 
+            this.Justify(options);
             RecalculateLineMetrics(this);
             return this;
         }
 
-        public TextLine Finalize()
-            => this.TrimTrailingWhitespaceAndRecalculateMetrics();
-
         public void Justify(TextOptions options)
         {
             if (options.WrappingLength == -1F || options.TextJustification == TextJustification.None)
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png
deleted file mode 100644
index d30b1f24..00000000
--- a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_100-usedLines_7_.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c09ad5c85f708f5cd2135a52b13aa248a130abbc7fe8f0449b44d62f9d360384
-size 4505
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png
deleted file mode 100644
index 60fccee3..00000000
--- a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_200-usedLines_6_.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5d54e2b3f85b01ee45f25e53c8f97e80ca9d7655ff6b8aa643664912812a3976
-size 4521
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png
deleted file mode 100644
index 7407b0cf..00000000
--- a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_25-usedLines_7_.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1161af3a0ffc7d4835e58bcfa064713fa06971747414a144c3b979fdabf1bbdd
-size 4855
diff --git a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png b/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png
deleted file mode 100644
index 4ec38250..00000000
--- a/tests/Images/ActualOutput/CountLinesWrappingLength_wrappingLength_50-usedLines_7_.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8a11863fa11c93d158560fadddcb4768047c5c7f0069054cbf9392b5dc234759
-size 4853
diff --git a/tests/Images/ReferenceOutput/CountLinesWrappingLength_100-4.png b/tests/Images/ReferenceOutput/CountLinesWrappingLength_100-4.png
new file mode 100644
index 00000000..aef12a47
--- /dev/null
+++ b/tests/Images/ReferenceOutput/CountLinesWrappingLength_100-4.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:071789cee1ac03354e5727bda21321e8bb875ae417adfe5e745877023d62c6d7
+size 2963
diff --git a/tests/Images/ReferenceOutput/CountLinesWrappingLength_200-3.png b/tests/Images/ReferenceOutput/CountLinesWrappingLength_200-3.png
new file mode 100644
index 00000000..7e9c8708
--- /dev/null
+++ b/tests/Images/ReferenceOutput/CountLinesWrappingLength_200-3.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3e2c7bb91aeeffc4d573d4509f55e58d5051221027003a584411f0b97141da16
+size 2966
diff --git a/tests/Images/ReferenceOutput/CountLinesWrappingLength_25-6.png b/tests/Images/ReferenceOutput/CountLinesWrappingLength_25-6.png
new file mode 100644
index 00000000..10880c45
--- /dev/null
+++ b/tests/Images/ReferenceOutput/CountLinesWrappingLength_25-6.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2959540711f38c08a319251355e838daabc3ac7721a89bb90261730732f6abab
+size 3033
diff --git a/tests/Images/ReferenceOutput/CountLinesWrappingLength_50-5.png b/tests/Images/ReferenceOutput/CountLinesWrappingLength_50-5.png
new file mode 100644
index 00000000..4ee0e7d2
--- /dev/null
+++ b/tests/Images/ReferenceOutput/CountLinesWrappingLength_50-5.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:907dcca95723f6b21aa3791e047476e31b2d8b13f88f3c5e6604696ce5b469f5
+size 3022
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png
new file mode 100644
index 00000000..67114c86
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e13708fcaf702a91540b1f68803cc2cff14de1a97cef771ca0bcc69e414a706e
+size 13446
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png
new file mode 100644
index 00000000..d0834260
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5a5ec93c6ece40f6c3577970cd0fa1f97d74d019ed52cf3ce9812b4d805624b0
+size 13613
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png
new file mode 100644
index 00000000..bee92951
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fcaefcb524334225b07b555b296d3b9030a98b775f726b5c1f61997aed0ce587
+size 14041
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png
new file mode 100644
index 00000000..7fcec502
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f2951c4befa6d1ceb5afbdd4b36dc5df51752ad1d3ad1a01be70b717cb5811be
+size 15221
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png
new file mode 100644
index 00000000..2c0ae06c
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1896cd5f5d0521261c4b9e366ae56baa89f48cfd21b7d1a5d4a4e4c50b3f8542
+size 13485
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png
new file mode 100644
index 00000000..bc6d223a
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0d714d870453515f516af0fe8267795df3b36059a9c8c91b0946889985cd089e
+size 13705
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png
new file mode 100644
index 00000000..1a34cb35
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3e6d7e2da8ffab3af8ec50463e38b6c5d96059919c4439271f5ee620b76c74e2
+size 14356
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png
new file mode 100644
index 00000000..19231d99
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreakMatchesMDN_238-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fe506ff8a945fea5f04fcd34a9a63ffcb89d610021acabe711be83ad19e6a96c
+size 15607
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png
new file mode 100644
index 00000000..44196676
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:66b5c1046ab68824efdd3af54c7753a18d7bb12b7a2960c956395b0d20bf19e2
+size 17444
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png
new file mode 100644
index 00000000..9cd73e78
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_BreakWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f9820e4f9f1719bf48b098991ee175f7c648f5edaa856b579402e75ea597a125
+size 19470
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png
new file mode 100644
index 00000000..a5aa2a33
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_KeepAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7ffff224f6a16286cffa7e02e9d5069b389d9ce7274ed917489652f7e0163e7
+size 12638
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png
new file mode 100644
index 00000000..53d98e24
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalBottomTop-wordBreaking_Standard_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f986a3bc5f09bd7c20108d9ed667e86de0b28ef813cc3426452b9d47b1ff31ba
+size 21147
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png
new file mode 100644
index 00000000..71ac3151
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a99e819c0a8d7380e7afbdd289ebe0a2ee6f8c62b51ab7d10b06cfddf0729c55
+size 17437
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png
new file mode 100644
index 00000000..932a7435
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_BreakWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1cb4294d6f5f72a6af860d8aab9f11b5224f7206add5d7bbcdc22d959a14a78c
+size 19409
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png
new file mode 100644
index 00000000..a6c638c8
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_KeepAll_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:09cac7e7096c0fc3f92d3620580465f10e758edb0a23882e19af7cbf49238180
+size 20415
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png
new file mode 100644
index 00000000..f41cf435
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordBreak_500-_layoutMode_HorizontalTopBottom-wordBreaking_Standard_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:35095f9cb0df878caa355d5fc22474ecd25e43a60bcb67b68293dcd43eafc0c3
+size 21227
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_10-width_87.125_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_10-width_87.125_.png
new file mode 100644
index 00000000..7413ec0e
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_10-width_87.125_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a504887655b5b61de10d5b09496330232136cc6854d28319c3c99375fb5424e8
+size 905
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_11.438-width_279.13_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_11.438-width_279.13_.png
new file mode 100644
index 00000000..fcdea96c
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_11.438-width_279.13_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3e5a90ec1b1cf8d69e0173d4c8a08a3bbbb2a428eee7250dc358e6f5abf6542d
+size 948
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_62.625-width_318.86_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_62.625-width_318.86_.png
new file mode 100644
index 00000000..02bc02c6
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalBottomTop_350-_height_62.625-width_318.86_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bcf9ccad091fde6016070afcaa24e9040d6a81672f04350e56aa446802675bff
+size 9272
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_10-width_87.125_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_10-width_87.125_.png
new file mode 100644
index 00000000..7413ec0e
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_10-width_87.125_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a504887655b5b61de10d5b09496330232136cc6854d28319c3c99375fb5424e8
+size 905
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_11.438-width_279.13_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_11.438-width_279.13_.png
new file mode 100644
index 00000000..fcdea96c
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_11.438-width_279.13_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3e5a90ec1b1cf8d69e0173d4c8a08a3bbbb2a428eee7250dc358e6f5abf6542d
+size 948
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_62.625-width_318.86_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_62.625-width_318.86_.png
new file mode 100644
index 00000000..ed9c7eee
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingHorizontalTopBottom_350-_height_62.625-width_318.86_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:afd34328b9e945944d90f8e56c33cff7d796a5ab22a55a4b1be5eeb629fb2f5a
+size 9268
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_171.25-width_10_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_171.25-width_10_.png
new file mode 100644
index 00000000..538e432c
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_171.25-width_10_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b81778b219f78d986e93643c3821c8c2d5c2eb2b488170db3ffa3cf42e6e0e0a
+size 853
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_267.25-width_23.875_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_267.25-width_23.875_.png
new file mode 100644
index 00000000..f48a36ba
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_267.25-width_23.875_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40e27a17bd7e5fe1d5177cb34e456a91cda7d555cfdd53c4e8a75722cbe41d09
+size 1083
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_318.563-width_62.813_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_318.563-width_62.813_.png
new file mode 100644
index 00000000..df8cb7f9
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalLeftRight_350-_height_318.563-width_62.813_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:57b1755082a3e82879b28ff20f2e4e2cd017aa8b0b236678a8dbf5cc6ddab17d
+size 10317
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png
new file mode 100644
index 00000000..d06deb08
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:739c2d5459270188f0c003ee017184c4127a686e04d939d731aa186d0daa68f7
+size 911
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_318.563-width_62.813_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_318.563-width_62.813_.png
new file mode 100644
index 00000000..df8cb7f9
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_318.563-width_62.813_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:57b1755082a3e82879b28ff20f2e4e2cd017aa8b0b236678a8dbf5cc6ddab17d
+size 10317
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png
new file mode 100644
index 00000000..7c5836e3
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cab59d7637dd6820a15b63fc68fc541482916d555ef38337fe5f583136bce5d5
+size 873
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_171.25-width_10_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_171.25-width_10_.png
new file mode 100644
index 00000000..538e432c
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_171.25-width_10_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b81778b219f78d986e93643c3821c8c2d5c2eb2b488170db3ffa3cf42e6e0e0a
+size 853
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_267.25-width_23.875_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_267.25-width_23.875_.png
new file mode 100644
index 00000000..d920a37d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_267.25-width_23.875_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:902799aae2e8229dcacbf04554eb5b64d30c7c47fe9a9794be8ac34616a414db
+size 1091
diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_318.563-width_62.813_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_318.563-width_62.813_.png
new file mode 100644
index 00000000..0aa163cb
--- /dev/null
+++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalRightLeft_350-_height_318.563-width_62.813_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b4b0bc75fa8b2ff464b38401741a58ce0fd7095767883d8fcd227e336a8f9c08
+size 10241
diff --git a/tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksA_400-4.png b/tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksA_400-4.png
new file mode 100644
index 00000000..1d754d10
--- /dev/null
+++ b/tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksA_400-4.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:576b23370c4d5ba88e835169c80babeda2ed03c32c767193724ee14ab3f18c48
+size 15102
diff --git a/tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksB_400-4.png b/tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksB_400-4.png
new file mode 100644
index 00000000..46544ba7
--- /dev/null
+++ b/tests/Images/ReferenceOutput/ShouldInsertExtraLineBreaksB_400-4.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:435603a3488351bfd79647eb11e598dcb14e90cde9e849832e2a1f2f5cf53e14
+size 15691
diff --git a/tests/Images/ReferenceOutput/ShouldMatchBrowserBreak__WrappingLength_372_.png b/tests/Images/ReferenceOutput/ShouldMatchBrowserBreak__WrappingLength_372_.png
new file mode 100644
index 00000000..0873537d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/ShouldMatchBrowserBreak__WrappingLength_372_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:82acb3f6de8b9682037417ddffb5f9815b0a74c727ee52f817c9b624ce3a7735
+size 5935
diff --git a/tests/Images/ReferenceOutput/ShouldNotInsertExtraLineBreaks__WrappingLength_400_.png b/tests/Images/ReferenceOutput/ShouldNotInsertExtraLineBreaks__WrappingLength_400_.png
new file mode 100644
index 00000000..4796c6bd
--- /dev/null
+++ b/tests/Images/ReferenceOutput/ShouldNotInsertExtraLineBreaks__WrappingLength_400_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:18b14563c798925aaeee959bbeb12bd392af681ab620fb15a0264f7f2904f2a6
+size 14829
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png
new file mode 100644
index 00000000..1be8b4c3
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_LeftToRight-TextJustification_InterCharacter_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a34f20be58409060a7a0c071de1ad19be52861b47d42af70e814a11605fc6d43
+size 8774
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png
new file mode 100644
index 00000000..1503764f
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Horizontal_400-_direction_RightToLeft-TextJustification_InterCharacter_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a9d261076440457a717da33a8681738e06a52182ef5430ea1c874c320a53c0e9
+size 8792
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png
new file mode 100644
index 00000000..7f0f8d4d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_LeftToRight-TextJustification_InterCharacter_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:41c2eb051160a94e3b63f789916daba0093af365b54bcd6fbd3c65a0114b295d
+size 7507
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png
new file mode 100644
index 00000000..7221c2e5
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterCharacter_Vertical_400-_direction_RightToLeft-TextJustification_InterCharacter_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b8e48085ea9ffbe30b4be7de45aad642645a61edef7a4dd0b19a612c37e66bc7
+size 7432
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png
new file mode 100644
index 00000000..dd5d86ea
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_LeftToRight-TextJustification_InterWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7d6b060baad2258565758b6eebe3cd23061490a861dcb2b7b3b9276714dc5174
+size 8842
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png
new file mode 100644
index 00000000..141b2191
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Horizontal_400-_direction_RightToLeft-TextJustification_InterWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f675cd2d88ed4e69b6710d002e73f9b43530c60a748f1112f39d040840b0f498
+size 8696
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png
new file mode 100644
index 00000000..aaacf0d3
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_LeftToRight-TextJustification_InterWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1a8a54b0c1707f57d99afdcd3cfb5518636e96f40ebab213e83cc8ef9a85edb7
+size 6725
diff --git a/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png
new file mode 100644
index 00000000..3917ae77
--- /dev/null
+++ b/tests/Images/ReferenceOutput/TextJustification_InterWord_Vertical_400-_direction_RightToLeft-TextJustification_InterWord_.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:73d1573b88cc105218d1a557c7b096a50ed7aeca0a76d3719d2243ef7f764020
+size 6721
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
index bfc9d16c..921d2cbe 100644
--- a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
@@ -3,6 +3,7 @@
 
 using System.Reflection;
 using System.Runtime.CompilerServices;
+using System.Text;
 using SixLabors.Fonts.Tests.ImageComparison;
 using SixLabors.ImageSharp;
 using SixLabors.ImageSharp.PixelFormats;
@@ -15,7 +16,7 @@ public static string DebugSave(
         this Image image,
         string extension = null,
         [CallerMemberName] string test = "",
-        object properties = null)
+        params object[] properties)
     {
         string outputDirectory = TestEnvironment.ActualOutputDirectoryFullPath;
         if (!Directory.Exists(outputDirectory))
@@ -34,7 +35,7 @@ public static void CompareToReference<TPixel>(
         float percentageTolerance = 0F,
         string extension = null,
         [CallerMemberName] string test = "",
-        object properties = null)
+        params object[] properties)
         where TPixel : unmanaged, IPixel<TPixel>
     {
         string path = image.DebugSave(extension, test, properties: properties);
@@ -55,7 +56,18 @@ public static void CompareToReference<TPixel>(
         }
     }
 
-    private static string FormatTestDetails(object properties)
+    private static string FormatTestDetails(params object[] properties)
+    {
+        if (properties?.Any() != true)
+        {
+            return "-";
+        }
+
+        StringBuilder sb = new();
+        return $"_{string.Join("-", properties.Select(FormatTestDetails))}";
+    }
+
+    public static string FormatTestDetails(object properties)
     {
         if (properties is null)
         {
@@ -70,13 +82,24 @@ private static string FormatTestDetails(object properties)
         {
             return FormattableString.Invariant($"-{s}-");
         }
+        else if (properties is Dictionary<string, object> dictionary)
+        {
+            return FormattableString.Invariant($"_{string.Join(
+                "-",
+                dictionary.Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
+        }
 
-        IEnumerable<PropertyInfo> runtimeProperties = properties.GetType().GetRuntimeProperties();
+        Type type = properties.GetType();
+        TypeInfo info = type.GetTypeInfo();
+        if (info.IsPrimitive || info.IsEnum || type == typeof(decimal))
+        {
+            return FormattableString.Invariant($"{properties}");
+        }
 
+        IEnumerable<PropertyInfo> runtimeProperties = type.GetRuntimeProperties();
         return FormattableString.Invariant($"_{string.Join(
             "-",
             runtimeProperties.ToDictionary(x => x.Name, x => x.GetValue(properties))
                 .Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
     }
 }
-
diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs
index 58ae66e5..2cf4ff9f 100644
--- a/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TolerantImageComparer.cs
@@ -58,7 +58,7 @@ public TolerantImageComparer(float imageThreshold, int perPixelManhattanThreshol
 
     public override ImageSimilarityReport<TPixelA, TPixelB> CompareImagesOrFrames<TPixelA, TPixelB>(int index, ImageFrame<TPixelA> expected, ImageFrame<TPixelB> actual)
     {
-        if (expected.Size != actual.Size)
+        if (expected.Size() != actual.Size())
         {
             throw new InvalidOperationException("Calling ImageComparer is invalid when dimensions mismatch!");
         }
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_367.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_367.cs
index 58b44e6f..aaa87cd2 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_367.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_367.cs
@@ -25,6 +25,8 @@ public void ShouldMatchBrowserBreak()
         Assert.Equal(3, lineCount);
 
         FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
+        TextLayoutTestUtilities.TestLayout(text, options);
+
         Assert.Equal(354.968658F, advance.Width, Comparer);
         Assert.Equal(48, advance.Height, Comparer);
     }
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
index 5b3bcc6f..f19d8f9e 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
@@ -22,10 +22,12 @@ public void ShouldNotInsertExtraLineBreaks()
             };
 
             int lineCount = TextMeasurer.CountLines(text, options);
-            Assert.Equal(3, lineCount);
+            Assert.Equal(4, lineCount);
 
             IReadOnlyList<GlyphLayout> layout = TextLayout.GenerateLayout(text, options);
-            Assert.Equal(47, layout.Count);
+            Assert.Equal(46, layout.Count);
+
+            TextLayoutTestUtilities.TestLayout(text, options);
         }
     }
 }
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs
index e0a0f4ea..01cc6bee 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs
@@ -8,9 +8,8 @@ namespace SixLabors.Fonts.Tests.Issues;
 public class Issues_434
 {
     [Theory]
-    [InlineData("- Lorem ipsullll\n\ndolor sit amet\n-consectetur elit", 3)]
-    [InlineData("- Lorem ipsullll\n\n\ndolor sit amet\n-consectetur elit", 3)]
-    public void ShouldNotInsertExtraLineBreaks(string text, int expectedLineCount)
+    [InlineData("- Lorem ipsullll\n\ndolor sit amet\n-consectetur elit", 4)]
+    public void ShouldInsertExtraLineBreaksA(string text, int expectedLineCount)
     {
         if (SystemFonts.TryGet("Arial", out FontFamily family))
         {
@@ -21,11 +20,40 @@ public void ShouldNotInsertExtraLineBreaks(string text, int expectedLineCount)
                 WrappingLength = 400,
             };
 
+            // Line count includes rendered lines only.
+            // Line breaks cause offsetting of subsequent lines.
             int lineCount = TextMeasurer.CountLines(text, options);
             Assert.Equal(expectedLineCount, lineCount);
 
             IReadOnlyList<GlyphLayout> layout = TextLayout.GenerateLayout(text, options);
-            Assert.Equal(47, layout.Count);
+            Assert.Equal(46, layout.Count);
+
+            TextLayoutTestUtilities.TestLayout(text, options, properties: expectedLineCount);
+        }
+    }
+
+    [Theory]
+    [InlineData("- Lorem ipsullll\n\n\ndolor sit amet\n-consectetur elit", 4)]
+    public void ShouldInsertExtraLineBreaksB(string text, int expectedLineCount)
+    {
+        if (SystemFonts.TryGet("Arial", out FontFamily family))
+        {
+            Font font = family.CreateFont(60);
+            TextOptions options = new(font)
+            {
+                Origin = new Vector2(50, 20),
+                WrappingLength = 400,
+            };
+
+            // Line count includes rendered lines only.
+            // Line breaks cause offsetting of subsequent lines.
+            int lineCount = TextMeasurer.CountLines(text, options);
+            Assert.Equal(expectedLineCount, lineCount);
+
+            IReadOnlyList<GlyphLayout> layout = TextLayout.GenerateLayout(text, options);
+            Assert.Equal(46, layout.Count);
+
+            TextLayoutTestUtilities.TestLayout(text, options, properties: expectedLineCount);
         }
     }
 }
diff --git a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
index 3114ff53..f94c0a62 100644
--- a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
+++ b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
@@ -10,7 +10,7 @@
     <!--Avoid culture analysis in FontCollection overloads-->
     <NoWarn>CA1304</NoWarn>
   </PropertyGroup>
-  
+
   <Choose>
     <When Condition="$(SIXLABORS_TESTING_PREVIEW) == true">
       <PropertyGroup>
@@ -23,7 +23,20 @@
       </PropertyGroup>
     </Otherwise>
   </Choose>
-  
+
+  <PropertyGroup>
+    <!--
+    Comment out this constant declaration to disable all tests based upon image generation.
+    This allows us to make breaking changes to the Fonts API without breaking the tests.
+    -->
+    <DefineConstants>$(DefineConstants);SUPPORTS_DRAWING</DefineConstants>
+    <HasSupportForDrawing Condition="$(DefineConstants.Contains('SUPPORTS_DRAWING'))">true</HasSupportForDrawing>
+  </PropertyGroup>
+
+  <ItemGroup Condition="$(HasSupportForDrawing) == false">
+    <Compile Remove="ImageComparison\**" />
+  </ItemGroup>
+
   <ItemGroup>
     <PackageReference Include="Moq" />
   </ItemGroup>
@@ -35,8 +48,8 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4" />
-    <PackageReference Include="Pegasus" Version="4.1.0" PrivateAssets="all" />    
+    <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4" Condition="$(HasSupportForDrawing)" />
+    <PackageReference Include="Pegasus" Version="4.1.0" PrivateAssets="all" />
     <Compile Include="..\..\src\UnicodeTrieGenerator\StateAutomation\DeterministicFiniteAutomata.cs" Link="Unicode\StateAutomation\DeterministicFiniteAutomata.cs" />
     <Compile Include="..\..\src\UnicodeTrieGenerator\StateAutomation\Compile.cs" Link="Unicode\StateAutomation\Compile.cs" />
     <Compile Include="..\..\src\UnicodeTrieGenerator\StateAutomation\State.cs" Link="Unicode\StateAutomation\State.cs" />
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
new file mode 100644
index 00000000..266f4220
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+
+#if SUPPORTS_DRAWING
+using SixLabors.Fonts.Tables.AdvancedTypographic;
+using SixLabors.Fonts.Tests.TestUtilities;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+#endif
+
+namespace SixLabors.Fonts.Tests;
+
+internal static class TextLayoutTestUtilities
+{
+    public static void TestLayout(
+        string text,
+        TextOptions options,
+        float percentageTolerance = 0F,
+        [CallerMemberName] string test = "",
+        params object[] properties)
+    {
+#if SUPPORTS_DRAWING
+        FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
+        int width = (int)(Math.Ceiling(advance.Width) + Math.Ceiling(options.Origin.X));
+        int height = (int)(Math.Ceiling(advance.Height) + Math.Ceiling(options.Origin.Y));
+
+        bool isVertical = !options.LayoutMode.IsHorizontal();
+        int wrappingLength = isVertical
+            ? (int)(Math.Ceiling(options.WrappingLength) + Math.Ceiling(options.Origin.Y))
+            : (int)(Math.Ceiling(options.WrappingLength) + Math.Ceiling(options.Origin.X));
+
+        int imageWidth = isVertical ? width : Math.Max(width, wrappingLength + 1);
+        int imageHeight = isVertical ? Math.Max(height, wrappingLength + 1) : height;
+
+        using Image<Rgba32> img = new(imageWidth, imageHeight, Color.White);
+
+        img.Mutate(ctx => ctx.DrawText(FromTextOptions(options), text, Color.Black));
+
+        if (wrappingLength > 0)
+        {
+            if (!options.LayoutMode.IsHorizontal())
+            {
+                img.Mutate(x => x.DrawLine(Color.Red, 1, new(0, wrappingLength), new(width, wrappingLength)));
+            }
+            else
+            {
+                img.Mutate(x => x.DrawLine(Color.Red, 1, new(wrappingLength, 0), new(wrappingLength, height)));
+            }
+
+            if (properties.Any())
+            {
+                List<object> extended = properties.ToList();
+                extended.Insert(0, options.WrappingLength);
+                img.CompareToReference(percentageTolerance: percentageTolerance, test: test, properties: extended.ToArray());
+            }
+            else
+            {
+                img.CompareToReference(percentageTolerance: percentageTolerance, test: test, properties: new { options.WrappingLength });
+            }
+        }
+        else
+        {
+            img.CompareToReference(percentageTolerance: percentageTolerance, test: test, properties: properties);
+        }
+
+#endif
+    }
+
+#if SUPPORTS_DRAWING
+    private static RichTextOptions FromTextOptions(TextOptions options)
+    {
+        RichTextOptions result = new(options.Font)
+        {
+            FallbackFontFamilies = new List<FontFamily>(options.FallbackFontFamilies),
+            TabWidth = options.TabWidth,
+            HintingMode = options.HintingMode,
+            Dpi = options.Dpi,
+            LineSpacing = options.LineSpacing,
+            Origin = options.Origin,
+            WrappingLength = options.WrappingLength,
+            WordBreaking = options.WordBreaking,
+            TextDirection = options.TextDirection,
+            TextAlignment = options.TextAlignment,
+            TextJustification = options.TextJustification,
+            HorizontalAlignment = options.HorizontalAlignment,
+            VerticalAlignment = options.VerticalAlignment,
+            LayoutMode = options.LayoutMode,
+            KerningMode = options.KerningMode,
+            ColorFontSupport = options.ColorFontSupport,
+            FeatureTags = new List<Tag>(options.FeatureTags),
+        };
+
+        if (options.TextRuns.Count > 0)
+        {
+            List<RichTextRun> runs = new(options.TextRuns.Count);
+            foreach (TextRun run in options.TextRuns)
+            {
+                runs.Add(new RichTextRun()
+                {
+                    Font = run.Font,
+                    Start = run.Start,
+                    End = run.End,
+                    TextAttributes = run.TextAttributes,
+                    TextDecorations = run.TextDecorations
+                });
+            }
+        }
+
+        return result;
+    }
+#endif
+}
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
index 497d7836..340aef0a 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs
@@ -4,12 +4,8 @@
 using System.Globalization;
 using System.Numerics;
 using SixLabors.Fonts.Tests.Fakes;
-using SixLabors.Fonts.Tests.TestUtilities;
 using SixLabors.Fonts.Unicode;
-using SixLabors.ImageSharp;
 using SixLabors.ImageSharp.Drawing.Processing;
-using SixLabors.ImageSharp.PixelFormats;
-using SixLabors.ImageSharp.Processing;
 
 namespace SixLabors.Fonts.Tests;
 
@@ -276,172 +272,195 @@ public void TryMeasureCharacterBounds()
     }
 
     [Theory]
-    [InlineData("hello world", 10, 310)]
-    [InlineData(
-        "hello world hello world hello world",
-        70, // 30 actual line height * 2 + 10 actual height
-        310)]
+    [InlineData("hello world", 10, 87.125F)]
+    [InlineData("hello world hello world hello world", 11.438F, 279.13F)]
     [InlineData(// issue https://github.com/SixLabors/ImageSharp.Drawing/issues/115
         "这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。",
-        160, // 30 actual line height * 2 + 10 actual height
-        310)]
+        62.625,
+        318.86F)]
     public void MeasureTextWordWrappingHorizontalTopBottom(string text, float height, float width)
     {
-        Font font = CreateFont(text);
-        FontRectangle size = TextMeasurer.MeasureBounds(text, new TextOptions(font)
+        if (SystemFonts.TryGet("SimSun", out FontFamily family))
         {
-            Dpi = font.FontMetrics.ScaleFactor,
-            WrappingLength = 350,
-            LayoutMode = LayoutMode.HorizontalTopBottom
-        });
+            Font font = family.CreateFont(16);
+            TextOptions options = new(font)
+            {
+                WrappingLength = 350,
+                LayoutMode = LayoutMode.HorizontalTopBottom
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
+            FontRectangle size = TextMeasurer.MeasureBounds(text, options);
+
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { height, width });
+        }
     }
 
     [Theory]
-    [InlineData("hello world", 10, 310)]
-    [InlineData(
-        "hello world hello world hello world",
-        70, // 30 actual line height * 2 + 10 actual height
-        310)]
+    [InlineData("hello world", 10, 87.125F)]
+    [InlineData("hello world hello world hello world", 11.438F, 279.13F)]
     [InlineData(// issue https://github.com/SixLabors/ImageSharp.Drawing/issues/115
         "这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。",
-        160, // 30 actual line height * 2 + 10 actual height
-        310)]
+        62.625,
+        318.86F)]
     public void MeasureTextWordWrappingHorizontalBottomTop(string text, float height, float width)
     {
-        Font font = CreateFont(text);
-        FontRectangle size = TextMeasurer.MeasureBounds(text, new TextOptions(font)
+        if (SystemFonts.TryGet("SimSun", out FontFamily family))
         {
-            Dpi = font.FontMetrics.ScaleFactor,
-            WrappingLength = 350,
-            LayoutMode = LayoutMode.HorizontalBottomTop
-        });
+            Font font = family.CreateFont(16);
+            TextOptions options = new(font)
+            {
+                WrappingLength = 350,
+                LayoutMode = LayoutMode.HorizontalBottomTop
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
+            FontRectangle size = TextMeasurer.MeasureBounds(text, options);
+
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { height, width });
+        }
     }
 
     [Theory]
-    [InlineData("hello world", 310, 10)]
-    [InlineData("hello world hello world hello world", 310, 70)]
-    [InlineData("这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。", 310, 160)]
+    [InlineData("hello world", 171.25F, 10)]
+    [InlineData("hello world hello world hello world", 267.25F, 23.875F)]
+    [InlineData("这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。", 318.563F, 62.813F)]
     public void MeasureTextWordWrappingVerticalLeftRight(string text, float height, float width)
     {
-        Font font = CreateFont(text);
-        FontRectangle size = TextMeasurer.MeasureBounds(text, new TextOptions(font)
+        if (SystemFonts.TryGet("SimSun", out FontFamily family))
         {
-            Dpi = font.FontMetrics.ScaleFactor,
-            WrappingLength = 350,
-            LayoutMode = LayoutMode.VerticalLeftRight
-        });
+            Font font = family.CreateFont(16);
+            TextOptions options = new(font)
+            {
+                WrappingLength = 350,
+                LayoutMode = LayoutMode.VerticalLeftRight
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
+            FontRectangle size = TextMeasurer.MeasureBounds(text, options);
+
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { height, width });
+        }
     }
 
     [Theory]
-    [InlineData("hello world", 310, 10)]
-    [InlineData("hello world hello world hello world", 310, 70)]
-    [InlineData("这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。", 310, 160)]
+    [InlineData("hello world", 171.25F, 10)]
+    [InlineData("hello world hello world hello world", 267.25F, 23.875F)]
+    [InlineData("这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。", 318.563F, 62.813F)]
     public void MeasureTextWordWrappingVerticalRightLeft(string text, float height, float width)
     {
-        Font font = CreateFont(text);
-        FontRectangle size = TextMeasurer.MeasureBounds(text, new TextOptions(font)
+        if (SystemFonts.TryGet("SimSun", out FontFamily family))
         {
-            Dpi = font.FontMetrics.ScaleFactor,
-            WrappingLength = 350,
-            LayoutMode = LayoutMode.VerticalRightLeft
-        });
+            Font font = family.CreateFont(16);
+            TextOptions options = new(font)
+            {
+                WrappingLength = 350,
+                LayoutMode = LayoutMode.VerticalRightLeft
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
+            FontRectangle size = TextMeasurer.MeasureBounds(text, options);
+
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { height, width });
+        }
     }
 
     [Theory]
-    [InlineData("hello world", 310, 10)]
-    [InlineData("hello world hello world hello world", 310, 70)]
-    [InlineData("这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。", 310, 160)]
+    [InlineData("hello world", 87.125F, 10)]
+    [InlineData("hello world hello world hello world", 279.125F, 11.438F)]
+    [InlineData("这是一段长度超出设定的换行宽度的文本,但是没有在设定的宽度处换行。这段文本用于演示问题。希望可以修复。如果有需要可以联系我。", 318.563F, 62.813F)]
     public void MeasureTextWordWrappingVerticalMixedLeftRight(string text, float height, float width)
     {
-        Font font = CreateFont(text);
-        FontRectangle size = TextMeasurer.MeasureBounds(text, new TextOptions(font)
+        if (SystemFonts.TryGet("SimSun", out FontFamily family))
         {
-            Dpi = font.FontMetrics.ScaleFactor,
-            WrappingLength = 350,
-            LayoutMode = LayoutMode.VerticalMixedLeftRight
-        });
+            Font font = family.CreateFont(16);
+            TextOptions options = new(font)
+            {
+                WrappingLength = 350,
+                LayoutMode = LayoutMode.VerticalMixedLeftRight
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
+            FontRectangle size = TextMeasurer.MeasureBounds(text, options);
+
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { height, width });
+        }
     }
 
-#if OS_WINDOWS
     [Theory]
-    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.Standard, 100, 870)]
-    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakAll, 120, 399)]
-    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakWord, 120, 400)]
-    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.KeepAll, 60, 699)]
-    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.Standard, 101, 870)]
-    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakAll, 121, 399)]
-    //[InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakWord, 121, 400)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.KeepAll, 61, 699)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.Standard, 100, 696.51F)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakAll, 129.29F, 237.53F)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakWord, 128, 237.53F)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.KeepAll, 65.29F, 699)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.Standard, 96F, 696.51F)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakAll, 129.29F, 237.53F)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakWord, 128, 237.53F)]
+    [InlineData("Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.KeepAll, 61, 699)]
     public void MeasureTextWordBreakMatchesMDN(string text, LayoutMode layoutMode, WordBreaking wordBreaking, float height, float width)
     {
-        // Testing using Windows only to ensure that actual glyphs are rendered
-        // against known physically tested values.
-        FontFamily arial = SystemFonts.Get("Arial");
-        FontFamily jhengHei = SystemFonts.Get("Microsoft JhengHei");
-
-        Font font = arial.CreateFont(16);
-        FontRectangle size = TextMeasurer.MeasureAdvance(
-            text,
-            new TextOptions(font)
+        // See https://developer.mozilla.org/en-US/docs/Web/CSS/word-break
+        if (SystemFonts.TryGet("Arial", out FontFamily arial) &&
+            SystemFonts.TryGet("Microsoft JhengHei", out FontFamily jhengHei))
+        {
+            Font font = arial.CreateFont(16);
+            TextOptions options = new(font)
             {
-                Dpi = 96,
                 WrappingLength = 238,
                 LayoutMode = layoutMode,
                 WordBreaking = wordBreaking,
                 FallbackFontFamilies = new[] { jhengHei }
-            });
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
-    }
+            FontRectangle size = TextMeasurer.MeasureAdvance(
+                text,
+                options);
 
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { layoutMode, wordBreaking });
+        }
+    }
 
     [Theory]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.Standard, 100, 870)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakAll, 120, 399)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakWord, 120, 400)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.KeepAll, 60, 699)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.Standard, 101, 870)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakAll, 121, 399)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakWord, 121, 400)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.Standard, 100, 870.635F)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakAll, 100, 500)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.BreakWord, 120, 490.35F)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalTopBottom, WordBreaking.KeepAll, 81.89F, 870.635F)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.Standard, 101, 870.635F)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakAll, 100, 500)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.BreakWord, 121, 490.35F)]
     [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉", LayoutMode.HorizontalBottomTop, WordBreaking.KeepAll, 61, 699)]
     public void MeasureTextWordBreak(string text, LayoutMode layoutMode, WordBreaking wordBreaking, float height, float width)
     {
-        // Testing using Windows only to ensure that actual glyphs are rendered
-        // against known physically tested values.
-        FontFamily arial = SystemFonts.Get("Arial");
-        FontFamily jhengHei = SystemFonts.Get("Microsoft JhengHei");
-
-        Font font = arial.CreateFont(20);
-        FontRectangle size = TextMeasurer.MeasureAdvance(
-            text,
-            new TextOptions(font)
+        // See https://developer.mozilla.org/en-US/docs/Web/CSS/word-break
+        if (SystemFonts.TryGet("Arial", out FontFamily arial) &&
+            SystemFonts.TryGet("Microsoft JhengHei", out FontFamily jhengHei))
+        {
+            Font font = arial.CreateFont(20);
+            TextOptions options = new(font)
             {
-                WrappingLength = 400,
+                WrappingLength = 500,
                 LayoutMode = layoutMode,
                 WordBreaking = wordBreaking,
                 FallbackFontFamilies = new[] { jhengHei }
-            });
+            };
 
-        Assert.Equal(width, size.Width, 4F);
-        Assert.Equal(height, size.Height, 4F);
+            FontRectangle size = TextMeasurer.MeasureAdvance(
+                text,
+                options);
+
+            Assert.Equal(width, size.Width, 4F);
+            Assert.Equal(height, size.Height, 4F);
+
+            TextLayoutTestUtilities.TestLayout(text, options, properties: new { layoutMode, wordBreaking });
+        }
     }
-#endif
 
     [Theory]
     [InlineData("ab", 477, 1081, false)] // no kerning rules defined for lowercase ab so widths should stay the same
@@ -530,30 +549,21 @@ public void CountLinesWithSpan()
     }
 
     [Theory]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 25, 7)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 50, 7)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 100, 7)]
-    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 200, 6)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 25, 6)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 50, 5)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 100, 4)]
+    [InlineData("This is a long and Honorificabilitudinitatibus califragilisticexpialidocious", 200, 3)]
     public void CountLinesWrappingLength(string text, int wrappingLength, int usedLines)
     {
         Font font = CreateRenderingFont();
         RichTextOptions options = new(font)
         {
-            // Dpi = font.FontMetrics.ScaleFactor,
             WrappingLength = wrappingLength
         };
 
         int count = TextMeasurer.CountLines(text, options);
-
-        // Assert.Equal(usedLines, count);
-        FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
-        int width = (int)Math.Ceiling(advance.Width);
-        int height = (int)Math.Ceiling(advance.Height);
-
-        using Image<Rgba32> img = new(Math.Max(wrappingLength + 1, width), height, Color.White);
-        img.Mutate(x => x.DrawLine(Color.Red, 1, new(wrappingLength, 0), new(wrappingLength, height)));
-        img.Mutate(ctx => ctx.DrawText(options, text, Color.Black));
-        img.DebugSave(properties: new { wrappingLength, usedLines });
+        Assert.Equal(usedLines, count);
+        TextLayoutTestUtilities.TestLayout(text, options, properties: usedLines);
     }
 
     [Fact]
@@ -660,8 +670,8 @@ public void TextJustification_InterCharacter_Horizontal(TextDirection direction)
     {
         const string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ornare maximus vehicula. Duis nisi velit, dictum id mauris vitae, lobortis pretium quam. Quisque sed nisi pulvinar, consequat justo id, feugiat leo. Cras eu elementum dui.";
         const float wrappingLength = 400;
-        const float pointSize = 20;
-        Font font = CreateFont(text, pointSize);
+        const float pointSize = 12;
+        Font font = CreateRenderingFont(pointSize);
         TextOptions options = new(font)
         {
             TextDirection = direction,
@@ -672,9 +682,11 @@ public void TextJustification_InterCharacter_Horizontal(TextDirection direction)
         // Collect the first line so we can compare it to the target wrapping length.
         IReadOnlyList<GlyphLayout> justifiedGlyphs = TextLayout.GenerateLayout(text.AsSpan(), options);
         IReadOnlyList<GlyphLayout> justifiedLine = CollectFirstLine(justifiedGlyphs);
-        TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan<GlyphBounds> justifiedCharacterBounds);
+        TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan<GlyphBounds> advances);
 
-        Assert.Equal(wrappingLength, justifiedCharacterBounds.ToArray().Sum(x => x.Bounds.Width), 4F);
+        TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, options.TextJustification });
+
+        Assert.Equal(wrappingLength, advances.ToArray().Sum(x => x.Bounds.Width), 4F);
 
         // Now compare character widths.
         options.TextJustification = TextJustification.None;
@@ -688,11 +700,11 @@ public void TextJustification_InterCharacter_Horizontal(TextDirection direction)
         {
             if (i == characterBounds.Length - 1)
             {
-                Assert.Equal(justifiedCharacterBounds[i].Bounds.Width, characterBounds[i].Bounds.Width);
+                Assert.Equal(advances[i].Bounds.Width, characterBounds[i].Bounds.Width);
             }
             else
             {
-                Assert.True(justifiedCharacterBounds[i].Bounds.Width > characterBounds[i].Bounds.Width);
+                Assert.True(advances[i].Bounds.Width > characterBounds[i].Bounds.Width);
             }
         }
     }
@@ -704,8 +716,8 @@ public void TextJustification_InterWord_Horizontal(TextDirection direction)
     {
         const string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ornare maximus vehicula. Duis nisi velit, dictum id mauris vitae, lobortis pretium quam. Quisque sed nisi pulvinar, consequat justo id, feugiat leo. Cras eu elementum dui.";
         const float wrappingLength = 400;
-        const float pointSize = 20;
-        Font font = CreateFont(text, pointSize);
+        const float pointSize = 12;
+        Font font = CreateRenderingFont(pointSize);
         TextOptions options = new(font)
         {
             TextDirection = direction,
@@ -718,6 +730,8 @@ public void TextJustification_InterWord_Horizontal(TextDirection direction)
         IReadOnlyList<GlyphLayout> justifiedLine = CollectFirstLine(justifiedGlyphs);
         TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan<GlyphBounds> justifiedCharacterBounds);
 
+        TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, options.TextJustification });
+
         Assert.Equal(wrappingLength, justifiedCharacterBounds.ToArray().Sum(x => x.Bounds.Width), 4F);
 
         // Now compare character widths.
@@ -748,8 +762,8 @@ public void TextJustification_InterCharacter_Vertical(TextDirection direction)
     {
         const string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ornare maximus vehicula. Duis nisi velit, dictum id mauris vitae, lobortis pretium quam. Quisque sed nisi pulvinar, consequat justo id, feugiat leo. Cras eu elementum dui.";
         const float wrappingLength = 400;
-        const float pointSize = 20;
-        Font font = CreateFont(text, pointSize);
+        const float pointSize = 12;
+        Font font = CreateRenderingFont(pointSize);
         TextOptions options = new(font)
         {
             LayoutMode = LayoutMode.VerticalLeftRight,
@@ -763,6 +777,8 @@ public void TextJustification_InterCharacter_Vertical(TextDirection direction)
         IReadOnlyList<GlyphLayout> justifiedLine = CollectFirstLine(justifiedGlyphs);
         TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan<GlyphBounds> justifiedCharacterBounds);
 
+        TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, options.TextJustification });
+
         Assert.Equal(wrappingLength, justifiedCharacterBounds.ToArray().Sum(x => x.Bounds.Height), 4F);
 
         // Now compare character widths.
@@ -793,8 +809,8 @@ public void TextJustification_InterWord_Vertical(TextDirection direction)
     {
         const string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ornare maximus vehicula. Duis nisi velit, dictum id mauris vitae, lobortis pretium quam. Quisque sed nisi pulvinar, consequat justo id, feugiat leo. Cras eu elementum dui.";
         const float wrappingLength = 400;
-        const float pointSize = 20;
-        Font font = CreateFont(text, pointSize);
+        const float pointSize = 12;
+        Font font = CreateRenderingFont(pointSize);
         TextOptions options = new(font)
         {
             LayoutMode = LayoutMode.VerticalLeftRight,
@@ -808,6 +824,8 @@ public void TextJustification_InterWord_Vertical(TextDirection direction)
         IReadOnlyList<GlyphLayout> justifiedLine = CollectFirstLine(justifiedGlyphs);
         TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan<GlyphBounds> justifiedCharacterBounds);
 
+        TextLayoutTestUtilities.TestLayout(text, options, properties: new { direction, options.TextJustification });
+
         Assert.Equal(wrappingLength, justifiedCharacterBounds.ToArray().Sum(x => x.Bounds.Height), 4F);
 
         // Now compare character widths.
@@ -1370,10 +1388,8 @@ public FontRectangle BenchmarkTest()
     private static readonly Font Arial = SystemFonts.CreateFont("Arial", 12);
 #endif
 
-    public static Font CreateRenderingFont()
-    {
-        return new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(12);
-    }
+    public static Font CreateRenderingFont(float pointSize = 12)
+        => new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(pointSize);
 
     public static Font CreateFont(string text)
     {

From 33fcad7e4b9b51f5556f92d19da564c83c09a91b Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Tue, 7 Jan 2025 19:45:33 +1000
Subject: [PATCH 04/11] Update .gitignore

---
 .gitignore | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.gitignore b/.gitignore
index d213743d..68bc8687 100644
--- a/.gitignore
+++ b/.gitignore
@@ -254,6 +254,8 @@ paket-files/
 *.sln.iml
 /samples/DrawWithImageSharp/Output
 
+# Tests
+**/Images/ActualOutput
 /tests/CodeCoverage/OpenCover.*
 SixLabors.Shapes.Coverage.xml
 /SixLabors.Fonts.Coverage.xml

From 8b6de4e0c455d6246330c4a6221a15180d1819ce Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 13:37:38 +1000
Subject: [PATCH 05/11] Add bin logging

---
 .github/workflows/build-and-test.yml | 4 +++-
 ci-build.ps1                         | 2 +-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index f9d086d7..eda8224b 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -155,7 +155,9 @@ jobs:
         if: failure()
         with:
           name: actual_output_${{ runner.os }}_${{ matrix.options.framework }}${{ matrix.options.runtime }}.zip
-          path: tests/Images/ActualOutput/
+          path: |
+              tests/Images/ActualOutput/
+              msbuild.log
 
       - name: Codecov Update
         uses: codecov/codecov-action@v4
diff --git a/ci-build.ps1 b/ci-build.ps1
index d45af6ff..7cecb4f4 100644
--- a/ci-build.ps1
+++ b/ci-build.ps1
@@ -8,4 +8,4 @@ dotnet clean -c Release
 $repositoryUrl = "https://github.com/$env:GITHUB_REPOSITORY"
 
 # Building for a specific framework.
-dotnet build -c Release -f $targetFramework /p:RepositoryUrl=$repositoryUrl
+dotnet build -c Release -f $targetFramework /p:RepositoryUrl=$repositoryUrl -bl

From 4b9ebb92bbb2b0c9b762742ae4c7210f8d8a441b Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 13:40:25 +1000
Subject: [PATCH 06/11] Use correct log file name

---
 .github/workflows/build-and-test.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index eda8224b..27f189f0 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -157,7 +157,7 @@ jobs:
           name: actual_output_${{ runner.os }}_${{ matrix.options.framework }}${{ matrix.options.runtime }}.zip
           path: |
               tests/Images/ActualOutput/
-              msbuild.log
+              **/msbuild.binlog
 
       - name: Codecov Update
         uses: codecov/codecov-action@v4

From dfedca528bf28b84af3fcd5b2d754c3f7906af22 Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 14:01:29 +1000
Subject: [PATCH 07/11] Temporarily disable the COM analyzer to work around
 build issue

---
 src/SixLabors.Fonts/SixLabors.Fonts.csproj | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/SixLabors.Fonts/SixLabors.Fonts.csproj b/src/SixLabors.Fonts/SixLabors.Fonts.csproj
index 0cf7745d..b90f8cfd 100644
--- a/src/SixLabors.Fonts/SixLabors.Fonts.csproj
+++ b/src/SixLabors.Fonts/SixLabors.Fonts.csproj
@@ -19,6 +19,9 @@
   <PropertyGroup>
     <Nullable>enable</Nullable>
     <WarningsAsErrors>Nullable</WarningsAsErrors>
+
+    <!--Temporarily disable the COM analyzer to work around build issue.-->
+    <NoWarn>$(NoWarn);IL2050;</NoWarn>
   </PropertyGroup>
 
   <PropertyGroup>

From 237eeab380a308529be8bdf67140d751857746ef Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 14:43:57 +1000
Subject: [PATCH 08/11] Use langversion 10

---
 .../ImageComparison/TestImageExtensions.cs           |  9 ++-------
 tests/SixLabors.Fonts.Tests/Issues/Issues_400.cs     | 12 ++++++++----
 .../SixLabors.Fonts.Tests.csproj                     |  2 +-
 3 files changed, 11 insertions(+), 12 deletions(-)

diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
index 921d2cbe..6fce63c6 100644
--- a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
@@ -84,9 +84,7 @@ public static string FormatTestDetails(object properties)
         }
         else if (properties is Dictionary<string, object> dictionary)
         {
-            return FormattableString.Invariant($"_{string.Join(
-                "-",
-                dictionary.Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
+            return FormattableString.Invariant($"_{string.Join("-", dictionary.Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
         }
 
         Type type = properties.GetType();
@@ -97,9 +95,6 @@ public static string FormatTestDetails(object properties)
         }
 
         IEnumerable<PropertyInfo> runtimeProperties = type.GetRuntimeProperties();
-        return FormattableString.Invariant($"_{string.Join(
-            "-",
-            runtimeProperties.ToDictionary(x => x.Name, x => x.GetValue(properties))
-                .Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
+        return FormattableString.Invariant($"_{string.Join("-", runtimeProperties.ToDictionary(x => x.Name, x => x.GetValue(properties)).Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
     }
 }
diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_400.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_400.cs
index 12f61e8e..fd9ec3cf 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_400.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_400.cs
@@ -1,6 +1,8 @@
 // Copyright (c) Six Labors.
 // Licensed under the Six Labors Split License.
 
+using System.Text;
+
 namespace SixLabors.Fonts.Tests.Issues;
 public class Issues_400
 {
@@ -14,11 +16,13 @@ public void RenderingTextIncludesAllGlyphs()
             WrappingLength = 1900
         };
 
-        const string content = """
-                          NEWS_CATEGORY=EWF&NEWS_HASH=4b298ff9277ef9fdf515356be95ea3caf57cd36&OFFSET=0&SEARCH_VALUE=CA88105E1088&ID_NEWS
-          """;
+        StringBuilder stringBuilder = new();
+        stringBuilder
+            .AppendLine()
+            .AppendLine("                NEWS_CATEGORY=EWF&NEWS_HASH=4b298ff9277ef9fdf515356be95ea3caf57cd36&OFFSET=0&SEARCH_VALUE=CA88105E1088&ID_NEWS")
+            .Append("          ");
 
-        int lineCount = TextMeasurer.CountLines(content, options);
+        int lineCount = TextMeasurer.CountLines(stringBuilder.ToString(), options);
         Assert.Equal(2, lineCount);
 #endif
     }
diff --git a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
index f94c0a62..686eaa70 100644
--- a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
+++ b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
@@ -3,7 +3,7 @@
   <PropertyGroup>
     <DebugSymbols>True</DebugSymbols>
     <Platforms>AnyCPU;x64;x86</Platforms>
-    <LangVersion>11</LangVersion>
+    <LangVersion>10</LangVersion>
   </PropertyGroup>
 
   <PropertyGroup>

From a214311eb44781126100f01d80ea3fc4b06a3c91 Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 16:38:55 +1000
Subject: [PATCH 09/11] Up default tolerance

---
 .../ImageComparison/TestImageExtensions.cs                      | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
index 6fce63c6..80183068 100644
--- a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
@@ -32,7 +32,7 @@ public static string DebugSave(
 
     public static void CompareToReference<TPixel>(
         this Image<TPixel> image,
-        float percentageTolerance = 0F,
+        float percentageTolerance = 0.05F,
         string extension = null,
         [CallerMemberName] string test = "",
         params object[] properties)

From 079ebc67323fd2775da5f5b19c47f29fb7e726cc Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 16:47:47 +1000
Subject: [PATCH 10/11] Use correct constructor

---
 .../ImageComparison/TestImageExtensions.cs                      | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
index 80183068..5fe7d3c9 100644
--- a/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
@@ -47,7 +47,7 @@ public static void CompareToReference<TPixel>(
         }
 
         using Image<Rgba64> expected = Image.Load<Rgba64>(referencePath);
-        TolerantImageComparer comparer = new(percentageTolerance / 100F);
+        ImageComparer comparer = ImageComparer.TolerantPercentage(percentageTolerance);
         ImageSimilarityReport report = comparer.CompareImagesOrFrames(expected, image);
 
         if (!report.IsEmpty)

From 62920eea86ebda19e5a6251fe8bd5f31c11568bd Mon Sep 17 00:00:00 2001
From: James Jackson-South <james_south@hotmail.com>
Date: Wed, 8 Jan 2025 18:06:57 +1000
Subject: [PATCH 11/11] Update TextLayoutTestUtilities.cs

---
 tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
index 266f4220..51c68030 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
@@ -19,7 +19,7 @@ internal static class TextLayoutTestUtilities
     public static void TestLayout(
         string text,
         TextOptions options,
-        float percentageTolerance = 0F,
+        float percentageTolerance = 0.05F,
         [CallerMemberName] string test = "",
         params object[] properties)
     {