Skip to content

Commit 42fff5b

Browse files
committed
Port PeachPDF's PDF-pipeline end-to-end tests (Batch 5, final)
Ports PeachPDF.Tests/Integration/{FixedPositionPaginationIntegrationTests, HandleLinksPaginationTests,TableHeaderPdfRenderingTests, TableHeaderRepetitionThroughTheGeneratorTests}.cs into Source/Test/HtmlRenderer.PdfSharp.Test/, adapted to this project's own established pattern (MultiPageTextVisibilityTest/FixedPositionRepeatsPerPdfPageTest): real PdfGenerator.GeneratePdf output, content-level assertions against the raw PDF content stream, not page-count-only checks. FixedPositionPaginationIntegrationTests (1/1 port, rewritten): PeachPDF's fixture used page-break-before to land a fixed box's background on 3 real pages - this fork has no page-break-before/after at all (already documented by FixedPositionRepeatsPerPageTest), so real multi-page output is forced with filler content instead, the same way the two existing fixed-position tests in this branch's history already do it. Not redundant with either of them: FixedPositionRepeatsPerPageTest asserts the fragment tree directly, never a real PDF; FixedPositionRepeatsPerPdfPageTest goes through the real generator but only counts Tj text operators, never a fixed box's own background fill (a different draw path, GraphicsAdapter.DrawRectangle) nor that it repeats at the same page-local position on every page. This test fills that specific gap. HandleLinksPaginationTests (2/2 port): link-annotation page attribution through the real generator. This fork's own PdfGenerator.HandleLinks was already rewritten (pre-dating this batch) to build a slotToPage map from tree.Fragmentainers and match each link against every fragmentainer's own Geometry band, so PeachPDF's two historical bugs here (unshifted MarginTop in the page-index formula; raw grid-slot used directly as a Pages index) don't reproduce - both tests are real regression pins of that already-correct behavior, not bug repros, verified through the real document.Pages[i].Annotations rather than assumed. page-break-before dropped for the same reason as above; both fixtures use genuine filler-driven pagination instead. TableHeaderPdfRenderingTests (4/5 ported, 1 dropped, 1 rewritten): this fork implements no <tfoot> repeat at all (TableHeaderRepeat.cs is thead-only), so TableFooter_MultiPageTable_GeneratesWithFooter is dropped outright, and TableHeaderAndFooter_SinglePageTable_PaintsEachCellTextExactlyOnce (which used PeachPDF's own mock DrawStringRecordingGraphics paint harness, not a real PDF) is rewritten as a header-only, real-PDF, content-stream regression pin instead (TableHeader_SinglePageTable_PaintsHeaderBackgroundExactlyOnce). All 4 surviving tests were upgraded past PeachPDF's own page-count/non-empty-stream checks (explicitly insufficient per this branch's own MultiPageTextVisibilityTest history) to confirm the header's background fill actually repeats on every real generated page, via a content-stream fill-operator pattern confirmed empirically against a real dump during porting. TableHeaderRepetitionThroughTheGeneratorTests (adapted down to 2 tests): dropped tfoot assertions and the @page-driven fixture (confirmed parse-only, no consumer anywhere) in favor of PdfGenerateConfig's own PageSize/margins, and replaced FragmentTree assertions with real content-stream checks - this project has no InternalsVisibleTo access to HtmlContainerInt (only HtmlRenderer.Test/ HtmlRenderer.IntegrationTest do). Porting PeachPDF's exact fixture shape (one row, one very tall continuing cell, no trailing row) surfaced that it lands squarely in an already-documented gap: TableSpannedBandRepetitionTests (Batch 4) pins that the header-repeat loop only re-checks for a band transition at the start of a NEXT row's own iteration, so a lone continuing row with nothing after it never gets the header repeated past its own starting page. Confirmed empirically here through the full real PdfGenerator pipeline (not just LayoutHarness) and pinned as such, extending Batch 4's coverage of the same gap rather than reshaping the fixture to dodge it or silently asserting the wrong thing as correct. No new production bugs found this batch - HandleLinks and the header-repeat loop's documented limitation were both already correct/already pinned by earlier stages of this branch. Full suite after this batch: HtmlRenderer.Test 2427/128/0 (unchanged), HtmlRenderer.IntegrationTest 323/101/0 (unchanged), HtmlRenderer.PdfSharp.Test 39/0/0 (was 30/0/0 - +9 new tests). Solution build clean across net8.0/ netstandard2.0/net462 with only the 1 known pre-existing CS8602 warning. This is the final batch of the 5-batch PeachPDF fragmentation test port.
1 parent ea99b55 commit 42fff5b

4 files changed

Lines changed: 495 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System.Text;
2+
using System.Text.RegularExpressions;
3+
using PdfSharp;
4+
using TheArtOfDev.HtmlRenderer.PdfSharp;
5+
6+
namespace HtmlRenderer.PdfSharp.Test;
7+
8+
/// <summary>
9+
/// Ported from PeachPDF.Tests' <c>FixedPositionPaginationIntegrationTests</c>, which confirms
10+
/// <c>position:fixed</c> content repeats identically across real, multi-page <see cref="PdfGenerator"/>
11+
/// output by scanning generated PDF content streams for a fixed box's own fill operator appearing on
12+
/// every one of 3 real pages, produced there via <c>page-break-before:always</c>.
13+
/// </summary>
14+
/// <remarks>
15+
/// <para>
16+
/// This fork has no <c>page-break-before</c>/<c>page-break-after</c> support at all - already confirmed
17+
/// and documented by <c>HtmlRenderer.IntegrationTest.Positioning.FixedPositionPaginationIntegrationTests</c>'s
18+
/// own <c>PageBreakBefore_PushesFollowingContentToTheNextSimulatedPage</c> test, which is <c>[Ignore]</c>d
19+
/// for exactly that reason (no <c>PageBreakBefore</c>/<c>PageBreakAfter</c> property anywhere in
20+
/// <c>CssBoxProperties</c>, and the default stylesheet's own rules for it are inert). Real multiple pages
21+
/// are forced here the same way <c>MultiPageTextVisibilityTest</c>/<c>FixedPositionRepeatsPerPdfPageTest</c>
22+
/// already do it: enough filler paragraph content to genuinely overflow several A4 pages.
23+
/// </para>
24+
/// <para>
25+
/// This is deliberately NOT a duplicate of the two fixed-position tests already in this branch's
26+
/// history:
27+
/// </para>
28+
/// <list type="bullet">
29+
/// <item><c>HtmlRenderer.IntegrationTest.FixedPositionRepeatsPerPageTest</c> asserts against the
30+
/// fragment tree directly (<c>FragmentainerFragment.Root</c> word positions) and never generates a real
31+
/// PDF at all.</item>
32+
/// <item><c>HtmlRenderer.PdfSharp.Test.FixedPositionRepeatsPerPdfPageTest</c> does go through the real
33+
/// generator, but only ever checks a relative <c>Tj</c>-operator COUNT for fixed TEXT content - it never
34+
/// confirms a fixed box's own painted geometry (a <c>background-color</c> fill, drawn through
35+
/// <c>GraphicsAdapter.DrawRectangle(RBrush,...)</c>, a different code path than <c>DrawString</c>), and
36+
/// never confirms the fixed content repaints at the SAME page-local position on every page rather than
37+
/// merely "some extra content exists somewhere".</item>
38+
/// </list>
39+
/// <para>
40+
/// This test fills that specific, previously-uncovered gap. The content-stream shape of a filled rect -
41+
/// a "&lt;r&gt; &lt;g&gt; &lt;b&gt; rg" color-set operator followed (not necessarily immediately, since
42+
/// PdfSharp's writer elides a redundant "gs"/state push in between) by an "x y w h re" path and an "f"
43+
/// fill operator - was confirmed empirically by dumping a real generated content stream for this exact
44+
/// fixture during porting, not guessed.
45+
/// </para>
46+
/// </remarks>
47+
[TestClass]
48+
[DoNotParallelize]
49+
public sealed class FixedPositionPaginationIntegrationTests
50+
{
51+
[TestMethod]
52+
public async Task FixedPositionBox_BackgroundRepeatsAtTheSamePosition_OnEveryRealGeneratedPage()
53+
{
54+
var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
55+
config.SetMargins(20);
56+
57+
// Generous filler, not a precisely-calibrated boundary - a tight "just barely" filler count is
58+
// fragile to font substitution across CI platforms (see StageF1VerificationTest's own remark).
59+
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. ";
60+
var body = $"<p>{string.Concat(Enumerable.Repeat(sentence, 200))}</p>";
61+
var html = $"""
62+
<html><body>
63+
<div style="position:fixed; top:10pt; left:10pt; width:30pt; height:30pt; background-color: rgb(12,34,56);"></div>
64+
{body}
65+
</body></html>
66+
""";
67+
68+
using var document = await PdfGenerator.GeneratePdf(html, config);
69+
70+
Assert.IsGreaterThanOrEqualTo(3, document.Pages.Count, "test content should span at least 3 pages for this to be a meaningful check.");
71+
72+
// rgb(12,34,56) as PDF 0..1 fractions (12/255, 34/255, 56/255 -> ~0.047, ~0.133, ~0.220),
73+
// tolerant of PdfSharp's own variable-precision decimal formatting of each component.
74+
var fixedRectPattern = new Regex(@"0\.04\d* 0\.13\d* 0\.2\d* rg[\s\S]{0,80}?([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) re\s*\r?\nf");
75+
76+
var hasFirst = false;
77+
var firstX = 0.0;
78+
var firstY = 0.0;
79+
for (var i = 0; i < document.Pages.Count; i++)
80+
{
81+
var content = document.Pages[i].Contents.Elements.GetDictionary(0);
82+
var text = Encoding.Latin1.GetString(content!.Stream.Value);
83+
var matches = fixedRectPattern.Matches(text);
84+
85+
Assert.AreEqual(1, matches.Count, $"page {i} should draw the fixed box's background exactly once - not zero (missing) and not more than one (duplicated).");
86+
87+
var x = double.Parse(matches[0].Groups[1].Value);
88+
var y = double.Parse(matches[0].Groups[2].Value);
89+
if (!hasFirst)
90+
{
91+
firstX = x;
92+
firstY = y;
93+
hasFirst = true;
94+
}
95+
else
96+
{
97+
Assert.AreEqual(firstX, x, 0.5, $"page {i}'s fixed box should paint at the same page-local X as every other page.");
98+
Assert.AreEqual(firstY, y, 0.5, $"page {i}'s fixed box should paint at the same page-local Y as every other page.");
99+
}
100+
}
101+
}
102+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using PdfSharp;
2+
using TheArtOfDev.HtmlRenderer.PdfSharp;
3+
4+
namespace HtmlRenderer.PdfSharp.Test;
5+
6+
/// <summary>
7+
/// Ported from PeachPDF.Tests' <c>HandleLinksPaginationTests</c>: end-to-end tests for link-annotation
8+
/// page attribution through the real <see cref="PdfGenerator"/> pipeline, asserting on the generated
9+
/// <c>PdfDocument</c>'s own <c>Pages[i].Annotations</c>.
10+
/// </summary>
11+
/// <remarks>
12+
/// PeachPDF's own two historical bugs this file documented (an un-shifted <c>MarginTop</c> in the
13+
/// page-index formula, and a raw grid-slot index used directly as a <c>document.Pages</c> index) don't
14+
/// map onto this fork's own <c>PdfGenerator.HandleLinks</c> (<c>Source/HtmlRenderer.PdfSharp/PdfGenerator.cs</c>
15+
/// ~235-285) verbatim - this fork was written already carrying the fix: it builds a <c>slotToPage</c>
16+
/// dictionary from <c>tree.Fragmentainers</c> up front (so a content-empty slot skipped by blank-page
17+
/// skipping is simply absent from the map, never silently misindexed) and matches each link against
18+
/// every fragmentainer's own <c>Geometry</c> band rather than dividing by a fixed page height. These
19+
/// tests are therefore regression PINS for that already-correct behavior, not bug repros - but real ones,
20+
/// exercised through the actual generator rather than assumed.
21+
///
22+
/// PeachPDF's first fixture used <c>page-break-before:always</c> to land its link deterministically on
23+
/// "page two" - this fork has no such property (confirmed elsewhere in this branch's own history; see
24+
/// <c>HtmlRenderer.PdfSharp.Test.FixedPositionPaginationIntegrationTests</c>'s remarks), so both fixtures
25+
/// here instead force genuine multi-page output the same way the other real-PDF tests in this project do:
26+
/// with enough filler content that the layout itself overflows onto further pages.
27+
/// </remarks>
28+
[TestClass]
29+
[DoNotParallelize]
30+
public sealed class HandleLinksPaginationTests
31+
{
32+
[TestMethod]
33+
public async Task Link_PastGenuineMultiPageFillerContent_LandsOnExactlyOneCorrectlyMappedPage()
34+
{
35+
var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
36+
config.SetMargins(20);
37+
38+
var filler = string.Concat(Enumerable.Repeat("<p style='margin:0;'>filler line of body text</p>", 150));
39+
var html = $"""
40+
<html><body>
41+
{filler}
42+
<p><a href="https://example.com/">a link well past the first page's worth of filler</a></p>
43+
</body></html>
44+
""";
45+
46+
using var document = await PdfGenerator.GeneratePdf(html, config);
47+
48+
Assert.IsGreaterThan(1, document.Pages.Count, "filler content should span multiple pages for this to be meaningful");
49+
Assert.AreEqual(0, document.Pages[0].Annotations.Count, "the link sits well past the first page's worth of filler");
50+
51+
var annotatedPages = Enumerable.Range(0, document.Pages.Count)
52+
.Where(i => document.Pages[i].Annotations.Count > 0)
53+
.ToList();
54+
Assert.AreEqual(1, annotatedPages.Count, "the single link should be attributed to exactly one page, never split or duplicated across pages by HandleLinks' per-fragmentainer band matching");
55+
Assert.AreEqual(1, document.Pages[annotatedPages[0]].Annotations.Count);
56+
}
57+
58+
[TestMethod]
59+
public async Task Link_AfterAContentEmptySpacer_LandsOnTheCorrectMaterializedPage()
60+
{
61+
// The spacer has no text/background/border, so every page-slot it alone spans is content-empty
62+
// and is never materialized as a fragmentainer at all (FragmentEmitter.HasContentInBand /
63+
// PdfGenerator.AddPdfPages' blank-page-skipping loop) - the linked paragraph sits several grid
64+
// slots into the document but on an early materialized PDF page. HandleLinks must map the link's
65+
// fragmentainer band to the PDF page actually generated for it via slotToPage, not to its raw
66+
// (non-contiguous) SlotIndex.
67+
const string html = """
68+
<html><body>
69+
<p style='margin:0;'>page one content</p>
70+
<div style='height:2500pt;'></div>
71+
<p><a href="https://example.com/">a link after the gap</a></p>
72+
</body></html>
73+
""";
74+
75+
var config = new PdfGenerateConfig { PageSize = PageSize.A4 };
76+
config.SetMargins(20);
77+
78+
using var document = await PdfGenerator.GeneratePdf(html, config);
79+
80+
Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count);
81+
Assert.AreEqual(0, document.Pages[0].Annotations.Count, "the link is not on the first page");
82+
83+
var annotatedPages = Enumerable.Range(0, document.Pages.Count)
84+
.Where(i => document.Pages[i].Annotations.Count > 0)
85+
.ToList();
86+
Assert.AreEqual(1, annotatedPages.Count, "the single link should be attributed to exactly one materialized page");
87+
Assert.AreEqual(1, document.Pages[annotatedPages[0]].Annotations.Count);
88+
89+
// Blank-page skipping (css-break-3 5.2's margin truncation, plus the content-empty-slot skip)
90+
// keeps the document short despite the 2500pt gap - if the link were misattributed by a raw,
91+
// non-contiguous slot index instead of the materialized-page index, this would either throw
92+
// (indexing document.Pages out of range) or silently land on the wrong page.
93+
Assert.IsLessThanOrEqualTo(6, document.Pages.Count, "the 2500pt content-empty gap should be skipped, not paginated through as blank pages");
94+
}
95+
}

0 commit comments

Comments
 (0)