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 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(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(options.FeatureTags), + }; + + if (options.TextRuns.Count > 0) + { + List 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 dff3522a..340aef0a 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTests.cs @@ -5,6 +5,7 @@ using System.Numerics; using SixLabors.Fonts.Tests.Fakes; using SixLabors.Fonts.Unicode; +using SixLabors.ImageSharp.Drawing.Processing; namespace SixLabors.Fonts.Tests; @@ -271,139 +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("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("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) + { + // 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) + { + WrappingLength = 238, + LayoutMode = layoutMode, + WordBreaking = wordBreaking, + FallbackFontFamilies = new[] { jhengHei } + }; + + 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.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 @@ -492,16 +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 = CreateFont(text); - int count = TextMeasurer.CountLines(text, new TextOptions(font) { Dpi = font.FontMetrics.ScaleFactor, WrappingLength = wrappingLength }); + Font font = CreateRenderingFont(); + RichTextOptions options = new(font) + { + WrappingLength = wrappingLength + }; + int count = TextMeasurer.CountLines(text, options); Assert.Equal(usedLines, count); + TextLayoutTestUtilities.TestLayout(text, options, properties: usedLines); } [Fact] @@ -608,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, @@ -620,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 justifiedGlyphs = TextLayout.GenerateLayout(text.AsSpan(), options); IReadOnlyList justifiedLine = CollectFirstLine(justifiedGlyphs); - TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan justifiedCharacterBounds); + TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan 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; @@ -636,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); } } } @@ -652,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, @@ -666,6 +730,8 @@ public void TextJustification_InterWord_Horizontal(TextDirection direction) IReadOnlyList justifiedLine = CollectFirstLine(justifiedGlyphs); TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan 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. @@ -696,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, @@ -711,6 +777,8 @@ public void TextJustification_InterCharacter_Vertical(TextDirection direction) IReadOnlyList justifiedLine = CollectFirstLine(justifiedGlyphs); TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan 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. @@ -741,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, @@ -756,6 +824,8 @@ public void TextJustification_InterWord_Vertical(TextDirection direction) IReadOnlyList justifiedLine = CollectFirstLine(justifiedGlyphs); TextMeasurer.TryGetCharacterAdvances(justifiedLine, options.Dpi, out ReadOnlySpan 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. @@ -1318,6 +1388,9 @@ public FontRectangle BenchmarkTest() private static readonly Font Arial = SystemFonts.CreateFont("Arial", 12); #endif + public static Font CreateRenderingFont(float pointSize = 12) + => new FontCollection().Add(TestFonts.OpenSansFile).CreateFont(pointSize); + public static Font CreateFont(string text) { var fc = (IFontMetricsCollection)new FontCollection();