Visitar URL original
Port PeachPDF's table fragmentation tests (Batch 4) · jhaygood86/HTML-Renderer@ea99b55 · GitHub
Skip to content

Commit ea99b55

Browse files
committed
Port PeachPDF's table fragmentation tests (Batch 4)
Ports PeachPDF.Tests/Integration/{RepeatedTableHeaderClipIntegrationTests, RepeatingTableRelayoutTests,WholeTableRelocationTests,TableRowspanContinuationTests, TableSpannedBandRepetitionTests,TableRepeatedGroupConditionsTests, TableRowBreakValueTests,PageBreakTableKeepWithNextIntegrationTests, BreakValueCascadeTests,StructuralCloneBreakValueBehaviourTests}.cs into Source/Test/HtmlRenderer.IntegrationTest/Tables/, and revises the existing PageBreakTableIntegrationTests.cs (an earlier ArthurHub#262 port, written before this branch's own css-tables-3 6.1 row-preservation-by-default landed in 362dee9) to match that table's now-current mechanics. Two real, confirmed production bugs were found and fixed, not just documented: 1. CssBoxProperties.InheritStyle's "everything: true" branch (structural-clone copying) never copied break-before/break-after/break-inside, so both of its real callers - TableHeaderRepeat.CloneSubtree's repeated <thead> row clones, and DomParser.CorrectBlockSplitBadBox's block-in-inline split - silently produced auto/auto/auto clones regardless of what the source element declared. Fixed by adding the three fields to that branch. 2. CssLayoutEngineTable.LayoutCells's repeated-header loop seeded its own lastRepeatSlot from PageIndexOf(starty) - and for a border-collapse:collapse table (GetVerticalSpacing() is -1, a deliberate one-pixel row/border overlap) sitting flush at a page's own content top, starty lands one pixel below PageIndexOf's slot boundary, flooring into the slot BEFORE the one the table actually starts in. This made the loop see a spurious "transition" at the very first body row, painting the header twice on the table's own first page (confirmed empirically: two HEADERMARKER draws at nearly the same position) while, in a combined effect, one repeat later in the document went missing. Fixed with a new PageSlotOf helper that clamps to CssBox.ClientTop (immune to the collapsed-border overlap) - scoped to this loop alone, not the row-shift straddle check a few lines below, which reacts to the same misread with a harmless 1px nudge two existing tests depend on (see PageSlotOf's own remarks). CssLayoutEngineTablePageBreakTests.RepeatedThead_SinglePageBorder CollapseTable_PhantomHeaderRepeatDueToNegativeSlotRounding, Ignored since Batch 1 with this exact symptom already documented, is un-Ignored - it now passes. Ported/revised, with test counts: - RepeatedTableHeaderClipIntegrationTests (3/3 port) - a repeated header's overflow:hidden clip resolves correctly per-page by construction here (each repeat is an independent, already-positioned CssBox clone, not PeachPDF's shared-subtree-plus-proxy architecture); regression pins, not bug repros. - RepeatingTableRelayoutTests (2/2 port, 4 cases) - a relocated card holding a repeating-header table is genuinely relaid out, not translated; the table's own RepeatedHeaderRows-reset-per-pass makes PeachPDF's excluding bug moot here. - WholeTableRelocationTests (7/7 port) - adapted off PerformLayoutEpilogue onto the real trigger, BlockFragmentation.RelocateIfNeeded, called from the block child loop after a table finishes its own layout. - TableRowspanContinuationTests (6 ported + 1 Ignored, down from 17) - PeachPDF's TableRowCursor/PageBreakBottoms/ShellIn/box-decoration-break-edge machinery has no counterpart; ported what's real here instead (ActualBottom extension on a rowspan cell whose ending row is shifted, deterministically). Also surfaces an unrelated, pre-existing InsertEmptyBoxes gap (a rowspan in a column past the ending row's own last existing cell gets no placeholder there at all, matching PeachPDF's own issue #522, not fixed here) - fixtures route around it. - TableSpannedBandRepetitionTests (4 ported, down from 13, tfoot half dropped) - BoxFragment.OverflowClip is always null here (no room-reservation/slicing mechanism exists), so PeachPDF's strip/confinement assertions have nothing to port onto; pins the real, already-documented "only the first band a lone tall row overflows onto gets a repeat" limitation instead. - TableRepeatedGroupConditionsTests (5 ported, down from 16, tfoot half and the print-media-only UA-stylesheet tests dropped) - the quarter-of-page-height cap (css-tables-3 6.2's second condition) is confirmed unimplemented; pinned as an inverted, real test rather than force-fit. - TableRowBreakValueTests (1 real + 4 Ignored) - CssLayoutEngineTable.cs never reads BreakBefore/BreakAfter anywhere; forced row breaks don't exist. - PageBreakTableIntegrationTests (revised in place) - un-Ignored SingleRowTable_CrossingPageBoundary_IsMovedToNextPage with a corrected assertion (row preservation shifts the straddling row's cells, never the table's own outer Location - the original assertion checked the wrong property); corrected the other two Ignore reasons to cite the real, current gaps (freely-fragmentable-via-width carve-out; EnforceKeepWithNext reading the table's own EffectiveTop, untouched by an internal row-shift) instead of a BreakPage() method that no longer exists anywhere in the source. - PageBreakTableKeepWithNextIntegrationTests (3 real + 2 Ignored, dropped the 3-way composition test) - PeachPDF's "Gap 1" does not reproduce here at all (BlockFragmentation.EnforceKeepWithNext is unconditional, not table-specific); "Gap 2"'s shape does, for a different, more fundamental reason (row preservation moves cells, never the table's own box - same fact as above). - BreakValueCascadeTests (9/9 port, adapted to hold break-inside:avoid fixed on the source and vary before/after, since the print-media UA default doesn't apply under this harness) and StructuralCloneBreakValueBehaviourTests (5/5 port) - characterize the InheritStyle fix's own real effect: correctly carried now, but still inert for both clone sites (detached header clones never reached by BlockFragmentation; the block-in-inline split's anonymous wrapper still breaks the sibling chain a following box would read). Full suite: HtmlRenderer.Test 2427 passed/128 skipped/0 failed (was 2426/129 - one previously-Ignored test now passes), HtmlRenderer.IntegrationTest 323 passed/101 skipped/0 failed (was 275/95), HtmlRenderer.PdfSharp.Test 30 passed/0 skipped/0 failed (unchanged). Clean build across net8.0/ netstandard2.0/net462, 1 pre-existing warning (unrelated nullable-dereference).
1 parent 11c35b3 commit ea99b55

14 files changed

Lines changed: 1800 additions & 58 deletions

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,22 @@ protected void InheritStyle(CssBox p, bool everything)
19221922
_width = p._width;
19231923
_maxWidth = p._maxWidth;
19241924
_wordSpacing = p._wordSpacing;
1925+
1926+
// css-break-3 3: break-before/break-after/break-inside attach to the ELEMENT, not to
1927+
// whichever one of its boxes happens to hold them - so a structural clone (a fragment
1928+
// of the same element, as opposed to an ordinary, unrelated child) must carry them too,
1929+
// even though they are not part of the ordinary CSS inheritance this method's non-
1930+
// "everything" branch above implements. Confirmed missing by direct inspection: this
1931+
// "everything" branch copied every other originating-element property (background,
1932+
// border, position, size...) but never these three, so both of this method's real
1933+
// "everything: true" callers silently produced auto/auto/auto clones regardless of what
1934+
// the source element declared - TableHeaderRepeat.CloneSubtree's per-page repeated
1935+
// <thead> row clones, and DomParser.CorrectBlockSplitBadBox's block-in-inline split
1936+
// (leftbox/rightBox), both of which exist specifically because one element is being
1937+
// represented by more than one box and every representative must agree.
1938+
_pageBreakInside = p._pageBreakInside;
1939+
_breakBefore = p._breakBefore;
1940+
_breakAfter = p._breakAfter;
19251941
}
19261942
}
19271943
}

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

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,12 +659,12 @@ private void LayoutCells(RGraphics g)
659659
// still theirs. Its own page is never itself a "repeat" - the header is already
660660
// there once, in flow.
661661
headerHeight = maxBottom - starty;
662-
lastRepeatSlot = pageGridContainer.PageIndexOf(starty);
662+
lastRepeatSlot = PageSlotOf(pageGridContainer, starty);
663663
}
664664

665665
if (repeatsHeader && i >= headerRowCount && lastRepeatSlot.HasValue)
666666
{
667-
var slot = pageGridContainer.PageIndexOf(cury);
667+
var slot = PageSlotOf(pageGridContainer, cury);
668668
if (slot > lastRepeatSlot.Value)
669669
{
670670
var pageTop = pageGridContainer.PageTopOf(slot);
@@ -884,6 +884,47 @@ private static int GetRowSpan(CssBox b)
884884
return rowspan;
885885
}
886886

887+
/// <summary>
888+
/// The pagination slot <paramref name="y"/> falls in, for the repeated-header loop above - which
889+
/// needs to know which page the row cursor is really on, as opposed to
890+
/// <see cref="HtmlContainerInt.PageIndexOf"/>'s raw arithmetic.
891+
/// </summary>
892+
/// <remarks>
893+
/// A confirmed, real bug found while porting this port's own table-fragmentation test suite: for a
894+
/// <c>border-collapse:collapse</c> table (<see cref="GetVerticalSpacing()"/> is <c>-1</c>, a
895+
/// deliberate one-pixel overlap between the first row and the table's own top border), <c>starty</c>
896+
/// is one pixel LESS than <see cref="CssBox.ClientTop"/> whenever the table sits flush at a page's
897+
/// own content top (the common case: the table is the first thing on a page, or was just relocated
898+
/// to <c>PageTopOf(slot)</c> by <see cref="Fragmentation.BlockFragmentation.RelocateIfNeeded"/>).
899+
/// Fed straight into <see cref="HtmlContainerInt.PageIndexOf"/>, that one pixel is enough to floor
900+
/// into the SLOT BEFORE the one the table's box actually starts in (observed directly: a 200px-tall
901+
/// page grid with <c>MarginTop=10</c>, table starting at <c>ClientTop=10</c>, gives
902+
/// <c>starty=9</c> and <c>PageIndexOf(9)=-1</c>, not <c>0</c>). Seeding <c>lastRepeatSlot</c> from
903+
/// that value made the repeated-header loop see a spurious "transition" into slot 0 at the very
904+
/// first body row, consuming its first repeat on a duplicate drawn almost exactly on top of the
905+
/// header the table already has in flow there (confirmed: before this fix, a table's own first page
906+
/// painted its header twice). <see cref="CssBox.ClientTop"/> itself is never subject to the
907+
/// collapsed-border overlap (it is the table's plain border/padding-resolved box edge), so clamping
908+
/// to it here is a safe floor: every legitimate use of <c>cury</c> for this loop's slot arithmetic
909+
/// is asking "which page is the table's own row cursor on", and that can never sensibly be a page
910+
/// before the table's own top.
911+
/// </remarks>
912+
/// <remarks>
913+
/// Deliberately NOT applied to the row-preservation straddle check a few lines below (which still
914+
/// calls <see cref="HtmlContainerInt.PageIndexOf"/> directly, unclamped) - confirmed by running the
915+
/// existing regression suite both ways: that check's own reaction to this exact -1/0 misread is a
916+
/// harmless, arguably-correct 1px nudge (shifting a row that starts 1px into the "previous" slot
917+
/// down to that slot's real top), and two pre-existing tests
918+
/// (<c>CssLayoutEngineTablePageBreakTests.AvailableHeight_PageBreakFiringPoint_RowDoesNotBleedIntoBottomMargin</c>/
919+
/// <c>TableLayout_MultiPageTable_RowsDoNotOverlapPageMargins</c>) depend on that nudge keeping a
920+
/// collapsed-border table's very first row flush with its page's own content top rather than
921+
/// poking one pixel above it. Clamping there too would remove a real, useful correction to fix a
922+
/// bug in a different, unrelated caller (the header-repeat loop, which reacts to the same misread
923+
/// by inserting visible duplicate content rather than by a sub-pixel nudge).
924+
/// </remarks>
925+
private int PageSlotOf(HtmlContainerInt container, double y) =>
926+
container.PageIndexOf(Math.Max(y, _tableBox.ClientTop));
927+
887928
/// <summary>
888929
/// css-tables-3 §6.1's "the cells spanning the row do not span any subsequent row" test: true
889930
/// if any cell in <paramref name="row"/> - real or the <see cref="CssSpacingBox"/> placeholder
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
using System.Linq;
2+
using HtmlRenderer.IntegrationTest.TestSupport;
3+
using Microsoft.VisualStudio.TestTools.UnitTesting;
4+
using TheArtOfDev.HtmlRenderer.Core.Dom;
5+
using TheArtOfDev.HtmlRenderer.Core.Utils;
6+
7+
namespace HtmlRenderer.IntegrationTest.Tables;
8+
9+
/// <summary>
10+
/// Ported from PeachPDF.Tests/Integration/BreakValueCascadeTests.cs: the survival of
11+
/// <c>break-before</c>/<c>break-after</c>/<c>break-inside</c> across a STRUCTURAL CLONE of a box -
12+
/// <see cref="TheArtOfDev.HtmlRenderer.Core.Dom.CssBoxProperties.InheritStyle"/>'s <c>everything: true</c>
13+
/// branch. These properties are not ordinarily inherited (an unrelated child must not pick them up from
14+
/// its parent), but a structural duplicate is a fragment of the same element, and css-break-3 §3 attaches
15+
/// these values to the element rather than to one of its boxes - so both directions have to hold.
16+
/// </summary>
17+
/// <remarks>
18+
/// A real, confirmed production bug was found and fixed while porting this file, not merely documented:
19+
/// <c>CssBoxProperties.InheritStyle</c>'s <c>everything: true</c> branch copied every other originating-
20+
/// element property (background, border, position, size, ...) but never <c>_pageBreakInside</c>/
21+
/// <c>_breakBefore</c>/<c>_breakAfter</c> - confirmed by grep, none of the three appeared anywhere in that
22+
/// method. Both of this method's real <c>everything: true</c> callers are exactly the two structural-clone
23+
/// sites this file is about: <c>TableHeaderRepeat.CloneSubtree</c> (a repeated <c>&lt;thead&gt;</c> row) and
24+
/// <c>DomParser.CorrectBlockSplitBadBox</c> (the block-in-inline split, <c>leftbox</c>/<c>rightBox</c>) - so
25+
/// every clone of either kind silently read <c>auto</c>/<c>auto</c>/<c>auto</c> regardless of what the
26+
/// source element declared, before this fix. Fixed at its source in <c>CssBoxProperties.cs</c> - see that
27+
/// method's own remarks for the full mechanism. These tests assert the fix's storage; what the stored value
28+
/// then does (or, mostly, does not do) to pagination is <see cref="StructuralCloneBreakValueBehaviourTests"/>.
29+
/// <para>
30+
/// Adapted, not renamed, for <see cref="RepeatedTableHeaderProxy_CarriesTheSourceBreakValues"/>: PeachPDF's
31+
/// UA stylesheet gives <c>thead</c> an avoiding <c>break-inside</c> unconditionally (its print-scoped rule
32+
/// applies to its own harness's media type), so its fixture can vary <c>break-inside</c> itself as one of
33+
/// the four cases under test and still get a proxy to inspect. This fork's UA default lives under
34+
/// <c>@media print</c>, which <see cref="LayoutHarness"/>'s <c>WinFormsAdapter</c> (media type
35+
/// <c>"screen"</c>) never matches - so <c>break-inside:avoid</c> is held FIXED across all three cases below
36+
/// (needed just to get a repeated header to inspect at all, per this repo's own established convention),
37+
/// varying <c>break-before</c>/<c>break-after</c> instead of also varying <c>break-inside</c> itself, which
38+
/// TableRepeatedGroupConditionsTests already covers from the opposite direction (does an EXPLICIT
39+
/// <c>break-inside:auto</c> suppress the repeat at all).
40+
/// </para>
41+
/// </remarks>
42+
[TestClass]
43+
[DoNotParallelize]
44+
public sealed class BreakValueCascadeTests
45+
{
46+
// ── the two structural-clone call sites ────────────────────────────────
47+
48+
// TableHeaderRepeat.CloneSubtree clones the <thead>'s own ROW (CssLayoutEngineTable's _allRows entry),
49+
// not the <thead> box itself - so the values under test have to be declared on the <tr>, whose own
50+
// clone is what CloneAndPosition actually returns into RepeatedHeaderRows. break-inside:avoid is held
51+
// fixed on the <thead> ITSELF throughout (needed only for repeatsHeader's own eligibility gate, and
52+
// confirmed by BreakInsideOnAThead_DoesNotReachItsRowsOrCells - TableRepeatedGroupConditionsTests - to
53+
// never cascade down onto the row being varied here).
54+
[TestMethod]
55+
[DataRow("break-inside:avoid", "avoid", "auto", "auto")]
56+
[DataRow("break-inside:avoid;break-after:avoid", "avoid", "auto", "avoid")]
57+
[DataRow("break-inside:avoid;break-before:page", "avoid", "page", "auto")]
58+
public void RepeatedTableHeaderProxy_CarriesTheSourceBreakValues(
59+
string css, string expectedInside, string expectedBefore, string expectedAfter)
60+
{
61+
var rows = string.Concat(Enumerable.Range(1, 20)
62+
.Select(i => $"<tr><td>Row {i}, Cell 1</td><td>Row {i}, Cell 2</td></tr>"));
63+
64+
var html = LayoutHarness.Wrap(
65+
"<table style='width:100%;border-collapse:collapse'>"
66+
+ $"<thead style='break-inside:avoid'><tr style='{css}'><th>Header 1</th><th>Header 2</th></tr></thead>"
67+
+ $"<tbody>{rows}</tbody></table>");
68+
69+
var (root, _) = LayoutHarness.Layout(html, 400, 300, margin: 20);
70+
var table = LayoutHarness.Descendants(root).First(b => b.Display == "table");
71+
72+
Assert.IsNotNull(table.RepeatedHeaderRows);
73+
Assert.IsTrue(table.RepeatedHeaderRows!.Count > 0);
74+
75+
Assert.IsTrue(table.RepeatedHeaderRows.All(clone => clone.BreakInside == expectedInside));
76+
Assert.IsTrue(table.RepeatedHeaderRows.All(clone => clone.BreakBefore == expectedBefore));
77+
Assert.IsTrue(table.RepeatedHeaderRows.All(clone => clone.BreakAfter == expectedAfter));
78+
}
79+
80+
// DomParser's block-in-inline correction splits one element's box into several (leftbox/rightBox in
81+
// CorrectBlockSplitBadBox). Every resulting box represents the same <span>, so each has to carry the
82+
// span's own resolved values.
83+
[TestMethod]
84+
[DataRow("break-inside:avoid", "avoid", "auto", "auto")]
85+
[DataRow("break-after:avoid", "auto", "auto", "avoid")]
86+
[DataRow("break-before:page", "auto", "page", "auto")]
87+
public void BlockInsideInlineSplit_EveryFragmentCarriesTheBreakValues(
88+
string css, string expectedInside, string expectedBefore, string expectedAfter)
89+
{
90+
var html = LayoutHarness.Wrap($"<span style='{css}'>before<div>block</div>after</span>");
91+
92+
var (root, _) = LayoutHarness.Layout(html);
93+
var spanBoxes = LayoutHarness.Descendants(root).Where(b => b.HtmlTag?.Name == "span").ToList();
94+
95+
Assert.IsTrue(spanBoxes.Count > 1, $"expected the span to be split, found {spanBoxes.Count} box(es)");
96+
97+
Assert.IsTrue(spanBoxes.All(b => b.BreakInside == expectedInside));
98+
Assert.IsTrue(spanBoxes.All(b => b.BreakBefore == expectedBefore));
99+
Assert.IsTrue(spanBoxes.All(b => b.BreakAfter == expectedAfter));
100+
}
101+
102+
// ── the other direction: not inherited ─────────────────────────────────
103+
104+
// An ordinary child is not a fragment of its parent, so it must not pick the values up. Without this,
105+
// moving the three fields into InheritStyle's "always" (non-"everything") section would pass every
106+
// test above just as well.
107+
[TestMethod]
108+
public void OrdinaryChild_DoesNotInheritItsParentsBreakValues()
109+
{
110+
var html = LayoutHarness.Wrap(
111+
"<div id='parent' style='break-inside:avoid;break-before:page;break-after:avoid'>"
112+
+ "<div id='child'>text</div></div>");
113+
114+
var (root, _) = LayoutHarness.Layout(html);
115+
var child = LayoutHarness.FindById(root, "child");
116+
Assert.IsNotNull(child);
117+
118+
Assert.AreEqual(CssConstants.Auto, child!.BreakInside);
119+
Assert.AreEqual(CssConstants.Auto, child.BreakBefore);
120+
Assert.AreEqual(CssConstants.Auto, child.BreakAfter);
121+
}
122+
123+
// A generated-content (::before) box is a real child of the element, not a duplicate of it, and is
124+
// created through the OTHER, non-"everything" InheritStyle overload (CssData.cs's
125+
// "beforePseudoBox.InheritStyle(box)" - the single-arg, default-everything:false call). Same guard as
126+
// above, at the other call site that could plausibly leak these values.
127+
[TestMethod]
128+
public void GeneratedContentBox_DoesNotPickUpItsOriginatingElementsBreakValues()
129+
{
130+
var html = """
131+
<!DOCTYPE html><html><head><style>
132+
#target { break-inside: avoid; break-before: page; break-after: avoid }
133+
#target::before { content: "x" }
134+
</style></head><body style='margin:0'><div id="target">text</div></body></html>
135+
""";
136+
137+
var (root, _) = LayoutHarness.Layout(html);
138+
var target = LayoutHarness.FindById(root, "target");
139+
Assert.IsNotNull(target);
140+
141+
var before = LayoutHarness.Descendants(target!).FirstOrDefault(b => b.IsBeforePseudoElement);
142+
Assert.IsNotNull(before);
143+
144+
Assert.AreEqual(CssConstants.Auto, before!.BreakInside);
145+
Assert.AreEqual(CssConstants.Auto, before.BreakBefore);
146+
Assert.AreEqual(CssConstants.Auto, before.BreakAfter);
147+
}
148+
149+
// And the element itself really does hold the values the two negative tests above are checking are not
150+
// propagated - so they are not passing merely because the cascade never stored anything at all.
151+
[TestMethod]
152+
public void TheElementItself_HoldsTheValuesTheClonesAreCheckedAgainst()
153+
{
154+
var html = LayoutHarness.Wrap(
155+
"<div id='target' style='break-inside:avoid;break-before:page;break-after:avoid'>text</div>");
156+
157+
var (root, _) = LayoutHarness.Layout(html);
158+
var target = LayoutHarness.FindById(root, "target");
159+
Assert.IsNotNull(target);
160+
161+
Assert.AreEqual(CssConstants.Avoid, target!.BreakInside);
162+
Assert.AreEqual(CssConstants.Page, target.BreakBefore);
163+
Assert.AreEqual(CssConstants.Avoid, target.BreakAfter);
164+
}
165+
}

0 commit comments

Comments
 (0)