diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 2ff4f1a88..782dc86bb 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -839,12 +839,16 @@ protected virtual void PerformLayoutImp(RGraphics g) // where the resolved value already IS the border-box width). width = CssValueParser.ParseLength(Width, availableWidth, this) + ActualBoxSizeIncludedWidth; } - else if (IsFloated) + else if (IsFloated || Position == CssConstants.Absolute) { - // CSS 2.1 10.3.5: a floated box with width:auto shrinks to fit its content - // instead of taking the full containing-block width like an ordinary block. - // GetMinMaxWidth already returns border-box-inclusive bounds (its own padding/ - // border baked in), so no box-sizing adjustment is needed here. + // CSS 2.1 10.3.5/10.3.7: a floated box, or an absolutely positioned box with no + // explicit width (the common case - both `left`/`right` auto), shrinks to fit its + // content instead of taking the full containing-block width like an ordinary block. + // (The full §10.3.7 seven-case width-auto-resolution algorithm - solving width from + // explicit left+right+margins - is not implemented; this covers the shrink-to-fit + // case PeachPDF's own Acid2 regression tests exercise.) GetMinMaxWidth already + // returns border-box-inclusive bounds (its own padding/border baked in), so no + // box-sizing adjustment is needed here. double minWidth, maxWidth; GetMinMaxWidth(out minWidth, out maxWidth); width = Math.Min(Math.Max(minWidth, width), maxWidth); @@ -871,13 +875,23 @@ protected virtual void PerformLayoutImp(RGraphics g) if (Position == CssConstants.Fixed) { - left = 0; - top = 0; + // Computed here (not eagerly from the Left/Top property setters, which used to + // race ahead of ActualMarginLeft/Top and ContainingBlock/HtmlContainer being ready + // and cache a margin-less Location that never got recomputed) so margin is always + // resolved against a fully-set-up box, matching every other positioning scheme. + var fixedLocation = GetActualLocation(Left, Top); + left = fixedLocation.X; + top = fixedLocation.Y; + Location = fixedLocation; + ActualBottom = top; } else { left = ContainingBlock.Location.X + ContainingBlock.ActualPaddingLeft + ActualMarginLeft + ContainingBlock.ActualBorderLeftWidth; - var baseTopWithoutMargin = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + (prevSibling != null ? prevSibling.ActualBottom + prevSibling.ActualBorderBottomWidth : 0); + // StaticBottom (not ActualBottom): a relatively-positioned previous sibling's visual + // offset must not drag this box down with it (CSS 2.1 9.4.3 - relative positioning + // "has no effect on the position of any other box"). Ported from PeachPDF. + var baseTopWithoutMargin = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + (prevSibling != null ? prevSibling.StaticBottom + prevSibling.ActualBorderBottomWidth : 0); if (_incomingToken != null && ReferenceEquals(_incomingToken.Box, this)) { @@ -932,6 +946,54 @@ protected virtual void PerformLayoutImp(RGraphics g) // static position committed above; float/clear now overwrite Location using that // static position as input. No-op for boxes that are neither floated nor clearing. CssLayoutEngine.FloatBox(this); + + // CSS 2.1 §9.4.3/§10.3.7: position:relative/absolute apply on top of the static + // position just committed above. Ported from PeachPDF's CssBox.CommitBlockChildOffset + // (adapted: this fork places a box within its own PerformLayoutImp rather than a + // parent placing its child, and position:fixed's own offset - which never runs + // through this static-flow branch at all, see the Position==Fixed arm above - is + // instead resolved by CssBoxProperties.GetActualLocation). + if (Position == CssConstants.Relative) + { + // Purely visual (§9.4.3): the offset is recorded separately (RelativeOffsetX/Y) + // so StaticBottom can back it out again for margin-collapse/sibling-placement + // consumers - "the effect of relative positioning on ... the box's parent's or + // following siblings' layout is nil". + var offsetX = ResolveNearFarOffset(this, Left, Right, ActualWidth); + var offsetY = ResolveNearFarOffset(this, Top, Bottom, ActualHeight); + + RelativeOffsetX = offsetX; + RelativeOffsetY = offsetY; + Location = new RPoint(Location.X + offsetX, Location.Y + offsetY); + ActualBottom = Location.Y; + } + else if (Position == CssConstants.Absolute) + { + var nearestPositionedAncestor = DomUtils.GetNearestPositionedAncestor(this); + var leftIsAuto = string.IsNullOrEmpty(Left) || Left == CssConstants.Auto; + var rightIsAuto = string.IsNullOrEmpty(Right) || Right == CssConstants.Auto; + + // left/top are measured from the containing block's PADDING edge (ClientLeft/ + // ClientTop), not its border-box edge, and the box's own margin still applies + // on top of that offset (CSS 2.1 §10.3.7). When left is auto but right is set, + // anchor off the containing block's right edge instead - this box's own + // border-box width (Size.Width) is already resolved by this point (the shrink- + // to-fit/explicit-width computation above), unlike its height (see the + // top/bottom case, resolved later in this method once ApplyHeight has run). + var absLeft = !leftIsAuto + ? nearestPositionedAncestor.ClientLeft + ActualMarginLeft + + ResolveOffsetOrZero(this, Left, nearestPositionedAncestor.ActualWidth) + : !rightIsAuto + ? nearestPositionedAncestor.ClientLeft + nearestPositionedAncestor.ActualWidth + - ActualMarginRight - ResolveOffsetOrZero(this, Right, nearestPositionedAncestor.ActualWidth) - Size.Width + : nearestPositionedAncestor.ClientLeft + ActualMarginLeft; + + var absTop = nearestPositionedAncestor.ClientTop + ActualMarginTop + + ResolveOffsetOrZero(this, Top, nearestPositionedAncestor.ActualHeight); + + Location = new RPoint(absLeft, absTop); + ActualBottom = Location.Y; + } } } @@ -1034,6 +1096,46 @@ protected virtual void PerformLayoutImp(RGraphics g) } ApplyHeight(); + if (Position == CssConstants.Absolute && Display != CssConstants.TableCell) + { + var topIsAuto = string.IsNullOrEmpty(Top) || Top == CssConstants.Auto; + var bottomIsAuto = string.IsNullOrEmpty(Bottom) || Bottom == CssConstants.Auto; + + if (topIsAuto && !bottomIsAuto) + { + // The top/bottom counterpart of the left/right shrink-to-fit-anchoring case above: + // unlike width, this box's own height is only known now, after ApplyHeight has run + // (auto height depends on this box's own already-laid-out content) - so the + // bottom-anchored case can't resolve at the same point the left/right one does, and + // is instead corrected here by shifting the whole subtree (OffsetTop, the same deep- + // move helper break relocation/table-header repetition already use) once this box's + // final height is known. CSS 2.1 §10.3.7. + // + // The ancestor's own ClientBottom/ActualBottom is NOT usable here: this box is still + // laying out as one of the ancestor's descendants, so the ancestor's own ApplyHeight + // (which sets ActualBottom, run only after ALL of its children finish) has not run yet + // either. ActualHeight, unlike ActualBottom, resolves directly from the ancestor's own + // explicit Height CSS string without depending on that - so the ancestor's content-box + // bottom edge is derived from Location.Y + ActualHeight instead. + var nearestPositionedAncestor = DomUtils.GetNearestPositionedAncestor(this); + var ancestorBorderBoxBottom = nearestPositionedAncestor.Location.Y + nearestPositionedAncestor.ActualHeight; + var ancestorClientBottom = ancestorBorderBoxBottom + - nearestPositionedAncestor.ActualPaddingBottom + - nearestPositionedAncestor.ActualBorderBottomWidth; + var offsetBottom = ResolveOffsetOrZero(this, Bottom, nearestPositionedAncestor.ActualHeight); + var targetBottom = ancestorClientBottom - ActualMarginBottom - offsetBottom; + var deltaY = targetBottom - ActualBottom; + + // ActualBottom is computed (Location.Y + Size.Height, see CssBoxProperties.ActualBottom), + // so shifting Location.Y via OffsetTop already moves it by the same delta - no separate + // update needed (and adding one double-counts the shift). + if (deltaY != 0) + { + OffsetTop(deltaY); + } + } + } + CreateListItemBox(g); if (!IsFixed) @@ -1369,11 +1471,22 @@ private static void GetMinMaxSumWords(CssBox box, ref double min, ref double max { double? oldSum = null; + // paddingSum must be scoped per "line" the same way maxSum is (see the oldSum save/restore + // below) - it represents the border/padding belonging to the WIDEST line found so far, not a + // running total across every sibling's own unrelated line. Without oldPaddingSum, a block + // box's own border/padding (and every descendant's, recursively) permanently accumulated into + // paddingSum and was never reset between siblings - e.g. a content-bearing box followed by + // border-only siblings summed all their unrelated border/padding into one shrink-to-fit width + // instead of using only the widest line's own padding. Ported from PeachPDF's GetMinMaxSumWords. + double? oldPaddingSum = null; + // not inline (block) boxes start a new line so we need to reset the max sum if (box.Display != CssConstants.Inline && box.Display != CssConstants.TableCell && box.WhiteSpace != CssConstants.NoWrap) { oldSum = maxSum; maxSum = marginSum; + oldPaddingSum = paddingSum; + paddingSum = 0; } // add the padding @@ -1406,16 +1519,42 @@ private static void GetMinMaxSumWords(CssBox box, ref double min, ref double max marginSum += childBox.ActualMarginLeft + childBox.ActualMarginRight; //maxSum += childBox.ActualMarginLeft + childBox.ActualMarginRight; + var maxSumBeforeChild = maxSum; GetMinMaxSumWords(childBox, ref min, ref maxSum, ref paddingSum, ref marginSum); + // This walk otherwise never consults a box's own explicit CSS `width` at all - only + // literal word/text content. That's usually fine (explicit width constrains layout + // AFTER content is measured) but breaks down for a child whose only real sizing + // signal IS an explicit width with no word content to measure (e.g. a solid-color + // box). A plain absolute length (not a percentage, which would read this box's own + // not-yet-final ActualWidth) is folded in as an explicit floor for this line's + // running total. Excludes a non-replaced inline box (Display:Inline with no Words of + // its own): per CSS2.1 10.3.3, `width` has no effect on a non-replaced inline-level + // box. A child that starts its OWN new "line" must have its explicit width combined + // via Math.Max against maxSum, NOT added to maxSumBeforeChild - which already + // reflects whatever an earlier, unrelated block-level sibling contributed and must + // compete for "widest line wins", not accumulate. Ported from PeachPDF. + if (CssValueParser.IsValidLength(childBox.Width) && !childBox.Width.EndsWith("%") + && !(childBox.Display == CssConstants.Inline && childBox.Words.Count == 0)) + { + var explicitContentWidth = CssValueParser.ParseLength(childBox.Width, 0, childBox); + var childStartsNewLine = childBox.Display != CssConstants.Inline + && childBox.Display != CssConstants.TableCell && childBox.WhiteSpace != CssConstants.NoWrap; + maxSum = childStartsNewLine + ? Math.Max(maxSum, explicitContentWidth) + : Math.Max(maxSum, maxSumBeforeChild + explicitContentWidth); + min = Math.Max(min, explicitContentWidth); + } + marginSum -= childBox.ActualMarginLeft + childBox.ActualMarginRight; } } - // max sum is max of all the lines in the box + // max sum (and its matching padding contribution) is the max of all the lines in the box if (oldSum.HasValue) { maxSum = Math.Max(maxSum, oldSum.Value); + paddingSum = Math.Max(paddingSum, oldPaddingSum!.Value); } } @@ -1515,7 +1654,9 @@ private double MarginBottomCollapse() var lastChildBottomMargin = lastInFlowBox.ActualMarginBottom; margin = Height == "auto" ? Math.Max(ActualMarginBottom, lastChildBottomMargin) : lastChildBottomMargin; } - return Math.Max(ActualBottom, lastInFlowBox.ActualBottom + margin + ActualPaddingBottom + ActualBorderBottomWidth); + // StaticBottom (not ActualBottom): a relatively-positioned last child's own visual offset must + // not widen this box's auto height (CSS 2.1 9.4.3). Ported from PeachPDF's MarginBottomCollapse. + return Math.Max(ActualBottom, lastInFlowBox.StaticBottom + margin + ActualPaddingBottom + ActualBorderBottomWidth); } /// @@ -1835,11 +1976,52 @@ protected override CssImage GetActualBackgroundImageValue(string value) protected override RPoint GetActualLocation(string X, string Y) { - var left = CssValueParser.ParseLength(X, this.HtmlContainer.PageSize.Width, this, null); - var top = CssValueParser.ParseLength(Y, this.HtmlContainer.PageSize.Height, this, null); + // position:fixed's own left/top offset resolves against the page/viewport size (CSS 2.1 + // §10.1: the initial containing block) and, like every other positioning scheme, the box's own + // margin still applies on top of that offset. Ported from PeachPDF's CommitBlockChildOffset + // Fixed branch (PeachPDF does not consult right/bottom for position:fixed either). + var left = ActualMarginLeft + ResolveOffsetOrZero(this, X, this.HtmlContainer.PageSize.Width); + var top = ActualMarginTop + ResolveOffsetOrZero(this, Y, this.HtmlContainer.PageSize.Height); return new RPoint(left, top); } + /// + /// CSS 2.1 §9.4.3's near/far offset resolution for one axis: the near offset (left/top) + /// wins when set; if it's auto and the far offset (right/bottom) isn't, the far + /// offset applies with its sign flipped; if both are auto, the offset is 0. Ported from + /// PeachPDF's CssBox.ResolveNearFarOffset. + /// + private static double ResolveNearFarOffset(CssBox box, string near, string far, double basis) + { + var nearIsAuto = string.IsNullOrEmpty(near) || near == CssConstants.Auto; + var farIsAuto = string.IsNullOrEmpty(far) || far == CssConstants.Auto; + + if (!nearIsAuto) + { + return CssValueParser.ParseLength(near, basis, box); + } + + if (!farIsAuto) + { + return -CssValueParser.ParseLength(far, basis, box); + } + + return 0; + } + + /// + /// Resolves a single left/top/right/bottom offset for the absolute/fixed + /// positioning branches, where the counterpart edge is never consulted (unlike the relative- + /// positioning near/far resolution in ) - an auto offset + /// simply contributes 0. Ported from PeachPDF's CssBox.ResolveOffsetOrZero. + /// + private static double ResolveOffsetOrZero(CssBox box, string offset, double basis) + { + return offset != CssConstants.Auto && !string.IsNullOrEmpty(offset) + ? CssValueParser.ParseLength(offset, basis, box) + : 0; + } + /// /// ToString override. /// diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs b/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs index ad44a68df..a2b31c828 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs @@ -48,7 +48,9 @@ protected override void PerformLayoutImp(RGraphics g) var prevSibling = DomUtils.GetPreviousSibling(this); double left = ContainingBlock.Location.X + ContainingBlock.ActualPaddingLeft + ActualMarginLeft + ContainingBlock.ActualBorderLeftWidth; - double top = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + MarginTopCollapse(prevSibling) + (prevSibling != null ? prevSibling.ActualBottom + prevSibling.ActualBorderBottomWidth : 0); + // 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); Location = new RPoint(left, top); ActualBottom = top; diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 748a9ea2c..1fe334ff9 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -571,26 +571,20 @@ public string Left get { return _left; } set { + // Deliberately no eager position:fixed recompute here (as this once had): it raced ahead + // of ActualMarginLeft/Top and ContainingBlock/HtmlContainer being ready and cached a + // margin-less Location that never got recomputed once they were. position:fixed placement + // is instead resolved once, in PerformLayoutImp, once the box is fully set up. _left = value; - - if (Position == CssConstants.Fixed) - { - _location = GetActualLocation(Left, Top); - } } } public string Top { get { return _top; } - set { + set + { _top = value; - - if (Position == CssConstants.Fixed) - { - _location = GetActualLocation(Left, Top); - } - } } @@ -726,6 +720,37 @@ public string Position set { _position = value; } } + public string Right + { + get { return _right; } + set { _right = value; } + } + + public string Bottom + { + get { return _bottom; } + set { _bottom = value; } + } + + /// + /// The visual-only offset a position:relative box's placement branch applied, per CSS 2.1 + /// §9.4.3 - kept separately so can back it back out for margin-collapse/ + /// sibling-placement consumers that must lay out against the box's un-offset (static) position. + /// Ported from PeachPDF's CssBox.RelativeOffsetX/Y. + /// + public double RelativeOffsetX { get; set; } + + /// + public double RelativeOffsetY { get; set; } + + /// + /// with any position:relative visual offset + /// backed out - the coordinate a following sibling or this box's own parent (for auto height) must + /// lay out against, since relative positioning "has no effect on the position of any other box" + /// (CSS 2.1 §9.4.3). Ported from PeachPDF's CssBox.StaticBottom. + /// + public double StaticBottom => ActualBottom - RelativeOffsetY; + public string LineHeight { get { return _lineHeight; } diff --git a/Source/HtmlRenderer/Core/Utils/CssConstants.cs b/Source/HtmlRenderer/Core/Utils/CssConstants.cs index 923f3a9c1..fdeade96a 100644 --- a/Source/HtmlRenderer/Core/Utils/CssConstants.cs +++ b/Source/HtmlRenderer/Core/Utils/CssConstants.cs @@ -95,6 +95,7 @@ internal static class CssConstants public const string Pre = "pre"; public const string PreWrap = "pre-wrap"; public const string PreLine = "pre-line"; + public const string Relative = "relative"; public const string Right = "right"; public const string Rtl = "rtl"; public const string SansSerif = "sans-serif"; @@ -103,6 +104,7 @@ internal static class CssConstants public const string Small = "small"; public const string Smaller = "smaller"; public const string Solid = "solid"; + public const string Static = "static"; public const string Sub = "sub"; public const string Super = "super"; public const string Square = "square"; diff --git a/Source/HtmlRenderer/Core/Utils/CssUtils.cs b/Source/HtmlRenderer/Core/Utils/CssUtils.cs index 412260e32..bbbec6cdd 100644 --- a/Source/HtmlRenderer/Core/Utils/CssUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CssUtils.cs @@ -49,7 +49,7 @@ internal static class CssUtils "padding-bottom", "padding-left", "padding-right", "padding-top", "page-break-inside", "break-inside", "break-before", "break-after", "page-break-before", "page-break-after", "widows", "orphans", "page", - "left", "top", "width", "max-width", "height", "min-height", "max-height", + "left", "top", "right", "bottom", "width", "max-width", "height", "min-height", "max-height", "background-color", "background-image", "background-position", "background-repeat", "content", "color", "display", "direction", "empty-cells", "float", "clear", "box-sizing", "position", "line-height", "vertical-align", "text-indent", "text-align", "text-decoration-line", @@ -171,6 +171,10 @@ public static string GetPropertyValue(CssBox cssBox, string propName) return cssBox.Left; case "top": return cssBox.Top; + case "right": + return cssBox.Right; + case "bottom": + return cssBox.Bottom; case "width": return cssBox.Width; case "max-width": @@ -376,6 +380,12 @@ public static void SetPropertyValue(CssBox cssBox, string propName, string value case "top": cssBox.Top = value; break; + case "right": + cssBox.Right = value; + break; + case "bottom": + cssBox.Bottom = value; + break; case "width": cssBox.Width = value; break; diff --git a/Source/HtmlRenderer/Core/Utils/DomUtils.cs b/Source/HtmlRenderer/Core/Utils/DomUtils.cs index 5fa75ca74..1fb3d30df 100644 --- a/Source/HtmlRenderer/Core/Utils/DomUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/DomUtils.cs @@ -231,6 +231,24 @@ public static bool IsBoxHasWhitespace(CssBox box) return false; } + /// + /// The nearest positioned ancestor (CSS 2.1 §10.1: a box whose position is anything other + /// than static) of , or the document root if none is found - the + /// containing block a position:absolute box's offsets/percentages resolve against. Ported + /// from PeachPDF's DomUtils.GetNearestPositionedAncestor. + /// + internal static CssBox GetNearestPositionedAncestor(CssBox box) + { + var current = box.ParentBox; + + while (current.ParentBox != null && current.Position == CssConstants.Static) + { + current = current.ParentBox; + } + + return current; + } + /// /// The candidate rectangle being tested against existing floats, either during float placement /// ('s FloatBoxLeft/FloatBoxRight) or while flowing a line's inline diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Tables.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Tables.png index e676ca361..4f008f4e8 100644 Binary files a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Tables.png and b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Tables.png differ diff --git a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs index 4a54a9eda..7b8d40b83 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/BoxModel/HrPlacementTests.cs @@ -28,11 +28,10 @@ public sealed class HrPlacementTests [TestMethod] public void ARelativelyPositionedPredecessor_DoesNotDragTheRuleWithIt() { - // Note: position:relative has no implementation anywhere in this fork's Core at all (confirmed - no - // "Relative"/CssConstants.Relative handling exists in Core/Dom/CssBox.cs or CssBoxProperties.cs), so - // 'top' on a relatively-positioned box is simply ignored; the box never moves in the first place. This - // test still genuinely passes - it just does so because relative offsetting is a no-op here, not because - // it's correctly excluded from the flow calculation the way CSS2.1 requires. + // CSS 2.1 §9.4.3: relative positioning is purely visual - the offset must not affect where a + // following sibling lays out. CssBoxHr.PerformLayoutImp reads prevSibling.StaticBottom (which backs + // the offset back out), not ActualBottom, so the rule ends up in the same place whether or not its + // predecessor is relatively positioned. var (staticRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap( "

")); var (offsetRoot, _) = LayoutHarness.Layout(LayoutHarness.Wrap( diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs new file mode 100644 index 000000000..ad0726aa7 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs @@ -0,0 +1,280 @@ +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.IntegrationTest.Layout; + +/// +/// Ported from PeachPDF.Tests/Integration/FloatLayoutRegressionTests.cs. CSS 2.1 §9.5: a floated box is taken +/// out of normal flow and shifted to the left/right edge of its containing block; subsequent inline content +/// flows around it, and other floats stack against it rather than overlapping. §9.5.2 defines clear as +/// only clearing floats of the matching (or "both") side. +/// +/// The four perf-regression-guard cases from the source file (FloatScanCalls/FloatScanBoxVisits +/// counters on HtmlContainerInt, guarding an O(document size) vs O(1) float-scan short-circuit) are not +/// ported: this fork's HtmlContainerInt has the same HasFloatedBoxes short-circuit and float-scan +/// helpers (confirmed in DomUtils.GetFirstIntersectingFloatBox et al.), but no FloatScanCalls/ +/// FloatScanBoxVisits instrumentation to observe it through - that's an internal perf counter, not CSS +/// 2.1 behavior, so it's out of scope for this pass rather than something to add. +/// +/// +[DoNotParallelize] +[TestClass] +public sealed class FloatLayoutIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + public void Float_PushesFollowingSiblingTextToTheRight() + { + var html = LayoutHarness.Wrap( + "
" + + "
" + + "

Hello world

"); + + var (root, _) = LayoutHarness.Layout(html); + var text = LayoutHarness.FindById(root, "text")!; + var firstWord = FindFirstWord(text); + + Assert.IsNotNull(firstWord); + Assert.IsTrue(firstWord!.Left >= 90, + $"first word should be pushed right past the 100px float, was at {firstWord.Left}"); + } + + [TestMethod] + public void WithoutFloat_SiblingTextStartsAtContainerEdge() + { + var html = LayoutHarness.Wrap( + "
" + + "
" + + "

Hello world

"); + + var (root, _) = LayoutHarness.Layout(html); + var text = LayoutHarness.FindById(root, "text")!; + var firstWord = FindFirstWord(text); + + Assert.IsNotNull(firstWord); + Assert.IsTrue(firstWord!.Left < 10, + $"first word should start at the container's left edge without a float, was at {firstWord.Left}"); + } + + [TestMethod] + public void Float_NarrowsAvailableWidth_SoTextWrapsToMoreLines() + { + const string longText = + "This is a fairly long sentence that should wrap across multiple lines once the available width is narrowed by a floated sibling element."; + + var withFloatHtml = LayoutHarness.Wrap( + $"
" + + $"

{longText}

"); + var withoutFloatHtml = LayoutHarness.Wrap( + $"

{longText}

"); + + var (withFloatRoot, _) = LayoutHarness.Layout(withFloatHtml); + var (withoutFloatRoot, _) = LayoutHarness.Layout(withoutFloatHtml); + + var withFloatText = LayoutHarness.FindById(withFloatRoot, "text")!; + var withoutFloatText = LayoutHarness.FindById(withoutFloatRoot, "text")!; + + Assert.IsTrue(withFloatText.ActualBottom - withFloatText.Location.Y + > withoutFloatText.ActualBottom - withoutFloatText.Location.Y, + "narrowing the line width with a float should force extra line wraps and a taller box " + + $"(with float height: {withFloatText.ActualBottom - withFloatText.Location.Y}, " + + $"without: {withoutFloatText.ActualBottom - withoutFloatText.Location.Y})"); + } + + [TestMethod] + public void FloatLeft_WrapsBelowAFullWidthFloatRightSibling() + { + // A float:left box that would overlap a previously-placed, full-width float:right sibling must wrap + // below it rather than overlapping. + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
"); + + var (root, _) = LayoutHarness.Layout(html); + var r = LayoutHarness.FindById(root, "r")!; + var l = LayoutHarness.FindById(root, "l")!; + + Assert.IsTrue(l.Location.Y >= r.ActualBottom, + $"float:left box should wrap below the full-width float:right sibling it can't fit beside " + + $"(l.Y={l.Location.Y}, r.ActualBottom={r.ActualBottom})"); + } + + [TestMethod] + public void FloatRight_InNarrowerNestedBlock_AvoidsAWiderAncestorFloatRightSibling() + { + // A float:right box placed inside a narrower, non-floated nested block still avoids an ancestor + // float:right sibling that sits past the nested block's own right edge. + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
"); + + var (root, _) = LayoutHarness.Layout(html); + var outerR = LayoutHarness.FindById(root, "outerR")!; + var r = LayoutHarness.FindById(root, "r")!; + + Assert.AreEqual(outerR.Location.X - outerR.ActualMarginLeft, r.ActualRight, Delta); + } + + [TestMethod] + public void FloatRight_InNarrowerNestedBlock_WithMarginLeft_StillAvoidsAWiderAncestorFloatRightSibling() + { + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
" + + "
"); + + var (root, _) = LayoutHarness.Layout(html); + var outerR = LayoutHarness.FindById(root, "outerR")!; + var r = LayoutHarness.FindById(root, "r")!; + + Assert.AreEqual(outerR.Location.X - outerR.ActualMarginLeft, r.ActualRight, Delta); + } + + [TestMethod] + public void FloatRight_NarrowsLineWrapWidth_SoTextWrapsBeforeReachingIt() + { + var html = LayoutHarness.Wrap( + "
" + + "
" + + "

this line of text should wrap before it reaches the floated box on the right

"); + + var (root, _) = LayoutHarness.Layout(html); + var floatBox = LayoutHarness.FindById(root, "f")!; + var text = LayoutHarness.FindById(root, "text")!; + var floatLeftEdge = floatBox.Location.X - floatBox.ActualMarginLeft; + + var wordsOverlappingFloat = WordsOverlappingVerticalSpan(text, floatBox.Location.Y, floatBox.ActualBottom); + + Assert.AreNotEqual(0, wordsOverlappingFloat.Count); + + foreach (var word in wordsOverlappingFloat) + { + Assert.IsTrue(word.Left + word.Width <= floatLeftEdge + 1, + $"word '{word.Text}' at right={word.Left + word.Width} overlaps the float:right box, " + + $"whose left edge (including margin) is at {floatLeftEdge}"); + } + } + + [TestMethod] + public void FloatRight_WithMarginLeft_StillReachesContainingBlockRightEdge() + { + var html = LayoutHarness.Wrap( + "
" + + "
"); + + var (root, _) = LayoutHarness.Layout(html); + var dl = LayoutHarness.FindById(root, "dd")!.ParentBox; + var dd = LayoutHarness.FindById(root, "dd")!; + + Assert.AreEqual(dl.ClientRight, dd.ActualRight, Delta); + } + + [TestMethod] + public void FloatLeft_StillNarrowsLineWrapWidth_AfterTheRightFloatFix() + { + const string longText = + "this line of text should wrap below and around the floated box on the left before it reaches the container edge"; + + var withFloatHtml = LayoutHarness.Wrap( + $"
" + + $"

{longText}

"); + var withoutFloatHtml = LayoutHarness.Wrap( + $"

{longText}

"); + + var (withFloatRoot, _) = LayoutHarness.Layout(withFloatHtml); + var (withoutFloatRoot, _) = LayoutHarness.Layout(withoutFloatHtml); + + var floatBox = LayoutHarness.FindById(withFloatRoot, "f")!; + var withFloatText = LayoutHarness.FindById(withFloatRoot, "text")!; + var withoutFloatText = LayoutHarness.FindById(withoutFloatRoot, "text")!; + var floatRightEdge = floatBox.ActualRight + floatBox.ActualMarginRight; + + var wordsOverlappingFloat = + WordsOverlappingVerticalSpan(withFloatText, floatBox.Location.Y, floatBox.ActualBottom); + + Assert.AreNotEqual(0, wordsOverlappingFloat.Count); + + foreach (var word in wordsOverlappingFloat) + { + Assert.IsTrue(word.Left >= floatRightEdge - 1, + $"word '{word.Text}' at left={word.Left} starts before the float:left box's right edge " + + $"(including margin) at {floatRightEdge}"); + } + + Assert.IsTrue(withFloatText.ActualBottom - withFloatText.Location.Y + > withoutFloatText.ActualBottom - withoutFloatText.Location.Y, + "narrowing the line width with a float:left should force extra line wraps and a taller box"); + } + + [TestMethod] + public void ClearLeft_IgnoresAPrecedingFloatRightSibling() + { + // clear:left only clears past float:left siblings - a float:right sibling must not push it down. + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
text
"); + + var (root, _) = LayoutHarness.Layout(html); + var cleared = LayoutHarness.FindById(root, "cleared")!; + + Assert.IsTrue(cleared.Location.Y < 80, + $"clear:left must not clear past a float:right sibling, was pushed to Y={cleared.Location.Y}"); + } + + [TestMethod] + public void ClearRight_IgnoresAPrecedingFloatLeftSibling() + { + // Symmetric case: clear:right ignoring a float:left sibling. + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
text
"); + + var (root, _) = LayoutHarness.Layout(html); + var cleared = LayoutHarness.FindById(root, "cleared")!; + + Assert.IsTrue(cleared.Location.Y < 80, + $"clear:right must not clear past a float:left sibling, was pushed to Y={cleared.Location.Y}"); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private static CssRect? FindFirstWord(CssBox box) + { + if (box.Words.Count > 0) return box.Words[0]; + foreach (var child in box.Boxes) + { + var found = FindFirstWord(child); + if (found is not null) return found; + } + return null; + } + + private static List WordsOverlappingVerticalSpan(CssBox box, double top, double bottom) + { + List words = []; + CollectWordsOverlappingVerticalSpan(box, top, bottom, words); + return words; + } + + private static void CollectWordsOverlappingVerticalSpan(CssBox box, double top, double bottom, List words) + { + foreach (var word in box.Words) + { + if (word.Top < bottom && word.Top + word.Height > top) + { + words.Add(word); + } + } + + foreach (var child in box.Boxes) + { + CollectWordsOverlappingVerticalSpan(child, top, bottom, words); + } + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs new file mode 100644 index 000000000..37e6bcb2b --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.Painting; + +/// +/// Ported from PeachPDF.Tests' Acid2FeatureVerificationTests.cs (the z-index/stacking-context section). +/// CSS 2.1 §9.9/Appendix E: a positioned box's z-index establishes a stacking context, and boxes in a +/// higher stacking context paint after (on top of) boxes in a lower one, regardless of document/source order - +/// e.g. a position:relative; z-index:2 box must paint over a position:fixed box declared later +/// in the document. +/// +[DoNotParallelize] +[TestClass] +public sealed class StackingContextTests +{ + [Ignore("z-index/stacking-context paint order is not implemented on this fork: FragmentPainter.cs's own " + + "class remarks explicitly document it as deferred (\"Stacking-context paint order and " + + "box-decoration-break slicing are follow-on work\"), and CssBoxProperties has no ZIndex field at " + + "all - the CSS-OM parses z-index (CssEngine/StyleProperties/Flow/ZIndexProperty.cs) but " + + "CssUtils.SetPropertyValue never dispatches it onto a box, so it has zero effect on paint order. " + + "This box tree currently paints in plain document order (normal flow, then absolute/fixed, per " + + "FragmentPainter's child-iteration order) regardless of any z-index value - a position:relative " + + "z-index:2 box painting over a LATER position:fixed sibling (this test's whole premise) is exactly " + + "the case document order alone cannot produce, so this reliably fails rather than passing by " + + "accident. Implementing real stacking-context ordering (a ZIndex box property, plus grouping/" + + "sorting descendants by stacking context per CSS2.1 Appendix E) is a separate, larger feature port.")] + [TestMethod] + public void PositionedZIndex_PaintsOverFixedPositionedContent() + { + // A black position:fixed bar declared AFTER (later in the box tree than) a white + // position:relative;z-index:2 box must still be painted BEFORE it (i.e. underneath). + var html = LayoutHarness.Wrap( + "
" + + "
"); + + var (root, container) = PaintHarness.Layout(html); + var recorder = PaintHarness.PaintPage(container); + + var fixedBar = PaintHarness.FindById(root, "fixedbar")!; + var intro = PaintHarness.FindById(root, "intro")!; + + var drawRectCalls = recorder.Log.OfType().ToList(); + var fixedBarPaintIndex = drawRectCalls.FindIndex(c => c.X == fixedBar.Location.X && c.Y == fixedBar.Location.Y); + var introPaintIndex = drawRectCalls.FindIndex(c => c.X == intro.Location.X && c.Y == intro.Location.Y); + + Assert.IsTrue(fixedBarPaintIndex < introPaintIndex, + "the z-index:2 box must paint after (on top of) the fixed bar, regardless of document order"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs new file mode 100644 index 000000000..265ce3cd7 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs @@ -0,0 +1,183 @@ +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.Positioning; + +/// +/// Ported from PeachPDF.Tests' Acid2FeatureVerificationTests.cs (the position:relative/absolute/fixed offset +/// section) and AbsolutePositioningIntegrationTests.cs's shrink-to-fit cases. CSS 2.1 §9.4.3: for each axis of +/// a relatively/absolutely positioned box, the "near" offset (left/top) wins when set; if it's +/// auto and the "far" offset (right/bottom) isn't, the far offset applies with its sign +/// flipped. §9.4.3 also requires relative positioning to be purely visual - it must not affect the parent's +/// content-driven height or any following sibling's layout. §10.3.7: an absolutely positioned box's offsets +/// are measured from its nearest positioned ancestor's PADDING edge, and (like every other positioning scheme) +/// its own margin still applies on top of that offset; with no explicit width, it shrinks to fit its content. +/// +/// The PeachPDF source file's flexbox/grid blockification cases and detached-<thead>/<tfoot> +/// containing-block cases are not ported: this fork has neither a flex/grid layout engine nor the notion of a +/// detached header/footer proxy box those target. +/// +/// +[DoNotParallelize] +[TestClass] +public sealed class AbsolutePositioningIntegrationTests +{ + private const double Delta = 1.0; + + [TestMethod] + public void PositionRelative_BottomOffset_MovesBoxOppositeDirection() + { + // top is auto, bottom is set - a positive "bottom" pulls the box UP, i.e. subtracts from Y. + var html = LayoutHarness.Wrap("
"); + var (root, _) = LayoutHarness.Layout(html); + var box = LayoutHarness.FindById(root, "t")!; + + // Static-flow position is Y=0 (LayoutHarness.Wrap sets body margin:0); "bottom:10px" must move it to Y=-10. + Assert.AreEqual(-10, box.Location.Y, Delta); + } + + [TestMethod] + public void PositionRelative_Offset_DoesNotAffectParentHeightOrFollowingSibling() + { + // The offset box (and its descendants) move, but the parent's content-driven height and every + // following sibling must lay out against the STATIC position. + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var parent = LayoutHarness.FindById(root, "parent")!; + var shifted = LayoutHarness.FindById(root, "shifted")!; + var after = LayoutHarness.FindById(root, "after")!; + + // The offset itself is applied visually: the child sits 30px below the parent's top... + Assert.AreEqual(30, shifted.Location.Y - parent.Location.Y, Delta); + + // ...but the parent is still exactly 40px tall (the child's static extent)... + Assert.AreEqual(40, parent.ActualBottom - parent.Location.Y, Delta); + + // ...and the following sibling starts at the parent's un-inflated bottom. + Assert.AreEqual(parent.ActualBottom, after.Location.Y, Delta); + } + + [TestMethod] + public void PositionRelative_OffsetOnBoxItself_DoesNotShiftFollowingSibling() + { + // "after" must lay out against "shifted"'s static bottom, not its visually offset bottom 25px lower. + var html = LayoutHarness.Wrap( + "
" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var shifted = LayoutHarness.FindById(root, "shifted")!; + var after = LayoutHarness.FindById(root, "after")!; + + Assert.AreEqual(25, shifted.RelativeOffsetY, Delta); + Assert.AreEqual(shifted.ActualBottom - 25, after.Location.Y, Delta); + } + + [TestMethod] + public void PositionAbsolute_BottomOffset_PositionsRelativeToContainingBlockBottomEdge() + { + var html = LayoutHarness.Wrap( + "
" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var cb = LayoutHarness.FindById(root, "cb")!; + var box = LayoutHarness.FindById(root, "t")!; + + // Box's bottom edge must sit 10px above the containing block's own bottom (padding) edge. + Assert.AreEqual(cb.ActualBottom - 10, box.ActualBottom, Delta); + } + + [TestMethod] + public void PositionAbsolute_WithMarginAndBorderedContainingBlock_AppliesBothCorrectly() + { + var html = LayoutHarness.Wrap( + "
" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var cb = LayoutHarness.FindById(root, "cb")!; + var box = LayoutHarness.FindById(root, "t")!; + + // Expected: containing block's PADDING edge (border-box + 16px border) + the box's own margin. + Assert.AreEqual(cb.Location.X + 16 + 60, box.Location.X, Delta); + Assert.AreEqual(cb.Location.Y + 16 + 36, box.Location.Y, Delta); + } + + [TestMethod] + public void PositionFixed_WithMargin_AppliesMarginOnTopOfOffset() + { + var html = LayoutHarness.Wrap( + "
"); + var (root, _) = LayoutHarness.Layout(html); + var box = LayoutHarness.FindById(root, "t")!; + + Assert.AreEqual(20 + 8, box.Location.X, Delta); + Assert.AreEqual(10 + 5, box.Location.Y, Delta); + } + + [TestMethod] + public void PositionAbsoluteAutoWidth_ShrinksToWidestChild_NotSumOfSiblingBorders() + { + // Three siblings under an absolutely-positioned, auto-width parent: #text (real content, ~short), + // #border1 (80px combined border, no content), #border2 (60px combined border, no content) - the + // correct shrink-to-fit width is #border1's own ~80px (the widest single line), not #border1 + + // #border2's borders summed together (~140px). + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
Hi
" + + "
" + + "
" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var target = LayoutHarness.FindById(root, "target")!; + + var targetWidth = target.ActualRight - target.Location.X; + + // Allow a little headroom above 80 for #text's own (much smaller) content contribution. + Assert.IsTrue(targetWidth is >= 79 and <= 100, + $"expected shrink-to-fit width near 80px (the widest single sibling), was {targetWidth}"); + } + + [TestMethod] + public void PositionAbsoluteAutoWidth_MultipleExplicitWidthSiblings_TakesWidestNotSum() + { + var html = LayoutHarness.Wrap( + "
" + + "
" + + "
" + + "
" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var target = LayoutHarness.FindById(root, "target")!; + + var targetWidth = target.ActualRight - target.Location.X; + + // The widest single sibling (100px) should win - the buggy summed-across-siblings result would be + // at least 100+90=190px. + Assert.IsTrue(targetWidth is >= 99 and <= 105, + $"expected shrink-to-fit width near 100px (the widest single sibling), was {targetWidth}"); + } + + [TestMethod] + public void PositionAbsoluteAutoWidth_NonReplacedInlineChildsExplicitWidth_HasNoEffect() + { + // Per CSS2.1 10.3.3, `width` has no effect on a non-replaced inline-level box - its explicit width + // must not be folded into an ancestor's shrink-to-fit computation. + var html = LayoutHarness.Wrap( + "
" + + "
" + + "" + + "
"); + var (root, _) = LayoutHarness.Layout(html); + var target = LayoutHarness.FindById(root, "target")!; + + var targetWidth = target.ActualRight - target.Location.X; + + // The inline child's own "width:200px" must be ignored - target should shrink to ~0 (no real + // content), not inflate to 200px. + Assert.IsTrue(targetWidth is >= 0 and <= 20, + $"expected shrink-to-fit width near 0px (inline width has no effect), was {targetWidth}"); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs b/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs index c75adbf89..83594b7df 100644 --- a/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs +++ b/Source/Test/HtmlRenderer.Test/Css/FloatPropertyTests.cs @@ -4,15 +4,14 @@ namespace HtmlRenderer.Test.Css; /// /// Ported from PeachPDF.Tests/CSS/PropertyTests/FloatClearProperty.cs. -/// Only the `float` cases apply: HTML-Renderer has no `clear` CSS property at all (no ClearProperty -/// type and no CssBoxProperties.Clear field/property - confirmed by grepping "Clear" across -/// CssBoxProperties.cs and HtmlConstants.cs), so every `clear` case from the source file was dropped. -/// The "invalid keyword" float case was also dropped: TheArtOfDev.HtmlRenderer.Core.Parse.CssParser -/// does not validate `float` values against a keyword set, and CssUtils.SetPropertyValue assigns -/// whatever string was parsed straight to CssBox.Float with no rejection path, so there is no "illegal +/// HTML-Renderer does have a `clear` CSS property (CssEngine.ClearProperty, CssBoxProperties.Clear) - +/// the original note claiming otherwise predates that property being added. Both the "invalid keyword" +/// float and clear cases are dropped: TheArtOfDev.HtmlRenderer.Core.Parse.CssParser does not validate +/// `float`/`clear` values against a keyword set, and CssUtils.SetPropertyValue assigns whatever string +/// was parsed straight to CssBox.Float/CssBox.Clear with no rejection path, so there is no "illegal /// keyword" outcome to observe in this fork. /// Exercised via the real box tree (LayoutHarness + inline style) rather than raw property parsing, so -/// the assertion is against the actual CssBoxProperties.Float value a laid-out box ends up with. +/// the assertion is against the actual CssBoxProperties.Float/Clear value a laid-out box ends up with. /// [TestClass] public sealed class FloatPropertyTests @@ -30,4 +29,19 @@ public void FloatKeywordLegal_SetsBoxFloat(string keyword) Assert.IsNotNull(target); Assert.AreEqual(keyword, target.Float); } + + [TestMethod] + [DataRow("left")] + [DataRow("right")] + [DataRow("both")] + [DataRow("none")] + public void ClearKeywordLegal_SetsBoxClear(string keyword) + { + var (root, _) = LayoutHarness.Layout(LayoutHarness.Wrap($"
content
")); + + var target = LayoutHarness.FindById(root, "target"); + + Assert.IsNotNull(target); + Assert.AreEqual(keyword, target.Clear); + } }