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]);