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
282 changes: 273 additions & 9 deletions Source/HtmlRenderer/Core/Dom/CssBox.cs

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Source/HtmlRenderer/Core/Dom/CssBoxHr.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ protected override void PerformLayoutImp(RGraphics g)

var prevSibling = DomUtils.GetPreviousSibling(this);
double left = ContainingBlock.Location.X + ContainingBlock.ActualPaddingLeft + ActualMarginLeft + ContainingBlock.ActualBorderLeftWidth;
// The baseline for this rule's own collapsed top margin, matching CssBox.PerformLayoutImp's
// own placement: a self-collapsing prevSibling (CSS 2.1 8.3.1) contributes no space of its own
// and must not be anchored against directly (see FindMarginAnchorSibling).
var marginAnchor = FindMarginAnchorSibling(prevSibling);
// StaticBottom (not ActualBottom): a relatively-positioned previous sibling's visual offset must
// not drag this rule down with it (CSS 2.1 9.4.3), matching the same fix in CssBox.PerformLayoutImp.
double top = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + MarginTopCollapse(prevSibling) + (prevSibling != null ? prevSibling.StaticBottom + prevSibling.ActualBorderBottomWidth : 0);
double top = (marginAnchor == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + MarginTopCollapse(prevSibling) + (marginAnchor != null ? marginAnchor.StaticBottom + marginAnchor.ActualBorderBottomWidth : 0);
Location = new RPoint(left, top);
ActualBottom = top;

Expand Down
Binary file modified Source/Test/HtmlRenderer.IntegrationTest/Baselines/ACID 1.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/Iframes.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/Languages.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.
Binary file modified Source/Test/HtmlRenderer.IntegrationTest/Baselines/Misc.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/Paragraphs.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
Expand Up @@ -7,22 +7,15 @@ namespace HtmlRenderer.IntegrationTest.BoxModel;
/// set, resolved at one break point by the frame the boxes are children of.
/// </summary>
/// <remarks>
/// <para>
/// The set spans two frames, and these pin both halves: what precedes a box is the frame's to see (a
/// predecessor's bottom margin, and everything a run of self-collapsing predecessors folds in), while the box's
/// own top margin and the first-in-flow-child chain adjoining it are a walk into its own subtree. §8.3.1
/// collapses the whole set at once, so the two are folded together rather than resolved separately.
/// </para>
/// <para>
/// This fork's margin-collapse machinery (<c>CssBox.MarginTopCollapse</c>/<c>MarginBottomCollapse</c>,
/// Dom/CssBox.cs ~1103-1183) only ever resolves a <i>pairwise</i> <c>Math.Max</c> between exactly two margins at
/// a time - never the full CSS2.1 "adjoining margin set" (which can span an arbitrary run of self-collapsing
/// boxes and nested first-in-flow children, and which spec requires collapsing to
/// <c>max(positives) + min(negatives)</c>, not <c>Math.Max</c> alone). <c>Math.Max</c> on two same-signed margins
/// happens to equal the spec result by coincidence, but it diverges the moment the set has to look past a single
/// pair - whether because a negative margin is mixed in, or because more than two margins are actually
/// adjoining. See each test's own <see cref="IgnoreAttribute"/> for the specific way it trips over this.
/// </para>
/// <c>CssBox.MarginTopCollapse</c> (Dom/CssBox.cs) implements the real adjoining-margin-set algorithm:
/// <c>CollapsedMarginTopChain</c>/<c>CollapsedMarginBottomChain</c> walk forward through an unblocked run of
/// first/last-in-flow children (so a nested child sits flush with its parent instead of being pushed down a
/// second time), <c>CollectAdjoiningMarginsBeforeSibling</c> walks backward through however many self-collapsing
/// (empty) siblings precede a box - reaching all the way to the parent's own top margin if the whole run turns
/// out self-collapsing - and <c>CollapseMarginSet</c> collapses the resulting set to
/// <c>max(positives) + min(negatives)</c>, not a plain <c>Math.Max</c> between just two values (which only
/// happens to match when both margins share a sign). A floated box's own margin never participates in
/// collapsing at all (CSS 2.1 §8.3.1: floats are out of flow).
/// </remarks>
[DoNotParallelize]
[TestClass]
Expand All @@ -45,12 +38,6 @@ public void AdjoiningSiblingMargins_CollapseToTheLargerPositive()
Assert.AreEqual(30, b.Location.Y - a.ActualBottom, Delta);
}

[Ignore("Confirmed real bug, not a porting mistake: CssBox.MarginTopCollapse's sibling branch (Dom/CssBox.cs " +
"~line 1108) computes `Math.Max(prevSibling.ActualMarginBottom, ActualMarginTop)` for the gap between " +
"two in-flow siblings - for a positive/negative mixed pair (30, -10) that picks 30 (the larger " +
"value, treating the negative margin as if it weren't there), instead of CSS2.1's mixed-sign rule of " +
"summing the largest positive and the most-negative value (30 + (-10) = 20). Confirmed: " +
"b.Location.Y - a.ActualBottom is 30 on this fork, not the spec-correct 20.")]
[TestMethod]
public void MixedSignSiblingMargins_Sum()
{
Expand All @@ -64,14 +51,6 @@ public void MixedSignSiblingMargins_Sum()
Assert.AreEqual(20, b.Location.Y - a.ActualBottom, Delta);
}

[Ignore("Confirmed real bug, not a porting mistake: because MarginTopCollapse only ever looks at the single " +
"immediate previous sibling (Dom/CssBox.cs ~1103-1127), it cannot treat a self-collapsing empty box " +
"as transparent for a single 3-way adjoining set the way CSS2.1 requires. Instead it resolves two " +
"SEPARATE pairwise gaps in series - a-to-middle: Math.Max(a.marginBottom=10, middle.marginTop=40)=40, " +
"then middle-to-b: Math.Max(middle.marginBottom=40, b.marginTop=10)=40 - and since the empty middle " +
"box has zero height (its own margin-bottom is never folded into its own ActualBottom), those two " +
"40px gaps stack in series instead of collapsing to one shared 40px value. Confirmed: " +
"b.Location.Y - a.ActualBottom is 80 on this fork (40+40), not the spec-correct 40.")]
[TestMethod]
public void ASelfCollapsingSiblingBetweenTwoBoxes_JoinsTheSet()
{
Expand All @@ -87,16 +66,6 @@ public void ASelfCollapsingSiblingBetweenTwoBoxes_JoinsTheSet()
Assert.AreEqual(40, b.Location.Y - a.ActualBottom, Delta);
}

[Ignore("Confirmed real bug, not a porting mistake: MarginTopCollapse's parent-child branch (Dom/CssBox.cs " +
"~1111-1114) is `Math.Max(0, ActualMarginTop - Math.Max(parent.ActualMarginTop, parent.CollapsedMarginTop))` " +
"- an approximation that does not implement 'the whole chain collapses to one value, taken once' the " +
"way CSS2.1 requires. For outer(margin-top:10)/inner(margin-top:30) here: outer's own placement " +
"against 'a' only applies outer's OWN 10px margin (Math.Max(a.marginBottom=0, outer.marginTop=10)=10, " +
"not the spec-correct 30 which should reflect the whole outer+inner set), and inner then gets pushed " +
"an ADDITIONAL Math.Max(0, 30-Math.Max(10,10))=20px below outer's own top - so inner ends up 20px " +
"below outer (not flush with it), and outer is only 10px below 'a' (not 30px). Confirmed on this " +
"fork: outer.Location.Y - a.ActualBottom is 10 (not 30), and inner.Location.Y - outer.Location.Y is " +
"20 (not 0).")]
[TestMethod]
public void AFirstInFlowChildsTopMargin_JoinsItsParentsOwnSet()
{
Expand All @@ -115,16 +84,6 @@ public void AFirstInFlowChildsTopMargin_JoinsItsParentsOwnSet()
Assert.AreEqual(outer.Location.Y, inner.Location.Y, Delta);
}

[Ignore("Confirmed real bug, not a porting mistake: the parent-child join's guard condition (Dom/CssBox.cs " +
"~1111) only checks `ActualPaddingTop < 0.1 && ActualPaddingBottom < 0.1` on the box and its parent - " +
"it never checks BORDER width at all, even though CSS2.1 requires ANY border or padding between " +
"parent and child to block the join, not just padding. So outer's border-top:1px here fails to block " +
"the outer/inner join: inner still gets pulled into the same Math.Max(0, inner.marginTop=30 - " +
"Math.Max(outer.marginTop=10, outer.CollapsedMarginTop=10))=20px-below-outer's-ClientTop computation " +
"as the no-border case, landing it 1px (border) + 20px = 21px below outer, not the spec-correct " +
"1px + 30px = 31px where the border fully blocks the join and both margins stand independently. (The " +
"outer-vs-'a' gap of 10px does coincidentally match here, since that part never depended on the " +
"border in the first place.)")]
[TestMethod]
public void ABorderOnTheParent_BlocksTheChainAndLeavesBothMarginsStanding()
{
Expand All @@ -141,15 +100,6 @@ public void ABorderOnTheParent_BlocksTheChainAndLeavesBothMarginsStanding()
Assert.AreEqual(31, inner.Location.Y - outer.Location.Y, Delta);
}

[Ignore("Confirmed real bug, not a porting mistake, compounding two gaps: (1) this fork's block-placement " +
"seam does not special-case Position=float the way it does display:none/absolute/fixed siblings (the " +
"same gap BoxModel.BlockPlacementSeamTests.AFloatedSibling_IsNotThePredecessorTheFrameResolvesAgainst " +
"documents) - so the floated 'f' box is positioned via the exact same in-flow " +
"top = prevSibling.ActualBottom + MarginTopCollapse(prevSibling) formula as any ordinary block, " +
"instead of being excluded from margin collapsing entirely per CSS2.1 (a float's margin should never " +
"adjoin anything). (2) That collapse is then itself the mixed-sign Math.Max bug this class's remarks " +
"describe: Math.Max(a.marginBottom=20, f.marginTop=-5)=20, not the spec-correct sum " +
"20+(-5)=15. Confirmed: f.Location.Y - a.ActualBottom is 20 on this fork, not 15.")]
[TestMethod]
public void AFloatsOwnMarginDoesNotMerge_ButThePredecessorsStillOccupiesSpace()
{
Expand All @@ -164,4 +114,32 @@ public void AFloatsOwnMarginDoesNotMerge_ButThePredecessorsStillOccupiesSpace()
// predecessor's trailing margin is real space it must sit after.
Assert.AreEqual(15, f.Location.Y - a.ActualBottom, Delta);
}

[TestMethod]
[DataRow("overflow:hidden")]
[DataRow("float:left;width:100px")]
[DataRow("position:absolute;width:100px")]
public void NewBlockFormattingContext_KeepsItsFirstChildsTopMarginInside(string parentStyle)
{
var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
$"<div id='p' style='{parentStyle}'><div id='c' style='margin-top:20px;height:10px'></div></div>"));

var p = LayoutHarness.FindById(root, "p")!;
var c = LayoutHarness.FindById(root, "c")!;

Assert.AreEqual(20, c.Location.Y - p.Location.Y, Delta);
}

[TestMethod]
public void FixedHeightParent_DoesNotPassItsLastChildsBottomMarginOn()
{
var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap(
"<div id='p' style='height:50px'><div style='height:10px;margin-bottom:30px'></div></div>"
+ "<div id='n' style='height:10px'></div>"));

var p = LayoutHarness.FindById(root, "p")!;
var n = LayoutHarness.FindById(root, "n")!;

Assert.AreEqual(p.ActualBottom, n.Location.Y, Delta);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using HtmlRenderer.IntegrationTest.TestSupport;

namespace HtmlRenderer.IntegrationTest.BoxModel;

/// <summary>
/// Ported from PeachPDF.Tests' Acid2 min-height/max-height regression cases (CSS 2.1 §10.7).
/// </summary>
[DoNotParallelize]
[TestClass]
public sealed class MinMaxHeightIntegrationTests
{
private const double Delta = 0.5;

[TestMethod]
public void MinHeightWinsOverConflictingMaxHeight()
{
// min-height (20px) and max-height (5px) conflict; min-height must win per §10.7, so the box's
// actual height must be 20px, not clamped down to 5px.
var html = LayoutHarness.Wrap("<div id='b' style='height:8px; min-height:20px; max-height:5px;'></div>");
var (root, _) = LayoutHarness.Layout(html);
var box = LayoutHarness.FindById(root, "b")!;

Assert.AreEqual(20, box.ActualBottom - box.Location.Y, Delta);
}

[TestMethod]
public void MaxHeightAppliesWhenNotConflictingWithMinHeight()
{
var html = LayoutHarness.Wrap("<div id='b' style='height:100px; max-height:20px;'></div>");
var (root, _) = LayoutHarness.Layout(html);
var box = LayoutHarness.FindById(root, "b")!;

Assert.AreEqual(20, box.ActualBottom - box.Location.Y, Delta);
}

[TestMethod]
public void PercentageHeight_AgainstAutoHeightContainingBlock_ResolvesToAuto_LettingMaxHeightWin()
{
// "height: 60%" must resolve to auto (zero intrinsic content) against an auto-height containing
// block (CSS 2.1 §10.5/§10.7), not against the container's own content-driven height - so
// max-height:20px is the only thing constraining #b's height.
var html = LayoutHarness.Wrap(
"<div id='cb'>"
+ "<div id='content' style='height:50px;'></div>"
+ "<div id='b' style='height:60%; max-height:20px;'></div>"
+ "</div>");
var (root, _) = LayoutHarness.Layout(html);
var box = LayoutHarness.FindById(root, "b")!;

var height = box.ActualBottom - box.Location.Y;
Assert.IsTrue(height is >= 0 and <= 20.5, $"expected height clamped to max-height:20px, was {height}");
}
}
Loading
Loading