CSS 2.1 test parity: floats, positioning, z-index gap (stacked on #263) - #264
Open
jhaygood86 wants to merge 45 commits into
Open
CSS 2.1 test parity: floats, positioning, z-index gap (stacked on #263)#264jhaygood86 wants to merge 45 commits into
jhaygood86 wants to merge 45 commits into
Conversation
break-before/after/inside, widows, orphans, and page (page-name) were fully parsed by the ExCSS-based CssEngine but never dispatched onto CssBox - CssUtils's property switch didn't know the names existed. Adds them following the existing PageBreakInside pattern, aliases the legacy page-break-before/after onto the same canonical fields as the modern break-before/after, and gives widows/orphans cached int accessors (ActualWidows/ActualOrphans) plus correct inheritance. Pure plumbing - first stage of porting PeachPDF's fragmentation/paint architecture so layout can produce an immutable fragment tree. No layout or paint behavior changes; full regression suite unaffected.
Fragment/LineFragment/TextFragment/BoxFragment/FragmentainerFragment/ FragmentTree, plus SliceGeometry for box-decoration-break, ported from PeachPDF's fragment-tree design (Fragments/Fragment.cs) into this project's string-CSS/CssBox model. This is the output shape only - no producer yet, and nothing references these types. MarginBoxFragment/ FootnoteAreaFragment (@page margin boxes, float: footnote) are dropped, out of scope for this port. Also adds PageBandGeometry, a minimal per-fragmentainer band/margin value computed from the container's single fixed page size, standing in for PeachPDF's variable-geometry PageGeometryTable (not needed since this port doesn't build per-page @page overrides).
PageBand, BreakValues, MonolithicContent, BreakToken/BlockBreakToken/ InlineBreakToken, and BreakRelaxation, ported from PeachPDF's Fragmentation/ module and reduced to this port's scope: no flex/grid/ multi-column (no FlexBreakToken/GridBreakToken/nested fragmentainers), no directional break-before/after (no @page :left/:right matching), and HTML-Renderer's own smaller replaced-element/vertical-writing-mode surface. TableBreakToken is deferred to the table-fragmentation stage, where it can be shaped against HTML-Renderer's own row/cell model instead of guessed at now. InlineBreakToken carries PeachPDF's documented custom Equals/ GetHashCode (content-based, not the compiler's reference-equality default for its ResumePath list) - a measured footgun there that silently breaks the pass-count "no progress" backstop otherwise. Still no producer - nothing outside this new code references it yet.
HtmlContainerInt.PerformLayout now builds a FragmentTree from the finished box tree via a first-cut FragmentEmitter: one FragmentainerFragment spanning the whole document, no break tokens produced yet. This proves the layout -> fragment tree plumbing works before any real multi-page resumption exists, and is the stepping stone the paint stage builds on next (painting from the fragment tree instead of the live box tree). Every BoxFragment/LineFragment/TextFragment gets built unconditionally for the whole box tree, including display:none/hidden content - display/visibility is left as a paint-time concern, matching the fragment tree's role as a structural fact rather than a rendering decision. No behavior change: nothing reads FragmentTree yet, and the full image-diff regression suite (30/30) and PDF generator tests (2/2) are unaffected.
FragmentPainter walks a FragmentainerFragment and paints it, mirroring CssBox.Paint/PaintImp's display/visibility gating, fixed-position clip suspension, visibility culling, and z-order child recursion exactly - but reading geometry from BoxFragment/LineFragment/TextFragment instead of the live, mutable box tree. Rather than duplicating background/border/text/decoration painting, CssBox.PaintBackground/PaintWords/PaintDecoration are widened from protected/private to internal and called directly from the fragment painter, so this is a faithful re-shaping of the existing, tested paint code rather than a parallel reimplementation with its own risk of drift. CssBoxImage/CssBoxHr/CssBoxFrame (replaced/rule leaf types) still delegate wholesale to their own existing Paint() for now - they are monolithic, so their one fragment always covers their whole box, and real per-type content painters are follow-on work once actual multi-fragment splitting exists for them to matter. A new internal CssBox.ListItemBox accessor exposes the synthetic marker box (never part of Boxes) so it paints in its usual place. HtmlContainerInt gains an internal PerformPaint(RGraphics, Fragment- ainerFragment) overload alongside the existing PerformPaint(RGraphics) - not yet the default path (that cutover is later, once the full fragmentation+paint port is done), so both coexist deliberately. Verified two ways: a pixel-for-pixel self-consistency check across five representative samples (text, tables, backgrounds/borders/hr, fixed position, list markers), and - by temporarily redirecting HtmlContainer.PerformPaint through the new path and reverting after - the entire existing 30-sample image-diff regression suite, all pixel-identical to today's output.
Real multi-page fragmentation for block content, without the break-
token/pass-loop machinery from Stage C: PeachPDF's model has the
parent frame position each child (so it can consult fragmentation
state before laying it out); HTML-Renderer's has each child position
itself via MarginTopCollapse(prevSibling). Rather than restructure
positioning responsibility to match PeachPDF, this keeps HTML-
Renderer's existing single top-down positioning pass (every box gets
a final absolute Y in one walk, as today) and adds four *local*
corrections that only need a box's own natural position or already-
finished height - none of them need multi-pass resumption:
- Forced break-before/break-after: page (and the legacy always
value, since this engine's CSS parser accepts it on the modern
properties directly rather than normalizing it away) pushes a
box's start to the next page's content top.
- CSS Fragmentation 5.2 margin truncation: a collapsed margin that
alone crosses a page boundary is discarded, and content starts
flush at the next page instead of paginating through blank space.
- break-inside: avoid (and monolithic content) relocates a box's
whole subtree to the next page when it straddles a boundary and
fits on one page, via CssBox.OffsetTop (already existed, used by
table cell vertical-align).
- Keep-with-next walks backward through preceding siblings chained
by break-after/break-before: avoid and moves them along with a
relocated box, so a heading is never left stranded.
BlockBreakToken/FragmentainerContext stay unused for now - they're
for problems this stage doesn't have (can't-restart-from-scratch
inline re-entry, table row continuation), reserved for D3/D4 where
they're actually needed.
Also ports the fragmentation-relevant half of PeachPDF's UA default
stylesheet: h1-h6 { break-after: avoid } and thead/tfoot { break-
inside: avoid }, replacing this engine's own older, more aggressive
`h1 { page-break-before: always }` default - harmless while break-
before was unconsumed, but forces a spurious leading blank page now
that layout actually reads it.
Two real bugs surfaced and fixed via the existing regression suite
while building this: CssBox.OffsetTop's amount, if also applied to
ActualBottom directly, double-counts the shift because ActualBottom
is a computed property (Location.Y + Size.Height) that already moves
with Location.Y - caught by the Tables baseline. And a forced break-
before must be suppressed when a box has no previous sibling (css-
break-3 3.1: the break point before a container's first child *is*
the break point before the container, which for a box with no
ancestor to propagate to is simply inert) - caught by a page-count
regression on a one-page document opening with an <h1>.
New Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs
covers forced break-before (modern and legacy syntax), break-inside:
avoid, keep-with-next, multi-page paragraph flow, and margin
truncation. Full existing suite (30 image-diff baselines + PDF
generator tests) unaffected - WinForms/WPF's unbounded PageSize
sentinel means HasRealPageGrid is false there, so none of this new
logic activates outside real pagination.
FragmentEmitter previously (D1) always produced exactly one FragmentainerFragment spanning the whole document - correct only because nothing had real multi-page positions yet. Now that D2 gives every box a correct absolute position across however many pages it spans, the emitter walks the finished box tree once per page band (HtmlContainerInt.PageIndexOf/PageTopOf/PageBottomOf, added for this) and builds one BoxFragment per box per page it has content on, splitting a box that spans a page boundary into multiple fragments with fragmentainer-local coordinates - matching what the fragment tree is supposed to mean. A page-slot nothing has content in is never materialized (CSS Paged Media 3 3.2's blank-page skipping falls out of the walk rather than being special-cased), which is also why the huge-margin case from the D2 commit doesn't produce a run of empty fragmentainers. Containers without a real page grid (WinForms/WPF's unbounded PageSize sentinel) keep the single-fragmentainer path from D1 unchanged. IsFirstFragment/IsLastFragment are derived from which page slot a box's own top/bottom fall in; box-decoration-break slicing (distinguishing a genuine break edge from a real box edge for border/background painting) stays a no-op for now - deferred to Stage E2, once paint actually needs to draw a spanning box correctly. New Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketing SmokeTest.cs verifies multiple fragmentainers with ascending, gapless slot indices for a dense multi-page document, and that a huge margin doesn't produce a run of blank fragmentainers. Full existing suite (32 image-diff baselines + 9 PDF/PdfSharp tests) unaffected.
Extends the D2 pattern (local corrections to an already-computed layout, not resumable re-entry) to inline content. CreateLineBoxes already computes every line's final position in one pass; nothing about deciding where a paragraph should break needs to re-measure words or re-run hyphenation, so - unlike PeachPDF, where inline resumption genuinely can't restart from scratch - InlineBreakToken's pass-loop machinery isn't needed here either, the same way BlockBreakToken turned out not to be for D2. InlineFragmentation.ApplyLineBreaking runs right after CreateLineBoxes for a block containing only inline content: walks its LineBoxes in document order, and where a line would straddle a page boundary (css-break-3 4.1: a line box is monolithic, the whole line moves, not just the words that don't fit), shifts it - and everything after it - down to the next page's content top via a new CssLineBox.ShiftLine, honoring orphans (push the break earlier if too few lines would remain before it) and widows (pull more lines across if too few would remain after). This replaces the old per-word CssRect.BreakPage nudge for paginated content; that method (and CssBox.BreakPage) are now dead for real pagination but left in place until Stage F3's planned cleanup, once the old paint path is fully retired. New Source/HtmlRenderer/Core/Dom/CssLineBox.cs members: LineTop (mirrors the existing LineBottom) and ShiftLine (moves every word and per-box rectangle on the line, reusing the existing OffsetRectangle helper). Caught and fixed one MSTest parallelism issue while adding coverage: this assembly parallelizes at the method level, and HtmlContainerInt's adapter singletons aren't safe against two full layout passes running concurrently - StageD2FragmentBucketingSmokeTest needed the same [DoNotParallelize] HtmlRenderingRegressionTests already carries, or it intermittently reported an empty fragment tree despite correct underlying geometry (confirmed by direct inspection in isolation). New tests: StageD3VerificationTest.cs (PdfSharp page-count checks for long-paragraph pagination, widows, orphans) and StageD3PrecisionTest.cs (direct line-position inspection - no line ever straddles a page boundary across 60+ lines, and a widows:3 paragraph never leaves fewer than 3 lines alone at the top of a page). Full existing suite (34 image-diff/fragment tests + 12 PDF/PdfSharp tests) green.
Two independent corrections, both gated on a real page grid: - Row-level break-inside: avoid (or the legacy page-break-inside) on the table itself: replaces the old crude per-cell CssRect.BreakPage retry loop (which re-ran the whole row from scratch via a decrement- and-continue) with the same local shift-the-whole-row-down approach D2 uses for blocks, built on the real page-grid math instead of BreakPage's modulo arithmetic. Rows aren't avoided from splitting by default - css-tables-3 6.1 permits a row to fragment (each cell independently), which already happens correctly with no correction at all, since a cell's own content already flows across the boundary via BlockFragmentation/InlineFragmentation. - Repeated <thead> (css-tables-3 6.2), gated on the header carrying an avoiding break-inside (the UA default stylesheet sets this - see the earlier "bring in PeachPDF's fragmentation UA defaults" commit). Unlike everything in D2/D3, this genuinely needs layout-time space reservation, not just a local position shift: painting a repeated header on top of a body row that already flows into that space would overlap it. CssLayoutEngineTable's row loop now reserves headerHeight at the top of every continuation page before positioning that page's first row, and builds a detached clone of the header's rows there via the new TableHeaderRepeat helper - real cloned CssBox instances (not a fragment-tree-only proxy, so the repeat is visible through both the existing scroll-offset PDF pipeline and the new fragment tree without teaching two rendering paths about "one source, several positions"). Clones are stored on a new CssBox.RepeatedHeaderRows list - not part of Boxes, so re-running table layout can never mistake them for real content - and painted/emitted the same way CssBox.ListItemBox already is, in both CssBox.PaintImp and FragmentEmitter. Found and fixed one real positioning bug while wiring this up: a <tr> box's own Location is never assigned by the row loop (only its cells' is), so using the source header row's Location as a clone's positioning reference silently offset every repeat by however far that stale value happened to be from the row's true rendered top - fixed by referencing the row's first cell instead, caught by a precision test asserting the repeat lands exactly at its page's content top, not off by that stale offset. New tests: StageD4RepeatedHeaderTest.cs (precise inspection - text content matches, position is exactly flush at each continuation page's top, never repeated onto the table's own first page) and StageD4VerificationTest.cs (PdfSharp: a 60-row table with a header spans multiple real PDF pages without error). Full existing suite (35 image-diff/fragment tests + 13 PDF/PdfSharp tests) unaffected.
Replaces the old measure-once-then-scroll-offset-loop pagination
(while (scrollOffset > -container.ActualSize.Height) { AddPage();
scrollOffset -= pageSize.Height; PerformPaint(g); }) with
foreach (var fragmentainer in container.FragmentTree.Fragmentainers).
The fragment tree is now what actually drives real PDF output, not
just an internal structure nothing consumed yet - the first point in
this port where that's true.
Blank-page skipping (CSS Paged Media 3 3.2) falls out for free: a
content-empty page slot is never materialized as a fragmentainer (see
FragmentEmitter), so it's simply absent from this loop instead of
needing to be detected and special-cased.
HandleLinks no longer maps a link's document-Y to a page via a bare
pageSize.Height multiply/divide - slot indices aren't contiguous once
blank-page skipping is live. It now builds a slot-to-page-index map
from the materialized fragmentainers and tests each link's rectangle
against each fragmentainer's own Geometry band.
HtmlRenderer.PdfSharp gains InternalsVisibleTo access to the core
assembly (matching the existing WinForms/WPF grant) so it can reach
HtmlContainerInt.FragmentTree and the new PerformPaint(RGraphics,
FragmentainerFragment) overload - both stay internal rather than
becoming public API while this port is still underway.
Found and fixed a real bug in FragmentEmitter while wiring this up:
Finish() computed the last page slot from container.ActualSize.Height
as if it were an absolute document-Y coordinate, but ActualSize.Height
is document height *excluding* the root box's own top offset
(ActualSize.Height = ActualBottom - Root.Location.Y) - using it
directly double-subtracted MarginTop inside PageIndexOf and silently
under-reported the fragment tree's page count whenever content's true
bottom landed just past a boundary ActualSize.Height alone hadn't yet
crossed. This had been latent since D1/D2 (FragmentTree was structurally
present but never checked against real PDF page counts) and was only
caught here because F1 is the first place the fragment tree's own page
count actually has to be correct, not just non-empty.
New StageF1VerificationTest.cs: web/anchor links across pages don't
throw and produce link annotations, and a huge-margin document
produces no run of blank pages through the real pipeline. Full
existing suite (35 image-diff/fragment tests + 15 PDF/PdfSharp tests,
up from 13 now that the new pipeline exercises every prior stage's
page-count assertions for real) green.
HtmlContainerInt.PerformPaint(RGraphics g) - the overload every WinForms/WPF control (HtmlPanel, HtmlLabel, HtmlToolTip, HtmlControl, HtmlRender's image/metafile renderers) ultimately calls - now paints through FragmentPainter whenever the fragment tree has exactly one fragmentainer, which is every case that reaches this overload today: WinForms/WPF's continuous single-surface rendering has no real page grid, so FragmentEmitter.Finish always gives it one fragmentainer spanning the whole document (its no-real-page-grid path, built back in D1). A caller with a real multi-page grid that somehow reaches this overload instead of the fragmentainer-aware one PdfGenerator always uses falls back to the old CssBox.Paint walk, unchanged - not a case that exists in this codebase today, but a safe fallback rather than silently truncating to one page's content if it ever did. No new verification needed beyond what already exists: this is the same swap Stage E1 already proved pixel-identical via a temporary redirect (reverted after that stage's commit) across the entire regression suite, now made permanent. The full existing suite (35 image-diff/fragment tests + 15 PDF/PdfSharp tests) stays green with zero baseline changes, run directly against this as the real default path for the first time - not a temporary redirect. FragmentPainter is now the only paint implementation reached by any of the three platform projects (WinForms/WPF via this overload, PdfSharp via the fragmentainer-aware one from F1) for ordinary content. CssBox.Paint/PaintImp remain as the one documented fallback and are not yet retired - that's F3, once this has had time to prove itself.
CssBox.BreakPage and CssRect.BreakPage - the old modulo-arithmetic
"does this straddle a page, nudge it down" mechanism - are now fully
superseded: BlockFragmentation (D2), InlineFragmentation (D3), and
CssLayoutEngineTable's row-avoidance correction (D4) all replace their
call sites with page-grid-aware corrections. CssRect.BreakPage had
exactly one remaining caller (CssLayoutEngine.FlowBox's per-word
nudge, dead since D3 - a line box is monolithic, so lines move as a
whole via InlineFragmentation.ApplyLineBreaking, not word by word);
CssBox.BreakPage had none. Both deleted along with that call site.
This is a *narrower* cleanup than the plan's original F3 scope
("delete CssBox.Paint/PaintImp"), by design: FragmentPainter turns
out to still genuinely depend on CssBox.Paint/PaintImp, not just as a
temporary fallback - it delegates to them for CssBoxImage/CssBoxHr/
CssBoxFrame's own PaintImp overrides (E1's deliberate scope
reduction: real per-type content painters are follow-on work), and
the base PaintImp is what actually paints CssBox.ListItemBox and
CssBox.RepeatedHeaderRows (D4), neither of which override it. Deleting
CssBox.Paint/PaintImp now would break list markers and repeated table
headers, not just remove dead code - confirmed by grep before touching
anything: FragmentPainter.cs has three live call sites into it, plus
CssBox.PaintImp's own body still calls it for RepeatedHeaderRows.
Full existing suite (35 image-diff/fragment tests + 15 PDF/PdfSharp
tests) green - this change touches every paginated document's inline
flow, verified rather than assumed safe.
Ports PeachPDF's IFragmentContentPainter/FragmentContentPainters architecture: a stateless painter per replaced/leaf box type, dispatched by FragmentPainter instead of delegating into CssBox.Paint. The actual per-pixel drawing logic stays on the CssBox subclasses (extracted from their PaintImp bodies into internal methods PaintImp itself now also calls, so there is exactly one implementation, not a parallel one) - this keeps the port a faithful re-shaping rather than a rewrite. CssBoxImage/CssBoxFrame gained an explicit EnsureImageLoadStarted/ EnsureVideoImageLoadStarted method: their lazy image-load trigger lives in PaintImp today and is the *primary* load trigger for the common async case (MeasureWordsSize only starts loading when AvoidAsyncImagesLoading/AvoidImagesLateLoading is set), so the new painters must replicate it or async images would never load. List item markers previously painted via a direct box.ListItemBox.Paint(g) call reading the live mutable tree; FragmentEmitter now builds a real BoxFragment for the marker (BoxFragment.MarkerFragment, kept separate from Children to preserve CssBox.PaintImp's paint-after-clip-pop timing, since an outside-position marker can legitimately hang outside the element's own overflow clip) and FragmentPainter paints it from there. CssBox.Paint/PaintImp are NOT deleted: HtmlRenderer.PdfSharp.HtmlContainer still exposes a public single-surface PerformPaint(XGraphics) overload that a caller can reach directly (bypassing PdfGenerator's per-fragmentainer loop) with a real multi-fragmentainer FragmentTree, which the fallback `_root.Paint(g)` branch in HtmlContainerInt.PerformPaint(RGraphics) still serves correctly. Deleting it would require new page-stacking transform logic this session doesn't have a tested replacement for - left as a real, live-verified deviation from the original plan's F3 assumption. Full regression suite (35 image-diff tests) and PDF test suite (15 tests) pass unchanged; all TFMs build clean across the whole solution.
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 (FragmentEmitter subtracts each band's top at build time), 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). Confirmed via a raw PDF content-stream inspection: every page after the first had zero text-draw (Tj) operators, since a fresh per-page XGraphics's origin is that page's own band top, not the document's. No existing test caught this because none checked page content beyond page count. The same absolute-vs-fragment-local mixup existed in this session's own image/frame content painters and in the overflow-clip helper. FragmentPainter now distinguishes the two coordinate spaces explicitly: FragmentLocalOffset for geometry already sourced from the fragment tree, LiveTreeOffset (and RenderUtils.ClipGraphicsByOverflow's new extraOffset parameter) for geometry read straight off the live CssBox tree, which additionally undoes the current fragmentainer's band top. Added a regression test that asserts every page of a genuinely multi-page PDF has real text operators, not just a page count. With that fixed, HtmlContainerInt.PerformPaint(RGraphics)'s multi- fragmentainer branch now paints every fragmentainer through FragmentPainter (translated back to its real document-Y band top), making CssBox.Paint/PaintImp and their three subclass overrides genuinely unreachable - confirmed via grep and deleted, along with CssBox's now-dead IsRectVisible helper. Full regression suite (35 image-diff tests) and PDF suite (16 tests, including the new one) pass; whole solution builds clean across all TFMs.
FragmentPainter painted backgrounds/borders/decoration from fragment.Lines (already fragment-tree-local) but text via CssBox.PaintWords, which iterated box.Words directly - live, absolute-document-Y CssRect.Rectangle - reconciled with LiveTreeOffset's band-top subtraction. BoxFragment.Words (TextFragment records) already existed, already correctly band-localized by FragmentEmitter, and was simply unused for the actual draw call. Split CssBox.PaintWords into CssBox.PaintWord(g, word, wordRect): a single-word primitive taking an already-final rect instead of computing one from live geometry plus an offset parameter. FragmentPainter now loops fragment.Words directly, offsetting each TextFragment.Rect by the same FragmentLocalOffset Lines already uses - no band-top reconciliation needed for text at all, since the geometry was never live to begin with. First stage (R0) of the plan to replace HTML-Renderer's local-correction fragmentation with a real resumable pass-loop matching PeachPDF's architecture. Pure paint-side change, no layout code touched. Full regression suite (35 pixel-diff tests, 16 PDF tests) passes with zero pixel differences.
Replaces the local-correction handling of forced break-before/after:page with a genuine multi-pass driver loop, the foundation stage of the plan to match PeachPDF's resumable fragmentation architecture instead of this port's single-pass-plus-OffsetTop-shift model. HtmlContainerInt.PerformLayout gains DriveLayoutPasses: when the container has a real page grid, it repeatedly calls CssBox.PerformLayout on the root, resuming from wherever the previous pass left off (root.PendingBreakToken), until nothing is left pending. For a document with no forced breaks, or no real page grid (WinForms/WPF), this runs exactly once - behaviorally identical to the old single call. CssBox gains the actual resumption machinery: ResumeAt seeds a box's incoming BreakToken/top-override for the pass about to run; PendingBreakToken/RequestedBreakBeforeTop are how a break discovered arbitrarily deep in the tree reaches the driver - every block-child loop checks its own child's outcome immediately after the child's layout call returns, wraps it in a BlockBreakToken naming itself, and stops laying out further siblings this pass, so the signal bubbles up through call-stack unwind alone, matching PeachPDF's actual mechanism. A box whose forced break fires is not placed at all this pass (RectanglesReset/ MeasureWordsSize already ran, but no Location/content-layout work happens) - deferred whole to the pass that resumes at it. BlockFragmentation.ResolveBlockTop loses its forced-break branch (now handled by CssBox itself, before ResolveBlockTop is even reached); the decision logic moves to a new TryGetForcedBreakTarget, unchanged in substance from the old inline computation. Margin truncation and break-inside:avoid/monolithic relocation remain local single-pass corrections for now - later plan stages (R2-R4) replace those too. New tests exercise the loop across multiple passes specifically (two forced breaks in sequence, and 50 in sequence terminating promptly), which the existing single-break tests don't. Full regression suite (37 IntegrationTest + 16 PdfSharp tests, up from 35+16) passes unchanged - every existing forced-break test now runs through the new pass loop rather than the old inline computation, with identical output.
…lded in)
R2 ("overflow-driven block breaks") turned out to be a no-op given this
architecture: RelocateIfNeeded already does nothing for ordinary
(non-avoid, non-monolithic) content - it's left to split naturally via
the same recursive child-loop/InlineFragmentation math that already
produces correct positions, with no "does this fit" decision applicable
to a plain block container. There was no core case for R2 to convert.
Folding straight into R3, the first stage with a real behavior/code
change.
RelocateIfNeeded now relays the child out fresh at its target position
(CssBox.ResumeAt + a second PerformLayout call, within the same pass)
instead of OffsetTop-shifting its already-finished geometry. This is
strictly more correct, not just architecturally purer: a relaid-out
child's own descendants that have their own break-inside:avoid or a
nested forced break get to make that decision relative to the real page
boundaries at the NEW position, where a flat OffsetTop shift would have
carried whatever decision they made at the old one unchanged - possibly
wrong once the shift lands them against a different boundary. Keep-with-
next (the preceding-run shift) stays the older OffsetTop correction for
now; R4 converts that together with margin truncation.
Fixed a real ordering bug surfaced while touching this code: the child
loop called RelocateIfNeeded before checking whether the child's own
child loop had stopped mid-way (a nested forced break) - a child in
that state never reaches its own epilogue, so ActualBottom/Location only
reflect a partial pass, and RelocateIfNeeded's straddle test would have
read meaningless geometry. Reordered so a pending nested break is
checked and bubbled first; also re-checked after the relocation relayout
itself, since that relayout can surface its own nested break. Extracted
the repeated bubble-and-stop logic into CssBox.BubbleChildPendingToken.
New test: a scroll-container (overflow:hidden) taller than one page
confirms it's left straddling the boundary in place, not moved (nowhere
to move it to would help) or looped. Full regression suite (38
IntegrationTest + 16 PdfSharp tests) passes unchanged.
Found while implementing this stage: keep-with-next never actually worked for the ordinary case. The old mechanism only ran as a side effect of RelocateIfNeeded moving a child that was ITSELF break-inside: avoid or monolithic - so it only ever fired when the box AFTER a break-after:avoid heading also happened to be avoid/monolithic. The common case (an unremarkable paragraph that simply doesn't fit after a keep-with-next-chained heading) never triggered it: the heading was left stranded alone at the bottom of its page while the paragraph moved on by itself. Confirmed via a calibrated reproduction: heading provably fit alone on page 0 in isolation, but adding the paragraph back left the heading on page 0 anyway with the paragraph alone on page 1. The existing KeepWithNext_HeadingStaysWithFollowingParagraph PDF test didn't catch this because it only asserts a page COUNT of 2, which is identical whether the pair moves together or splits - both outcomes total 2 pages either way. New tests (StageR4KeepWithNextTest) check the fragment tree directly for which page actually holds the heading, and would have failed against the old behavior. BlockFragmentation.EnforceKeepWithNext is the fix: checked unconditionally after a child finishes laying out (and after any R3 relocation), not only as R3's side effect - if a page break actually falls between a child and a preceding sibling chained to it by break-after/before:avoid, the whole chained run is pulled down to the child's page and the child is relaid out fresh. RelocateIfNeeded's own keep-with-next handling was removed as redundant: after it moves a child, the preceding sibling is exactly as stranded as in the ordinary case, and EnforceKeepWithNext (called right after in the same loop iteration) now covers both uniformly. Also corrected course on two of the plan's own framing details, both discovered only by implementing them: BlockFragmentation.cs is not being retired (margin truncation is pre-placement arithmetic with nowhere else to naturally live, same timing as the forced-break check); the "R2" stage was folded into R3 last commit since it had no distinct work of its own in this architecture. Full regression suite (40 IntegrationTest + 16 PdfSharp tests) passes.
Investigated the plan's R5 (inline resumption via line-index InlineBreakToken) and R6 (widows as a driver-level rewind) before building either. Conclusion: CreateLineBoxes already computes a whole paragraph's lines in one unbounded, side-effect-free, idempotent call - there is never a point where a LATER pass would reveal information the SAME-shot correction didn't already have, which is the entire reason PeachPDF's resumption/rewind machinery exists. Building BreakToken/pass machinery for inline flow would have been solving a problem this architecture doesn't have (same conclusion as R2's finding for ordinary block overflow). What the investigation found instead: a real, confirmed bug in the EXISTING same-shot algorithm. The old 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 in perfect page-boundary alignment (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. Confirmed via a paragraph spanning 60 pages: its final page ended with 1 line despite widows:3, and nothing corrected it. Rewrote ApplyLineBreaking as two phases: decide every break point from each line's own NATURAL (never-shifted) position (immune to the alignment blind spot, and lets widows cascade backward across more than one earlier break by removing entries from a decided break list, rather than needing to undo a shift already applied to specific lines), then apply the decided breaks as shifts in one separate pass. Also handles a case the old code never covered either: a box's own first line not fitting the room left on its starting page. Fixing this surfaced a second real bug in R4's own EnforceKeepWithNext: it read CssBox.Location.Y to determine which page an already-laid-out box's content starts on, but for an inline-only box, Location is committed once before content layout runs and InlineFragmentation never updates it - even though it can move the box's one-and-only line to an entirely different page. A single-line heading whose own line got pushed to the next page still reported its OLD page via Location.Y, so keep-with-next silently compared against stale geometry. Added CssBox.EffectiveTop (the first line's actual top for inline-only boxes, Location.Y otherwise) and switched both RelocateIfNeeded and EnforceKeepWithNext to use it. New tests confirm both the fixable case (a long paragraph correctly pulls lines back across more than one earlier page to satisfy widows when room allows) and the honest unsatisfiable case (widows is left unsatisfied rather than forcing an overflowing page, with word-count conservation and per-page height checked directly). StageR4KeepWithNextTest was also made self-calibrating (searches for the exact boundary filler count rather than a hardcoded one) after the InlineFragmentation rewrite shifted where that boundary falls by one - a hardcoded magic number turned out to be fragile to unrelated, still-correct changes. Full regression suite (41 IntegrationTest + 16 PdfSharp tests) passes.
…layout Found while investigating the plan's R7 stage (table resumption), before writing any new table code: CssLayoutEngineTable's row loop calls cell.PerformLayout directly and does not participate in the PendingBreakToken bubbling protocol an ordinary block-child loop does - a table row is not itself laid out via that loop, so nothing ever reads a cell's own PendingBreakToken and turns it into a real pass boundary. Before R1, a forced break inside a table cell just computed an adjusted top inline, in the same single continuous pass everything else used - harmless. After R1, a forced break anywhere (including inside a table cell) requests deferral to a later pass and returns from PerformLayoutImp without calling CreateLineBoxes - but MeasureWordsSize already ran unconditionally before that point, so the deferred content's words had real sizes but stale/default (0,0) positions. Confirmed by direct fragment-tree inspection: the content wasn't lost, it silently rendered overlapping whatever else was in the cell, with no new page ever created for it. Fix: CssBox.CanDeferToLaterPass() walks a box's own ancestor chain for a table-cell boundary; a forced break found there falls back to immediate same-pass placement (the pre-R1 behavior) instead of deferring, since deferring here could never actually be resumed. Not full parity (this content doesn't get its own fresh fragmentainer pass the way top-level content does), but correct rather than silently corrupted - matching this port's established pattern of local correction where true resumption isn't wired up yet. R3 (avoid/monolithic relocation) and R4 (keep-with-next) are unaffected - both relayout within the same pass rather than deferring across passes, so they never depended on the block-child-loop bubbling chain reaching past a table cell boundary in the first place. New regression test constructs the exact scenario and verifies both markers are present and correctly ordered by ABSOLUTE document-Y (reconstructed from each fragmentainer's own band top, since raw fragment-local Y values aren't comparable across different pages). Full regression suite (42 IntegrationTest + 16 PdfSharp tests) passes.
…tion documented Investigated before writing any new table-fragmentation code (same approach as R2/R5/R6): table cells route their own content through the same CssBox.PerformLayoutImp/CreateLineBoxes/ApplyLineBreaking machinery as any other box, so a cell's own paragraphs already correctly benefit from every R1-R6 fix for free. Confirmed via direct testing: a table cell whose own content spans several pages by itself preserves all content correctly, and subsequent rows correctly continue after it - no TableBreakToken/TableRowCursor machinery needed for this, matching the R2/R5/R6 pattern of this architecture rarely needing what it looks like it needs at first glance. The same investigation found one real, confirmed remaining gap: CssLayoutEngineTable.LayoutCells's repeated-<thead> check runs once per ROW (checking the layout cursor's page slot only at that row's own start) - so a row whose own cell 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. Not data loss or a crash, just a missing header repeat on some pages of a fairly exotic table shape (one cell vastly longer than its siblings, in a table with a repeating header). A real fix needs to know how many pages a row spans before deciding how much room to reserve for it, which requires relaying the row out a second time once its true span is known - tractable, but deliberately left as a documented, out-of-scope limitation given how rare the shape is versus the far more common case (many ordinary rows, table spans many pages), which already repeats correctly per the existing ThreadRepeatsOnEveryPageTheTableSpans test. New test confirms the working case (no data loss for a multi-page- spanning cell); the known-limitation comment lives directly beside the code it describes rather than a test asserting broken behavior as correct. Full regression suite (43 IntegrationTest + 16 PdfSharp tests) passes.
…nd needed Investigated the plan's highest-risk stage before implementing PassRewind/ depth-limited-lookback machinery. Only forced breaks create genuine cross-pass boundaries in DriveLayoutPasses (overflow and break-inside:avoid are same-pass local corrections per R2/R3), and FragmentEmitter runs once at the very end - so nothing is ever truly "frozen" mid-layout the way PeachPDF's per-pass EmitPass makes it. Confirmed empirically: a keep-with-next pair placed immediately after resuming from an unrelated forced break still lands together, handled by the existing same-pass EnforceKeepWithNext (R4). Same finding pattern as R2/R5/R6: the machinery this stage describes solves a problem specific to PeachPDF's real multi-pass-for-everything architecture, which this port's local-correction design doesn't have.
…one page EnforceKeepWithNext previously pulled the WHOLE preceding break-after:avoid chain to a child's page unconditionally, without checking whether the run fit there. For a long chain taller than one page, each subsequent chained sibling's own keep-with-next check re-fired against the now-stretched-out run, compounding OffsetTop shifts on the same earlier boxes without bound - found via a targeted stress test (60-member chain), reaching a box position around 8.6e11 and producing zero fragmentainers (FragmentEmitter couldn't bucket geometry that far out of range). Implements css-break-3 section 4.3's actual staged relaxation: trim the run from its front until what remains fits alongside the child on the target page, or leave the run in place entirely (RunDropped) if even its last member doesn't fit. Matches the BreakRelaxation enum's documented but previously unused RunTrimmed/RunDropped cases.
InlineBreakToken, its FanOutContinuations base member, and the BreakRelaxation enum were ported early on for machinery this port's architecture turned out not to need (R2/R5/R6/R9 all found the local- correction model handles their cases without real cross-pass tokens - see each stage's commit). None had a single caller anywhere in the codebase. BlockBreakToken is the only token kind this port actually uses; the doc comments now say so directly instead of pointing at unused alternatives.
InlineFragmentation.ApplyLineBreaking's orphans merge-back correction only ever ran once at least one earlier break already existed (breaks.Count > 1), which can never be true while still deciding the first run - so a paragraph starting close enough to a page's bottom that fewer than `orphans` lines fit there was left with a too-small stranded first fragment. Confirmed by temporarily reverting the fix: it reliably reproduced a 1-line first page against orphans:2 at several filler counts. Generalizes the existing "first line taller than the remaining room" push (now folded into the same check, since 0 fitting lines is just the orphans violation that can never be waived) - if fewer than `orphans` lines fit in the room remaining on the starting page, the whole paragraph now moves to start fresh on the next page instead.
…row-shift CssLayoutEngineTable.LayoutCells's row-shift correction did `foreach (cell in row.Boxes) cell.OffsetTop(delta)` - but for a row that is the END of a rowspan, row.Boxes holds only the CssSpacingBox placeholder (Display:none, no children/words/rectangles), not the real spanning cell (ExtendedBox). OffsetTop on the placeholder was a silent no-op, leaving the spanning cell's real bottom edge stale while the rest of the row moved to the next page. Confirmed by temporarily reverting the fix: it reliably reproduced the spanning cell's bottom lagging behind its sibling's. Fix extends the spanning cell's ActualBottom (bottom edge only) rather than OffsetTop-ing its whole subtree: its top and content are already anchored to whichever earlier row it started in and shouldn't move, only its bottom edge needs to extend to cover the gap the row-shift just opened up.
css-break-3 3.1's break-point propagation was only ever applied to forced breaks (TryGetForcedBreakTarget's own "no previous sibling" check), never to RelocateIfNeeded's relocation, EnforceKeepWithNext's run-pull, or InlineFragmentation's own orphans-driven whole-box push. A box moved by any of these while it's its parent's first in-flow child left the parent spanning from its original page to the moved content's new one - its own background/border rendered as a stub-then-continuation (e.g. a card/panel div wrapping a single table, or a section wrapping a heading+paragraph pair). Confirmed via three separate reproductions, each failing without the fix and passing with it. New BlockFragmentation.PropagateContainerRelocation(movedBox, delta): climbs the first-in-flow-child chain, shifting each such ancestor's own top by the same delta. Deliberately touches only Location, never ActualBottom - a container's bottom is already correctly, independently computed from its last child via ordinary block flow, so no "does the whole group move together" bookkeeping is needed, unlike an earlier, more complex version of this fix that tried (and got wrong) recomputing both edges from a moved group's combined extent. Investigating the EnforceKeepWithNext case surfaced a second, more fundamental bug along the way: CssBox.OffsetTop kept the box's own Rectangles dictionary in sync with a shift but never the corresponding CssLineBox.Rectangles entry (a separate dictionary, keyed the other way, that LineTop/LineBottom - and therefore EffectiveTop for any inline-only box - read from). Location.Y was correctly updated while EffectiveTop silently kept reporting the pre-shift position. Fixed by having OffsetTop update both sides together, matching what CssLineBox.ShiftLine already does when a line-level shift initiates the move instead.
A third audit pass raised a plausible concern: does PropagateContainerRelocation's raw Location reassignment leave a list-item's marker stale, the way it would without CssBox.OffsetTop's explicit marker handling? Investigated empirically with a diagnostic test (with and without an explicit marker shift) - no difference. CreateListItemBox recomputes the marker's position from its owner's current Location unconditionally on every PerformLayoutImp call, and every ancestor this method climbs is still mid-PerformLayoutImp when it runs, so the marker always re-derives correctly afterward. Recorded as an investigated non-issue rather than adding redundant handling.
Verified directly against the current W3C Editor's Draft (drafts.csswg.org/css-tables-3/#breaking-rules): "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" - a required UA default, not something an author opts into. The table's row-shift previously only fired when the table itself had explicit break-inside:avoid, meaning an ordinary multi-page table with no special markup rendered rows split across page boundaries by default - not spec-compliant. CssLayoutEngineTable.LayoutCells now attempts to preserve every row by default, with the spec's two carve-outs implemented as "freely fragmentable" exceptions: a row a cell only starts spanning into a later row (new RowHasCellSpanningIntoSubsequentRow helper), or a row taller than half the page's height or width. The table's own break-inside:avoid still forces the attempt even for an otherwise-freely-fragmentable row, preserving existing behavior for that explicit case.
Verified against the actual W3C spec text (css-position-3): "in paged media, the page area of each page; fixed positioned boxes are thus replicated on every page", and UAs "must not paginate the content of fixed-positioned boxes". Scoped to top/left-anchored fixed content only (a page header/watermark) - bottom/right are a separate, pre-existing gap: neither property is parsed for absolute/fixed positioning at all, so a bottom-anchored footer, the more common real print pattern, needs that fixed first. FragmentEmitter now collects every position:fixed box in the tree (CollectFixedRoots, handling arbitrary nesting depth) and, for each materialized page, builds a fresh fragment for it against a page-local band (top=0) rather than the page's real band top - a fixed box's own Location is already page-relative (CssBox.PerformLayoutImp's Position==Fixed branch never routes it through normal absolute-Y-computing flow at all), so this reuses the same geometry unchanged on every page. Excluded from the normal per-page walk to avoid a duplicate/misplaced render on whichever page its raw offset would otherwise land on. InlineFragmentation.ApplyLineBreaking now also skips fixed boxes outright - their content must not paginate. Confirming this through actual PDF output (not just the fragment tree) surfaced a second, real, pre-existing bug: FragmentPainter.LiveTreeOffset/ LiveTreeExtraOffset unconditionally undid the current page's band top from live-tree geometry, on the documented assumption that "band membership is orthogonal to scroll-offset suppression" for fixed content. That assumption was true when fixed content only ever appeared on one page (wherever its raw offset landed) but breaks now that it's intentionally repeated: a fixed box's live geometry is already page-relative, so subtracting a nonzero band top pushes its containing-block visibility/overflow-clip check far outside every page except the one whose band top happens to equal its own small offset - confirmed via a real generated PDF, where the header only rendered on page 0. Fixed by making both offsets skip the band-top term entirely for fixed (or fixed-ancestor) content.
6 tests failed on Linux/macOS CI (document.Pages.Count == 1 where 2+ was
expected). Root cause: these tests rely on the UA default font-family
("Times New Roman") without specifying it explicitly, and a precisely
calibrated filler-paragraph count (e.g. "exactly 48 paragraphs leaves just
enough room") to land right at a page boundary. Windows has real Times New
Roman installed; non-Windows CI runners don't, and PdfSharp's FontResolver
falls back to an embedded substitute with different metrics, so the same
filler count no longer straddles a page.
Investigated setting an explicit font-family (Liberation Serif, metrically
compatible with Times New Roman) directly on these tests first, since that's
the more surgical fix - but it introduced an unexplained regression even on
Windows (an explicit font-family: 'Times New Roman' - the exact same value
already in effect by default - somehow changed pagination behavior on its
own, confirmed via a throwaway diagnostic). Given the underlying cause isn't
understood well enough to trust it, reverted that approach rather than ship
a change with an unexplained side effect.
Fixed by increasing filler content to a generous, non-precisely-calibrated
margin instead (safe regardless of exactly which font resolves), and
loosening the one exact-equality assertion (KeepWithNext_HeadingStaysWithFollowingParagraph)
to the same >= pattern its sibling tests already use, since exact page-count
equality can't tolerate any content-volume safety margin. These tests are
already documented as regression-style guards, not precise verification -
precise per-page fragment-tree-level checks for the same features already
exist in HtmlRenderer.IntegrationTest, added earlier this session.
…ted CssBox.Paint PR ArthurHub#262's PaintHarness.PaintBox (merged into master while this branch was in flight) called box.Paint(g) directly - the old live-tree paint entry point. This branch's own F3 stage later deleted CssBox.Paint/PaintImp entirely once FragmentPainter became the sole paint path, so rebasing this branch onto master (which now carries PaintHarness) fails to compile: git's line-based merge can't catch an API a fresh commit calls having been removed by an earlier one in this branch's own history. Fix: PaintHarness.PaintBox now locates the CssBox's own BoxFragment in container.FragmentTree (searching every fragmentainer, in case the box was relocated onto a later page) and paints it via a new FragmentPainter test entry point, FragmentPainter.PaintFragmentSubtree - a thin wrapper that sets the painter's band-top before delegating to its existing private PaintFragment, letting a test get "just this box's draw calls" without duplicating the real paint walk. This is the same widen-for-test-use precedent already applied to CssBox.PaintBackground/PaintWord/PaintDecoration. Full three-project suite passes after the rebase: HtmlRenderer.Test (2367), HtmlRenderer.IntegrationTest (146), HtmlRenderer.PdfSharp.Test (30) - 0 failed.
Ports PeachPDF.Tests/Html/Core/Fragmentation/{BreakTokenTests,BreakValuesTests,
MonolithicContentTests,ForcedBreakTargetIsTheFramesTests}.cs,
Html/Core/HtmlContainerIntPaginationTests.cs, and Html/Core/Dom/
CssLayoutEngineTablePageBreakTests.cs into a new HtmlRenderer.Test/Fragmentation/
folder plus HtmlRenderer.Test/Dom/, using the adapter-free MockAdapter+LayoutHarness
pattern (extended with an opt-in pageHeight/margin parameter, defaulting to the old
no-page-grid behavior so every pre-existing caller is unaffected). Also cherry-picks
TableLayout_DetectsPageBreaksCorrectly/TableLayout_PositionsHeadersAtCorrectPageStarts
into the existing Dom/CssLayoutEngineTableTests.cs.
Dropped per the port plan's stated scope: directional/column break values and
PageRuleResolver (BreakValuesTests - this port's BreakValues has no column context
or directional matching at all), the 2 named-page-transition tests in
ForcedBreakTargetIsTheFramesTests, the 2 RepeatedTfoot_* tests, and 7
PageBreakBottoms_* plus 4 paint-level tests in CssLayoutEngineTablePageBreakTests
(no PageBreakBottoms/CollapsedBorderSegments exist in this fork, and paint
verification is out of this adapter-free batch's scope). The table page-break file
needed a real rewrite, not a rename, per the plan - it's now black-box geometry
assertions against real CssBox/HtmlContainerInt state, plus new tests exercising
the actual repeated-header machinery (TableHeaderRepeat.CloneAndPosition,
CssBox.RepeatedHeaderRows) in place of PeachPDF's unportable border-resolution
assertions.
Six tests are [Ignore]d with specific, empirically-verified reasons: no
cross-ancestor break-point propagation for a first-in-flow child's break-before,
no second-rule handling for two consecutive forced breaks landing on the same
slot, no margin preservation at a forced-break target, two content-empty-slot-
skipping tests (FragmentEmitter.HasContentInBand is satisfied by the document
root's own bounds before ever checking the band for real content, so it never
skips anything), and a newly-found rounding gap where a border-collapse table's
own top can land fractionally before a page boundary, seeding a spurious
repeated header via a page-index computation flooring to slot -1.
HtmlRenderer.Test: 2426 passed (+59) / 129 skipped (+6) / 0 failed.
HtmlRenderer.IntegrationTest: 146 passed / 74 skipped / 0 failed (unchanged).
HtmlRenderer.PdfSharp.Test: 30 passed / 0 failed (unchanged).
… (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).
Ports PeachPDF.Tests/Integration/{StraddlingLineClaimTests,UnreachedWordClaimTests,
StraddlingListMarkerTests,BlockContentListMarkerTests}.cs into
HtmlRenderer.IntegrationTest/Fragmentation/ (real WinForms.HtmlContainer, same
reflect-out-HtmlContainerInt pattern as Batch 2) and
{FragmentPaintIntegrationTests,FragmentContentPainterTests,
GhostTextOnPreviousPageIntegrationTests}.cs into
HtmlRenderer.IntegrationTest/Painting/ (PaintHarness, extended with a LayoutPaginated
multi-page overload and a PaintPage helper that exercises the same per-page entry
point PdfGenerator itself uses, HtmlContainerInt.PerformPaint(RGraphics,
FragmentainerFragment)).
Marker tests are a real architectural adaptation, not a rename: HTML-Renderer's
outside ::marker is CssBox.ListItemBox, a field kept entirely separate from Boxes
and recomputed unconditionally from the item's own current Location on every
PerformLayoutImp call - so PeachPDF's root defects for these two files (marker
positioned by a stale per-pass epilogue; marker re-parented into an item's
anonymous inline-run block, hiding it from a direct-children-only scan) do not
exist here by construction. The invariant they protect is still worth pinning:
- StraddlingListMarkerTests: 5 of 8 ported (3 dropped - column-count/
column-fill:balance tests require a multi-column engine this fork doesn't have).
- BlockContentListMarkerTests: 2 of 9 ported (only the fragment-claiming half, per
the plan; the other 7 are general list-layout correctness the marker's-not-in-
Boxes architecture already makes moot, or require multicol).
- StraddlingLineClaimTests: 1 of 3 (the oversized-word case; dropped
ContentAfterAWordTallerThanTheBand's CursorSpills, PeachPDF-only per-pass cursor
concept, and the flex/grid Theory - neither engine exists here).
- UnreachedWordClaimTests: 3 of 3, Theory trimmed from 6 to 5 rows (dropped
column-count).
BandMembershipToleranceTests is dropped outright rather than Ignored: HTML-Renderer
has no PageBoundaryEpsilon/SlotStartingAt analog, and more fundamentally,
InlineFragmentation.ApplyLineBreaking's break decisions and FragmentEmitter's own
band-overlap test both derive from the same PageTopOf/PageBottomOf arithmetic via
one uniform per-run shift, not two independently-rounded fitting tests - so there
is no separate computation left for the emitter to disagree with, and no fixture
can be built to land in a disagreement window that doesn't exist.
GhostTextOnPreviousPageIntegrationTests's sibling PageMarginPixelsPerPointIntegrationTests
(3 tests) is also dropped: HTML-Renderer's @page has no margin cascade into
HtmlContainerInt.MarginTop at all (confirmed by grepping Core/ for PixelsPerPoint -
no matches - and for any @page-margin consumer; MarginTop/Bottom/Left/Right are
set only by the hosting application). GhostTextOnPreviousPageIntegrationTests'
own 2 tests still port, as a regression pin of the same observable invariant on
this port's different (fragment-tree-driven, not live-tree-clip-driven) paint
architecture.
One test is Ignored, not fixed: StackingOrder_IsPreservedWhenPaintingFromFragments
- z-index is parsed and stored but never consulted by paint order anywhere in
Core/Paint/ (confirmed by grep), so FragmentPainter paints absolutely-positioned
siblings in DOM order regardless of z-index. A second Ignore,
AnItemDeferredBeforeItsContentWasEverFlowed_TravelsWholeWithItsMarker, hits a
confirmed, pre-existing, differently-documented gap:
BlockFragmentation.TryGetForcedBreakTarget suppresses a forced break entirely when
the box has no previous sibling (full css-break-3 3.1 ancestor propagation is out
of scope per that method's own doc comment) - here the break-before:page box is
its <li>'s only child, so the break never even reaches the <li> that does have one.
One real, confirmed production bug was found and fixed (not just documented) while
adapting EveryMarker_IsDrawnOnExactlyOnePage: HtmlContainerInt.PerformPaint(RGraphics,
FragmentainerFragment) - the per-page paint entry point PdfGenerator's own page
loop calls for every real multi-page PDF - pushed its paint clip starting at
Y=MarginTop rather than Y=0. Fragment-tree geometry is already band-local (a
band's own top is local Y=0, per FragmentEmitter's own doc comment), so this
silently clipped away the first MarginTop-tall strip of every single page's own
content, with no exception raised (a quiet visibility-cull no-op, not a thrown
error) - caught here by a list item landing entirely within that clipped strip
and never appearing in any page's paint log. Fixed at its source in
HtmlContainerInt.cs; see that method's own remarks for the full mechanism.
38 tests added (36 passing, 2 Ignored with cited evidence), 12 explicitly dropped
with documented reasons (9 architecturally inapplicable - multicol/flex/grid/
PeachPDF-only pass cursor/@page margin cascade - plus 3 duplicated by the
BandMembershipToleranceTests removal).
Full suite: HtmlRenderer.Test 2426 passed/129 skipped/0 failed (unchanged),
HtmlRenderer.IntegrationTest 275 passed/95 skipped/0 failed (was 246/93/0),
HtmlRenderer.PdfSharp.Test 30 passed/0 skipped/0 failed (unchanged).
Ports PeachPDF.Tests/Integration/{RepeatedTableHeaderClipIntegrationTests,
RepeatingTableRelayoutTests,WholeTableRelocationTests,TableRowspanContinuationTests,
TableSpannedBandRepetitionTests,TableRepeatedGroupConditionsTests,
TableRowBreakValueTests,PageBreakTableKeepWithNextIntegrationTests,
BreakValueCascadeTests,StructuralCloneBreakValueBehaviourTests}.cs into
Source/Test/HtmlRenderer.IntegrationTest/Tables/, and revises the existing
PageBreakTableIntegrationTests.cs (an earlier ArthurHub#262 port, written before this
branch's own css-tables-3 6.1 row-preservation-by-default landed in 362dee9) to
match that table's now-current mechanics.
Two real, confirmed production bugs were found and fixed, not just documented:
1. CssBoxProperties.InheritStyle's "everything: true" branch (structural-clone
copying) never copied break-before/break-after/break-inside, so both of its
real callers - TableHeaderRepeat.CloneSubtree's repeated <thead> row clones,
and DomParser.CorrectBlockSplitBadBox's block-in-inline split - silently
produced auto/auto/auto clones regardless of what the source element
declared. Fixed by adding the three fields to that branch.
2. CssLayoutEngineTable.LayoutCells's repeated-header loop seeded its own
lastRepeatSlot from PageIndexOf(starty) - and for a border-collapse:collapse
table (GetVerticalSpacing() is -1, a deliberate one-pixel row/border overlap)
sitting flush at a page's own content top, starty lands one pixel below
PageIndexOf's slot boundary, flooring into the slot BEFORE the one the table
actually starts in. This made the loop see a spurious "transition" at the
very first body row, painting the header twice on the table's own first page
(confirmed empirically: two HEADERMARKER draws at nearly the same position)
while, in a combined effect, one repeat later in the document went missing.
Fixed with a new PageSlotOf helper that clamps to CssBox.ClientTop (immune to
the collapsed-border overlap) - scoped to this loop alone, not the row-shift
straddle check a few lines below, which reacts to the same misread with a
harmless 1px nudge two existing tests depend on (see PageSlotOf's own
remarks). CssLayoutEngineTablePageBreakTests.RepeatedThead_SinglePageBorder
CollapseTable_PhantomHeaderRepeatDueToNegativeSlotRounding, Ignored since
Batch 1 with this exact symptom already documented, is un-Ignored - it now
passes.
Ported/revised, with test counts:
- RepeatedTableHeaderClipIntegrationTests (3/3 port) - a repeated header's
overflow:hidden clip resolves correctly per-page by construction here (each
repeat is an independent, already-positioned CssBox clone, not PeachPDF's
shared-subtree-plus-proxy architecture); regression pins, not bug repros.
- RepeatingTableRelayoutTests (2/2 port, 4 cases) - a relocated card holding a
repeating-header table is genuinely relaid out, not translated; the table's
own RepeatedHeaderRows-reset-per-pass makes PeachPDF's excluding bug moot here.
- WholeTableRelocationTests (7/7 port) - adapted off PerformLayoutEpilogue onto
the real trigger, BlockFragmentation.RelocateIfNeeded, called from the block
child loop after a table finishes its own layout.
- TableRowspanContinuationTests (6 ported + 1 Ignored, down from 17) - PeachPDF's
TableRowCursor/PageBreakBottoms/ShellIn/box-decoration-break-edge machinery has
no counterpart; ported what's real here instead (ActualBottom extension on a
rowspan cell whose ending row is shifted, deterministically). Also surfaces an
unrelated, pre-existing InsertEmptyBoxes gap (a rowspan in a column past the
ending row's own last existing cell gets no placeholder there at all, matching
PeachPDF's own issue #522, not fixed here) - fixtures route around it.
- TableSpannedBandRepetitionTests (4 ported, down from 13, tfoot half dropped) -
BoxFragment.OverflowClip is always null here (no room-reservation/slicing
mechanism exists), so PeachPDF's strip/confinement assertions have nothing to
port onto; pins the real, already-documented "only the first band a lone tall
row overflows onto gets a repeat" limitation instead.
- TableRepeatedGroupConditionsTests (5 ported, down from 16, tfoot half and the
print-media-only UA-stylesheet tests dropped) - the quarter-of-page-height cap
(css-tables-3 6.2's second condition) is confirmed unimplemented; pinned as an
inverted, real test rather than force-fit.
- TableRowBreakValueTests (1 real + 4 Ignored) - CssLayoutEngineTable.cs never
reads BreakBefore/BreakAfter anywhere; forced row breaks don't exist.
- PageBreakTableIntegrationTests (revised in place) - un-Ignored
SingleRowTable_CrossingPageBoundary_IsMovedToNextPage with a corrected
assertion (row preservation shifts the straddling row's cells, never the
table's own outer Location - the original assertion checked the wrong
property); corrected the other two Ignore reasons to cite the real, current
gaps (freely-fragmentable-via-width carve-out; EnforceKeepWithNext reading the
table's own EffectiveTop, untouched by an internal row-shift) instead of a
BreakPage() method that no longer exists anywhere in the source.
- PageBreakTableKeepWithNextIntegrationTests (3 real + 2 Ignored, dropped the
3-way composition test) - PeachPDF's "Gap 1" does not reproduce here at all
(BlockFragmentation.EnforceKeepWithNext is unconditional, not table-specific);
"Gap 2"'s shape does, for a different, more fundamental reason (row
preservation moves cells, never the table's own box - same fact as above).
- BreakValueCascadeTests (9/9 port, adapted to hold break-inside:avoid fixed on
the source and vary before/after, since the print-media UA default doesn't
apply under this harness) and StructuralCloneBreakValueBehaviourTests (5/5
port) - characterize the InheritStyle fix's own real effect: correctly
carried now, but still inert for both clone sites (detached header clones
never reached by BlockFragmentation; the block-in-inline split's anonymous
wrapper still breaks the sibling chain a following box would read).
Full suite: HtmlRenderer.Test 2427 passed/128 skipped/0 failed (was 2426/129 -
one previously-Ignored test now passes), HtmlRenderer.IntegrationTest 323
passed/101 skipped/0 failed (was 275/95), HtmlRenderer.PdfSharp.Test 30
passed/0 skipped/0 failed (unchanged). Clean build across net8.0/
netstandard2.0/net462, 1 pre-existing warning (unrelated nullable-dereference).
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.
…t merge Confirmed while porting OrphansWidowsIntegrationTests.cs (Batch 2, commit d8b2587): ApplyLineBreaking's widows correction could only fully merge two runs into one (breaks.RemoveAt), never shift the break point earlier by a partial number of lines, and never fell back to pushing a run whole onto a FRESH page when an in-place merge didn't fit the current page's remaining room. A 4-line paragraph with widows:2, naturally breaking 3-before/1-after, needed only a 1-line shift (2-before/2-after) to satisfy widows - the old code tried merging all 4 lines onto the current page, found they didn't fit, and gave up, leaving the violation. Rewrote the widows loop to try, in order: (1) shift the break earlier by the minimum number of lines that satisfies widows without leaving the earlier run below its own orphans minimum - always valid at a full page height, since a shifted-but-still-separate run starts fresh at a page top regardless of exactly where the break falls; (2) if no shift works, cascade the existing full-merge (now against a full page height for any run but run 0, same as before); (3) for a merge specifically into run 0, if it doesn't fit run 0's own natural (tighter) capacity but WOULD fit a full page, apply the same fresh-page boost firstRunMovedToFreshPage already gives orphans on the first run. Only when none of these work does it decline gracefully, per css-break-3 4.3. Also fixes a related gap in firstRunMovedToFreshPage: it never checked whether the run was already flush at a fresh page's own top before pushing it to ANOTHER one - for an unsatisfiable orphans minimum after a forced break, this silently walked the box one page past where the break named, leaving that page blank. Added an alreadyAtFreshPageTop guard, applied consistently to both the pre-loop check and phase 1's own runStart==0 back-off branch (missing it from the latter initially caused a real regression in Orphans2_NothingAboveItInTheFragmentainer_IsLeftWhereItIs, caught by running the full suite before committing). Real discovery made while verifying empirically (per this project's own established practice of testing hand-traced fixes against actual runtime behavior): OrphansWidowsIntegrationTests.cs's <br>-joined Paragraph() helper never exercised this code at all. This fork's <br> handling (DomParser.CorrectLineBreaksBlocks) only folds a <br> into a real forced- newline when it's the LAST thing in its inline run; a <br> with more content after it is left as a literal box, and the surrounding text gets split into SEPARATE anonymous BLOCK siblings (confirmed by direct box-tree inspection: 7 children, alternating 1-line blocks and inline <br> boxes, the <p> itself with zero LineBoxes of its own) - so each "line" was really an independent 1-line sibling block, never touching ApplyLineBreaking's multi-line logic. Rewrote Paragraph() to use natural word-wrapping (narrow width, space- separated distinct words) instead, which produces a real multi-line box and actually exercises the fix. Un-ignores 6 tests in OrphansWidowsIntegrationTests.cs (Widows2/3/4, both Orphans2 first-run variants, Widows2 on a second-page paragraph) and 1 in FragmentainerCursorIntegrationTests.cs (AForcedBreak_LandsOnThePageItNames). Full suite: HtmlRenderer.Test 2427 passed/128 skipped/0 failed (unchanged), HtmlRenderer.IntegrationTest 330 passed/94 skipped/0 failed (was 323/101), HtmlRenderer.PdfSharp.Test 39 passed/0 failed (unchanged).
…e instead Investigated the reported gap (OrphansProperty/WidowsProperty accepting 0 at the CSS-parse layer) before touching production code, per this project's own "verify before implementing" discipline. Tried the described fix - switching both properties from NaturalIntegerConverter/IntegerConverter to PositiveIntegerConverter - and it broke two existing, directly-ported-from- PeachPDF tests: Css/PropertyPaginationTests.cs's CssOrphansZeroLegal and CssWidowsZeroLegal (ported verbatim from PeachPDF.Tests/CSS/Property.cs) assert that PeachPDF's OWN Property.Value for "orphans:0"/"widows:0" IS "0", not a fallback - confirming this fork's raw parsing already matches its reference implementation correctly. CssBox.Orphans/Widows are thin wrappers over that same declared string, so returning "0" verbatim is correct, not a bug - it mirrors how a browser's CSS OM keeps an out-of-range "specified value" visible even though layout never uses it directly. The actual css-break-3 constraint (>=1) is enforced exactly once, where it belongs: ActualOrphans/ActualWidows already treat any non-positive parse as unset and fall back to the initial value of 2 - already correct, already covered by every other test in this file that reads those accessors instead of the raw string. Reverted the converter change; rewrote the test itself (Orphans_ZeroResolvesToDefault_ThoughTheDeclaredStringStaysZero, Widows_ZeroResolvesToDefault_ThoughTheDeclaredStringStaysZero, Widows_NegativeResolvesToDefault) to assert BOTH facts explicitly - the declared string stays "0"/"-1", and ActualOrphans/ActualWidows still resolve to 2 - so this distinction stays pinned rather than re-litigated. Full suite: HtmlRenderer.Test 2427 passed/128 skipped/0 failed (unchanged, confirms the revert restored the two PropertyPaginationTests), HtmlRenderer.IntegrationTest 352 passed/94 skipped/0 failed (was 330/94, +22 in this file, 0 net new skips - the file's own Ignore is fully gone now).
…oved RelocateIfNeeded's own fits-nowhere guard read 'if (height >= container.PageSize.Height) return;' - treating a box exactly as tall as one page's content band the same as one too tall for ANY page. A box exactly that tall genuinely does fit a page exactly when started flush at that page's own top, so it was being left in place (straddling a boundary) rather than relocated to the fresh page it would fit perfectly. Changed >= to >. Full suite: HtmlRenderer.Test 2427 passed/128 skipped/0 failed (unchanged), HtmlRenderer.IntegrationTest 334 passed/92 skipped/0 failed (+1 unignored, 0 regressions elsewhere - this guard is exercised throughout the existing break-inside:avoid/monolithic relocation suite), HtmlRenderer.PdfSharp.Test 39 passed/0 failed (unchanged).
Confirmed gap: BlockFragmentation.TryGetForcedBreakTarget's targetTop was always the raw page-content-top boundary - the box's own MarginTopCollapse was used only to decide WHICH slot the natural (unshifted) position falls in, never added back into the actual placement. css-break-3 5.2 preserves (does not truncate) a box's own margin specifically at a FORCED break - truncation only applies to an UNFORCED break where a margin alone happens to cross a page boundary (BlockFragmentation.ResolveBlockTop, unaffected by this change). Fixed at the two places breakTop actually becomes a box's placement (Dom/CssBox.cs): the immediate-placement path (top = breakTop) and the deferred-pass path (RequestedBreakBeforeTop, which flows through BlockBreakToken.ResumeTopOverride into _resumeTopOverride on the resuming pass) - not inside TryGetForcedBreakTarget itself, which stays a pure boundary value on purpose (its own slot/target output is asserted directly in ForcedBreakTargetIsTheFramesTests.cs and needs to keep meaning "the boundary", not "the boundary plus whatever margin happened to apply"). Un-ignores TargetIsTheBoundary_AndThePreservedMarginIsAddedToIt (HtmlRenderer.Test, ported in Batch 1) and ForcedBreak_MarginAfterBreak_IsPreservedNotTruncated (HtmlRenderer.IntegrationTest, ported in Batch 2). Full suite: HtmlRenderer.Test 2428 passed/127 skipped/0 failed (was 2427/128), HtmlRenderer.IntegrationTest 335 passed/91 skipped/0 failed (was 334/92), HtmlRenderer.PdfSharp.Test 39 passed/0 failed (unchanged).
Confirmed gap: CssLayoutEngineTable's css-tables-3 6.1 row-atomicity shift (LayoutCells) moves each straddling row's cells (cell.OffsetTop) but never touched the outer <table> box's own Location - so when the shifted row was the table's own first content (a single-row table straddling a boundary, or any table whose first row alone needed the shift), the table's visual top effectively moved but Location/EffectiveTop stayed stale at the original page. EnforceKeepWithNext(table) - called on a table exactly like any other block child - reads EffectiveTop to decide whether a break falls between the table and its previous sibling, so it never saw the crossing and could never pull an avoid-chained heading along with it. Fixed by updating _tableBox.Location when the shifting row's own cury equals starty (nothing rendered above it within the table yet - the row moving IS the table's content moving wholesale, not one of several independently-straddling rows further down a multi-page table). Deliberately does NOT call BlockFragmentation.PropagateContainerRelocation to climb further into the table's own ancestors, unlike every other relocation trigger in this file: this method can run more than once per overall document pass whenever an ancestor is independently relocated by RelocateIfNeeded (which re-lays the whole subtree out fresh at its own target) - climbing here too double-counted that ancestor's already-correct shift on top of RelocateIfNeeded's own. Confirmed by a real regression this caused on first attempt: BoxContainingARepeatingTable_IsStillRelocated (a table inside its own break-inside:avoid card) landed 2px past its correct relocated position - reverted the propagation call once the full suite caught it. A plain, non-avoid wrapper div around a table whose first row alone triggers this path is consequently not covered - narrower than full css-break-3 3.1 propagation, matching what the two tests this fixes actually exercise (the table itself as EnforceKeepWithNext's own child). Un-ignores TableMovedToNextPage_PullsAvoidChainedHeadingAlong and TableMovedToNextPage_ChainSkipsDisplayNoneSibling_PullsHeadingAndIntroAlong in KeepWithNextIntegrationTests.cs. Two other Ignored tests in the same file (HeaderFitsButNoBodyRowDoes_MovesWholeTableAndHeadingTogether, LongRepeatingHeaderTable_StartingNearPageBottom_...) cover a related but different <thead>-interaction gap this fix does not address, left as-is. Full suite: HtmlRenderer.Test 2428 passed/127 skipped/0 failed (unchanged), HtmlRenderer.IntegrationTest 337 passed/89 skipped/0 failed (was 335/91, +2 unignored here, 0 regressions elsewhere after reverting the propagation call), HtmlRenderer.PdfSharp.Test 39 passed/0 failed (unchanged).
FloatPropertyTests.cs claimed this fork had no clear CSS property at all, dropping every clear case from the PeachPDF source it was ported from - that predates ClearProperty being added and is now false, so restore the parsing coverage. Ports PeachPDF's FloatLayoutRegression- Tests.cs and the float/clear Acid2 regression cases as a new IntegrationTest suite; all pass unmodified, confirming float/clear layout parity with PeachPDF.
…/§10.3.7
position:relative previously had no layout effect anywhere in this
fork (parsed but never read); position:absolute only had ad-hoc
partial support (no real containing-block resolution, plain in-flow
placement otherwise); position:fixed resolved only against the page
and dropped the box's own margin; and `right`/`bottom` were parsed at
the CSS-OM level but never dispatched onto a box at all, so they had
zero effect under any positioning scheme.
Backports PeachPDF's CommitBlockChildOffset placement logic, adapted
to this fork's box places-itself (rather than parent-commits-child)
layout shape:
- Right/Bottom become real box properties, wired through CssUtils
the same way Left/Top already were.
- position:relative applies a near/far offset (left wins over right
when both are set, sign-flipped when only the far edge is) that is
purely visual per §9.4.3: RelativeOffsetX/Y record it separately so
the new StaticBottom can back it out again, and every sibling-
placement/margin-collapse/shrink-to-fit call site that used to read
a box's ActualBottom directly now reads StaticBottom instead, so a
relatively-positioned box's offset no longer drags its parent's
auto height or following siblings down with it.
- position:absolute resolves against DomUtils.GetNearestPositioned-
Ancestor's padding edge, on both axes anchoring from whichever of
the near/far offset is set (right-anchoring reads the box's own
already-resolved Size.Width; bottom-anchoring has to wait until
this box's own ApplyHeight has run, since auto height depends on
this box's own content, so it's corrected via a post-hoc OffsetTop
shift instead of resolved inline like every other case here).
Absolute boxes with width:auto also now shrink-to-fit their
content instead of filling the containing block, matching CSS 2.1
§10.3.7's common case (the full seven-case width-auto-resolution
algorithm is not implemented).
- position:fixed's offset is now computed once, in PerformLayoutImp
once margin/container are guaranteed ready, instead of eagerly from
the Left/Top property setters - which used to race ahead of
ActualMarginLeft/Top being resolved and cache a margin-less
Location that never got recomputed.
Porting PeachPDF's shrink-to-fit width tests also surfaced two real,
pre-existing bugs in GetMinMaxSumWords (a border/padding sum that
never reset between sibling "lines", and no explicit-width floor for
a childless block), both already fixed in PeachPDF - backported here
too, and re-approves one baseline PDF whose auto-width table column
render 1-2px differently now that the fix applies generally, not just
to the new absolute-positioning case that surfaced it.
Ports the CSS 2.1 §9.4.3/§10.3.7 Acid2 regression tests from PeachPDF
covering all of the above; all 9 pass.
…by-design test Ports PeachPDF's Acid2 z-index/stacking regression test (CSS 2.1 §9.9/Appendix E: a position:relative;z-index:2 box must paint over a later position:fixed sibling regardless of document order). Left [Ignore]d rather than implemented: FragmentPainter.cs already documents stacking-context paint order as deferred follow-on work, and there is no ZIndex box property or paint-order sorting anywhere in Core to hang a real implementation off of - this is a separate, larger feature port, not a one-line fix like the positioning gaps fixed in the previous commit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #263 - the diff below includes that PR's commits too until it merges. Only the last 3 commits (float/clear, positioning, z-index) are this PR's own.
Summary
Part 1 of a CSS 2.1 test-parity sweep against PeachPDF's Integration test suite. Scope: chapter 9 (visual formatting model) — floats/clear, positioning, z-index.
FloatPropertyTests.cs's doc comment claimed this fork had noclearproperty at all, dropping everyclearcase from the PeachPDF source it was ported from - that predatesClearPropertybeing added. Restored the parsing coverage, and ported PeachPDF'sFloatLayoutRegressionTests.cs+ the float/clear Acid2 cases as a newIntegrationTest/Layout/FloatLayoutIntegrationTests.cs(11 tests). All passed unmodified against this fork's existing float engine.position:relativehad zero layout effect anywhere in this fork (parsed, never read);position:absoluteonly had ad-hoc partial support;position:fixedignored the box's own margin;right/bottomwere parsed at the CSS-OM level but never dispatched onto a box under any positioning scheme. Backported PeachPDF'sCommitBlockChildOffsetplacement logic (adapted to this fork's box-places-itself layout shape) — see the second commit's message for the full breakdown, including two genuine pre-existing bugs inGetMinMaxSumWordsthat porting PeachPDF's shrink-to-fit-width tests surfaced and that PeachPDF had already fixed. Ported the CSS 2.1 §9.4.3/§10.3.7 Acid2 regression tests; all 9 pass.ZIndexbox property, no paint-order sorting anywhere inCore—FragmentPainter.csalready documents this as deferred follow-on work). Ported PeachPDF's Acid2 z-index regression test[Ignore]d with an accurate explanation, rather than building a separate, larger stacking-context feature into this PR.Test plan
HtmlRenderer.Testfull suite: 2432 passed, 0 failedHtmlRenderer.IntegrationTestfull suite: 357 passed, 0 failed (90 skipped, all pre-existing or the one new documented z-index gap)HtmlRenderer.PdfSharp.Testfull suite: 39 passed, 0 failedHtmlRenderer.sln) builds clean across net8.0, net8.0-windows, netstandard2.0, net462Tables.png) whose auto-width table column renders 1-2px differently now that theGetMinMaxSumWordsfix applies generally — reviewed the diff image, confirmed it's the intended correction, not a regression