diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 02977b44..1e737110 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -203,11 +203,11 @@ jobs: **/msbuild.binlog - name: Codecov Update - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 if: matrix.options.codecov == true && startsWith(github.repository, 'SixLabors') with: flags: unittests - + token: ${{ secrets.CODECOV_TOKEN }} Publish: needs: [Build] diff --git a/src/SixLabors.Fonts/LineMetrics.cs b/src/SixLabors.Fonts/LineMetrics.cs new file mode 100644 index 00000000..00968cbf --- /dev/null +++ b/src/SixLabors.Fonts/LineMetrics.cs @@ -0,0 +1,83 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.Fonts; + +/// +/// Encapsulates measured metrics for a single laid-out text line. +/// +/// +/// This type is layout-mode agnostic: +/// +/// Horizontal layouts: is the X start position and is the width. +/// Vertical layouts: is the Y start position and is the height. +/// +/// +public readonly struct LineMetrics +{ + /// + /// Initializes a new instance of the struct. + /// + /// Ascender line position within the line box. + /// Baseline position within the line box. + /// Descender line position within the line box. + /// Total line-box size (includes effective line spacing). + /// Line start position in the primary layout flow direction after alignment. + /// Line extent in the primary layout flow direction. + public LineMetrics( + float ascender, + float baseline, + float descender, + float lineHeight, + float start, + float extent) + { + this.Ascender = ascender; + this.Baseline = baseline; + this.Descender = descender; + this.LineHeight = lineHeight; + this.Start = start; + this.Extent = extent; + } + + /// + /// Gets the ascender line position within the line box. + /// + /// + /// This is a position value (not a baseline-relative distance). + /// Use this value to draw the ascender guide line relative to the current line origin. + /// + public float Ascender { get; } + + /// + /// Gets the baseline position within the line box. + /// + /// + /// Use this value as the guide-line position for drawing a baseline relative to the current line origin. + /// + public float Baseline { get; } + + /// + /// Gets the descender line position within the line box. + /// + /// + /// This is a position value (not a baseline-relative distance). + /// Use this value to draw the descender guide line relative to the current line origin. + /// + public float Descender { get; } + + /// + /// Gets the total line-box size for this line. + /// + public float LineHeight { get; } + + /// + /// Gets the line start position in the primary layout flow direction. + /// + public float Start { get; } + + /// + /// Gets the line extent in the primary layout flow direction. + /// + public float Extent { get; } +} diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index 54a2fb08..7dd460c8 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -85,7 +85,7 @@ public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, Text return textRuns; } - private static TextBox ProcessText(ReadOnlySpan text, TextOptions options) + internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options) { // Gather the font and fallbacks. Font[] fallbackFonts = (options.FallbackFontFamilies?.Count > 0) @@ -324,9 +324,14 @@ private static List LayoutLineHorizontal( { // Offset the location to center the line vertically. bool isFirstLine = index == 0; - float lineHeight = textLine.ScaledMaxLineHeight; - float advanceY = lineHeight * options.LineSpacing; - float offsetY = (advanceY - lineHeight) * .5F; + float scaledLineHeight = textLine.ScaledMaxLineHeight; + + // Recover the unscaled line height to calculate proper centering + float unscaledLineHeight = scaledLineHeight / options.LineSpacing; + float advanceY = scaledLineHeight; + + // Center the glyphs within the extra space created by LineSpacing + float offsetY = (advanceY - unscaledLineHeight) * .5F; float yLineAdvance = advanceY - offsetY; float originX = penLocation.X; @@ -355,14 +360,14 @@ private static List LayoutLineHorizontal( case VerticalAlignment.Center: for (int i = 0; i < textBox.TextLines.Count; i++) { - offsetY -= textBox.TextLines[i].ScaledMaxLineHeight * options.LineSpacing * .5F; + offsetY -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; } break; case VerticalAlignment.Bottom: for (int i = 0; i < textBox.TextLines.Count; i++) { - offsetY -= textBox.TextLines[i].ScaledMaxLineHeight * options.LineSpacing; + offsetY -= textBox.TextLines[i].ScaledMaxLineHeight; } break; @@ -484,8 +489,13 @@ private static List LayoutLineVertical( // Offset the location to center the line horizontally. float scaledMaxLineHeight = textLine.ScaledMaxLineHeight; - float advanceX = scaledMaxLineHeight * options.LineSpacing; - float offsetX = (advanceX - scaledMaxLineHeight) * .5F; + + // Recover the unscaled line height to calculate proper centering + float unscaledLineHeight = scaledMaxLineHeight / options.LineSpacing; + float advanceX = scaledMaxLineHeight; + + // Center the glyphs within the extra space created by LineSpacing + float offsetX = (advanceX - unscaledLineHeight) * .5F; float xLineAdvance = advanceX - offsetX; // Set the Y-Origin for the line. @@ -553,14 +563,14 @@ private static List LayoutLineVertical( case HorizontalAlignment.Right: for (int i = 0; i < textBox.TextLines.Count; i++) { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * options.LineSpacing; + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight; } break; case HorizontalAlignment.Center: for (int i = 0; i < textBox.TextLines.Count; i++) { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * options.LineSpacing * .5F; + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; } break; @@ -783,8 +793,13 @@ private static List LayoutLineVerticalMixed( // Offset the location to center the line horizontally. float scaledMaxLineHeight = textLine.ScaledMaxLineHeight; - float advanceX = scaledMaxLineHeight * options.LineSpacing; - float offsetX = (advanceX - scaledMaxLineHeight) * .5F; + + // Recover the unscaled line height to calculate proper centering + float unscaledLineHeight = scaledMaxLineHeight / options.LineSpacing; + float advanceX = scaledMaxLineHeight; + + // Center the glyphs within the extra space created by LineSpacing + float offsetX = (advanceX - unscaledLineHeight) * .5F; float xLineAdvance = advanceX - offsetX; // Set the Y-Origin for the line. @@ -828,7 +843,7 @@ private static List LayoutLineVerticalMixed( } bool isFirstLine = index == 0; - float extraAdvance = 0; + float yExtraAdvance = 0; if (isFirstLine) { // First mixed vertical line: compute any extra ascent required for this line. @@ -843,7 +858,7 @@ private static List LayoutLineVerticalMixed( // fully visible, and store the extra amount so we can also expand the // advance along the flow direction for all glyphs in this column. offsetY += extraAscent; - extraAdvance += extraAscent; + yExtraAdvance += extraAscent; } // Set the X-Origin for horizontal alignment. @@ -852,14 +867,14 @@ private static List LayoutLineVerticalMixed( case HorizontalAlignment.Right: for (int i = 0; i < textBox.TextLines.Count; i++) { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * options.LineSpacing; + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight; } break; case HorizontalAlignment.Center: for (int i = 0; i < textBox.TextLines.Count; i++) { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * options.LineSpacing * .5F; + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; } break; @@ -913,12 +928,12 @@ private static List LayoutLineVerticalMixed( float descenderAbs = Math.Abs(data.ScaledDescender); float descenderDelta = (Math.Abs(textLine.ScaledMaxDescender) - descenderAbs) * .5F; - // For rotated glyphs, extraAdvance represents additional "height" + // For rotated glyphs, yExtraAdvance represents additional "height" // that we allocated to the column to fit tall stacks above the baseline. // Adding half of that to the horizontal center offset keeps sideways // glyphs visually centered within the now taller column. float centerOffsetX = baselineDelta + descenderAbs + descenderDelta; - centerOffsetX += extraAdvance * .5F; + centerOffsetX += yExtraAdvance * .5F; glyphs.Add(new GlyphLayout( new Glyph(metric, data.PointSize), @@ -926,7 +941,7 @@ private static List LayoutLineVerticalMixed( penLocation + new Vector2(centerOffsetX, 0), Vector2.Zero, advanceX, - data.ScaledAdvance + extraAdvance, + data.ScaledAdvance + yExtraAdvance, GlyphLayoutMode.VerticalRotated, i == 0 && j == 0, data.GraphemeIndex, @@ -950,7 +965,7 @@ private static List LayoutLineVerticalMixed( penLocation + new Vector2((scaledMaxLineHeight - data.ScaledLineHeight) * .5F, 0), offset, advanceX, - data.ScaledAdvance + extraAdvance, + data.ScaledAdvance + yExtraAdvance, GlyphLayoutMode.Vertical, i == 0 && j == 0, data.GraphemeIndex, @@ -960,7 +975,7 @@ private static List LayoutLineVerticalMixed( } } - penLocation.Y += data.ScaledAdvance + extraAdvance; + penLocation.Y += data.ScaledAdvance + yExtraAdvance; } boxLocation.Y = originY; @@ -1196,7 +1211,6 @@ VerticalOrientationType.Rotate or } // Calculate the advance for the current codepoint. - float glyphAdvance; // This should never happen, but we need to ensure that the buffer is large enough // if, for some crazy reason, a glyph does contain more than 64 metrics. @@ -1204,6 +1218,7 @@ VerticalOrientationType.Rotate or ? new float[metrics.Count] : decomposedAdvancesBuffer[..(isDecomposed ? metrics.Count : 1)]; + float glyphAdvance; if (isHorizontalLayout || shouldRotate) { glyphAdvance = glyph.AdvanceWidth; @@ -1367,7 +1382,6 @@ VerticalOrientationType.Rotate or // The delta centers the font's line box within the CSS line box when // LineHeight differs from the nominal font size. - // This ensures vertical centering similar to browser rendering. float delta = ((metricsHeader.LineHeight * scaleY) - lineHeight) * 0.5F; // Adjust ascender and descender symmetrically by delta to preserve visual balance. @@ -1392,6 +1406,7 @@ VerticalOrientationType.Rotate or lineHeight, ascender, descender, + delta, bidiRuns[bidiMap[codePointIndex]], graphemeIndex, isLastInGrapheme, @@ -1400,7 +1415,8 @@ VerticalOrientationType.Rotate or shouldRotate || shouldOffset, isDecomposed, stringIndex, - mode); + mode, + options.LineSpacing); } codePointIndex++; @@ -1559,6 +1575,107 @@ VerticalOrientationType.Rotate or return new TextBox(textLines); } + internal static float CalculateLineOffsetX( + float lineAdvance, + float maxScaledAdvance, + HorizontalAlignment horizontalAlignment, + TextAlignment textAlignment, + TextDirection direction) + { + float offsetX = 0; + + // Set the X-Origin for horizontal alignment. + switch (horizontalAlignment) + { + case HorizontalAlignment.Right: + offsetX = -maxScaledAdvance; + break; + case HorizontalAlignment.Center: + offsetX = -(maxScaledAdvance * .5F); + break; + } + + // Set the alignment of lines within the text. + if (direction == TextDirection.LeftToRight) + { + switch (textAlignment) + { + case TextAlignment.End: + offsetX += maxScaledAdvance - lineAdvance; + break; + case TextAlignment.Center: + offsetX += (maxScaledAdvance * .5F) - (lineAdvance * .5F); + break; + } + } + else + { + switch (textAlignment) + { + case TextAlignment.Start: + offsetX += maxScaledAdvance - lineAdvance; + break; + case TextAlignment.Center: + offsetX += (maxScaledAdvance * .5F) - (lineAdvance * .5F); + break; + } + } + + return offsetX; + } + + internal static float CalculateLineOffsetY( + float lineAdvance, + float maxScaledAdvance, + VerticalAlignment verticalAlignment, + TextAlignment textAlignment, + TextDirection direction) + { + float offsetY = 0; + + // Set the Y-Origin for the line. + switch (verticalAlignment) + { + case VerticalAlignment.Top: + offsetY = 0; + break; + case VerticalAlignment.Center: + offsetY -= maxScaledAdvance * .5F; + break; + case VerticalAlignment.Bottom: + offsetY -= maxScaledAdvance; + break; + } + + // Set the alignment of lines within the text. + if (direction == TextDirection.LeftToRight) + { + switch (textAlignment) + { + case TextAlignment.End: + offsetY += maxScaledAdvance - lineAdvance; + break; + case TextAlignment.Center: + offsetY += (maxScaledAdvance * .5F) - (lineAdvance * .5F); + break; + } + } + else + { + switch (textAlignment) + { + case TextAlignment.Start: + offsetY += maxScaledAdvance - lineAdvance; + break; + case TextAlignment.Center: + offsetY += (maxScaledAdvance * .5F) - (lineAdvance * .5F); + break; + } + } + + return offsetY; + } + internal sealed class TextBox { private float? scaledMaxAdvance; @@ -1598,6 +1715,8 @@ internal sealed class TextLine public float ScaledMaxDescender { get; private set; } = -1; + public float ScaledMaxDelta { get; private set; } = float.MinValue; + public float ScaledMinY { get; private set; } public GlyphLayoutData this[int index] => this.data[index]; @@ -1609,6 +1728,7 @@ public void Add( float scaledLineHeight, float scaledAscender, float scaledDescender, + float scaledDelta, BidiRun bidiRun, int graphemeIndex, bool isLastInGrapheme, @@ -1617,8 +1737,12 @@ public void Add( bool isTransformed, bool isDecomposed, int stringIndex, - GlyphLayoutMode layoutMode) + GlyphLayoutMode layoutMode, + float lineSpacing) { + // Apply LineSpacing to scaledLineHeight before storing + scaledLineHeight *= lineSpacing; + // Reset metrics. // We track the maximum metrics for each line to ensure glyphs can be aligned. if (graphemeCodePointIndex == 0) @@ -1630,6 +1754,7 @@ public void Add( this.ScaledMaxLineHeight = MathF.Max(this.ScaledMaxLineHeight, scaledLineHeight); this.ScaledMaxAscender = MathF.Max(this.ScaledMaxAscender, scaledAscender); this.ScaledMaxDescender = MathF.Max(this.ScaledMaxDescender, scaledDescender); + this.ScaledMaxDelta = MathF.Max(this.ScaledMaxDelta, scaledDelta); // Track the true top of the ink in device space (Y down, baseline at 0). // For scripts with stacked marks (Tibetan, etc) this can be significantly @@ -1665,6 +1790,7 @@ public void Add( scaledLineHeight, scaledAscender, scaledDescender, + scaledDelta, scaledMinY, bidiRun, graphemeIndex, @@ -1987,6 +2113,7 @@ private static void RecalculateLineMetrics(TextLine textLine) float advance = 0; float ascender = 0; float descender = 0; + float delta = 0; float lineHeight = 0; float minY = 0; for (int i = 0; i < textLine.Count; i++) @@ -1995,6 +2122,7 @@ private static void RecalculateLineMetrics(TextLine textLine) advance += glyph.ScaledAdvance; ascender = MathF.Max(ascender, glyph.ScaledAscender); descender = MathF.Max(descender, glyph.ScaledDescender); + delta = MathF.Max(delta, glyph.ScaledDelta); lineHeight = MathF.Max(lineHeight, glyph.ScaledLineHeight); minY = MathF.Min(minY, glyph.ScaledMinY); } @@ -2002,6 +2130,7 @@ private static void RecalculateLineMetrics(TextLine textLine) textLine.ScaledLineAdvance = advance; textLine.ScaledMaxAscender = ascender; textLine.ScaledMaxDescender = descender; + textLine.ScaledMaxDelta = delta; textLine.ScaledMaxLineHeight = lineHeight; textLine.ScaledMinY = minY; @@ -2079,6 +2208,7 @@ public GlyphLayoutData( float scaledLineHeight, float scaledAscender, float scaledDescender, + float scaledDelta, float scaledMinY, BidiRun bidiRun, int graphemeIndex, @@ -2095,6 +2225,7 @@ public GlyphLayoutData( this.ScaledLineHeight = scaledLineHeight; this.ScaledAscender = scaledAscender; this.ScaledDescender = scaledDescender; + this.ScaledDelta = scaledDelta; this.ScaledMinY = scaledMinY; this.BidiRun = bidiRun; this.GraphemeIndex = graphemeIndex; @@ -2120,6 +2251,8 @@ public GlyphLayoutData( public float ScaledDescender { get; } + public float ScaledDelta { get; } + public float ScaledMinY { get; } public BidiRun BidiRun { get; } diff --git a/src/SixLabors.Fonts/TextMeasurer.cs b/src/SixLabors.Fonts/TextMeasurer.cs index fdbfd013..427014c3 100644 --- a/src/SixLabors.Fonts/TextMeasurer.cs +++ b/src/SixLabors.Fonts/TextMeasurer.cs @@ -140,7 +140,134 @@ public static int CountLines(string text, TextOptions options) /// The text shaping options. /// The line count. public static int CountLines(ReadOnlySpan text, TextOptions options) - => TextLayout.GenerateLayout(text, options).Count(x => x.IsStartOfLine); + { + if (text.IsEmpty) + { + return 0; + } + + return TextLayout.ProcessText(text, options).TextLines.Count; + } + + /// + /// Gets per-line layout metrics for the supplied text. + /// + /// The text to measure. + /// The text shaping and layout options. + /// + /// An array of in pixel units, one entry per laid-out line. + /// + /// + /// + /// The returned and are expressed + /// in the primary flow direction for the active layout mode. + /// + /// + /// , , and + /// are line-box positions relative to the current line origin and are suitable for drawing guide lines. + /// + /// + /// Horizontal layouts: Start = X position, Extent = width. + /// Vertical layouts: Start = Y position, Extent = height. + /// + /// + public static LineMetrics[] GetLineMetrics(string text, TextOptions options) + => GetLineMetrics(text.AsSpan(), options); + + /// + /// Gets per-line layout metrics for the supplied text. + /// + /// The text to measure. + /// The text shaping and layout options. + /// + /// An array of in pixel units, one entry per laid-out line. + /// + /// + /// + /// The returned and are expressed + /// in the primary flow direction for the active layout mode. + /// + /// + /// , , and + /// are line-box positions relative to the current line origin and are suitable for drawing guide lines. + /// + /// + /// Horizontal layouts: Start = X position, Extent = width. + /// Vertical layouts: Start = Y position, Extent = height. + /// + /// + public static LineMetrics[] GetLineMetrics(ReadOnlySpan text, TextOptions options) + { + if (text.IsEmpty) + { + return []; + } + + TextLayout.TextBox textBox = TextLayout.ProcessText(text, options); + LineMetrics[] metrics = new LineMetrics[textBox.TextLines.Count]; + + // Determine the line-box extent used for alignment within the flow direction. + float maxScaledAdvance = textBox.ScaledMaxAdvance(); + if (options.TextAlignment != TextAlignment.Start && options.WrappingLength > 0) + { + maxScaledAdvance = MathF.Max(options.WrappingLength / options.Dpi, maxScaledAdvance); + } + + TextDirection direction = textBox.TextDirection(); + LayoutMode layoutMode = options.LayoutMode; + bool isHorizontalLayout = layoutMode.IsHorizontal(); + + for (int i = 0; i < textBox.TextLines.Count; i++) + { + TextLayout.TextLine line = textBox.TextLines[i]; + + // Calculate the line start position in the current flow direction. + float offset = isHorizontalLayout + ? TextLayout.CalculateLineOffsetX( + line.ScaledLineAdvance, + maxScaledAdvance, + options.HorizontalAlignment, + options.TextAlignment, + direction) + : TextLayout.CalculateLineOffsetY( + line.ScaledLineAdvance, + maxScaledAdvance, + options.VerticalAlignment, + options.TextAlignment, + direction); + + // Delta captured during layout when ascender/descender were symmetrically + // adjusted to match browser-like line-box behavior. + float delta = line.ScaledMaxDelta; + + // Core typographic region within the line box. + // We add back 2*delta to recover the pre-adjustment ascender+descender span + // used for deriving guide positions. + float coreHeight = line.ScaledMaxAscender + line.ScaledMaxDescender + (2 * delta); + + // Additional leading in the line box (for example from line spacing). + float extra = line.ScaledMaxLineHeight - coreHeight; + + // Baseline position within the line box. + float baseline = (extra * 0.5f) + line.ScaledMaxAscender + delta; + + // Ascender line position relative to the same origin. + float ascender = baseline - line.ScaledMaxAscender + delta; + + // Descender line position relative to the same origin. + float descender = baseline + line.ScaledMaxDescender + delta; + + metrics[i] = new LineMetrics( + ascender * options.Dpi, + baseline * options.Dpi, + descender * options.Dpi, + line.ScaledMaxLineHeight * options.Dpi, + offset * options.Dpi, + line.ScaledLineAdvance * options.Dpi); + } + + return metrics; + } internal static FontRectangle GetAdvance(IReadOnlyList glyphLayouts, float dpi) { diff --git a/tests/Images/ReferenceOutput/Test_Issue_353_400.png b/tests/Images/ReferenceOutput/Test_Issue_353_400.png new file mode 100644 index 00000000..a99559be --- /dev/null +++ b/tests/Images/ReferenceOutput/Test_Issue_353_400.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7745d1104771f83ebb78d835c08d6539df408a10c10923637325c50fca603178 +size 21193 diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_353.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_353.cs new file mode 100644 index 00000000..18437150 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_353.cs @@ -0,0 +1,81 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SixLabors.Fonts.Tests.Issues; + +public class Issues_353 +{ + [Fact] + public void Test_Issue_353() + { + FontCollection fontCollection = new(); + string name = fontCollection.Add(TestFonts.EbGaramond).Name; + FontFamily family = fontCollection.Get(name); + Font font = family.CreateFont(30, FontStyle.Regular); + + string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris."; + TextOptions options = new(font) + { + WrappingLength = 400, + LineSpacing = 1.6f + }; + + LineMetrics[] l = TextMeasurer.GetLineMetrics(text, options); + + // Numeric assertions for line metrics to complement the visual test. + Assert.NotEmpty(l); + float expectedLineHeight = l[0].LineHeight; + foreach (LineMetrics m in l) + { + // Line height must be positive and consistent across lines. + Assert.True(m.LineHeight > 0, "LineHeight should be positive."); + Assert.Equal(expectedLineHeight, m.LineHeight, 3); + + // Ascender/Baseline/Descender should be ordered within the line box. + Assert.InRange(m.Ascender, 0, m.LineHeight); + Assert.InRange(m.Baseline, m.Ascender, m.LineHeight); + Assert.InRange(m.Descender, m.Baseline, m.LineHeight); + + // Horizontal metrics should describe a valid span. + Assert.True(m.Extent > m.Start, "Extent should be greater than Start."); + } + + void DrawLines(Image image) + { + // Draw four separate lines for ascender(orange), baseline (red), descender (blue), + // and line bottom (green). + // + // `offset` represents the Y coordinate of the top of the current line box. + // It is advanced by `m.LineHeight` after each iteration. + float offset = 0; + for (int i = 0; i < l.Length; i++) + { + LineMetrics m = l[i]; + + float ascent = offset + m.Ascender; + float baseline = offset + m.Baseline; + float descender = offset + m.Descender; + float lineBottom = offset + m.LineHeight; + + image.Mutate(x => x.DrawLine(Color.Orange, 1, new(m.Start, ascent), new(m.Start + m.Extent, ascent))); + image.Mutate(x => x.DrawLine(Color.Red, 1, new(m.Start, baseline), new(m.Start + m.Extent, baseline))); + image.Mutate(x => x.DrawLine(Color.Blue, 1, new(m.Start, descender), new(m.Start + m.Extent, descender))); + image.Mutate(x => x.DrawLine(Color.Green, 1, new(m.Start, lineBottom), new(m.Start + m.Extent, lineBottom))); + + // Advance to the next line's top-of-line-box. + offset += m.LineHeight; + } + } + + TextLayoutTestUtilities.TestLayout( + text, + options, + includeGeometry: false, + beforeAction: DrawLines); + } +} diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs index ded5df55..5cab65bd 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs @@ -24,6 +24,10 @@ public static void TestLayout( bool includeGeometry = false, bool customDecorations = false, [CallerMemberName] string test = "", +#if SUPPORTS_DRAWING + Action> beforeAction = null, + Action> afterAction = null, +#endif params object[] properties) { #if SUPPORTS_DRAWING @@ -48,6 +52,8 @@ public static void TestLayout( // First render the text using the rich text renderer. using Image img = new(Configuration.Default, imageWidth, imageHeight, Color.White.ToPixel()); + beforeAction?.Invoke(img); + img.Mutate(ctx => ctx.DrawText(FromTextOptions(options, customDecorations), text, Color.Black)); if (options.WrappingLength > 0) @@ -62,6 +68,8 @@ public static void TestLayout( } } + afterAction?.Invoke(img); + img.DebugSave("png", test, properties: [.. extended]); img.CompareToReference(percentageTolerance: percentageTolerance, test: test, properties: [.. extended]);