From b97ee6144e60fdacb460c39d671eb9209e60773e Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 13:47:20 -0400 Subject: [PATCH 1/3] Port PeachPDF's float/clear layout tests, restore dropped clear cases FloatPropertyTests.cs claimed this fork had no clear CSS property at all, dropping every clear case from the PeachPDF source it was ported from - that predates ClearProperty being added and is now false, so restore the parsing coverage. Ports PeachPDF's FloatLayoutRegression- Tests.cs and the float/clear Acid2 regression cases as a new IntegrationTest suite; all pass unmodified, confirming float/clear layout parity with PeachPDF. --- .../Layout/FloatLayoutIntegrationTests.cs | 280 ++++++++++++++++++ .../Css/FloatPropertyTests.cs | 28 +- 2 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Layout/FloatLayoutIntegrationTests.cs 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.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); + } } From 0b33325e252a52c453a5df2d8f784660c13d616d Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 13:47:41 -0400 Subject: [PATCH 2/3] =?UTF-8?q?Implement=20position:relative/absolute/fixe?= =?UTF-8?q?d=20layout,=20per=20CSS=202.1=20=C2=A79.4.3/=C2=A710.3.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit position:relative previously had no layout effect anywhere in this fork (parsed but never read); position:absolute only had ad-hoc partial support (no real containing-block resolution, plain in-flow placement otherwise); position:fixed resolved only against the page and dropped the box's own margin; and `right`/`bottom` were parsed at the CSS-OM level but never dispatched onto a box at all, so they had zero effect under any positioning scheme. Backports PeachPDF's CommitBlockChildOffset placement logic, adapted to this fork's box places-itself (rather than parent-commits-child) layout shape: - Right/Bottom become real box properties, wired through CssUtils the same way Left/Top already were. - position:relative applies a near/far offset (left wins over right when both are set, sign-flipped when only the far edge is) that is purely visual per §9.4.3: RelativeOffsetX/Y record it separately so the new StaticBottom can back it out again, and every sibling- placement/margin-collapse/shrink-to-fit call site that used to read a box's ActualBottom directly now reads StaticBottom instead, so a relatively-positioned box's offset no longer drags its parent's auto height or following siblings down with it. - position:absolute resolves against DomUtils.GetNearestPositioned- Ancestor's padding edge, on both axes anchoring from whichever of the near/far offset is set (right-anchoring reads the box's own already-resolved Size.Width; bottom-anchoring has to wait until this box's own ApplyHeight has run, since auto height depends on this box's own content, so it's corrected via a post-hoc OffsetTop shift instead of resolved inline like every other case here). Absolute boxes with width:auto also now shrink-to-fit their content instead of filling the containing block, matching CSS 2.1 §10.3.7's common case (the full seven-case width-auto-resolution algorithm is not implemented). - position:fixed's offset is now computed once, in PerformLayoutImp once margin/container are guaranteed ready, instead of eagerly from the Left/Top property setters - which used to race ahead of ActualMarginLeft/Top being resolved and cache a margin-less Location that never got recomputed. Porting PeachPDF's shrink-to-fit width tests also surfaced two real, pre-existing bugs in GetMinMaxSumWords (a border/padding sum that never reset between sibling "lines", and no explicit-width floor for a childless block), both already fixed in PeachPDF - backported here too, and re-approves one baseline PDF whose auto-width table column render 1-2px differently now that the fix applies generally, not just to the new absolute-positioning case that surfaced it. Ports the CSS 2.1 §9.4.3/§10.3.7 Acid2 regression tests from PeachPDF covering all of the above; all 9 pass. --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 206 +++++++++++++++++- Source/HtmlRenderer/Core/Dom/CssBoxHr.cs | 4 +- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 49 ++++- .../HtmlRenderer/Core/Utils/CssConstants.cs | 2 + Source/HtmlRenderer/Core/Utils/CssUtils.cs | 12 +- Source/HtmlRenderer/Core/Utils/DomUtils.cs | 18 ++ .../Baselines/Tables.png | Bin 27558 -> 27539 bytes .../BoxModel/HrPlacementTests.cs | 9 +- .../AbsolutePositioningIntegrationTests.cs | 183 ++++++++++++++++ 9 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Positioning/AbsolutePositioningIntegrationTests.cs 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 e676ca3613fbcc97e1385e302d99ce3cda2a6b1f..4f008f4e8f280799d328e3b86222287d14b4571a 100644 GIT binary patch delta 19882 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 G^Zx)6d`-Oo 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/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}"); + } +} From 543e402982e8be6d7bca46963882187b135426e9 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 13:47:47 -0400 Subject: [PATCH 3/3] Document the z-index/stacking-context paint-order gap with a failing-by-design test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports PeachPDF's Acid2 z-index/stacking regression test (CSS 2.1 §9.9/Appendix E: a position:relative;z-index:2 box must paint over a later position:fixed sibling regardless of document order). Left [Ignore]d rather than implemented: FragmentPainter.cs already documents stacking-context paint order as deferred follow-on work, and there is no ZIndex box property or paint-order sorting anywhere in Core to hang a real implementation off of - this is a separate, larger feature port, not a one-line fix like the positioning gaps fixed in the previous commit. --- .../Painting/StackingContextTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Painting/StackingContextTests.cs 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"); + } +}