Skip to content

Commit 600dd54

Browse files
jhaygood86eXpl0it3r
authored andcommitted
Port CSS float/clear layout from PeachPDF, fix box-sizing height/width bugs it exposed
Implements float:left/float:right/clear support (block placement, line-box text wrapping, and clearance), ported from PeachPDF's CssLayoutEngine and DomUtils. Getting the W3C ACID1 test to render correctly surfaced several pre-existing, unrelated layout bugs that floats exposed but didn't cause: - GetPreviousSibling didn't skip floated siblings, so a box's static position was chained off a preceding float instead of the last normal-flow sibling. - No box-sizing support at all: explicit width/height values were treated as border-box measures unconditionally instead of honoring the CSS default content-box (plus a margin was double-subtracted from explicit widths). Ported BoxSizing/ActualBoxSizeIncludedWidth/Height from PeachPDF. - FlowBox's own height-extension logic double-counted a block's top+bottom padding/border when it had both an explicit height and floated positioning - fixed by porting PeachPDF's GetBoxHeight/ApplyHeight/ IsHeightCalculated (also adds min-height/max-height support). - A container whose children were all out-of-flow (e.g. <ul> with only floated <li>s) used the last floated child's position to compute its own auto-height, pushing later siblings down incorrectly - ported PeachPDF's IsOutOfFlow-aware MarginBottomCollapse. - Two float:right positioning bugs found by direct comparison against PeachPDF (confirmed reproducible there too, reported upstream, and fixed once PeachPDF landed the fixes): GetLastRightIntersectingFloatBox used a point-collision test that can't detect a right float in advance (PR #687), and FloatRightStartX double-subtracted a box's own margin-left (PR #688). ACID 1 now visually matches the W3C reference rendering; 29/30 regression tests pass (the one failure is a pre-existing, unrelated machine-specific font-inventory mismatch in "Fonts decorations").
1 parent 6d01d29 commit 600dd54

14 files changed

Lines changed: 754 additions & 17 deletions

File tree

‎Source/Demo/Common/HtmlRenderer.Demo.Common.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
<EmbeddedResource Include="TestSamples\33.Fixed position.htm" />
7474
<EmbeddedResource Include="TestSamples\34.Breaking pages 1 - Paragraphs.htm" />
7575
<EmbeddedResource Include="TestSamples\35.Breaking pages 2 - Tables.htm" />
76+
<EmbeddedResource Include="TestSamples\36.Float wrap.htm" />
7677
<EmbeddedResource Include="Samples\00.Intro.htm" />
7778
<EmbeddedResource Include="Samples\01.History.htm" />
7879
<EmbeddedResource Include="Samples\02.Text.htm" />
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Float wrap</title>
5+
</head>
6+
<body>
7+
<div style="float: left; width: 80px; height: 80px; background-color: #4a90d9; margin: 0 10px 10px 0;"></div>
8+
<div style="float: right; width: 80px; height: 60px; background-color: #d94a4a; margin: 0 0 10px 10px;"></div>
9+
<p style="margin: 0;">This paragraph wraps around a blue box floated left and a red box floated right, so its lines should narrow to avoid both floats until the text runs past their bottom edges and returns to the full available width for the remaining lines of text in this paragraph.</p>
10+
<div style="clear: both; background-color: #cccccc; padding: 4px;">This div has clear: both and should start below both floated boxes, not overlap them.</div>
11+
</body>
12+
</html>

‎Source/HtmlRenderer/Core/CssDefaults.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@ @media print {
169169
{ "font-variant", "normal" },
170170
{ "font-weight", "normal" },
171171
{ "float", "none" },
172+
{ "clear", "none" },
173+
{ "box-sizing", "content-box" },
172174
{ "height", "auto" },
175+
{ "min-height", "0" },
176+
{ "max-height", "none" },
173177
{ "margin-bottom", "0" },
174178
{ "margin-left", "0" },
175179
{ "margin-right", "0" },

‎Source/HtmlRenderer/Core/Dom/CssBox.cs‎

Lines changed: 161 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,25 @@ public bool IsBlock
188188
get { return Display == CssConstants.Block; }
189189
}
190190

191+
/// <summary>
192+
/// Is this box floated (float: left or float: right).
193+
/// </summary>
194+
public bool IsFloated
195+
{
196+
get { return Float == CssConstants.Left || Float == CssConstants.Right; }
197+
}
198+
199+
/// <summary>
200+
/// True for a box removed from normal flow: floated, or absolutely/fixed positioned. Ported
201+
/// from PeachPDF's CssBox.IsOutOfFlow - used to find a container's last IN-FLOW child (e.g.
202+
/// <see cref="MarginBottomCollapse"/>), since an out-of-flow box doesn't contribute to a
203+
/// non-BFC-establishing container's own auto-height (CSS 2.1 10.6.3/10.6.7).
204+
/// </summary>
205+
public bool IsOutOfFlow
206+
{
207+
get { return IsFloated || Position == CssConstants.Absolute || Position == CssConstants.Fixed; }
208+
}
209+
191210
/// <summary>
192211
/// Is the css box clickable (by default only "a" elements that are actual hyperlinks - i.e.
193212
/// have an "href" - are clickable; an "a" used only as a named anchor/target has no href and
@@ -633,6 +652,90 @@ public virtual void Dispose()
633652

634653
#region Private Methods
635654

655+
/// <summary>
656+
/// Computes this box's used height, ported from PeachPDF's CssLayoutEngine.GetBoxHeight -
657+
/// scoped to the height features this engine supports (no aspect-ratio, no absolutely-
658+
/// positioned top+bottom fill, no per-page height: PeachPDF features this engine never had).
659+
/// PeachPDF's Size.Height is content-only, so its GetBoxHeight starts from
660+
/// ActualBoxSizingHeight (Size.Height + padding/border) as the auto-height baseline and can
661+
/// return null for an indeterminate percentage height, leaving ApplyHeight's Math.Max to
662+
/// preserve whatever content-driven value is already there. This engine's Size.Height is
663+
/// already the border-box measure (see <see cref="ActualRight"/>), so the equivalent baseline
664+
/// is Size.Height itself, and there is no null case to thread through - an indeterminate
665+
/// percentage height simply falls back to that same baseline directly.
666+
/// </summary>
667+
private double GetBoxHeight()
668+
{
669+
double height = Size.Height;
670+
671+
if (CssValueParser.IsValidLength(Height) && !(Height.EndsWith("%") && !ContainingBlock.IsHeightCalculated))
672+
{
673+
height = CssValueParser.ParseLength(Height, ContainingBlock.Size.Height, this) + ActualBoxSizeIncludedHeight;
674+
}
675+
676+
if (CssValueParser.IsValidLength(MinHeight) && (ContainingBlock.IsHeightCalculated || !MinHeight.EndsWith("%")))
677+
{
678+
var minHeight = CssValueParser.ParseLength(MinHeight, ContainingBlock.Size.Height, this) + ActualBoxSizeIncludedHeight;
679+
if (minHeight > height)
680+
{
681+
height = minHeight;
682+
}
683+
}
684+
685+
return height;
686+
}
687+
688+
/// <summary>
689+
/// Applies this box's used height (CSS 2.1 10.5/10.6.3/10.7) to <see cref="CssBoxProperties.ActualBottom"/>,
690+
/// and computes whether this box counts as height-calculated for a descendant's percentage
691+
/// height resolution. Ported from PeachPDF's CssLayoutEngine.ApplyHeight.
692+
/// <para>
693+
/// The assignment below is unconditional (not the plain <c>Math.Max(ActualBottom, ...)</c>
694+
/// this replaced) - a no-op for auto height, since <see cref="GetBoxHeight"/>'s baseline is
695+
/// already ActualBottom's own current value, but a real correction for a definite height,
696+
/// able to shrink ActualBottom back down, which Math.Max alone never could. That correction is
697+
/// what fixes a real bug this exposed: <see cref="CssLayoutEngine.FlowBox"/>'s own "handle
698+
/// height setting" step (for a nested atomic inline-block) pushes a block's own maxBottom out
699+
/// to the block's full ActualHeight measured from its content top, and CreateLineBoxes then
700+
/// adds that same block's bottom padding/border again on top - inflating ActualBottom by one
701+
/// extra padding+border box for any floated or block-level box with both an explicit height
702+
/// and non-zero padding/border (e.g. every floated box in the W3C ACID1 test). A definite
703+
/// height here authoritatively overwrites that inflated value instead of merely preserving it.
704+
/// </para>
705+
/// </summary>
706+
private void ApplyHeight()
707+
{
708+
var height = GetBoxHeight();
709+
ActualBottom = Location.Y + height;
710+
711+
var isRootWithPageHeight = ParentBox == null && HtmlContainer != null;
712+
var isDefiniteHeight = CssValueParser.IsValidLength(Height) && (ContainingBlock.IsHeightCalculated || !Height.EndsWith("%"));
713+
IsHeightCalculated = isRootWithPageHeight || isDefiniteHeight;
714+
715+
if (CssValueParser.IsValidLength(MaxHeight) && MaxHeight != CssConstants.None
716+
&& (ContainingBlock.IsHeightCalculated || !MaxHeight.EndsWith("%")))
717+
{
718+
var maxHeight = CssValueParser.ParseLength(MaxHeight, ContainingBlock.Size.Height, this) + ActualBoxSizeIncludedHeight;
719+
var maxBottom = Location.Y + maxHeight;
720+
721+
if (ActualBottom > maxBottom)
722+
{
723+
ActualBottom = maxBottom;
724+
725+
if (CssValueParser.IsValidLength(MinHeight) && (ContainingBlock.IsHeightCalculated || !MinHeight.EndsWith("%")))
726+
{
727+
var minHeight = CssValueParser.ParseLength(MinHeight, ContainingBlock.Size.Height, this) + ActualBoxSizeIncludedHeight;
728+
var minBottom = Location.Y + minHeight;
729+
730+
if (ActualBottom < minBottom)
731+
{
732+
ActualBottom = minBottom;
733+
}
734+
}
735+
}
736+
}
737+
}
738+
636739
/// <summary>
637740
/// Measures the bounds of box and children, recursively.<br/>
638741
/// Performs layout of the DOM structure creating lines by set bounds restrictions.<br/>
@@ -651,19 +754,44 @@ protected virtual void PerformLayoutImp(RGraphics g)
651754
// Because their width and height are set by CssTable
652755
if (Display != CssConstants.TableCell && Display != CssConstants.Table)
653756
{
654-
double width = ContainingBlock.Size.Width
757+
double availableWidth = ContainingBlock.Size.Width
655758
- ContainingBlock.ActualPaddingLeft - ContainingBlock.ActualPaddingRight
656759
- ContainingBlock.ActualBorderLeftWidth - ContainingBlock.ActualBorderRightWidth;
760+
double width = availableWidth;
761+
bool explicitWidth = Width != CssConstants.Auto && !string.IsNullOrEmpty(Width);
657762

658-
if (Width != CssConstants.Auto && !string.IsNullOrEmpty(Width))
763+
if (explicitWidth)
659764
{
660-
width = CssValueParser.ParseLength(Width, width, this);
765+
// CSS 2.1 10.2: a percentage/length `width` resolves against the containing block's
766+
// content width. Per CSS Box Sizing 3, that resolved value is this box's CONTENT
767+
// width under the default `box-sizing: content-box` - its border-box width (what
768+
// Size.Width holds throughout this engine) additionally includes this box's own
769+
// padding+border (ActualBoxSizeIncludedWidth is 0 for `box-sizing: border-box`,
770+
// where the resolved value already IS the border-box width).
771+
width = CssValueParser.ParseLength(Width, availableWidth, this) + ActualBoxSizeIncludedWidth;
772+
}
773+
else if (IsFloated)
774+
{
775+
// CSS 2.1 10.3.5: a floated box with width:auto shrinks to fit its content
776+
// instead of taking the full containing-block width like an ordinary block.
777+
// GetMinMaxWidth already returns border-box-inclusive bounds (its own padding/
778+
// border baked in), so no box-sizing adjustment is needed here.
779+
double minWidth, maxWidth;
780+
GetMinMaxWidth(out minWidth, out maxWidth);
781+
width = Math.Min(Math.Max(minWidth, width), maxWidth);
661782
}
662783

663784
Size = new RSize(width, Size.Height);
664785

665-
// must be separate because the margin can be calculated by percentage of the width
666-
Size = new RSize(width - ActualMarginLeft - ActualMarginRight, Size.Height);
786+
if (!explicitWidth && !IsFloated)
787+
{
788+
// CSS 2.1 10.3.3: only a plain (non-floated) width:auto block absorbs its own
789+
// margin into the available fill width to become its used (border-box) width - an
790+
// explicit width, or a floated shrink-to-fit width, is the used width as-is; margin
791+
// is separate space outside the border box (already accounted for via this box's
792+
// Location.X), not a further reduction of Size.Width on top of that.
793+
Size = new RSize(width - ActualMarginLeft - ActualMarginRight, Size.Height);
794+
}
667795
}
668796

669797
if (Display != CssConstants.TableCell)
@@ -683,6 +811,10 @@ protected virtual void PerformLayoutImp(RGraphics g)
683811
top = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + MarginTopCollapse(prevSibling) + (prevSibling != null ? prevSibling.ActualBottom + prevSibling.ActualBorderBottomWidth : 0);
684812
Location = new RPoint(left, top);
685813
ActualBottom = top;
814+
815+
// static position committed above; float/clear now overwrite Location using that
816+
// static position as input. No-op for boxes that are neither floated nor clearing.
817+
CssLayoutEngine.FloatBox(this);
686818
}
687819
}
688820

@@ -706,7 +838,19 @@ protected virtual void PerformLayoutImp(RGraphics g)
706838
childBox.PerformLayout(g);
707839
}
708840
ActualRight = CalculateActualRight();
709-
ActualBottom = MarginBottomCollapse();
841+
842+
// CSS 2.1 10.6.3/10.6.7: a plain (non-BFC-establishing) box's own auto-height
843+
// doesn't contribute from out-of-flow (floated/absolutely-positioned) children
844+
// at all - if EVERY child is out-of-flow (e.g. a <ul> whose only children are
845+
// floated <li>s), there is no in-flow content to measure, so ActualBottom is
846+
// left exactly as the static-position commit above already set it (its own
847+
// top, i.e. zero content height) rather than being pulled down to match a
848+
// floated child's position. Ported from PeachPDF's CssBox.PerformLayoutImp
849+
// (`if (Boxes.Any(b => !b.IsOutOfFlow)) { ActualBottom = MarginBottomCollapse(); }`).
850+
if (Boxes.Exists(b => !b.IsOutOfFlow))
851+
{
852+
ActualBottom = MarginBottomCollapse();
853+
}
710854
}
711855
}
712856
}
@@ -720,7 +864,7 @@ protected virtual void PerformLayoutImp(RGraphics g)
720864
ActualBottom = prevSibling.ActualBottom;
721865
}
722866
}
723-
ActualBottom = Math.Max(ActualBottom, Location.Y + ActualHeight);
867+
ApplyHeight();
724868

725869
CreateListItemBox(g);
726870

@@ -1193,13 +1337,20 @@ private double CalculateActualRight()
11931337
/// <returns>Resulting bottom margin</returns>
11941338
private double MarginBottomCollapse()
11951339
{
1340+
// Only called when at least one in-flow child exists (see the caller's IsOutOfFlow gate),
1341+
// so this is guaranteed to find one - an out-of-flow (floated/absolutely-positioned) child
1342+
// doesn't contribute to this box's own auto-height (CSS 2.1 10.6.3/10.6.7) and must not be
1343+
// used as "the last child" here, matching PeachPDF's own MarginBottomCollapse
1344+
// (`Boxes.Last(b => !b.IsOutOfFlow)`).
1345+
var lastInFlowBox = _boxes.FindLast(b => !b.IsOutOfFlow);
1346+
11961347
double margin = 0;
11971348
if (ParentBox != null && ParentBox.Boxes.IndexOf(this) == ParentBox.Boxes.Count - 1 && _parentBox.ActualMarginBottom < 0.1)
11981349
{
1199-
var lastChildBottomMargin = _boxes[_boxes.Count - 1].ActualMarginBottom;
1350+
var lastChildBottomMargin = lastInFlowBox.ActualMarginBottom;
12001351
margin = Height == "auto" ? Math.Max(ActualMarginBottom, lastChildBottomMargin) : lastChildBottomMargin;
12011352
}
1202-
return Math.Max(ActualBottom, _boxes[_boxes.Count - 1].ActualBottom + margin + ActualPaddingBottom + ActualBorderBottomWidth);
1353+
return Math.Max(ActualBottom, lastInFlowBox.ActualBottom + margin + ActualPaddingBottom + ActualBorderBottomWidth);
12031354
}
12041355

12051356
/// <summary>
@@ -1468,7 +1619,7 @@ protected void PaintDecoration(RGraphics g, RRect rectangle, bool isFirst, bool
14681619

14691620
// text-decoration-line may list several space-separated line keywords (e.g. "underline
14701621
// overline") - draw each one present rather than only the first/only value.
1471-
foreach (var line in TextDecoration.Split(' ', StringSplitOptions.RemoveEmptyEntries))
1622+
foreach (var line in TextDecoration.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
14721623
{
14731624
double y;
14741625
if (line == CssConstants.Underline)

0 commit comments

Comments
 (0)