Skip to content

Commit d8b2587

Browse files
committed
Port PeachPDF's core forced-break/keep-with-next/orphans-widows tests (Batch 2)
Ports PeachPDF.Tests/Integration/{PageBreakIntegrationTests,BreakPropagationIntegrationTests, KeepWithNextIntegrationTests,OrphansWidowsIntegrationTests,EarlyBreakLayoutIntegrationTests, MonolithicContentLayoutIntegrationTests,ResumableBlockLayoutIntegrationTests, PageMarginPaginationIntegrationTests,FragmentainerCursorIntegrationTests, EngineRelayoutIdempotencyTests,JustifiedLineAtABreakTests}.cs into a new HtmlRenderer.IntegrationTest/Fragmentation/ folder, using a real WinForms.HtmlContainer (reflecting out HtmlContainerInt) with an explicit Container.Location = (0, MarginTop) - confirmed, the hard way, to be required: HtmlContainerInt.Location defaults to (0,0) regardless of MarginTop, so PageIndexOf/PageTopOf's grid silently disagrees with where box geometry actually starts unless Location is set to match. Dropped per general exclusion rules: directional break values (left/right/recto/verso), named pages (page property is parse-only - PageNameProperty.cs/HtmlContainerInt has no NamedPageElements), and PeachPDF's own resumable-pass-rewind architecture (PassRewind, FragmentainerPasses/PassRewinds counters, CssProxyBox table header proxies) which this port never built - BreakToken.cs's own doc comment confirms only a forced break-before/after:page ever produces a real cross-pass token here; InlineFragmentation.ApplyLineBreaking computes an entire paragraph's lines in one unbounded, side-effect-free call. flex/grid/multicol Theories dropped outright (no such engines exist; MonolithicContent.RunsAnEngineOfItsOwn narrows to table only). 11 tests Ignored with specific, empirically-verified reasons - most notably three newly-found gaps, confirmed by reverting to unignored and observing the actual failure: - BreakValues.IsForcedBreak treats "always" as a forced break on the modern break-before/ break-after properties too (a deliberate, documented design choice - ported inverted, not dropped) - but TryGetForcedBreakTarget's own targetTop discards a forced-break box's margin entirely rather than preserving it, and css-break-3 §3.1 ancestor-propagation only works for RelocateIfNeeded/EnforceKeepWithNext's real relayout, never for a forced break itself or for margin-truncation-caused first-child overflow (ContainerLeftBehindTest.cs's own fix doesn't reach either case). - CssLayoutEngineTable's row-atomicity shift offsets only each cell's own rectangle, never the outer <table> box's Location/EffectiveTop, so EnforceKeepWithNext(table) never observes a row-atomicity relocation and a table's own avoid-chained heading is never pulled along. - InlineFragmentation.ApplyLineBreaking's widows merge-back can only remove a break entirely (fully merging two runs), never shift a break point earlier by fewer lines nor fall back to pushing a run to a fresh page when an in-place merge doesn't fit the current page's remaining room - producing real, reproducible orphans/widows violations at several swept filler heights (traced by hand against the algorithm and confirmed by running the tests unignored). A fourth, narrower gap: OrphansProperty/WidowsProperty use NaturalIntegerConverter (accepts 0) instead of PositiveIntegerConverter, so "orphans:0" parses instead of falling back to the default (the resolved ActualOrphans/ActualWidows self-correct via their own > 0 check, so this doesn't affect real layout, only the raw string property). One further table-specific gap: a position:absolute child of a table cell loses one line of its own content under a real page grid, root cause not chased further (out of this batch's scope). HtmlRenderer.Test: 2426 passed / 129 skipped / 0 failed (unchanged). HtmlRenderer.IntegrationTest: 246 passed / 93 skipped / 0 failed (+100 passed / +19 skipped). HtmlRenderer.PdfSharp.Test: 30 passed / 0 failed (unchanged).
1 parent 5fbc4e0 commit d8b2587

11 files changed

Lines changed: 3189 additions & 0 deletions
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using System.Collections.Generic;
2+
using System.Drawing;
3+
using System.Reflection;
4+
using Microsoft.VisualStudio.TestTools.UnitTesting;
5+
using TheArtOfDev.HtmlRenderer.Adapters.Entities;
6+
using TheArtOfDev.HtmlRenderer.Core;
7+
using TheArtOfDev.HtmlRenderer.Core.Dom;
8+
using TheArtOfDev.HtmlRenderer.WinForms;
9+
10+
namespace TheArtOfDev.HtmlRenderer.IntegrationTest.Fragmentation;
11+
12+
/// <summary>
13+
/// Ported from PeachPDF.Tests/Integration/BreakPropagationIntegrationTests.cs: css-break-3 §3.1 forced-
14+
/// break combination and propagation - whether a break point stated before/after a box travels up to an
15+
/// ancestor that begins/ends with it.
16+
/// </summary>
17+
/// <remarks>
18+
/// Confirmed, by reading both the call site (<c>CssBox.PerformLayoutImp</c>'s child loop, which calls
19+
/// <c>BlockFragmentation.TryGetForcedBreakTarget(this, prevSibling, ...)</c> with <c>prevSibling</c> scoped
20+
/// to the box's OWN parent's child list) and <c>TryGetForcedBreakTarget</c>'s own remark: unlike PeachPDF's
21+
/// <c>BreakPropagation.PropagatesBreakBeforeOutward</c>, this port does NOT climb the ancestor chain for a
22+
/// forced <c>break-before</c>/<c>break-after</c> - a first-in-flow child's forced break-before is
23+
/// suppressed outright (never redirected onto its parent), and a last-in-flow child's <c>break-after</c>
24+
/// never bubbles up to make its parent's own <c>BreakAfter</c> "page" either (<c>CssBoxProperties</c> has
25+
/// no such cascade - confirmed by reading the <c>BreakAfter</c>/<c>BreakBefore</c> property getters, plain
26+
/// backing-field reads with no ancestor lookup). This is explicitly called out as "out of scope for this
27+
/// port" in <c>TryGetForcedBreakTarget</c>'s own doc remark. Real ancestor-following DOES exist, but only
28+
/// for the RELOCATION-triggered movers (<c>BlockFragmentation.PropagateContainerRelocation</c>, called from
29+
/// both <c>RelocateIfNeeded</c> and <c>EnforceKeepWithNext</c>) - confirmed working end to end by this
30+
/// repo's own pre-existing <c>ContainerLeftBehindTest.cs</c>/<c>ContainerLeftBehindKeepWithNextTest.cs</c>.
31+
/// Three tests below (<c>ForcedBreakBeforeAFirstChild_MovesTheContainer</c>,
32+
/// <c>ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins</c>,
33+
/// <c>BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling</c>) are ported with their PeachPDF
34+
/// assertions intact but <c>[Ignore]</c>d against this confirmed gap; a fourth
35+
/// (<c>AForcedBreakPropagatedOutOfAContainer_BreaksTheKeepWithNextChain</c>) is dropped rather than
36+
/// Ignored, since its premise (a forced break that travelled out of the container) never occurs here at
37+
/// all, so there is nothing left of the scenario to characterize as pending.
38+
/// <para>
39+
/// Also dropped: the 2 directional-break-value tests (<c>recto</c>/<c>verso</c>, unsupported per the port
40+
/// plan's exclusion list) and <c>ForcedBreakBeforeAnEngineItem_DoesNotTravelOutOfTheEngine</c> (flex/grid -
41+
/// no such layout engine exists in this port, and PeachPDF's own <c>BreakPropagation</c> type it asserts
42+
/// against has no counterpart here either).
43+
/// </para>
44+
/// </remarks>
45+
[TestClass]
46+
[DoNotParallelize]
47+
public sealed class BreakPropagationIntegrationTests
48+
{
49+
private const double PageHeight = 300;
50+
51+
private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
52+
{
53+
var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
54+
return (HtmlContainerInt)prop.GetValue(wrapper)!;
55+
}
56+
57+
private static async Task<(CssBox Root, HtmlContainerInt Container)> BuildAsync(string bodyHtml)
58+
{
59+
var wrapper = new HtmlContainer();
60+
await wrapper.SetHtml($"<html><body style='margin:0'>{bodyHtml}</body></html>");
61+
62+
var container = GetInternal(wrapper);
63+
container.PageSize = new RSize(400, PageHeight);
64+
container.MarginTop = 0;
65+
container.Location = new RPoint(0, 0);
66+
wrapper.MaxSize = new SizeF(400, 0);
67+
68+
using var bitmap = new Bitmap(400, 40000);
69+
using var g = Graphics.FromImage(bitmap);
70+
wrapper.PerformLayout(g);
71+
72+
return (container.Root!, container);
73+
}
74+
75+
private static IEnumerable<CssBox> Walk(CssBox box)
76+
{
77+
yield return box;
78+
foreach (var b in box.Boxes)
79+
foreach (var d in Walk(b))
80+
yield return d;
81+
}
82+
83+
private static CssBox FindById(CssBox root, string id)
84+
{
85+
foreach (var box in Walk(root))
86+
if (box.HtmlTag?.TryGetAttribute("id") == id)
87+
return box;
88+
return null!;
89+
}
90+
91+
private static int SlotOf(HtmlContainerInt container, CssBox box) => container.PageIndexOf(box.Location.Y);
92+
93+
#region Propagation moves the container, not only the child (confirmed gap - forced breaks only)
94+
95+
[TestMethod]
96+
[Ignore("Confirmed gap: BlockFragmentation.TryGetForcedBreakTarget suppresses a forced break-before on a "
97+
+ "first-in-flow child (no previous sibling) rather than redirecting it onto the parent - see this "
98+
+ "method's own doc remark ('Full cross-ancestor propagation is out of scope for this port'). Unlike "
99+
+ "PeachPDF, the break is simply dropped: 'wrap' never moves and 'first' lands wherever ordinary flow "
100+
+ "puts it, both on the original page.")]
101+
public async Task ForcedBreakBeforeAFirstChild_MovesTheContainer()
102+
{
103+
var (root, container) = await BuildAsync(
104+
"<div id='lead' style='height:60pt'>lead</div>"
105+
+ "<div id='wrap' style='border:1pt solid black'>"
106+
+ "<div id='first' style='break-before:page;height:40pt'>first</div>"
107+
+ "</div>");
108+
109+
var wrap = FindById(root, "wrap");
110+
var first = FindById(root, "first");
111+
Assert.IsNotNull(wrap);
112+
Assert.IsNotNull(first);
113+
114+
Assert.AreEqual(container.PageTopOf(1), wrap.Location.Y, 3);
115+
Assert.AreEqual(wrap.ClientTop, first.Location.Y, 3);
116+
}
117+
118+
[TestMethod]
119+
[Ignore("Same confirmed gap as ForcedBreakBeforeAFirstChild_MovesTheContainer - see this class's own doc "
120+
+ "remark - just nested one level deeper.")]
121+
public async Task ForcedBreakBeforeANestedFirstChild_MovesTheOutermostContainerItBegins()
122+
{
123+
var (root, container) = await BuildAsync(
124+
"<div id='lead' style='height:60pt'>lead</div>"
125+
+ "<div id='outer'><div id='inner'>"
126+
+ "<div id='deep' style='break-before:page;height:40pt'>deep</div>"
127+
+ "</div></div>");
128+
129+
var outer = FindById(root, "outer");
130+
Assert.IsNotNull(outer);
131+
132+
Assert.AreEqual(container.PageTopOf(1), outer.Location.Y, 3);
133+
}
134+
135+
// A box with an in-flow sibling above it names its own break point directly (prevSibling != null in
136+
// TryGetForcedBreakTarget), so nothing needs to propagate and the container stays where it is.
137+
[TestMethod]
138+
public async Task ForcedBreakBeforeALaterChild_LeavesTheContainerWhereItIs()
139+
{
140+
var (root, container) = await BuildAsync(
141+
"<div id='wrap'>"
142+
+ "<div id='first' style='height:60pt'>first</div>"
143+
+ "<div id='second' style='break-before:page;height:40pt'>second</div>"
144+
+ "</div>");
145+
146+
var wrap = FindById(root, "wrap");
147+
var second = FindById(root, "second");
148+
Assert.IsNotNull(wrap);
149+
Assert.IsNotNull(second);
150+
151+
Assert.AreEqual(0, SlotOf(container, wrap));
152+
Assert.AreEqual(1, SlotOf(container, second));
153+
}
154+
155+
// §3.1 propagation would stop before breaking through the fragmentation root, so the chain here reaches
156+
// the root either way (whether or not ancestor propagation exists) and no break is taken - which is
157+
// also §4.4's "no empty fragmentainer" falling out rather than being asserted. Passes in this port for
158+
// the same *suppression* that makes the two Ignored tests above fail their PeachPDF assertions - the
159+
// observable outcome (nothing moves, single page) happens to coincide here.
160+
[TestMethod]
161+
public async Task ForcedBreakBeforeTheFirstBoxInTheFlow_ManufacturesNoBlankPage()
162+
{
163+
var (root, container) = await BuildAsync(
164+
"<div id='wrap'><div id='only' style='break-before:page;height:40pt'>only</div></div>");
165+
166+
var wrap = FindById(root, "wrap");
167+
Assert.IsNotNull(wrap);
168+
169+
Assert.AreEqual(0, SlotOf(container, wrap));
170+
Assert.AreEqual(1, container.FragmentTree!.Fragmentainers.Count);
171+
}
172+
173+
#endregion
174+
175+
#region Combination and precedence (§3.1)
176+
177+
[TestMethod]
178+
[Ignore("Confirmed gap: a container's own BreakAfter is a plain CSS-cascaded backing field "
179+
+ "(CssBoxProperties.BreakAfter) with no bubbling from its last in-flow child's break-after - "
180+
+ "TryGetForcedBreakTarget only ever tests prevSibling.BreakAfter directly, and prevSibling here is "
181+
+ "'wrap' itself (whose own break-after was never set), not 'tail' (whose break-after:page never "
182+
+ "reaches 'wrap'). So 'next' is not pushed to a new page at all.")]
183+
public async Task BreakAfterOnALastChild_ForcesTheBreakBeforeTheFollowingSibling()
184+
{
185+
var (root, container) = await BuildAsync(
186+
"<div id='wrap'><div id='tail' style='break-after:page;height:40pt'>tail</div></div>"
187+
+ "<div id='next' style='height:40pt'>next</div>");
188+
189+
var next = FindById(root, "next");
190+
Assert.IsNotNull(next);
191+
192+
Assert.AreEqual(container.PageTopOf(1), next.Location.Y, 3);
193+
}
194+
195+
#endregion
196+
197+
#region Keep-with-next across a container (relocation-triggered - confirmed working)
198+
199+
// The §4.3 movers (RelocateIfNeeded/EnforceKeepWithNext) reach ancestor-propagation through a real,
200+
// confirmed mechanism (PropagateContainerRelocation): the run is collected, the anchor (the first
201+
// in-flow box the relocated box begins) travels, and the child loop that owns the run positions it.
202+
[TestMethod]
203+
public async Task BreakInsideAvoidOnAFirstChild_PullsTheRunAcrossTheContainer()
204+
{
205+
var (root, container) = await BuildAsync(
206+
"<div id='lead' style='height:200pt'>lead</div>"
207+
+ "<h2 id='head' style='margin:0;break-after:avoid'>Head</h2>"
208+
+ "<div id='wrap'>"
209+
+ "<div id='body' style='break-inside:avoid;height:150pt'>body</div>"
210+
+ "</div>");
211+
212+
var head = FindById(root, "head");
213+
var wrap = FindById(root, "wrap");
214+
var body = FindById(root, "body");
215+
Assert.IsNotNull(head);
216+
Assert.IsNotNull(wrap);
217+
Assert.IsNotNull(body);
218+
219+
Assert.AreEqual(1, SlotOf(container, body));
220+
Assert.AreEqual(1, SlotOf(container, wrap));
221+
Assert.AreEqual(1, SlotOf(container, head));
222+
}
223+
224+
#endregion
225+
}

0 commit comments

Comments
 (0)