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; + } +}