TargetLineOne TargetLineTwo TargetLineThree TargetLineFour TargetLineFive TargetLineSix TargetLineSeven TargetLineEight TargetLineNine TargetLineTen
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(200, 300);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(200, 0);
+
+ using var bitmap = new Bitmap(200, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+
+ var wordInfo = new List<(string Text, int Page, double Top)>();
+ for (var pi = 0; pi < tree.Fragmentainers.Count; pi++)
+ foreach (var w in AllTargetWords(tree.Fragmentainers[pi].Root))
+ wordInfo.Add((w.Text, pi, System.Math.Round(w.Top, 1)));
+
+ if (wordInfo.Count == 0)
+ continue; // paragraph didn't appear in this bitmap height at this filler count - try the next
+
+ var firstPage = wordInfo[0].Page;
+ var linesOnFirstPage = wordInfo.Where(w => w.Page == firstPage).Select(w => w.Top).Distinct().Count();
+
+ Assert.IsGreaterThanOrEqualTo(2, linesOnFirstPage,
+ $"at fillerCount={fillerCount}, the paragraph's first page-fragment kept only {linesOnFirstPage} line(s), fewer than orphans:2 requires");
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs
new file mode 100644
index 000000000..7c575d2fd
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs
@@ -0,0 +1,108 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+/// Walk(CssBox box)
+ {
+ yield return box;
+ foreach (var b in box.Boxes)
+ foreach (var d in Walk(b))
+ yield return d;
+ }
+
+ [TestMethod]
+ public async Task RowspanCellSpanningAShiftedRow_BottomTracksTheShift_NotLeftStale()
+ {
+ // Sweep filler counts - the exact boundary where the row-shift fires depends on font-metric
+ // arithmetic (see this session's established testing lesson: never hardcode a "just barely
+ // straddles" calibration).
+ var checkedAnyShift = false;
+
+ for (var fillerCount = 1; fillerCount < 60; fillerCount++)
+ {
+ using var wrapper = new HtmlContainer();
+ var filler = string.Concat(Enumerable.Repeat("filler line
", fillerCount));
+ // Many extra rows before the rowspan pair push the table's own total height well past one
+ // page, so RelocateIfNeeded's table-level relocation (which requires the whole table to fit
+ // within one page) declines, leaving CssLayoutEngineTable's own row-level shift as the ONLY
+ // mechanism that can act on the straddling row - otherwise a small table gets moved wholesale
+ // and never exercises this bug at all.
+ var extraRows = string.Concat(Enumerable.Range(0, 40).Select(i => $"| Extra{i}A | Extra{i}B |
"));
+ await wrapper.SetHtml(
+ $"""
+
+ {filler}
+
+ {extraRows}
+ | SpanCellContent | Row1Cell2 |
+ | Row2Cell2 |
+
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(300, 0);
+
+ using var bitmap = new Bitmap(300, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var allBoxes = Walk(container.Root).ToList();
+
+ CssBox? FindEnclosingTd(string text)
+ {
+ var wordBox = allBoxes.FirstOrDefault(b => b.Words.Any(w => w.Text.Contains(text)));
+ for (var b = wordBox; b != null; b = b.ParentBox)
+ if (b.HtmlTag?.Name == "td")
+ return b;
+ return null;
+ }
+
+ var spanCell = FindEnclosingTd("SpanCellContent");
+ var row2Cell = FindEnclosingTd("Row2Cell2");
+ if (spanCell == null || row2Cell == null)
+ continue;
+
+ // Only meaningful once the shift has actually fired for this row (row2Cell flush at a fresh
+ // page top) - otherwise there's nothing to have gotten stale in the first place.
+ if (System.Math.Abs(row2Cell.Location.Y - container.PageTopOf(container.PageIndexOf(row2Cell.Location.Y))) > 0.5)
+ continue;
+
+ checkedAnyShift = true;
+ Assert.IsGreaterThanOrEqualTo(row2Cell.ActualBottom - 0.5, spanCell.ActualBottom,
+ $"at fillerCount={fillerCount}, the rowspan cell's bottom ({spanCell.ActualBottom:F1}) fell short of its sibling's ({row2Cell.ActualBottom:F1}) after the row-shift");
+ }
+
+ Assert.IsTrue(checkedAnyShift, "no filler count in range actually exercised the row-shift - test is not meaningful as written");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs
new file mode 100644
index 000000000..2337554ee
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs
@@ -0,0 +1,84 @@
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+// This assembly parallelizes at the method level (MSTestSettings.cs); HtmlContainerInt's underlying
+// adapter singletons (font/brush caches, etc.) aren't safe against that for tests that drive full
+// layout passes directly - HtmlRenderingRegressionTests already opts out for the same reason.
+[TestClass]
+[DoNotParallelize]
+public sealed class StageD2FragmentBucketingSmokeTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ [TestMethod]
+ public async Task MultiPageDocument_ProducesOneFragmentainerPerPage_WithSplitBoxFragments()
+ {
+ using var wrapper = new HtmlContainer();
+ var paragraphs = string.Concat(Enumerable.Repeat("filler line of text for pagination
", 80));
+ await wrapper.SetHtml($"{paragraphs}");
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+ Assert.IsTrue(tree.Fragmentainers.Count > 1, $"expected multiple fragmentainers, got {tree.Fragmentainers.Count}");
+
+ // Slot indices are ascending and each fragmentainer's band matches its slot.
+ for (var i = 0; i < tree.Fragmentainers.Count; i++)
+ {
+ var f = tree.Fragmentainers[i];
+ Assert.AreEqual(f.SlotIndex, i, "no blank slots expected in this dense document");
+ }
+
+ // The document root CssBox (which spans the whole document) must produce a distinct
+ // BoxFragment per fragmentainer - the same underlying box, multiple fragments.
+ Assert.AreEqual(tree.Fragmentainers.Count, tree.Fragmentainers.Select(f => f.Root).Distinct().Count());
+
+ // Every fragmentainer's root should trace back to the same document root CssBox.
+ foreach (var f in tree.Fragmentainers)
+ {
+ Assert.AreSame(container.Root, f.Root.Box);
+ }
+ }
+
+ [TestMethod]
+ public async Task HugeMargin_SkipsBlankFragmentainers()
+ {
+ using var wrapper = new HtmlContainer();
+ await wrapper.SetHtml("content
");
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+
+ // The margin is truncated (D2), so content should land on an early page, not one 3000px down -
+ // this also implicitly confirms no run of ~4 blank fragmentainers was materialized for the gap.
+ Assert.IsTrue(tree.Fragmentainers.Count <= 2, $"expected at most 2 fragmentainers, got {tree.Fragmentainers.Count}");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs
new file mode 100644
index 000000000..9d1584af0
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs
@@ -0,0 +1,114 @@
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+// See StageD2FragmentBucketingSmokeTest for why this opts out of this assembly's default
+// method-level parallelization (MSTestSettings.cs).
+[TestClass]
+[DoNotParallelize]
+public sealed class StageD3PrecisionTest
+{
+ private static HtmlContainerInt Layout(string html, int pageWidth, int pageHeight, out HtmlContainer wrapper, out Bitmap bitmap)
+ {
+ wrapper = new HtmlContainer();
+ wrapper.SetHtml(html).GetAwaiter().GetResult();
+
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var container = (HtmlContainerInt)prop.GetValue(wrapper)!;
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(pageWidth, pageHeight);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(pageWidth, 0);
+
+ bitmap = new Bitmap(pageWidth, 8000);
+ var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+ g.Dispose();
+
+ return container;
+ }
+
+ [TestMethod]
+ public void NoLine_EverStraddlesAPageBoundary()
+ {
+ var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty ";
+ var html = $"{string.Concat(Enumerable.Repeat(sentence, 60))}
";
+
+ var container = Layout(html, 500, 700, out var wrapper, out var bitmap);
+ try
+ {
+ var p = DomUtils.GetBoxByTagName(container.Root, "p");
+ Assert.IsTrue(p.LineBoxes.Count > 5, "expected many lines to make this test meaningful");
+
+ foreach (var line in p.LineBoxes)
+ {
+ var top = line.LineTop;
+ var bottom = line.LineBottom;
+ if (bottom <= top) continue;
+
+ var topSlot = container.PageIndexOf(top);
+ var bottomSlot = container.PageIndexOf(System.Math.Max(top, bottom - 0.01));
+ Assert.AreEqual(topSlot, bottomSlot, $"line [{top:F1},{bottom:F1}) straddles a page boundary");
+ }
+ }
+ finally
+ {
+ bitmap.Dispose();
+ wrapper.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void Widows_NeverLeavesFewerThanMinimumLinesAtTopOfPage()
+ {
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 53));
+ var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty ";
+ var html = $"""
+
+ {filler}
+ {string.Concat(Enumerable.Repeat(sentence, 6))}
+
+ """;
+
+ var container = Layout(html, 500, 700, out var wrapper, out var bitmap);
+ try
+ {
+ // Find the widowed specifically (the last
, since filler
s come first).
+ var body = DomUtils.GetBoxByTagName(container.Root, "body");
+ var target = body.Boxes[body.Boxes.Count - 1];
+
+ AssertNoStraddleAndWidowsHonored(container, target, minWidows: 3);
+ }
+ finally
+ {
+ bitmap.Dispose();
+ wrapper.Dispose();
+ }
+ }
+
+ private static void AssertNoStraddleAndWidowsHonored(HtmlContainerInt container, CssBox box, int minWidows)
+ {
+ var lines = box.LineBoxes;
+ var breakLineIndex = -1;
+ for (var i = 1; i < lines.Count; i++)
+ {
+ if (container.PageIndexOf(lines[i].LineTop) != container.PageIndexOf(lines[i - 1].LineTop))
+ {
+ breakLineIndex = i;
+ break;
+ }
+ }
+
+ if (breakLineIndex < 0) return; // whole box fit on one page - nothing to check
+
+ var linesAfterBreak = lines.Count - breakLineIndex;
+ Assert.IsTrue(linesAfterBreak >= minWidows,
+ $"only {linesAfterBreak} lines after the break, expected at least {minWidows}");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs
new file mode 100644
index 000000000..f9ddb1022
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs
@@ -0,0 +1,83 @@
+using System.Drawing;
+using System.Reflection;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+// See StageD2FragmentBucketingSmokeTest for why this opts out of this assembly's default
+// method-level parallelization (MSTestSettings.cs).
+[TestClass]
+[DoNotParallelize]
+public sealed class StageD4RepeatedHeaderTest
+{
+ [TestMethod]
+ public void ThreadRepeatsOnEveryPageTheTableSpans()
+ {
+ // break-inside: avoid is explicit here rather than relied on from the UA default stylesheet's
+ // "@media print { thead, tfoot { break-inside: avoid } }" - this test renders via WinForms,
+ // whose adapter reports a "screen" media type, so that print-scoped rule never matches here
+ // (confirmed intentional: only PdfSharpAdapter overrides DefaultMediaType to "print").
+ var sb = new StringBuilder("
| Col A | Col B |
");
+ for (var i = 0; i < 60; i++)
+ {
+ sb.Append($"| row {i} a | row {i} b |
");
+ }
+ sb.Append("
");
+
+ using var wrapper = new HtmlContainer();
+ wrapper.SetHtml(sb.ToString()).GetAwaiter().GetResult();
+
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var container = (HtmlContainerInt)prop.GetValue(wrapper)!;
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var table = DomUtils.GetBoxByTagName(container.Root, "table");
+ Assert.IsNotNull(table);
+
+ // The table must genuinely span multiple pages for this test to be meaningful.
+ Assert.IsTrue(container.PageIndexOf(table.ActualBottom - 0.01) > container.PageIndexOf(table.Location.Y),
+ "expected the table to span more than one page");
+
+ Assert.IsNotNull(table.RepeatedHeaderRows, "expected at least one repeated header row set");
+ Assert.IsTrue(table.RepeatedHeaderRows.Count > 0);
+
+ // Each repeated row's text content should match the original header's text (Col A / Col B).
+ var headerRow = table.RepeatedHeaderRows[0];
+ var text = string.Join(" ", CollectWords(headerRow));
+ StringAssert.Contains(text, "Col A");
+ StringAssert.Contains(text, "Col B");
+
+ // Every repeated header must land at the top of a page slot the table's body actually spans,
+ // and must not be positioned on the table's own first page (it's already there once, in flow).
+ // The row itself carries no Location (only its cells do - see CssLayoutEngineTable's row loop),
+ // so the first cell is the reference point.
+ var firstSlot = container.PageIndexOf(table.Location.Y);
+ var slot = container.PageIndexOf(headerRow.Boxes[0].Location.Y);
+ Assert.IsTrue(slot > firstSlot, "repeated header should not land back on the table's own first page");
+ Assert.AreEqual(container.PageTopOf(slot), headerRow.Boxes[0].Location.Y, 0.5, "repeated header should sit flush at its page's content top");
+ }
+
+ private static System.Collections.Generic.IEnumerable CollectWords(TheArtOfDev.HtmlRenderer.Core.Dom.CssBox box)
+ {
+ foreach (var word in box.Words)
+ {
+ if (!string.IsNullOrWhiteSpace(word.Text))
+ yield return word.Text;
+ }
+ foreach (var child in box.Boxes)
+ {
+ foreach (var w in CollectWords(child))
+ yield return w;
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs
new file mode 100644
index 000000000..f74201377
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs
@@ -0,0 +1,111 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies the R1 stage of the fragmentation-engine-parity plan: forced page breaks now go through a
+/// real resumable pass loop ('s per-fragmentainer driver, CssBox's
+/// ResumeAt/PendingBreakToken child-loop bubbling) instead of a single-pass local
+/// correction. These tests exercise the loop across multiple passes specifically, which the existing
+/// single-forced-break tests (StageD2VerificationTest) don't - a bug in child-index bookkeeping
+/// across repeated resumes wouldn't necessarily show up with only one break in the document.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR1DriverLoopTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ [TestMethod]
+ public async Task TwoForcedBreaksInSequence_EachStartsANewPageWithCorrectContent()
+ {
+ using var wrapper = new HtmlContainer();
+ await wrapper.SetHtml(
+ """
+
+ First page content.
+ Second page content.
+ Third page content.
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+ Assert.AreEqual(3, tree.Fragmentainers.Count, "each forced break should land its own div on its own page");
+
+ // Each page's fragmentainer must be flush at its own band top (no leftover offset carried
+ // across the second break from the first, which an off-by-one in ResumeChildIndex would produce).
+ for (var slot = 0; slot < 3; slot++)
+ {
+ var fragmentainer = tree.Fragmentainers[slot];
+ Assert.AreEqual(slot, fragmentainer.SlotIndex);
+ }
+
+ // The three divs resolve to three distinct, correctly-ordered per-page fragments - proves the
+ // second break resumed the child loop at the right index rather than re-processing or skipping
+ // a sibling.
+ StringAssert.Contains(AllText(tree.Fragmentainers[0].Root), "First");
+ StringAssert.Contains(AllText(tree.Fragmentainers[1].Root), "Second");
+ StringAssert.Contains(AllText(tree.Fragmentainers[2].Root), "Third");
+ }
+
+ private static string AllText(BoxFragment fragment)
+ {
+ var words = new List();
+ Collect(fragment, words);
+ return string.Join(" ", words);
+
+ static void Collect(BoxFragment f, List into)
+ {
+ foreach (var word in f.Words)
+ into.Add(word.Word.Text);
+ foreach (var child in f.Children)
+ Collect(child, into);
+ }
+ }
+
+ [TestMethod]
+ public async Task ManyForcedBreaksInSequence_TerminatesPromptlyWithOnePagePerBreak()
+ {
+ using var wrapper = new HtmlContainer();
+ var divs = string.Concat(Enumerable.Range(0, 50).Select(i =>
+ $"Section {i}
"));
+ await wrapper.SetHtml($"{divs}");
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ // 50 divs, every one but the first forcing its own break: 50 pages. A hang or a runaway pass
+ // count would fail this test by timeout rather than by assertion - that's the point of covering
+ // the pass loop's backstop with a large-but-realistic case rather than only single/double breaks.
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+ Assert.AreEqual(50, tree.Fragmentainers.Count);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs
new file mode 100644
index 000000000..22392cf61
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs
@@ -0,0 +1,56 @@
+using System.Drawing;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies the R3 stage of the fragmentation-engine-parity plan: break-inside:avoid/monolithic
+/// relocation now relays the child out fresh at its target position (CssBox.ResumeAt + a second
+/// PerformLayout call within the same pass) instead of shifting already-finished geometry with
+/// OffsetTop.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR3RelocationTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ [TestMethod]
+ public async Task MonolithicContentTallerThanOnePage_IsLeftInPlace_NotMoved()
+ {
+ using var wrapper = new HtmlContainer();
+ // A scroll container (overflow:hidden, MonolithicContent.IsScrollContainer) taller than the
+ // 700px page - RelocateIfNeeded's "fits on no single page" guard must leave it straddling the
+ // boundary in place rather than moving it (nowhere to move it TO would help) or looping.
+ await wrapper.SetHtml(
+ """
+
+ filler
+ monolithic content taller than one page
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+ // Straddles the one boundary it naturally crosses (250 + 900 = 1150, past the 700px mark) and
+ // stops there - not moved to a later page (which would still not fit it whole) and not spun
+ // into extra pages by a mistaken relocation attempt.
+ Assert.AreEqual(2, tree.Fragmentainers.Count);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs
new file mode 100644
index 000000000..f516db0a0
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies the R4 stage of the fragmentation-engine-parity plan: keep-with-next
+/// (BlockFragmentation.EnforceKeepWithNext) now fires for the ordinary case, not just as a side
+/// effect of the following box also being break-inside:avoid/monolithic.
+///
+///
+/// The pre-existing KeepWithNext_HeadingStaysWithFollowingParagraph PDF test (still passing, still
+/// kept) only ever asserted a page COUNT of 2 - which is also exactly what you get if the heading is left
+/// stranded alone at the bottom of page 1 while the paragraph moves to page 2 by itself (2 pages either
+/// way). It never actually proved the heading and paragraph land on the SAME page. This test does, using
+/// the fragment tree directly: filler content is calibrated so the heading provably fits alone on page 0
+/// in isolation (confirmed by a companion assertion with no trailing paragraph), then, with the paragraph
+/// present, both must appear in the SAME fragmentainer.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR4KeepWithNextTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ private static async Task LayoutAsync(string bodyHtml)
+ {
+ using var wrapper = new HtmlContainer();
+ await wrapper.SetHtml($"{bodyHtml}");
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(595, 800);
+ container.MarginTop = 20;
+ wrapper.MaxSize = new SizeF(595, 0);
+
+ using var bitmap = new Bitmap(595, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ return container.FragmentTree;
+ }
+
+ private static string AllText(BoxFragment fragment)
+ {
+ var words = new List();
+ Collect(fragment, words);
+ return string.Join(" ", words);
+
+ static void Collect(BoxFragment f, List into)
+ {
+ foreach (var word in f.Words)
+ into.Add(word.Word.Text);
+ foreach (var child in f.Children)
+ Collect(child, into);
+ }
+ }
+
+ private static string Filler(int count) =>
+ string.Concat(Enumerable.Repeat("filler line of text
", count));
+
+ // WinForms reports media type "screen", not "print" - the UA stylesheet's h1-h6 { break-after: avoid }
+ // rule lives under @media print (see PdfSharpAdapter vs RAdapter.DefaultMediaType) and never applies
+ // to this IntegrationTest project's WinForms-based HtmlContainer. Set it explicitly rather than
+ // relying on the UA default.
+ private const string HeadingStyle = "margin:0; break-after: avoid;";
+
+ ///
+ /// Finds, by direct search rather than a hardcoded magic number, a filler count where the heading
+ /// fits alone on page 0 but heading+paragraph together do not - the exact boundary this stage's real
+ /// test needs. Hardcoding the count made this test fragile to unrelated, still-correct changes
+ /// elsewhere in the pagination arithmetic (this happened once already, when InlineFragmentation's
+ /// algorithm was rewritten for an unrelated widows bug and shifted the boundary by one filler).
+ ///
+ private static async Task FindBoundaryFillerCountAsync()
+ {
+ for (var count = 20; count < 80; count++)
+ {
+ var headingAlone = await LayoutAsync($"{Filler(count)}Section heading
");
+ var headingFitsAlone = StringContains(AllText(headingAlone.Fragmentainers[0].Root), "Section heading");
+ if (!headingFitsAlone)
+ continue;
+
+ var withParagraph = await LayoutAsync(
+ $"{Filler(count)}Section heading
Paragraph right after the heading.
");
+ var bothFitOnPageZero = withParagraph.Fragmentainers.Count >= 1
+ && StringContains(AllText(withParagraph.Fragmentainers[0].Root), "Paragraph right after the heading.");
+ if (!bothFitOnPageZero)
+ return count; // heading alone fits; heading+paragraph together doesn't - the boundary.
+ }
+
+ Assert.Fail("could not find a filler count where the heading fits alone but not with its paragraph");
+ return -1;
+ }
+
+ private static bool StringContains(string haystack, string needle) => haystack.Contains(needle);
+
+ [TestMethod]
+ public async Task HeadingAndParagraph_LandOnTheSamePage_NotStranded()
+ {
+ var count = await FindBoundaryFillerCountAsync();
+
+ var tree = await LayoutAsync(
+ $"{Filler(count)}Section heading
Paragraph right after the heading.
");
+
+ // Page 0's own text must NOT contain the heading - it should have been pulled forward to join
+ // the paragraph, not left stranded where the boundary search shows it would otherwise fit alone.
+ var pageZeroText = AllText(tree.Fragmentainers[0].Root);
+ StringAssert.DoesNotMatch(pageZeroText, new System.Text.RegularExpressions.Regex("Section heading"));
+
+ var withHeading = tree.Fragmentainers.Select(f => AllText(f.Root)).FirstOrDefault(t => t.Contains("Section heading"));
+ Assert.IsNotNull(withHeading, "heading should appear on some page");
+ StringAssert.Contains(withHeading, "Paragraph right after the heading.");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs
new file mode 100644
index 000000000..5eb2dfa50
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs
@@ -0,0 +1,135 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies a real bug found while investigating the fragmentation-engine-parity plan's R5/R6 stages
+/// (inline resumption, widows as a driver-level rewind): the investigation concluded neither stage needed
+/// new resumption machinery after all - CreateLineBoxes already computes an entire paragraph's
+/// lines in one unbounded, side-effect-free call, so there is never a point where a later pass reveals
+/// information the same-shot correction didn't already have. What it DID find was a real bug in that
+/// same-shot correction's own cascading logic.
+///
+///
+/// The old single-pass version of InlineFragmentation.ApplyLineBreaking shifted lines
+/// incrementally as it walked them, driven by "did this line straddle a page boundary". Once a shift
+/// happened to land a run of lines in perfect page-boundary alignment (very common with uniform line
+/// heights), no line ever straddled again for the rest of the paragraph - so widows was silently never
+/// re-checked for any later page transition. A paragraph long enough to span dozens of pages could end
+/// with a final page far short of its `widows` minimum and nothing would catch it. The rewritten version
+/// computes every break point up front from each line's own natural (never-shifted) position, which has
+/// no such blind spot, and applies the decided breaks in a single separate pass.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR5WidowsMultiPageTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ private static int CountWords(BoxFragment f)
+ {
+ var n = f.Words.Count(w => !w.Word.IsLineBreak);
+ foreach (var c in f.Children)
+ n += CountWords(c);
+ return n;
+ }
+
+ private static void CollectWordTops(BoxFragment f, List into)
+ {
+ foreach (var w in f.Words)
+ if (!w.Word.IsLineBreak)
+ into.Add(w.Rect.Top);
+ foreach (var c in f.Children)
+ CollectWordTops(c, into);
+ }
+
+ [TestMethod]
+ public async Task LongParagraph_PullsBackAcrossMultipleEarlierPages_WhenTheFirstDoesNotHaveRoom()
+ {
+ using var wrapper = new HtmlContainer();
+ // A deliberately non-round page height relative to the line height (100 vs a 24-tall line: 4
+ // lines is 96, leaving 4 units of slack; a straight single-page-back merge for widows:3 needs to
+ // reach past that slack into the page before it too) - this is exactly the shape the old
+ // single-pass algorithm's "stops checking after perfect alignment" blind spot could miss, and
+ // the shape the two-phase rewrite's break-list (rather than incremental-shift) design exists to
+ // handle: cascading the merge across more than one earlier break by removing list entries,
+ // without needing to undo a shift already applied to specific lines.
+ var sentence = "Alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey ";
+ var paragraph = string.Concat(Enumerable.Repeat(sentence, 40));
+ await wrapper.SetHtml($"{paragraph}
");
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(220, 100);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(220, 0);
+
+ using var bitmap = new Bitmap(220, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsGreaterThan(3, tree.Fragmentainers.Count, "test content should span several pages for this to be meaningful");
+
+ var lastPageWords = CountWords(tree.Fragmentainers[tree.Fragmentainers.Count - 1].Root);
+ Assert.IsGreaterThanOrEqualTo(3, lastPageWords,
+ $"the final page has only {lastPageWords} line(s), fewer than widows:3 - the paragraph's own last line was left stranded");
+ }
+
+ [TestMethod]
+ public async Task LongParagraph_DeclinesGracefully_WhenSatisfyingWidowsWouldOverflowAPage()
+ {
+ using var wrapper = new HtmlContainer();
+ // Deliberately degenerate: a single repeated word gives every line identical height, so pages
+ // pack to exactly the same capacity throughout - satisfying widows:3 on the trailing page would
+ // require merging in lines from an already-full preceding page, producing a run taller than any
+ // page can hold. This must not overflow, crash, or loop - it must simply leave the shorter final
+ // page as the best achievable result (css-break-3 4.3's "some constraints can't always be
+ // satisfied" relaxation philosophy).
+ var words = string.Concat(Enumerable.Repeat("word ", 300));
+ await wrapper.SetHtml($"{words}
");
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(60, 100);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(60, 0);
+
+ using var bitmap = new Bitmap(60, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsGreaterThan(3, tree.Fragmentainers.Count);
+
+ // No page's own words may span more vertical room than the page itself has - the real
+ // regression this guards against is a "fix" that satisfies widows by producing a run that
+ // silently overflows its fragmentainer (word rects are already fragmentainer-local, so a span
+ // near or under one page height is the correct expectation regardless of scroll/margin setup).
+ foreach (var fragmentainer in tree.Fragmentainers)
+ {
+ var tops = new List();
+ CollectWordTops(fragmentainer.Root, tops);
+ if (tops.Count == 0)
+ continue;
+
+ var span = tops.Max() - tops.Min();
+ Assert.IsLessThanOrEqualTo(container.PageSize.Height, span,
+ $"fragmentainer at slot {fragmentainer.SlotIndex} holds words spanning more than one page's height");
+ }
+
+ // The total word count must be conserved - nothing dropped, nothing duplicated, across however
+ // many pages the graceful-decline path produced.
+ var total = tree.Fragmentainers.Sum(f => CountWords(f.Root));
+ Assert.AreEqual(300, total);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableCellForcedBreakTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableCellForcedBreakTest.cs
new file mode 100644
index 000000000..9ac047a82
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableCellForcedBreakTest.cs
@@ -0,0 +1,90 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies a real regression found while investigating the fragmentation-engine-parity plan's R7 stage
+/// (table resumption), introduced by R1's forced-break deferral: CssLayoutEngineTable's row loop
+/// calls cell.PerformLayout directly and does not participate in the PendingBreakToken
+/// bubbling protocol an ordinary block-child loop does. A forced break nested inside a table cell (e.g. a
+/// <div style="break-before:page"> inside a <td>) would request deferral to a
+/// later pass exactly like any other box - but nothing ever reads that request or resumes it, since a
+/// table row is not itself laid out via the block-child loop. The deferred content's own layout returned
+/// before ever calling CreateLineBoxes, yet its words had already been measured (unconditional,
+/// at the top of every PerformLayoutImp call) - so it ended up rendered at a stale/default (0,0)
+/// position, silently overlapping whatever else was there, rather than being lost outright or correctly
+/// paginated. Confirmed by direct fragment-tree inspection before the fix: the word appeared, but at the
+/// wrong position, with no new page created for it.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR7TableCellForcedBreakTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ ///
+ /// Reconstructs each word's ABSOLUTE document-Y (fragment rects are page-band-local, so comparing
+ /// raw Rect.Top values across different fragmentainers is meaningless - a word at local Y=0
+ /// on page 2 is not "above" a word at local Y=10 on page 1).
+ ///
+ private static void CollectWordsWithAbsoluteY(BoxFragment f, double bandTop, List<(string Text, double AbsoluteTop)> into)
+ {
+ foreach (var w in f.Words)
+ if (!w.Word.IsLineBreak)
+ into.Add((w.Word.Text, w.Rect.Top + bandTop));
+ foreach (var c in f.Children)
+ CollectWordsWithAbsoluteY(c, bandTop, into);
+ }
+
+ [TestMethod]
+ public async Task ForcedBreakInsideTableCell_DoesNotOverlapOrLoseContent()
+ {
+ using var wrapper = new HtmlContainer();
+ await wrapper.SetHtml(
+ """
+
+ |
+ BeforeMarker
+ AfterMarker
+ |
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(500, 0);
+
+ using var bitmap = new Bitmap(500, 5000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+
+ var words = new List<(string Text, double AbsoluteTop)>();
+ foreach (var f in tree.Fragmentainers)
+ CollectWordsWithAbsoluteY(f.Root, f.LocalOriginY, words);
+
+ var before = words.Find(w => w.Text == "BeforeMarker");
+ var after = words.Find(w => w.Text == "AfterMarker");
+
+ Assert.IsNotNull(before.Text, "BeforeMarker must still be present");
+ Assert.IsNotNull(after.Text, "AfterMarker must still be present - not silently dropped");
+
+ // The real regression: AfterMarker rendered at the SAME position as BeforeMarker (or at a
+ // stale/default position near zero) rather than being placed below it in normal document flow.
+ Assert.IsGreaterThan(before.AbsoluteTop, after.AbsoluteTop,
+ $"AfterMarker (absoluteTop={after.AbsoluteTop}) must render below BeforeMarker (absoluteTop={before.AbsoluteTop}), not overlapping it");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs
new file mode 100644
index 000000000..fbea9344b
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs
@@ -0,0 +1,86 @@
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies the fragmentation-engine-parity plan's R7 investigation finding: a table cell whose own
+/// content spans several pages by itself (the content routes through the same, already-fixed
+/// CssBox.PerformLayoutImp/CreateLineBoxes/ApplyLineBreaking machinery as any other
+/// box) is preserved intact and subsequent rows correctly continue after it - no TableBreakToken/
+/// TableRowCursor machinery needed for this case, matching the R2/R5/R6 finding that this port's
+/// architecture rarely needs what it looks like it needs at first glance.
+///
+///
+/// Does NOT cover repeated-header behavior for this shape - a row whose own content spans multiple
+/// pages by itself only gets a header repeat inserted for the first page it crosses onto, not further
+/// intermediate pages that same row continues to span (see the KNOWN LIMITATION comment beside
+/// CssLayoutEngineTable.LayoutCells's repeat-check). Confirmed via direct testing, not fixed - the far
+/// more common shape (many ordinary rows, table spans many pages) already repeats correctly per
+/// StageD4RepeatedHeaderTest.ThreadRepeatsOnEveryPageTheTableSpans.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR7TableMultiPageCellTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ private static string AllText(BoxFragment f)
+ {
+ var words = new System.Collections.Generic.List();
+ void Collect(BoxFragment x)
+ {
+ foreach (var w in x.Words)
+ if (!w.Word.IsLineBreak)
+ words.Add(w.Word.Text);
+ foreach (var c in x.Children)
+ Collect(c);
+ }
+ Collect(f);
+ return string.Join(" ", words);
+ }
+
+ [TestMethod]
+ public async Task RowAfterAMultiPageSpanningCell_IsNotLost()
+ {
+ using var wrapper = new HtmlContainer();
+ var sentence = "one two three four five six seven eight nine ten ";
+ var longCell = string.Concat(Enumerable.Repeat(sentence, 100));
+ await wrapper.SetHtml(
+ $"""
+
+
+ | {longCell} | short |
+ | RowTwoCellOne | RowTwoCellTwo |
+
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(400, 700);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(400, 0);
+
+ using var bitmap = new Bitmap(400, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+ Assert.IsGreaterThan(3, tree.Fragmentainers.Count, "the long cell should genuinely span several pages for this to be meaningful");
+
+ var allText = string.Join(" ", tree.Fragmentainers.Select(f => AllText(f.Root)));
+ StringAssert.Contains(allText, "short");
+ StringAssert.Contains(allText, "RowTwoCellOne");
+ StringAssert.Contains(allText, "RowTwoCellTwo");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs
new file mode 100644
index 000000000..081e61487
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs
@@ -0,0 +1,99 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies the fragmentation-engine-parity plan's R9 investigation finding: PeachPDF's "keep-with-next
+/// run-pull rewind across an already-frozen fragmentainer" does not have a counterpart problem in this
+/// port's architecture, so no new rewind machinery is needed - the existing same-pass
+/// (R4)
+/// already covers it.
+///
+///
+/// PeachPDF needs a real cross-pass rewind because ordinary overflow-driven pagination is itself a real
+/// pass boundary there - a keep-with-next violation discovered while laying out page N+1 may need to
+/// reach back into page N's content, which was already committed via that pass's own EmitPass.
+/// In this port, only a FORCED break (break-before/after: page) ever creates a real pass boundary
+/// in HtmlContainerInt.DriveLayoutPasses - ordinary overflow and break-inside:avoid are both
+/// same-pass local corrections (R2/R3), and FragmentEmitter runs once, only after every pass has
+/// settled, so nothing is ever truly "frozen" mid-layout the way PeachPDF's per-pass emit makes it.
+/// A keep-with-next run is therefore always laid out - and checked by EnforceKeepWithNext - within
+/// the SAME pass as the sibling it's chained to, even immediately after resuming from an unrelated forced
+/// break earlier in the document, as this test confirms directly against the fragment tree. And a run
+/// could never need to be pulled across a forced break itself either way: the forced break is the
+/// intentional separator keep-with-next exists to avoid accidentally recreating, not an obstacle to undo.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR9KeepWithNextAcrossForcedBreakTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ private static string AllText(BoxFragment f)
+ {
+ var words = new List();
+ void Collect(BoxFragment x)
+ {
+ foreach (var w in x.Words)
+ if (!w.Word.IsLineBreak)
+ words.Add(w.Word.Text);
+ foreach (var c in x.Children)
+ Collect(c);
+ }
+ Collect(f);
+ return string.Join(" ", words);
+ }
+
+ [TestMethod]
+ public async Task KeepWithNextPairRightAfterAForcedBreak_StaysTogether_OnTheResumedPage()
+ {
+ using var wrapper = new HtmlContainer();
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 39));
+ await wrapper.SetHtml(
+ $"""
+
+ ForcedBreakMarker
+ {filler}
+ Section heading
+ Paragraph right after the heading.
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(595, 800);
+ container.MarginTop = 20;
+ wrapper.MaxSize = new SizeF(595, 0);
+
+ using var bitmap = new Bitmap(595, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+ Assert.IsGreaterThanOrEqualTo(2, tree.Fragmentainers.Count, "the forced break must actually introduce a real pass boundary for this test to be meaningful");
+
+ var pageOfHeading = -1;
+ var pageOfParagraph = -1;
+ for (var i = 0; i < tree.Fragmentainers.Count; i++)
+ {
+ var text = AllText(tree.Fragmentainers[i].Root);
+ if (text.Contains("Section heading")) pageOfHeading = i;
+ if (text.Contains("Paragraph right after the heading.")) pageOfParagraph = i;
+ }
+
+ Assert.AreNotEqual(-1, pageOfHeading, "heading must not be lost");
+ Assert.AreNotEqual(-1, pageOfParagraph, "paragraph must not be lost");
+ Assert.AreEqual(pageOfHeading, pageOfParagraph, "break-after:avoid must keep the heading with its paragraph even immediately after resuming from an unrelated forced break");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs
new file mode 100644
index 000000000..7d44e2376
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs
@@ -0,0 +1,84 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Fragments;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies a real bug found while investigating the fragmentation-engine-parity plan's R9 stage: an
+/// earlier version of
+/// always pulled the WHOLE preceding break-after:avoid-chained run to a child's page without
+/// checking whether the run then fit there. For a long chain (taller than one page combined), this did
+/// not just mis-place content - it corrupted layout outright: each subsequent chained sibling's own
+/// keep-with-next check re-fired against the now artificially-stretched-out run, compounding
+/// CssBox.OffsetTop shifts on the same earlier boxes without bound (observed reaching a box
+/// position of roughly 8.6e11 for a 60-member chain on a short page, before the fix). The fix implements
+/// css-break-3 §4.3's actual staged relaxation - trim the run from its front until what remains fits, or
+/// drop it entirely rather than pulling something that can't fit.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class StageR9OversizedKeepWithNextRunTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ private static IEnumerable AllWords(BoxFragment f)
+ {
+ foreach (var w in f.Words)
+ if (!w.Word.IsLineBreak)
+ yield return w.Word.Text;
+ foreach (var c in f.Children)
+ foreach (var w in AllWords(c))
+ yield return w;
+ }
+
+ [TestMethod]
+ public async Task LongAvoidChainTallerThanOnePage_NeverCorruptsGeometry_AndLosesNothing()
+ {
+ using var wrapper = new HtmlContainer();
+ var runMembers = string.Concat(Enumerable.Range(0, 60).Select(i =>
+ $"RunMember{i} filler filler filler filler filler
"));
+ await wrapper.SetHtml(
+ $"""
+
+ TopMarker
+ {runMembers}
+ FinalParagraph
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(595, 400);
+ container.MarginTop = 20;
+ wrapper.MaxSize = new SizeF(595, 0);
+
+ using var bitmap = new Bitmap(595, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tree = container.FragmentTree;
+ Assert.IsNotNull(tree);
+
+ // The real bug produced an ActualSize.Height in the hundreds of billions and zero fragmentainers
+ // (FragmentEmitter could not bucket geometry that far out of range) - a sane document is nowhere
+ // close to that regardless of exact page count, which depends on font metrics.
+ Assert.IsLessThan(100_000.0, wrapper.ActualSize.Height, "document height must stay sane - not blow up from compounding OffsetTop shifts");
+ Assert.IsGreaterThan(0, tree.Fragmentainers.Count);
+
+ var allWords = tree.Fragmentainers.SelectMany(f => AllWords(f.Root)).ToList();
+ var expected = Enumerable.Range(0, 60).Select(i => $"RunMember{i}").Append("FinalParagraph").Append("TopMarker");
+ foreach (var e in expected)
+ {
+ Assert.AreEqual(1, allWords.Count(w => w == e), $"'{e}' must appear exactly once - not lost or duplicated");
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs
new file mode 100644
index 000000000..cc95bfd63
--- /dev/null
+++ b/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs
@@ -0,0 +1,135 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Dom;
+using TheArtOfDev.HtmlRenderer.WinForms;
+
+namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
+
+///
+/// Verifies a real, spec-confirmed default-behavior gap found while auditing this port's fragmentation
+/// engine against PeachPDF a third time, then checking the actual W3C text directly
+/// (css-tables-3 §6.1, current
+/// Editor's Draft): "When fragmenting a table, user agents must attempt to preserve the table rows
+/// unfragmented if the cells spanning the row do not span any subsequent row, and their height is at
+/// least twice smaller than both the fragmentainer height and width. Other rows are said freely
+/// fragmentable." This is phrased as a required UA default, not something an author opts into -
+/// CssLayoutEngineTable.LayoutCells previously only preserved a row when the TABLE had explicit
+/// break-inside:avoid, meaning an ordinary multi-page table with no special markup at all rendered
+/// rows split across page boundaries by default, which the spec does not permit as the default.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class TableRowDefaultAtomicityTest
+{
+ private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
+ {
+ var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ return (HtmlContainerInt)prop.GetValue(wrapper)!;
+ }
+
+ private static IEnumerable Walk(CssBox box)
+ {
+ yield return box;
+ foreach (var b in box.Boxes)
+ foreach (var d in Walk(b))
+ yield return d;
+ }
+
+ [TestMethod]
+ public async Task OrdinaryRowWithNoBreakInsideAvoid_IsStillPreservedUnfragmented_ByDefault()
+ {
+ var checkedAnyStraddleCandidate = false;
+
+ for (var fillerCount = 1; fillerCount < 60; fillerCount++)
+ {
+ using var wrapper = new HtmlContainer();
+ var filler = string.Concat(Enumerable.Repeat("filler line
", fillerCount));
+ // Deliberately no break-inside:avoid anywhere - this is the plain, no-special-markup case
+ // css-tables-3 §6.1 says every conformant UA must handle this way by default.
+ await wrapper.SetHtml(
+ $"""
+
+ {filler}
+
+ | RowOneCell |
+ | TargetRowCellText with several words giving it real, non-trivial height |
+
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(300, 0);
+
+ using var bitmap = new Bitmap(300, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var tds = Walk(container.Root).Where(b => b.HtmlTag?.Name == "td").ToList();
+ if (tds.Count < 2)
+ continue;
+ var targetCell = tds[1];
+
+ var topSlot = container.PageIndexOf(targetCell.Location.Y);
+ var bottomSlot = container.PageIndexOf(System.Math.Max(targetCell.Location.Y, targetCell.ActualBottom - 0.01));
+
+ checkedAnyStraddleCandidate = true;
+ Assert.AreEqual(topSlot, bottomSlot,
+ $"at fillerCount={fillerCount}, the second row straddles page slots {topSlot}->{bottomSlot} with no break-inside:avoid anywhere - css-tables-3 6.1 requires it stay whole by default");
+ }
+
+ Assert.IsTrue(checkedAnyStraddleCandidate, "no filler count in range produced a target cell - test is not meaningful as written");
+ }
+
+ [TestMethod]
+ public async Task RowSpanningIntoASubsequentRow_RemainsFreelyFragmentable()
+ {
+ // css-tables-3 6.1's own carve-out: a row a rowspan cell only STARTS in (spanning further rows)
+ // is explicitly excluded from the "preserve unfragmented" default - confirming the new default
+ // atomicity doesn't overreach into content the spec says must stay freely fragmentable.
+ var foundAStraddle = false;
+
+ for (var fillerCount = 1; fillerCount < 30; fillerCount++)
+ {
+ using var wrapper = new HtmlContainer();
+ var filler = string.Concat(Enumerable.Repeat("filler line
", fillerCount));
+ await wrapper.SetHtml(
+ $"""
+
+ {filler}
+
+ | SpanCell | Row1Cell2 with enough words to make this row meaningfully tall for the straddle test to matter |
+ | Row2Cell |
+
+
+ """);
+
+ var container = GetInternal(wrapper);
+ container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300);
+ container.MarginTop = 0;
+ wrapper.MaxSize = new SizeF(300, 0);
+
+ using var bitmap = new Bitmap(300, 20000);
+ using var g = Graphics.FromImage(bitmap);
+ wrapper.PerformLayout(g);
+
+ var row1Cell2 = Walk(container.Root)
+ .FirstOrDefault(b => b.Words.Any(w => w.Text.Contains("Row1Cell2")))
+ ?.ParentBox;
+ if (row1Cell2 == null)
+ continue;
+
+ var topSlot = container.PageIndexOf(row1Cell2.Location.Y);
+ var bottomSlot = container.PageIndexOf(System.Math.Max(row1Cell2.Location.Y, row1Cell2.ActualBottom - 0.01));
+ if (topSlot != bottomSlot)
+ foundAStraddle = true;
+ }
+
+ Assert.IsTrue(foundAStraddle, "expected at least one filler count where the rowspan-starting row straddles a page boundary - if none do, this test isn't exercising the carve-out");
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionRepeatsPerPdfPageTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionRepeatsPerPdfPageTest.cs
new file mode 100644
index 000000000..b0a61632b
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionRepeatsPerPdfPageTest.cs
@@ -0,0 +1,55 @@
+using System.Text;
+using PdfSharp;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+///
+/// End-to-end confirmation, through the real path, of the fragment-tree-level
+/// fix verified in FixedPositionRepeatsPerPageTest (HtmlRenderer.IntegrationTest): a
+/// position:fixed element (css-position-3, paged media - "fixed positioned boxes are thus
+/// replicated on every page") must show up in every generated PDF page, not just the page its
+/// top/left offset happened to land on when misinterpreted as an absolute document
+/// coordinate.
+///
+///
+/// Verified by a RELATIVE Tj-operator-count comparison (with the fixed header vs. without, same filler
+/// content otherwise), not a literal-text search: PdfSharp draws through a Type0/CID font here, so a
+/// page's content stream holds hex glyph-index strings (<0037004B...> Tj), never the source
+/// text itself - the same reality MultiPageTextVisibilityTest works around by checking only for a
+/// Tj operator's presence, not its content. A page with genuinely one extra line of fixed content
+/// drawn on it gets exactly one extra Tj versus the same page without that content.
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class FixedPositionRepeatsPerPdfPageTest
+{
+ private static int CountTj(byte[] streamBytes) =>
+ Encoding.Latin1.GetString(streamBytes).Split("Tj").Length - 1;
+
+ [TestMethod]
+ public async Task FixedHeaderMarker_AddsOneExtraTextOperatorToEveryGeneratedPage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. ";
+ var body = $"{string.Concat(Enumerable.Repeat(sentence, 200))}
";
+
+ using var withFixed = await PdfGenerator.GeneratePdf(
+ $"""FixedHeaderMarkerText
{body}""",
+ config);
+ using var withoutFixed = await PdfGenerator.GeneratePdf($"{body}", config);
+
+ Assert.IsGreaterThanOrEqualTo(3, withoutFixed.Pages.Count, "test content should span at least 3 pages for this to be meaningful");
+ Assert.AreEqual(withoutFixed.Pages.Count, withFixed.Pages.Count, "adding a fixed header should not itself change how many pages the body content needs");
+
+ for (var i = 0; i < withFixed.Pages.Count; i++)
+ {
+ var withCount = CountTj(withFixed.Pages[i].Contents.Elements.GetDictionary(0)!.Stream.Value);
+ var withoutCount = CountTj(withoutFixed.Pages[i].Contents.Elements.GetDictionary(0)!.Stream.Value);
+ Assert.AreEqual(withoutCount + 1, withCount,
+ $"page {i} should have exactly one extra text-drawing operator for the repeated fixed header (with={withCount}, without={withoutCount})");
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs
new file mode 100644
index 000000000..a6daa971b
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs
@@ -0,0 +1,49 @@
+using System.Text;
+using PdfSharp;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+///
+/// Regression coverage for a real bug found while giving FragmentPainter a page-origin translate
+/// (so HtmlContainerInt.PerformPaint(RGraphics)'s multi-fragmentainer fallback could stop
+/// depending on CssBox.Paint): FragmentPainter.PaintFragmentContent painted line
+/// backgrounds/borders from the fragment tree's already page-local rects, but painted the actual text via
+/// CssBox.PaintWords, which reads CssRect.Rectangle straight off the live box tree - still
+/// absolute document-Y - offset only by ScrollOffset (always zero for PDF generation). Every page
+/// after the first got a content stream with zero text-draw operators, since a fresh per-page
+/// XGraphics's origin is that page's own band top, not the document's. Existing tests only ever
+/// asserted page *count*, never that a page's content stream actually contains text - this would have
+/// stayed silently broken indefinitely otherwise.
+///
+// Concurrent full-layout-pass tests race on shared adapter singleton state (same MSTest ClassLevel
+// parallelism issue documented for HtmlRenderer.IntegrationTest) - reproduced here: this test passes
+// reliably alone but intermittently reports a missing Tj on page 0 when run alongside the rest of the
+// suite.
+[TestClass]
+[DoNotParallelize]
+public sealed class MultiPageTextVisibilityTest
+{
+ [TestMethod]
+ public async Task EveryPage_HasRealTextDrawingOperators()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // Sized well past what fits on one A4 page, so every page has genuine paragraph content, not
+ // just a trailing sliver.
+ var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. ";
+ var html = $"{string.Concat(Enumerable.Repeat(sentence, 200))}
";
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThanOrEqualTo(3, document.Pages.Count, "Test content should span at least 3 pages for this to be a meaningful check.");
+
+ for (var i = 0; i < document.Pages.Count; i++)
+ {
+ var content = document.Pages[i].Contents.Elements.GetDictionary(0);
+ var text = Encoding.Latin1.GetString(content!.Stream.Value);
+ StringAssert.Contains(text, "Tj", $"Page {i} has no text-drawing operators - its content is invisible.");
+ }
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs
new file mode 100644
index 000000000..544e5bbd2
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs
@@ -0,0 +1,143 @@
+using PdfSharp;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+[TestClass]
+public sealed class StageD2VerificationTest
+{
+ [TestMethod]
+ public async Task ForcedBreakBefore_Page_StartsNewPage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ const string html = """
+
+ Page one content.
+ Page two content.
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.AreEqual(2, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task NoForcedBreak_SmallContent_StaysOnOnePage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ const string html = "Title
Body text.
";
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.AreEqual(1, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task LegacyPageBreakBefore_Always_StartsNewPage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ const string html = """
+
+ Page one content.
+ Page two content.
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.AreEqual(2, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task BreakInsideAvoid_KeepsBlockTogether_OnOnePage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // Enough filler to span several pages regardless of exactly which font ends up resolving on
+ // whatever machine runs this (a small, precisely-calibrated filler count is fragile to font
+ // substitution - CI's non-Windows runners fall back to an embedded font with different metrics
+ // than Windows' real "Times New Roman", so a boundary tuned for one silently misses the other;
+ // see this project's own established testing lesson about hardcoded "just barely" magic
+ // numbers). Precise per-page content verification lives in HtmlRenderer.IntegrationTest's
+ // ContainerLeftBehindTest/StageR3RelocationTest, which read the fragment tree directly instead
+ // of inferring behavior from a PDF's total page count - this is only a regression-style guard
+ // that the avoid-block relocation doesn't crash or misbehave outright.
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 150));
+ var html = $"""
+
+ {filler}
+
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task ManyParagraphs_FlowAcrossMultiplePages()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ var paragraphs = string.Concat(Enumerable.Repeat(
+ "A reasonably long paragraph of filler text used to force real multi-page pagination in this test.
",
+ 120));
+ var html = $"{paragraphs}";
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThan(1, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task HugeMargin_DoesNotProduceRunawayBlankPages()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // A margin far taller than a single page - margin truncation (css-break-3 5.2) must
+ // discard it rather than paginating through blank vertical space.
+ const string html = "content
";
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsLessThanOrEqualTo(2, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task KeepWithNext_HeadingStaysWithFollowingParagraph()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // h4 has UA break-after: avoid. Generous filler (see BreakInsideAvoid_KeepsBlockTogether_OnOnePage's
+ // own remark on why a precisely-calibrated boundary is fragile to font substitution across CI
+ // platforms) - this is a regression-style guard that the pair doesn't blow up across an
+ // unreasonable number of pages, not a precise "did they move together" check (that lives at the
+ // fragment-tree level, in HtmlRenderer.IntegrationTest's ContainerLeftBehindKeepWithNextTest).
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 150));
+ var html = $"""
+
+ {filler}
+ Section heading
+ Paragraph right after the heading.
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs
new file mode 100644
index 000000000..e9a808854
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs
@@ -0,0 +1,76 @@
+using PdfSharp;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+[TestClass]
+public sealed class StageD3VerificationTest
+{
+ [TestMethod]
+ public async Task LongParagraph_SpansPagesWithoutError()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // Generous repeat count - a precisely-calibrated boundary is fragile to font substitution
+ // across CI platforms (non-Windows runners fall back to an embedded font with different metrics
+ // than Windows' real "Times New Roman"); this only needs to comfortably exceed one page
+ // regardless of exactly which font resolves.
+ var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. ";
+ var html = $"{string.Concat(Enumerable.Repeat(sentence, 100))}
";
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThan(1, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task Widows_PullsMinimumLinesToNextPage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // Generous filler, not precisely calibrated to a specific boundary - see
+ // StageD2VerificationTest.BreakInsideAvoid_KeepsBlockTogether_OnOnePage's remark on why a tight
+ // "just barely" filler count is fragile to font substitution across CI platforms. Precise
+ // per-page widows verification lives in HtmlRenderer.IntegrationTest's StageR5WidowsMultiPageTest,
+ // which reads the fragment tree directly.
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 150));
+ var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty ";
+ var html = $"""
+
+ {filler}
+ {string.Concat(Enumerable.Repeat(sentence, 6))}
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ // The widowed paragraph must not leave fewer than 3 of its lines alone at the top of a page -
+ // this is a structural/behavioral guard (page count is stable and small) rather than pixel
+ // inspection, matching the other D2/D3 verification tests in this project.
+ Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count);
+ }
+
+ [TestMethod]
+ public async Task Orphans_KeepsMinimumLinesOnFirstPage()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // Generous filler - see Widows_PullsMinimumLinesToNextPage's own remark on why a tight "just
+ // barely" filler count is fragile to font substitution across CI platforms.
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 150));
+ var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty ";
+ var html = $"""
+
+ {filler}
+ {string.Concat(Enumerable.Repeat(sentence, 6))}
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs
new file mode 100644
index 000000000..2a4950485
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs
@@ -0,0 +1,30 @@
+using PdfSharp;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+[TestClass]
+public sealed class StageD4VerificationTest
+{
+ [TestMethod]
+ public async Task LargeTableWithHeader_SpansMultiplePagesWithoutError()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ var rows = string.Concat(Enumerable.Range(0, 60)
+ .Select(i => $"| row {i} a | row {i} b |
"));
+ var html = $"""
+
+
+ | Column A | Column B |
+ {rows}
+
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThan(1, document.Pages.Count);
+ }
+}
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs
new file mode 100644
index 000000000..33cacb7f5
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs
@@ -0,0 +1,61 @@
+using PdfSharp;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+[TestClass]
+public sealed class StageF1VerificationTest
+{
+ [TestMethod]
+ public async Task WebLinkAndAnchorLink_AcrossPages_DoNotThrow()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ // Generous filler, not a precisely-calibrated boundary - a tight "just barely" filler count is
+ // fragile to font substitution across CI platforms (non-Windows runners fall back to an
+ // embedded font with different metrics than Windows' real "Times New Roman").
+ var filler = string.Concat(Enumerable.Repeat("filler line of text
", 150));
+ var html = $"""
+
+ external link on page one
+ jump to anchor
+ {filler}
+ anchor target, on a later page
+
+ """;
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ Assert.IsGreaterThan(1, document.Pages.Count);
+
+ // At least one page carries some link annotation (either the web link or the document link) -
+ // this is a smoke check that HandleLinks' new slot-to-page mapping runs without throwing and
+ // actually attaches annotations, not a check of which exact page holds which link.
+ var anyLinks = false;
+ for (var i = 0; i < document.PageCount; i++)
+ {
+ if (document.Pages[i].Annotations.Count > 0)
+ {
+ anyLinks = true;
+ break;
+ }
+ }
+ Assert.IsTrue(anyLinks, "expected at least one page to carry a link annotation");
+ }
+
+ [TestMethod]
+ public async Task HugeMargin_ProducesNoBlankPages()
+ {
+ var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
+ config.SetMargins(20);
+
+ const string html = "content
";
+
+ using var document = await PdfGenerator.GeneratePdf(html, config);
+
+ // css-break-3 5.2 margin truncation (D2) keeps this on very few pages; blank-page skipping
+ // (F1's page-per-fragmentainer loop) means whatever pages exist are never content-empty.
+ Assert.IsLessThanOrEqualTo(2, document.Pages.Count);
+ }
+}