diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs
index d84d32590..60248becd 100644
--- a/Source/HtmlRenderer/Core/Dom/CssBox.cs
+++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs
@@ -1986,6 +1986,11 @@ internal void OffsetTop(double amount)
word.Top += amount;
}
+ foreach (var line in LineBoxes)
+ {
+ line.ShiftStrut(amount);
+ }
+
foreach (CssBox b in Boxes)
{
b.OffsetTop(amount);
diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs
index 6acf2a0be..4e4a21759 100644
--- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs
+++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs
@@ -151,6 +151,7 @@ public static void CreateLineBoxes(RGraphics g, CssBox blockBox)
//Gets the rectangles for each line-box
foreach (var linebox in blockBox.LineBoxes)
{
+ ApplyBaselineAlignment(g, linebox);
ApplyHorizontalAlignment(g, linebox);
ApplyRightToLeft(blockBox, linebox);
BubbleRectangles(blockBox, linebox);
@@ -457,8 +458,13 @@ private static void FlowBox(RGraphics g, CssBox blockbox, CssBox box, double lim
foreach (var word in b.Words)
{
- if (maxbottom - cury < box.ActualLineHeight)
- maxbottom += box.ActualLineHeight - (maxbottom - cury);
+ // A line with text is at least as tall as the block's own font, even if all its text
+ // is smaller (the strut, CSS 2.1 10.8.1).
+ var lineHeight = word.IsImage
+ ? box.ActualLineHeight
+ : Math.Max(box.ActualLineHeight, Math.Max(blockbox.ActualLineHeight, GetFontHeight(g, blockbox.ActualFont)));
+ if (maxbottom - cury < lineHeight)
+ maxbottom += lineHeight - (maxbottom - cury);
double actualLimitRight = limitRight;
var rightObstructingFloat = DomUtils.GetLastRightIntersectingFloatBox(box, cury);
@@ -723,6 +729,73 @@ private static void ApplyRightToLeftOnSingleBox(CssLineBox lineBox, CssBox box)
}
}
+ ///
+ /// Puts every text word of the line on one baseline, the lowest one any word or the block's own font
+ /// (the line's strut) needs. Words are flowed top-aligned, so a smaller font would otherwise hang from
+ /// the top of the line instead of sitting on it. Images and words inside a sub/super box keep their
+ /// current position, every other vertical-align value isn't supported and sits on the baseline.
+ ///
+ private static void ApplyBaselineAlignment(RGraphics g, CssLineBox lineBox)
+ {
+ double top = double.MaxValue;
+ double maxAscent = 0;
+ var aligned = new List();
+
+ foreach (var word in lineBox.Words)
+ {
+ if (word.IsImage || !IsOnParentBaseline(word.OwnerBox, lineBox.OwnerBox))
+ continue;
+
+ aligned.Add(word);
+ top = Math.Min(top, word.Top);
+ maxAscent = Math.Max(maxAscent, GetAscent(g, word.OwnerBox.ActualFont));
+ }
+
+ if (aligned.Count == 0)
+ return;
+
+ var blockBox = lineBox.OwnerBox;
+ var baseline = top + Math.Max(maxAscent, GetAscent(g, blockBox.ActualFont));
+ lineBox.SetStrut(top, Math.Max(blockBox.ActualLineHeight, GetFontHeight(g, blockBox.ActualFont)));
+
+ foreach (var word in aligned)
+ {
+ word.Top = baseline - GetAscent(g, word.OwnerBox.ActualFont);
+ }
+ }
+
+ private static bool IsOnParentBaseline(CssBox box, CssBox blockBox)
+ {
+ for (var b = box; b != null && b != blockBox; b = b.ParentBox)
+ {
+ // sub/super are the only values ApplyVerticalAlignment positions itself
+ if (b.VerticalAlign == CssConstants.Sub || b.VerticalAlign == CssConstants.Super)
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// The distance from the top of a line of this font to its baseline. Some adapters only fill in the
+ /// font metrics on the first measurement.
+ ///
+ private static double GetAscent(RGraphics g, RFont font)
+ {
+ if (font.Height < 0)
+ g.MeasureString("a", font);
+
+ return font.UnderlineOffset;
+ }
+
+ private static double GetFontHeight(RGraphics g, RFont font)
+ {
+ if (font.Height < 0)
+ g.MeasureString("a", font);
+
+ return font.Height;
+ }
+
///
/// Applies vertical alignment to the linebox
///
@@ -764,8 +837,7 @@ private static void ApplyVerticalAlignment(RGraphics g, CssLineBox lineBox)
break;
default:
- //case: baseline
- lineBox.SetBaseLine(g, box, baseline);
+ // baseline: already done by ApplyBaselineAlignment
break;
}
}
diff --git a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs
index 39e037756..2042dc5d4 100644
--- a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs
+++ b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs
@@ -33,6 +33,11 @@ internal sealed class CssLineBox
private readonly Dictionary _rects;
private readonly List _relatedBoxes;
+ // The vertical extent of the line's strut, so the line keeps the block's own line height even when
+ // all its text is smaller. Null for a line without text.
+ private double? _strutTop;
+ private double _strutBottom;
+
#endregion
@@ -104,7 +109,7 @@ public double LineBottom
{
get
{
- double bottom = 0;
+ double bottom = _strutTop.HasValue ? _strutBottom : 0;
foreach (var rect in _rects)
{
bottom = Math.Max(bottom, rect.Value.Bottom);
@@ -120,7 +125,7 @@ internal double LineTop
{
get
{
- double top = double.MaxValue;
+ double top = _strutTop ?? double.MaxValue;
foreach (var rect in _rects)
{
top = Math.Min(top, rect.Value.Top);
@@ -136,6 +141,8 @@ internal double LineTop
///
internal void ShiftLine(double delta)
{
+ ShiftStrut(delta);
+
foreach (var word in _words)
{
word.Top += delta;
@@ -150,6 +157,21 @@ internal void ShiftLine(double delta)
}
}
+ internal void SetStrut(double top, double height)
+ {
+ _strutTop = top;
+ _strutBottom = top + height;
+ }
+
+ internal void ShiftStrut(double delta)
+ {
+ if (_strutTop.HasValue)
+ {
+ _strutTop += delta;
+ _strutBottom += delta;
+ }
+ }
+
///
/// Lets the linebox add the word an its box to their lists if necessary.
///
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Fonts decorations.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Fonts decorations.png
index d0f6979f1..21ee890fa 100644
Binary files a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Fonts decorations.png and b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Fonts decorations.png differ
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Line break.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Line break.png
index 6b24798cb..d662c8019 100644
Binary files a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Line break.png and b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Line break.png differ
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/MaxWidth.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/MaxWidth.png
index 16a5fa4e6..805fdde9b 100644
Binary files a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/MaxWidth.png and b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/MaxWidth.png differ
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Media queries and color scheme.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Media queries and color scheme.png
index 190c61052..088edc6e2 100644
Binary files a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Media queries and color scheme.png and b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Media queries and color scheme.png differ
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Text.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Text.png
index 74d8db18f..f40341b39 100644
Binary files a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Text.png and b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Text.png differ
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs
new file mode 100644
index 000000000..145b1643f
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/LineBaselineIntegrationTests.cs
@@ -0,0 +1,66 @@
+using HtmlRenderer.IntegrationTest.TestSupport;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+
+namespace HtmlRenderer.IntegrationTest.Text;
+
+///
+/// Text of different sizes on one line shares a baseline, and a line is at least as tall as the block's own
+/// font (CSS 2.1 10.8). The layout uses real fonts, so the expected positions come from their metrics, where
+/// UnderlineOffset is the distance from the top of the line to the baseline.
+///
+[DoNotParallelize]
+[TestClass]
+public sealed class LineBaselineIntegrationTests
+{
+ private const double Delta = 0.5;
+
+ [TestMethod]
+ public void SmallerText_SitsOnTheBaselineOfTheLargerText()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "Big small
"));
+ var big = FirstWord(LayoutHarness.FindById(root, "big")!)!;
+ var small = FirstWord(LayoutHarness.FindById(root, "small")!)!;
+
+ Assert.IsGreaterThan(big.Top, small.Top, "the smaller text must not hang from the top of the line");
+ Assert.AreEqual(big.Top + Ascent(big), small.Top + Ascent(small), Delta);
+ }
+
+ [TestMethod]
+ public void SmallerText_SitsOnTheBaselineOfTheBlocksOwnFont()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "small
"));
+ var block = LayoutHarness.FindById(root, "x")!;
+ var small = FirstWord(LayoutHarness.FindById(root, "small")!)!;
+
+ Assert.AreEqual(block.Location.Y + block.ActualFont.UnderlineOffset, small.Top + Ascent(small), Delta);
+ }
+
+ [TestMethod]
+ public void LineWithOnlySmallerText_IsAsTallAsTheBlocksFont()
+ {
+ var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
+ "small
"));
+ var block = LayoutHarness.FindById(root, "x")!;
+
+ Assert.AreEqual(block.ActualFont.Height, block.ActualBottom - block.Location.Y, Delta);
+ }
+
+ private static double Ascent(CssRect word) => word.OwnerBox.ActualFont.UnderlineOffset;
+
+ private static CssRect? FirstWord(CssBox box)
+ {
+ if (box.Words.Count > 0)
+ return box.Words[0];
+
+ foreach (var child in box.Boxes)
+ {
+ var word = FirstWord(child);
+ if (word != null)
+ return word;
+ }
+
+ return null;
+ }
+}