Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Source/HtmlRenderer/Core/Dom/CssBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
80 changes: 76 additions & 4 deletions Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -723,6 +729,73 @@ private static void ApplyRightToLeftOnSingleBox(CssLineBox lineBox, CssBox box)
}
}

/// <summary>
/// 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.
/// </summary>
private static void ApplyBaselineAlignment(RGraphics g, CssLineBox lineBox)
{
double top = double.MaxValue;
double maxAscent = 0;
var aligned = new List<CssRect>();

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

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// Applies vertical alignment to the linebox
/// </summary>
Expand Down Expand Up @@ -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;
}
}
Expand Down
26 changes: 24 additions & 2 deletions Source/HtmlRenderer/Core/Dom/CssLineBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ internal sealed class CssLineBox
private readonly Dictionary<CssBox, RRect> _rects;
private readonly List<CssBox> _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


Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -136,6 +141,8 @@ internal double LineTop
/// </summary>
internal void ShiftLine(double delta)
{
ShiftStrut(delta);

foreach (var word in _words)
{
word.Top += delta;
Expand All @@ -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;
}
}

/// <summary>
/// Lets the linebox add the word an its box to their lists if necessary.
/// </summary>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Source/Test/HtmlRenderer.IntegrationTest/Baselines/Line break.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Source/Test/HtmlRenderer.IntegrationTest/Baselines/MaxWidth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Source/Test/HtmlRenderer.IntegrationTest/Baselines/Text.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using HtmlRenderer.IntegrationTest.TestSupport;
using TheArtOfDev.HtmlRenderer.Core.Dom;

namespace HtmlRenderer.IntegrationTest.Text;

/// <summary>
/// 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
/// <c>UnderlineOffset</c> is the distance from the top of the line to the baseline.
/// </summary>
[DoNotParallelize]
[TestClass]
public sealed class LineBaselineIntegrationTests
{
private const double Delta = 0.5;

[TestMethod]
public void SmallerText_SitsOnTheBaselineOfTheLargerText()
{
var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
"<div style='font-size:20px'><span id='big'>Big</span> <span id='small' style='font-size:10px'>small</span></div>"));
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(
"<div id='x' style='font-size:20px'><span id='small' style='font-size:10px'>small</span></div>"));
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(
"<div id='x' style='font-size:20px'><span style='font-size:10px'>small</span></div>"));
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;
}
}
Loading