diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index f9d086d7..27f189f0 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.binlog
- name: Codecov Update
uses: codecov/codecov-action@v4
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
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
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 @@
enable
Nullable
+
+
+ $(NoWarn);IL2050;
diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index 976b00ff..46137fcb 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -695,10 +695,11 @@ private static IEnumerable 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),
@@ -901,23 +902,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 lineBreaks = new();
- while (lineBreakEnumerator.MoveNext())
- {
- lineBreaks.Add(lineBreakEnumerator.Current);
- }
-
- int lineBreakIndex = 0;
- LineBreak lastLineBreak = lineBreaks[lineBreakIndex];
- LineBreak currentLineBreak = lineBreaks[lineBreakIndex];
int graphemeIndex;
int codePointIndex = 0;
float lineAdvance = 0;
List textLines = new();
TextLine textLine = new();
- int glyphCount = 0;
int stringIndex = 0;
// No glyph should contain more than 64 metrics.
@@ -1089,7 +1078,6 @@ VerticalOrientationType.Rotate or
{
float scaleAX = pointSize / glyph.ScaleFactor.X;
glyphAdvance *= scaleAX;
-
for (int i = 0; i < decomposedAdvances.Length; i++)
{
decomposedAdvances[i] *= scaleAX;
@@ -1105,127 +1093,6 @@ VerticalOrientationType.Rotate or
}
}
- // Should we start a new line?
- bool requiredBreak = false;
- if (graphemeCodePointIndex == 0)
- {
- // Mandatory wrap at index.
- if (currentLineBreak.PositionWrap == codePointIndex && currentLineBreak.Required)
- {
- 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)
- {
- 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)
- {
- // Exact length match. Check for CJK
- TextLine split = textLine.SplitAt(currentLineBreak, 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))
- {
- // 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.
- TextLine split = textLine.SplitAt(lastLineBreak, keepAll);
- if (split != textLine)
- {
- textLines.Add(textLine.Finalize());
- textLine = split;
- lineAdvance = split.ScaledLineAdvance;
- }
- else if (breakWord && textLine.Count > 0)
- {
- 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)
- {
- 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++)
{
@@ -1259,6 +1126,7 @@ VerticalOrientationType.Rotate or
bidiRuns[bidiMap[codePointIndex]],
graphemeIndex,
codePointIndex,
+ graphemeCodePointIndex,
shouldRotate || shouldOffset,
isDecomposed,
stringIndex);
@@ -1271,37 +1139,144 @@ VerticalOrientationType.Rotate or
stringIndex += graphemeEnumerator.Current.Length;
}
+ // 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 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(options));
+ textLine = remaining;
+ i = 0;
+ lineAdvance = 0;
+ }
+ else if (shouldWrap)
+ {
+ float currentAdvance = lineAdvance + glyphAdvance;
+ if (currentAdvance >= wrappingLength)
+ {
+ if (breakAll)
+ {
+ // 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 (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;
+ }
+ }
+ }
+ }
+ }
+
+ // Find the next line break.
+ if (lineBreakIndex < maxLineBreakIndex &&
+ (currentLineBreak.PositionWrap == codePointIndex))
+ {
+ lastLineBreak = currentLineBreak;
+ currentLineBreak = lineBreaks[++lineBreakIndex];
+ }
+ }
+
// 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 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 textLines)
+ => this.TextLines = textLines;
public IReadOnlyList 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;
}
internal sealed class TextLine
{
- private readonly List data = new();
+ private readonly List data;
+
+ public TextLine() => this.data = new(16);
+
+ public TextLine(int capacity) => this.data = new(capacity);
public int Count => this.data.Count;
@@ -1324,7 +1299,8 @@ public void Add(
float scaledDescender,
BidiRun bidiRun,
int graphemeIndex,
- int offset,
+ int codePointIndex,
+ int graphemeCodePointIndex,
bool isTransformed,
bool isDecomposed,
int stringIndex)
@@ -1345,12 +1321,58 @@ public void Add(
scaledDescender,
bidiRun,
graphemeIndex,
- offset,
+ codePointIndex,
+ graphemeCodePointIndex,
isTransformed,
isDecomposed,
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);
+
+ this.data.RemoveRange(index, this.data.Count - index);
+ RecalculateLineMetrics(this);
+ 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;
@@ -1358,8 +1380,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;
}
@@ -1367,15 +1388,12 @@ 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)
- this.TrimTrailingWhitespaceAndRecalculateMetrics();
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.
@@ -1391,50 +1409,30 @@ 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)
- this.TrimTrailingWhitespaceAndRecalculateMetrics();
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);
-
- // Now trim trailing whitespace from this line.
- this.TrimTrailingWhitespaceAndRecalculateMetrics();
-
+ this.data.RemoveRange(index, count);
+ RecalculateLineMetrics(this);
return result;
}
- private void TrimTrailingWhitespaceAndRecalculateMetrics()
+ private void TrimTrailingWhitespace()
{
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;
}
@@ -1446,29 +1444,19 @@ 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);
- }
+ public TextLine Finalize(TextOptions options)
+ {
+ this.TrimTrailingWhitespace();
+ this.BidiReOrder();
+ RecalculateLineMetrics(this);
- this.ScaledLineAdvance = advance;
- this.ScaledMaxAscender = ascender;
- this.ScaledMaxDescender = descender;
- this.ScaledMaxLineHeight = lineHeight;
+ this.Justify(options);
+ RecalculateLineMetrics(this);
+ return this;
}
- public TextLine Finalize() => this.BidiReOrder();
-
public void Justify(TextOptions options)
{
if (options.WrappingLength == -1F || options.TextJustification == TextJustification.None)
@@ -1541,7 +1529,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;
@@ -1591,7 +1579,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.
@@ -1619,8 +1607,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;
}
///
@@ -1696,7 +1704,8 @@ public GlyphLayoutData(
float scaledDescender,
BidiRun bidiRun,
int graphemeIndex,
- int offset,
+ int codePointIndex,
+ int graphemeCodePointIndex,
bool isTransformed,
bool isDecomposed,
int stringIndex)
@@ -1709,7 +1718,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;
@@ -1735,7 +1745,9 @@ public GlyphLayoutData(
public int GraphemeIndex { get; }
- public int Offset { get; }
+ public int GraphemeCodePointIndex { get; }
+
+ public int CodePointIndex { get; }
public bool IsTransformed { get; }
@@ -1746,7 +1758,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/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/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 CompareImagesOrFrames(
+ int index,
+ ImageFrame expected,
+ ImageFrame 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 differences = new();
+ Configuration configuration = expected.Configuration;
+ Buffer2D expectedBuffer = expected.PixelBuffer;
+ Buffer2D actualBuffer = actual.PixelBuffer;
+
+ for (int y = 0; y < actual.Height; y++)
+ {
+ Span aSpan = expectedBuffer.DangerousGetRowSpan(y);
+ Span bSpan = actualBuffer.DangerousGetRowSpan(y);
+
+ PixelOperations.Instance.ToRgba64(configuration, aSpan, aBuffer);
+ PixelOperations.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(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 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);
+
+ ///
+ /// Returns an instance of .
+ /// Individual Manhattan pixel difference is only added to total image difference when the individual difference is over 'perPixelManhattanThreshold'.
+ ///
+ /// The maximal tolerated difference represented by a value between 0 and 1.
+ /// Gets the threshold of the individual pixels before they accumulate towards the overall difference.
+ /// A ImageComparer instance.
+ public static ImageComparer Tolerant(
+ float imageThreshold = TolerantImageComparer.DefaultImageThreshold,
+ int perPixelManhattanThreshold = 0) =>
+ new TolerantImageComparer(imageThreshold, perPixelManhattanThreshold);
+
+ ///
+ /// Returns Tolerant(imageThresholdInPercent/100)
+ ///
+ /// The maximal tolerated difference represented by a value between 0 and 100.
+ /// Gets the threshold of the individual pixels before they accumulate towards the overall difference.
+ /// A ImageComparer instance.
+ public static ImageComparer TolerantPercentage(float imageThresholdInPercent, int perPixelManhattanThreshold = 0)
+ => Tolerant(imageThresholdInPercent / 100F, perPixelManhattanThreshold);
+
+ public abstract ImageSimilarityReport CompareImagesOrFrames(
+ int index,
+ ImageFrame expected,
+ ImageFrame actual)
+ where TPixelA : unmanaged, IPixel
+ where TPixelB : unmanaged, IPixel;
+}
+
+public static class ImageComparerExtensions
+{
+ public static ImageSimilarityReport CompareImagesOrFrames(
+ this ImageComparer comparer,
+ Image expected,
+ Image actual)
+ where TPixelA : unmanaged, IPixel
+ where TPixelB : unmanaged, IPixel => comparer.CompareImagesOrFrames(0, expected.Frames.RootFrame, actual.Frames.RootFrame);
+
+ public static IEnumerable> CompareImages(
+ this ImageComparer comparer,
+ Image expected,
+ Image actual,
+ Func predicate = null)
+ where TPixelA : unmanaged, IPixel
+ where TPixelB : unmanaged, IPixel
+ {
+ List> 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 report = comparer.CompareImagesOrFrames(i, expected.Frames[i], actual.Frames[i]);
+ if (!report.IsEmpty)
+ {
+ result.Add(report);
+ }
+ }
+
+ return result;
+ }
+
+ public static void VerifySimilarity(
+ this ImageComparer comparer,
+ Image expected,
+ Image actual,
+ Func predicate = null)
+ where TPixelA : unmanaged, IPixel
+ where TPixelB : unmanaged, IPixel
+ {
+ 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 reports = comparer.CompareImages(expected, actual, predicate);
+ if (reports.Any())
+ {
+ throw new ImageDifferenceIsOverThresholdException(reports.ToArray());
+ }
+ }
+
+ public static void VerifySimilarityIgnoreRegion(
+ this ImageComparer comparer,
+ Image expected,
+ Image actual,
+ Rectangle ignoredRegion)
+ where TPixelA : unmanaged, IPixel
+ where TPixelB : unmanaged, IPixel
+ {
+ 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> reports = comparer.CompareImages(expected, actual);
+ if (reports.Any())
+ {
+ List> cleanedReports = new(reports.Count());
+ foreach (ImageSimilarityReport r in reports)
+ {
+ IEnumerable 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(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 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 : ImageSimilarityReport
+ where TPixelA : unmanaged, IPixel
+ where TPixelB : unmanaged, IPixel
+{
+ public ImageSimilarityReport(
+ int index,
+ ImageFrame expectedImage,
+ ImageFrame actualImage,
+ IEnumerable differences,
+ float? totalNormalizedDifference = null)
+ : base(index, expectedImage, actualImage, differences, totalNormalizedDifference)
+ {
+ }
+
+ public static ImageSimilarityReport Empty =>
+ new(0, null, null, Enumerable.Empty(), 0f);
+
+ public new ImageFrame ExpectedImage => (ImageFrame)base.ExpectedImage;
+
+ public new ImageFrame ActualImage => (ImageFrame)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..5fe7d3c9
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/ImageComparison/TestImageExtensions.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text;
+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 = "",
+ params object[] properties)
+ {
+ 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(
+ this Image image,
+ float percentageTolerance = 0.05F,
+ string extension = null,
+ [CallerMemberName] string test = "",
+ params object[] properties)
+ where TPixel : unmanaged, IPixel
+ {
+ 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 expected = Image.Load(referencePath);
+ ImageComparer comparer = ImageComparer.TolerantPercentage(percentageTolerance);
+ ImageSimilarityReport report = comparer.CompareImagesOrFrames(expected, image);
+
+ if (!report.IsEmpty)
+ {
+ throw new ImageDifferenceIsOverThresholdException(report);
+ }
+ }
+
+ 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)
+ {
+ return "-";
+ }
+
+ if (properties is FormattableString fs)
+ {
+ return FormattableString.Invariant(fs);
+ }
+ else if (properties is string s)
+ {
+ return FormattableString.Invariant($"-{s}-");
+ }
+ else if (properties is Dictionary dictionary)
+ {
+ return FormattableString.Invariant($"_{string.Join("-", dictionary.Select(x => FormattableString.Invariant($"{x.Key}_{x.Value}")))}_");
+ }
+
+ Type type = properties.GetType();
+ TypeInfo info = type.GetTypeInfo();
+ if (info.IsPrimitive || info.IsEnum || type == typeof(decimal))
+ {
+ return FormattableString.Invariant($"{properties}");
+ }
+
+ IEnumerable 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
new file mode 100644
index 00000000..2cf4ff9f
--- /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);
+
+ ///
+ /// Individual Manhattan pixel difference is only added to total image difference when the individual difference is over 'perPixelManhattanThreshold'.
+ ///
+ /// The maximal tolerated difference represented by a value between 0.0 and 1.0 scaled to 0 and 65535.
+ /// Gets the threshold of the individual pixels before they accumulate towards the overall difference.
+ public TolerantImageComparer(float imageThreshold, int perPixelManhattanThreshold = 0)
+ {
+ Guard.MustBeGreaterThanOrEqualTo(imageThreshold, 0, nameof(imageThreshold));
+
+ this.ImageThreshold = imageThreshold;
+ this.PerPixelManhattanThreshold = perPixelManhattanThreshold;
+ }
+
+ ///
+ ///
+ /// 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
+ ///
+ ///
+ /// The total differences is the sum of all pixel differences normalized by image dimensions!
+ /// The individual distances are calculated using the Manhattan function:
+ ///
+ /// https://en.wikipedia.org/wiki/Taxicab_geometry
+ ///
+ /// 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
+ ///
+ ///
+ public float ImageThreshold { get; }
+
+ ///
+ /// Gets the threshold of the individual pixels before they accumulate towards the overall difference.
+ /// For an individual pixel pair the value is the Manhattan distance of pixels:
+ ///
+ /// https://en.wikipedia.org/wiki/Taxicab_geometry
+ ///
+ ///
+ public int PerPixelManhattanThreshold { get; }
+
+ public override ImageSimilarityReport CompareImagesOrFrames(int index, ImageFrame expected, ImageFrame 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 differences = new();
+ Configuration configuration = expected.Configuration;
+ Buffer2D expectedBuffer = expected.PixelBuffer;
+ Buffer2D actualBuffer = actual.PixelBuffer;
+
+ for (int y = 0; y < actual.Height; y++)
+ {
+ Span aSpan = expectedBuffer.DangerousGetRowSpan(y);
+ Span bSpan = actualBuffer.DangerousGetRowSpan(y);
+
+ PixelOperations.Instance.ToRgba64(configuration, aSpan, aBuffer);
+ PixelOperations.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(index, expected, actual, differences, normalizedDifference);
+ }
+
+ return ImageSimilarityReport.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/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_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_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/Issues/Issues_431.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
index ec47bbd4..f19d8f9e 100644
--- a/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_431.cs
@@ -26,6 +26,8 @@ public void ShouldNotInsertExtraLineBreaks()
IReadOnlyList layout = TextLayout.GenerateLayout(text, options);
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
new file mode 100644
index 00000000..01cc6bee
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_434.cs
@@ -0,0 +1,59 @@
+// 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", 4)]
+ public void ShouldInsertExtraLineBreaksA(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 layout = TextLayout.GenerateLayout(text, options);
+ 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 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 2014f031..686eaa70 100644
--- a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
+++ b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
@@ -3,14 +3,14 @@
True
AnyCPU;x64;x86
- 11
+ 10
CA1304
-
+
@@ -23,7 +23,20 @@
-
+
+
+
+ $(DefineConstants);SUPPORTS_DRAWING
+ true
+
+
+
+
+
+
@@ -35,7 +48,8 @@
-
+
+
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 SolutionDirectoryFullPathLazy = new(GetSolutionDirectoryFullPathImpl);
@@ -20,15 +27,43 @@ internal static class TestEnvironment
///
internal static string UnicodeTestDataFullPath => GetFullPath(UnicodeTestDataRelativePath);
- private static string GetSolutionDirectoryFullPathImpl()
- {
- string assemblyLocation = Path.GetDirectoryName(new Uri(typeof(TestEnvironment).GetTypeInfo().Assembly.CodeBase).LocalPath);
+ ///
+ /// Gets the correct full path to the Actual Output directory. (To be written to by the test cases.)
+ ///
+ internal static string ActualOutputDirectoryFullPath => GetFullPath(ActualOutputDirectoryRelativePath);
+
+ ///
+ /// Gets the correct full path to the Expected Output directory. (To compare the test results to.)
+ ///
+ 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;
+
+
+ ///
+ /// Gets a value indicating whether test execution runs on CI.
+ ///
+#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/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
new file mode 100644
index 00000000..51c68030
--- /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 = 0.05F,
+ [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 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