Skip to content

Commit 5fbc4e0

Browse files
committed
Port PeachPDF's pure-logic fragmentation unit tests (Batch 1)
Ports PeachPDF.Tests/Html/Core/Fragmentation/{BreakTokenTests,BreakValuesTests, MonolithicContentTests,ForcedBreakTargetIsTheFramesTests}.cs, Html/Core/HtmlContainerIntPaginationTests.cs, and Html/Core/Dom/ CssLayoutEngineTablePageBreakTests.cs into a new HtmlRenderer.Test/Fragmentation/ folder plus HtmlRenderer.Test/Dom/, using the adapter-free MockAdapter+LayoutHarness pattern (extended with an opt-in pageHeight/margin parameter, defaulting to the old no-page-grid behavior so every pre-existing caller is unaffected). Also cherry-picks TableLayout_DetectsPageBreaksCorrectly/TableLayout_PositionsHeadersAtCorrectPageStarts into the existing Dom/CssLayoutEngineTableTests.cs. Dropped per the port plan's stated scope: directional/column break values and PageRuleResolver (BreakValuesTests - this port's BreakValues has no column context or directional matching at all), the 2 named-page-transition tests in ForcedBreakTargetIsTheFramesTests, the 2 RepeatedTfoot_* tests, and 7 PageBreakBottoms_* plus 4 paint-level tests in CssLayoutEngineTablePageBreakTests (no PageBreakBottoms/CollapsedBorderSegments exist in this fork, and paint verification is out of this adapter-free batch's scope). The table page-break file needed a real rewrite, not a rename, per the plan - it's now black-box geometry assertions against real CssBox/HtmlContainerInt state, plus new tests exercising the actual repeated-header machinery (TableHeaderRepeat.CloneAndPosition, CssBox.RepeatedHeaderRows) in place of PeachPDF's unportable border-resolution assertions. Six tests are [Ignore]d with specific, empirically-verified reasons: no cross-ancestor break-point propagation for a first-in-flow child's break-before, no second-rule handling for two consecutive forced breaks landing on the same slot, no margin preservation at a forced-break target, two content-empty-slot- skipping tests (FragmentEmitter.HasContentInBand is satisfied by the document root's own bounds before ever checking the band for real content, so it never skips anything), and a newly-found rounding gap where a border-collapse table's own top can land fractionally before a page boundary, seeding a spurious repeated header via a page-index computation flooring to slot -1. HtmlRenderer.Test: 2426 passed (+59) / 129 skipped (+6) / 0 failed. HtmlRenderer.IntegrationTest: 146 passed / 74 skipped / 0 failed (unchanged). HtmlRenderer.PdfSharp.Test: 30 passed / 0 failed (unchanged).
1 parent 059afcb commit 5fbc4e0

8 files changed

Lines changed: 1145 additions & 3 deletions

‎Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTablePageBreakTests.cs‎

Lines changed: 304 additions & 0 deletions
Large diffs are not rendered by default.

‎Source/Test/HtmlRenderer.Test/Dom/CssLayoutEngineTableTests.cs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Linq;
22
using HtmlRenderer.Test.TestSupport;
3+
using TheArtOfDev.HtmlRenderer.Core.Dom;
34

45
namespace HtmlRenderer.Test.Dom;
56

@@ -142,4 +143,98 @@ public void TableLayout_RespectsSpecifiedColumnWidths()
142143
// explicitly-widened column) instead of an arbitrary absolute threshold.
143144
Assert.IsTrue(auto1Width < wideWidth, $"Auto cell ({auto1Width}) should be narrower than the explicitly-widened first cell ({wideWidth})");
144145
}
146+
147+
// Cherry-picked from PeachPDF's CssLayoutEngineTableTests (the rest of that file is general table
148+
// layout, already out of scope for this port - see Fragmentation/CssLayoutEngineTablePageBreakTests.cs
149+
// for the dedicated pagination-focused port). Adapted: PeachPDF's own version asserts against
150+
// table.Boxes.OfType<CssProxyBox>() (its in-tree header-repeat proxy mechanism); this fork instead
151+
// detaches repeated header clones onto CssBox.RepeatedHeaderRows (Core/Fragmentation/TableHeaderRepeat.cs)
152+
// rather than inserting them into the live tree, so the assertions are rewritten onto that. Also drops
153+
// the source's "@page { size: A4; margin: 20mm }" CSS rule (this fork's page grid is set on the
154+
// container directly, via LayoutHarness's pageHeight parameter, not through @page).
155+
[TestMethod]
156+
public void TableLayout_DetectsPageBreaksCorrectly()
157+
{
158+
var html = LayoutHarness.Wrap(
159+
"<table style='width:100%;border-collapse:collapse;'>" +
160+
"<thead style='break-inside:avoid;'><tr><th style='border:1px solid black;padding:10px;'>Header</th></tr></thead>" +
161+
"<tbody>" +
162+
string.Concat(Enumerable.Range(1, 30).Select(i =>
163+
$"<tr><td style='border:1px solid black;padding:10px;'>Row {i}</td></tr>")) +
164+
"</tbody></table>");
165+
166+
const double pageHeight = 400.0;
167+
var (root, container) = LayoutHarness.Layout(html, pageHeight: pageHeight, margin: 20);
168+
169+
var table = FindTableBox(root);
170+
Assert.IsNotNull(table);
171+
172+
var tableHeight = table!.ActualBottom - table.Location.Y;
173+
174+
Assert.IsTrue(tableHeight > pageHeight, $"Table height ({tableHeight}) should exceed page height ({pageHeight})");
175+
Assert.IsNotNull(table.RepeatedHeaderRows);
176+
Assert.IsTrue(table.RepeatedHeaderRows!.Count >= 2,
177+
$"Should have at least 2 repeated header row-sets for a multi-page table, found {table.RepeatedHeaderRows.Count}");
178+
}
179+
180+
[TestMethod]
181+
public void TableLayout_PositionsHeadersAtCorrectPageStarts()
182+
{
183+
var html = LayoutHarness.Wrap(
184+
// Deliberately not border-collapse:collapse/padding - see
185+
// CssLayoutEngineTablePageBreakTests.RepeatedThead_SinglePageBorderCollapseTable_... for a
186+
// dedicated, [Ignore]d test pinning down why that combination can shift a table's own top
187+
// fractionally off a page boundary and produce a spurious extra repeat.
188+
"<table style='width:100%;'>" +
189+
"<thead style='break-inside:avoid;'><tr><th style='height:20px;padding:0;margin:0;'>Header</th></tr></thead>" +
190+
"<tbody>" +
191+
string.Concat(Enumerable.Range(1, 10).Select(i =>
192+
$"<tr><td style='height:20px;padding:0;margin:0;'>Row {i}</td></tr>")) +
193+
"</tbody></table>");
194+
195+
// Very short pages to force multiple page breaks.
196+
var (root, container) = LayoutHarness.Layout(html, pageHeight: 200, margin: 20);
197+
198+
var table = FindTableBox(root);
199+
Assert.IsNotNull(table);
200+
Assert.IsNotNull(table!.RepeatedHeaderRows);
201+
Assert.IsTrue(table.RepeatedHeaderRows!.Count >= 1, "Should have at least one repeated header row-set");
202+
203+
// Each repeated header row's own cell carries its real position - the row box itself is never
204+
// positioned by table layout (see CssLayoutEngineTablePageBreakTests' identical note).
205+
var headerYPositions = table.RepeatedHeaderRows
206+
.Select(row => row.Boxes.Count > 0 ? row.Boxes[0].Location.Y : row.Location.Y)
207+
.OrderBy(y => y)
208+
.ToList();
209+
210+
// Every repeated header should land at one of this page grid's own real page tops.
211+
foreach (var y in headerYPositions)
212+
{
213+
var slot = container.PageIndexOf(y);
214+
Assert.AreEqual(container.PageTopOf(slot), y, 0.5, $"repeated header at Y={y} should sit flush at page-slot {slot}'s content top");
215+
}
216+
217+
// If there are multiple repeats, they must be at different Y positions - not all collapsed onto
218+
// the same page.
219+
if (headerYPositions.Count > 1)
220+
{
221+
var uniquePositions = headerYPositions.Distinct().Count();
222+
Assert.IsTrue(uniquePositions > 1,
223+
$"Multiple repeated headers should be at different Y positions, but all {headerYPositions.Count} were the same");
224+
}
225+
}
226+
227+
private static CssBox? FindTableBox(CssBox box)
228+
{
229+
if (box.Display == TheArtOfDev.HtmlRenderer.Core.Utils.CssConstants.Table)
230+
return box;
231+
232+
foreach (var child in box.Boxes)
233+
{
234+
var found = FindTableBox(child);
235+
if (found is not null) return found;
236+
}
237+
238+
return null;
239+
}
145240
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using TheArtOfDev.HtmlRenderer.Core.Dom;
2+
using TheArtOfDev.HtmlRenderer.Core.Fragmentation;
3+
4+
namespace HtmlRenderer.Test.Fragmentation;
5+
6+
/// <summary>
7+
/// Ported from PeachPDF.Tests/Html/Core/Fragmentation/BreakTokenTests.cs (BreakTokenTests).
8+
/// </summary>
9+
/// <remarks>
10+
/// The resumption record: says <i>where</i> layout stopped and nothing about geometry - the box tree still
11+
/// holds the coordinates - so these pin its shape rather than any placement. PeachPDF's own file also covers
12+
/// <c>InlineBreakToken</c>/<c>FlexBreakToken</c>/<c>GridBreakToken</c>/<c>FlexColumnBreakToken</c>, none of
13+
/// which exist here - <c>BreakToken.cs</c>'s own doc comment confirms only forced <c>break-before</c>/
14+
/// <c>break-after: page</c> ever produces a real cross-pass token in this port (everything else - overflow,
15+
/// break-inside:avoid, keep-with-next, widows/orphans, table-row breaks - turned out to be same-pass local
16+
/// corrections instead), so <see cref="BlockBreakToken"/> is the only concrete <see cref="BreakToken"/> to
17+
/// test. Every test below that PeachPDF built over a different token kind is dropped as out of scope rather
18+
/// than adapted; <c>Chain_...</c> is kept but rewritten to chain only <see cref="BlockBreakToken"/> links,
19+
/// since that's the only concrete kind this port has to chain.
20+
/// </remarks>
21+
[TestClass]
22+
public sealed class BreakTokenTests
23+
{
24+
[TestMethod]
25+
public void BreakBefore_CarriesNoChildToken()
26+
{
27+
var box = new CssBox(null, null);
28+
29+
var token = new BlockBreakToken(box, ResumeSlotIndex: 1, ResumeChildIndex: 3, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
30+
31+
// A break *before* a child means the child was never entered, so there is nothing inside it
32+
// to resume - this is what makes "no fragment in the earlier fragmentainer" structural.
33+
Assert.IsTrue(token.IsBreakBefore);
34+
Assert.IsNull(token.ChildToken);
35+
Assert.AreEqual(3, token.ResumeChildIndex);
36+
Assert.AreSame(box, token.Box);
37+
}
38+
39+
[TestMethod]
40+
public void BreakInside_CarriesTheChildsOwnToken()
41+
{
42+
var parent = new CssBox(null, null);
43+
var child = new CssBox(null, null);
44+
45+
var childToken = new BlockBreakToken(child, ResumeSlotIndex: 1, ResumeChildIndex: 1, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
46+
var token = new BlockBreakToken(parent, ResumeSlotIndex: 1, ResumeChildIndex: 0, ChildToken: childToken, IsBreakBefore: false, ResumeTopOverride: null);
47+
48+
Assert.IsFalse(token.IsBreakBefore);
49+
Assert.AreSame(childToken, token.ChildToken);
50+
}
51+
52+
[TestMethod]
53+
public void Chain_NestsOneLinkPerAncestorOnThePathToTheContextRoot()
54+
{
55+
var root = new CssBox(null, null);
56+
var middle = new CssBox(null, null);
57+
var leaf = new CssBox(null, null);
58+
59+
var leafToken = new BlockBreakToken(leaf, ResumeSlotIndex: 1, ResumeChildIndex: 2, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
60+
var middleToken = new BlockBreakToken(middle, ResumeSlotIndex: 1, ResumeChildIndex: 1, ChildToken: leafToken, IsBreakBefore: false, ResumeTopOverride: null);
61+
var rootToken = new BlockBreakToken(root, ResumeSlotIndex: 1, ResumeChildIndex: 2, ChildToken: middleToken, IsBreakBefore: false, ResumeTopOverride: null);
62+
63+
// Walking the chain down from the root is exactly how a resumed pass re-enters each ancestor
64+
// mid-flight while leaving boxes off the path alone.
65+
var boxes = new List<CssBox>();
66+
for (BlockBreakToken? t = rootToken; t is not null; t = t.ChildToken as BlockBreakToken)
67+
boxes.Add(t.Box);
68+
69+
CollectionAssert.AreEqual(new[] { root, middle, leaf }, boxes);
70+
}
71+
72+
[TestMethod]
73+
public void ResumeTopOverride_IsCarriedForTheAdjustedTargetPathsThatComputeIt()
74+
{
75+
var box = new CssBox(null, null);
76+
77+
var token = new BlockBreakToken(box, ResumeSlotIndex: 1, ResumeChildIndex: 0, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: 1234.5);
78+
79+
// Margin truncation and the keep-with-next pull have already worked out where the box goes;
80+
// the resumed pass must use that value rather than re-deriving it.
81+
Assert.AreEqual(1234.5, token.ResumeTopOverride);
82+
}
83+
84+
[TestMethod]
85+
public void BlockBreakToken_IsARecord_WithStructuralEquality()
86+
{
87+
var box = new CssBox(null, null);
88+
89+
// A record's compiler-generated equality is what lets a resumed pass compare "did this pass land
90+
// on the same resumption point as a previous one" without hand-written Equals/GetHashCode - two
91+
// independently-built tokens over the same field values must compare equal.
92+
var first = new BlockBreakToken(box, ResumeSlotIndex: 2, ResumeChildIndex: 4, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
93+
var second = new BlockBreakToken(box, ResumeSlotIndex: 2, ResumeChildIndex: 4, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
94+
95+
Assert.AreEqual(first, second);
96+
Assert.AreEqual(first.GetHashCode(), second.GetHashCode());
97+
}
98+
99+
[TestMethod]
100+
public void BlockBreakToken_WithADifferentResumeChildIndex_ComparesUnequal()
101+
{
102+
var box = new CssBox(null, null);
103+
104+
var first = new BlockBreakToken(box, ResumeSlotIndex: 2, ResumeChildIndex: 4, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
105+
var second = new BlockBreakToken(box, ResumeSlotIndex: 2, ResumeChildIndex: 5, ChildToken: null, IsBreakBefore: true, ResumeTopOverride: null);
106+
107+
Assert.AreNotEqual(first, second);
108+
}
109+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using TheArtOfDev.HtmlRenderer.Core.Fragmentation;
2+
3+
namespace HtmlRenderer.Test.Fragmentation;
4+
5+
/// <summary>
6+
/// Ported from PeachPDF.Tests/Html/Core/Fragmentation/BreakValuesTests.cs (BreakValuesTests).
7+
/// </summary>
8+
/// <remarks>
9+
/// PeachPDF's <c>BreakValues</c> answers several questions - a full css-break-3 §3.1 forced-break value
10+
/// set (including the four directional values), a <c>RequiredSide</c>/<c>PageSide</c> resolver for them, a
11+
/// two-context (<c>page</c>/<c>column</c>) <c>AvoidsBreak</c>/<c>IsForcedBreak</c>, a <c>SlotIsOn</c> parity
12+
/// helper, and <c>PageRuleResolver.IsRightPage</c>. This port's own <see cref="BreakValues"/> is reduced to
13+
/// exactly what this port's engine (pages only, no multi-column, no directional/<c>@page :left</c>/<c>:right</c>
14+
/// matching - see its own class doc comment) needs: <see cref="BreakValues.IsForcedBreak"/> and
15+
/// <see cref="BreakValues.AvoidsBreak"/>, each single-argument (no <c>FragmentationContext</c> - there is
16+
/// only ever one context, the page) and single-context-valued (only <c>page</c>/<c>always</c> force a break;
17+
/// only <c>avoid</c>/<c>avoid-page</c> forbid one). Every PeachPDF theory row naming a directional
18+
/// (<c>left</c>/<c>right</c>/<c>recto</c>/<c>verso</c>), <c>region</c>/<c>avoid-region</c>, or
19+
/// <c>column</c>/<c>avoid-column</c> value is dropped per the port plan's scope decision, and with them the
20+
/// entire <c>RequiredSide</c>/<c>PageSide</c>/<c>SlotIsOn</c>/<c>PageRuleResolver</c> surface, which has no
21+
/// counterpart here at all.
22+
///
23+
/// One real divergence from PeachPDF found while porting: PeachPDF's classifier rejects the legacy
24+
/// <c>always</c> spelling outright (it only ever reaches a box through the legacy <c>page-break-*</c> alias,
25+
/// which PeachPDF's own CssUtils rewrites to <c>page</c> before the classifier ever sees it). This port's
26+
/// <see cref="BreakValues.IsForcedBreak"/> (Core/Fragmentation/BreakValues.cs) accepts <c>always</c> directly
27+
/// instead, per its own doc comment: "HTML-Renderer's CSS engine accepts directly on the modern properties
28+
/// too... rather than normalizing it away at parse time - so both spellings are classified here." Confirmed
29+
/// independently by <c>PropertyBreakTests.BreakBeforeAfter_AcceptsAlwaysUnlikePeachPdf</c>
30+
/// (Css/PropertyBreakTests.cs), which documents the same divergence at the CSS-parsing layer.
31+
/// </remarks>
32+
[TestClass]
33+
public sealed class BreakValuesTests
34+
{
35+
[TestMethod]
36+
[DataRow("page", true)]
37+
[DataRow("always", true)]
38+
[DataRow("auto", false)]
39+
[DataRow("avoid", false)]
40+
[DataRow("avoid-page", false)]
41+
[DataRow(null, false)]
42+
public void IsForcedBreak_MatchesThisPortsReducedValueSet(string value, bool expected) =>
43+
Assert.AreEqual(expected, BreakValues.IsForcedBreak(value));
44+
45+
// The divergence from PeachPDF documented in the class remarks: unlike PeachPDF's
46+
// IsForcedPageBreak_RejectsTheLegacyAlwaysSpelling, this port's classifier accepts "always" directly.
47+
[TestMethod]
48+
public void IsForcedBreak_AcceptsTheLegacyAlwaysSpellingUnlikePeachPdf() =>
49+
Assert.IsTrue(BreakValues.IsForcedBreak("always"));
50+
51+
[TestMethod]
52+
[DataRow("avoid", true)]
53+
[DataRow("avoid-page", true)]
54+
[DataRow("auto", false)]
55+
[DataRow("page", false)]
56+
[DataRow("always", false)]
57+
[DataRow(null, false)]
58+
public void AvoidsBreak_MatchesThisPortsReducedValueSet(string value, bool expected) =>
59+
Assert.AreEqual(expected, BreakValues.AvoidsBreak(value));
60+
}

0 commit comments

Comments
 (0)