diff --git a/Source/HtmlRenderer.PdfSharp/HtmlContainer.cs b/Source/HtmlRenderer.PdfSharp/HtmlContainer.cs index 6861a23eb..d6bcc1f65 100644 --- a/Source/HtmlRenderer.PdfSharp/HtmlContainer.cs +++ b/Source/HtmlRenderer.PdfSharp/HtmlContainer.cs @@ -349,6 +349,31 @@ public void PerformPaint(XGraphics g) } } + /// + /// The immutable fragment tree layout produced from the box tree on the last + /// call - one fragmentainer per real page the document spans. Null before the first layout. + /// + internal Core.Fragments.FragmentTree FragmentTree + { + get { return _htmlContainerInt.FragmentTree; } + } + + /// + /// Render one fragmentainer using the given device, reading from the immutable fragment tree + /// rather than walking the mutable box tree directly. + /// + /// the device to use to render + /// the fragmentainer to paint + internal void PerformPaint(XGraphics g, Core.Fragments.FragmentainerFragment fragmentainer) + { + ArgChecker.AssertArgNotNull(g, "g"); + + using (var ig = new GraphicsAdapter(g)) + { + _htmlContainerInt.PerformPaint(ig, fragmentainer); + } + } + public void Dispose() { _htmlContainerInt.Dispose(); diff --git a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs index a78ea5259..0389cab58 100644 --- a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs +++ b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs @@ -14,10 +14,12 @@ using PdfSharp.Drawing; using PdfSharp.Pdf; using System; +using System.Collections.Generic; using System.Threading.Tasks; using TheArtOfDev.HtmlRenderer.Adapters; using TheArtOfDev.HtmlRenderer.Core; using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Core.Fragments; using TheArtOfDev.HtmlRenderer.Core.Utils; using TheArtOfDev.HtmlRenderer.PdfSharp.Adapters; @@ -195,9 +197,14 @@ public static async Task AddPdfPages(PdfDocument document, string html, PdfGener container.PerformLayout(measure); } - // while there is un-rendered HTML, create another PDF page and render with proper offset for the next page - double scrollOffset = 0; - while (scrollOffset > -container.ActualSize.Height) + // One PDF page per fragmentainer the fragment tree actually materialized - a + // content-empty page slot (CSS Paged Media 3 3.2, e.g. a huge margin that would + // otherwise paginate through blank vertical space - see the margin-truncation + // correction in BlockFragmentation) is simply never in this list, which is what + // gives blank-page skipping for free here instead of the old ceil(height/pageHeight) + // loop's naive page count. + var tree = container.FragmentTree; + foreach (var fragmentainer in tree?.Fragmentainers ?? (IReadOnlyList)Array.Empty()) { var page = document.AddPage(); page.Height = XUnit.FromPoint(orgPageSize.Height); @@ -206,17 +213,14 @@ public static async Task AddPdfPages(PdfDocument document, string html, PdfGener using (var g = XGraphics.FromPdfPage(page)) { - //g.IntersectClip(new XRect(config.MarginLeft, config.MarginTop, pageSize.Width, pageSize.Height)); g.IntersectClip(new XRect(0, 0, page.Width.Point, page.Height.Point)); - container.ScrollOffset = new XPoint(0, scrollOffset); - container.PerformPaint(g); + container.PerformPaint(g, fragmentainer); } - scrollOffset -= pageSize.Height; } // add web links and anchors - HandleLinks(document, container, orgPageSize, pageSize); + HandleLinks(document, container, orgPageSize, tree); } } } @@ -228,17 +232,34 @@ public static async Task AddPdfPages(PdfDocument document, string html, PdfGener /// /// Handle HTML links by create PDF Documents link either to external URL or to another page in the document. /// - private static void HandleLinks(PdfDocument document, HtmlContainer container, XSize orgPageSize, XSize pageSize) + private static void HandleLinks(PdfDocument document, HtmlContainer container, XSize orgPageSize, FragmentTree tree) { + if (tree == null || tree.Fragmentainers.Count == 0) + return; + + // Pagination slot -> PDF page index. Not a bare multiply/divide by page height any more: + // a content-empty slot is never materialized as a fragmentainer at all (blank-page + // skipping), so slot indices are not contiguous across tree.Fragmentainers the way a + // fixed-size page grid's would be. + var slotToPage = new Dictionary(); + for (var pageIndex = 0; pageIndex < tree.Fragmentainers.Count; pageIndex++) + { + slotToPage[tree.Fragmentainers[pageIndex].SlotIndex] = pageIndex; + } + foreach (var link in container.GetLinks()) { - int i = (int)(link.Rectangle.Top / pageSize.Height); - for (; i < document.Pages.Count && pageSize.Height * i < link.Rectangle.Bottom; i++) + foreach (var fragmentainer in tree.Fragmentainers) { - var offset = pageSize.Height * i; + var bandTop = fragmentainer.Geometry.Top; + var bandBottom = bandTop + fragmentainer.Geometry.Height; + if (link.Rectangle.Top >= bandBottom || link.Rectangle.Bottom <= bandTop) + continue; // this link has no part on this fragmentainer's page + + var pageIndex = slotToPage[fragmentainer.SlotIndex]; // fucking position is from the bottom of the page - var xRect = new XRect(link.Rectangle.Left, orgPageSize.Height - (link.Rectangle.Height + link.Rectangle.Top - offset), link.Rectangle.Width, link.Rectangle.Height); + var xRect = new XRect(link.Rectangle.Left, orgPageSize.Height - (link.Rectangle.Height + link.Rectangle.Top - bandTop), link.Rectangle.Width, link.Rectangle.Height); if (link.IsAnchor) { @@ -246,26 +267,39 @@ private static void HandleLinks(PdfDocument document, HtmlContainer container, X var anchorRect = container.GetElementRectangle(link.AnchorId); if (anchorRect.HasValue) { + var anchorSlot = SlotContaining(tree, anchorRect.Value.Top); // document links to the same page as the link is not allowed - int anchorPageIdx = (int)(anchorRect.Value.Top / pageSize.Height); - - // in case that not find the page index, set to the first page. - if (anchorPageIdx == 0) - anchorPageIdx = 1; - - if (i != anchorPageIdx) - document.Pages[i].AddDocumentLink(new PdfRectangle(xRect), anchorPageIdx); + if (anchorSlot.HasValue && slotToPage.TryGetValue(anchorSlot.Value, out var anchorPageIdx) && pageIndex != anchorPageIdx) + { + document.Pages[pageIndex].AddDocumentLink(new PdfRectangle(xRect), anchorPageIdx); + } } } else { // create link to URL - document.Pages[i].AddWebLink(new PdfRectangle(xRect), link.Href); + document.Pages[pageIndex].AddWebLink(new PdfRectangle(xRect), link.Href); } } } } + /// + /// The pagination slot whose content band contains document-space Y coordinate , + /// or null if it falls in no materialized fragmentainer's band (e.g. an anchor inside a + /// content-empty page slot that was skipped, or past the end of the document). + /// + private static int? SlotContaining(FragmentTree tree, double y) + { + foreach (var fragmentainer in tree.Fragmentainers) + { + var bandTop = fragmentainer.Geometry.Top; + if (y >= bandTop && y < bandTop + fragmentainer.Geometry.Height) + return fragmentainer.SlotIndex; + } + return null; + } + #endregion } } diff --git a/Source/HtmlRenderer/Core/CssDefaults.cs b/Source/HtmlRenderer/Core/CssDefaults.cs index fa143788a..00dc80708 100644 --- a/Source/HtmlRenderer/Core/CssDefaults.cs +++ b/Source/HtmlRenderer/Core/CssDefaults.cs @@ -98,11 +98,20 @@ internal static class CssDefaults *[DIR=""ltr""] { direction: ltr; unicode-bidi: embed } *[DIR=""rtl""] { direction: rtl; unicode-bidi: embed } + /* Ported from PeachPDF's CssDefaults (spelt with css-break-3's break-* properties rather + than the legacy page-break-* aliases - the two share their storage and initial value, + see InitialValues below, so this is the same cascade either way). Replaces this engine's + own older `h1 { page-break-before: always }` default, which forced a leading blank page + before any document that opened with a heading now that break-before is actually + consumed by layout - break-after: avoid (keep-with-next) is the behavior real print + engines give headings by default. */ @media print { - h1 { page-break-before: always } h1, h2, h3, - h4, h5, h6 { page-break-after: avoid } - ul, ol, dl { page-break-before: avoid } + h4, h5, h6 { break-after: avoid } + + /* css-tables-3 6.2 repeats a header or footer group across the pages a table spans only + where the group carries an avoid break-inside. */ + thead, tfoot { break-inside: avoid } } /* Not in the specification but necessary */ @@ -191,6 +200,14 @@ @media print { { "padding-right", "0" }, { "padding-top", "0" }, { "page-break-inside", "auto" }, + { "break-inside", "auto" }, + { "break-before", "auto" }, + { "break-after", "auto" }, + { "page-break-before", "auto" }, + { "page-break-after", "auto" }, + { "widows", "2" }, + { "orphans", "2" }, + { "page", "auto" }, { "text-align", "" }, { "text-decoration-line", "" }, { "text-indent", "0" }, @@ -225,6 +242,7 @@ @media print { "line-height", "word-break", "direction", + "widows", "orphans", }; /// diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index 4b26a8fd4..4bba9a165 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -16,6 +16,7 @@ using TheArtOfDev.HtmlRenderer.Adapters; using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Core.Fragmentation; using TheArtOfDev.HtmlRenderer.Core.Handlers; using TheArtOfDev.HtmlRenderer.Core.Parse; using TheArtOfDev.HtmlRenderer.Core.Utils; @@ -72,6 +73,66 @@ internal class CssBox : CssBoxProperties, IDisposable protected bool _wordsSizeMeasured; private CssBox _listItemBox; + + /// + /// The synthetic list-item marker box, if this box has one - not part of + /// (it has no parent box), so it is otherwise unreachable by a tree walk. + /// + internal CssBox ListItemBox + { + get { return _listItemBox; } + } + + /// + /// For a table box only: detached clones of the table's own <thead> rows, one set per + /// continuation page the table's body spans (css-tables-3 6.2's repeated headers) - not part + /// of (so re-running table layout can never mistake them for real body + /// content), rebuilt from scratch on every layout pass by . + /// Null when the table has no header or never crosses a page boundary. + /// + internal List RepeatedHeaderRows { get; set; } + + /// + /// The resumption record this box should re-enter its own child loop with this pass, seeded by + /// the parent's call right before invoking this box's layout - null for a + /// box entered fresh this pass (no earlier pass stopped inside it). See 's + /// own doc comment for the chain shape. + /// + private BreakToken _incomingToken; + + /// + /// A pre-decided document-Y top this box must place itself at this pass, rather than deriving one + /// from its previous sibling - set only for a box being placed for the first time after an earlier + /// pass requested a break before it (). Must not be re-derived: + /// re-deriving it would reach the same "doesn't fit" conclusion and request a break before itself + /// again, forever. + /// + private double? _resumeTopOverride; + + /// + /// Set by this box's own child-loop right after a child's layout call returns with either + /// set (wrapped as an IsBreakBefore link) or its own + /// set (wrapped as a continuation link) - the mechanism that lets a + /// break discovered arbitrarily deep in the tree reach 's pass loop: + /// every ancestor's own child loop checks this immediately after its child's layout call returns, + /// and if set, stops laying out further siblings this pass and reflects the same fact to its own + /// parent. Reset to null at the top of every call. + /// + internal BreakToken PendingBreakToken { get; private set; } + + /// + /// Set by this box's own layout when a forced break-before/break-after means it + /// cannot be placed this pass at all - the box performs no further layout work and returns + /// immediately, leaving its parent's child loop to notice this (right after the layout call + /// returns) and stop, wrapping /this value into a + /// BlockBreakToken(IsBreakBefore: true). Reset to null at the top of every + /// call. + /// + internal double? RequestedBreakBeforeTop { get; private set; } + + /// The pagination slot falls in. + internal int RequestedBreakBeforeSlot { get; private set; } + private CssLineBox _firstHostingLineBox; private CssLineBox _lastHostingLineBox; @@ -343,6 +404,18 @@ internal List LineBoxes get { return _lineBoxes; } } + /// + /// This box's actual rendered top, for page-index comparisons against an already-laid-out box - + /// 's Y for a block container, but the first line's actual + /// top for an inline-only box. Location is committed once, before content layout runs, and + /// never updates it even though + /// it can move the box's one-and-only line (or first of several) to an entirely different page - + /// a single-line paragraph pushed whole onto the next page by orphans/widows is the case that + /// actually surfaces this: Location.Y stays wherever the box was originally positioned, + /// silently wrong for any caller using it to ask "which page does this box's content start on." + /// + internal double EffectiveTop => _lineBoxes.Count > 0 ? _lineBoxes[0].LineTop : Location.Y; + /// /// Gets the linebox(es) that contains words of this box (if inline) /// @@ -501,59 +574,50 @@ public void PerformLayout(RGraphics g) } /// - /// Paints the fragment + /// Seeds this box's resumption state for the upcoming call - called + /// by a parent's child loop right before re-entering a box on a break token's resume path (or by + /// on the document root at the start of every pass). Both parameters + /// default to null/absent for a box being entered fresh this pass. /// - /// Device context to use - public void Paint(RGraphics g) + /// + /// how this box should resume its own child/content loop - . Null both + /// for a genuinely fresh box and for a box being placed for the first time via + /// (nothing to resume into, since it was never entered before). + /// + /// + /// a pre-decided top this box must place itself at, bypassing its own natural-position derivation + /// - . + /// + internal void ResumeAt(BreakToken token, double? resumeTopOverride = null) { - try - { - if (Display != CssConstants.None && Visibility == CssConstants.Visible) - { - // use initial clip to draw blocks with Position = fixed. I.e. ignrore page margins - if (this.Position == CssConstants.Fixed) - { - g.SuspendClipping(); - } - - // don't call paint if the rectangle of the box is not in visible rectangle - bool visible = Rectangles.Count == 0; - if (!visible) - { - var clip = g.GetClip(); - var rect = ContainingBlock.ClientRectangle; - rect.X -= 2; - rect.Width += 2; - if (!IsFixed) - { - //rect.Offset(new RPoint(-HtmlContainer.Location.X, -HtmlContainer.Location.Y)); - rect.Offset(HtmlContainer.ScrollOffset); - } - clip.Intersect(rect); - - if (clip != RRect.Empty) - visible = true; - } - - if (visible) - PaintImp(g); - - // Restore clips - if (this.Position == CssConstants.Fixed) - { - g.ResumeClipping(); - } + _incomingToken = token; + _resumeTopOverride = resumeTopOverride; + } - } - } - catch (Exception ex) + /// + /// Whether a forced break here could actually be deferred to (and resumed in) a later pass - + /// false anywhere inside a table cell's subtree. 's row loop + /// calls cell.PerformLayout directly, the same way it always has, and does not participate + /// in the bubbling protocol an ordinary block-child loop does (see + /// that property's doc comment) - a table row is not itself laid out via that loop, so nothing + /// would ever read a cell's own and turn it into a real pass + /// boundary. Deferring anyway would leave the deferred content measured but never positioned + /// (its call returns before reaching CreateLineBoxes/the + /// block-child loop, yet nothing ever resumes it) - found as a real regression while + /// investigating table fragmentation, once R1's forced-break deferral existed to trigger it. + /// + private bool CanDeferToLaterPass() + { + for (var box = this; box != null; box = box.ParentBox) { - HtmlContainer.ReportError(HtmlRenderErrorType.Paint, "Exception in box paint", ex); + if (box.Display == CssConstants.TableCell) + return false; } + return true; } /// - /// Set this box in + /// Set this box in /// /// public void SetBeforeBox(CssBox before) @@ -743,6 +807,11 @@ private void ApplyHeight() /// Device context to use protected virtual void PerformLayoutImp(RGraphics g) { + // Pass-scoped signal state - stale values from an earlier pass must never leak into this one. + PendingBreakToken = null; + RequestedBreakBeforeTop = null; + RequestedBreakBeforeSlot = 0; + if (Display != CssConstants.None) { RectanglesReset(); @@ -808,7 +877,47 @@ protected virtual void PerformLayoutImp(RGraphics g) else { left = ContainingBlock.Location.X + ContainingBlock.ActualPaddingLeft + ActualMarginLeft + ContainingBlock.ActualBorderLeftWidth; - top = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + MarginTopCollapse(prevSibling) + (prevSibling != null ? prevSibling.ActualBottom + prevSibling.ActualBorderBottomWidth : 0); + var baseTopWithoutMargin = (prevSibling == null && ParentBox != null ? ParentBox.ClientTop : ParentBox == null ? Location.Y : 0) + (prevSibling != null ? prevSibling.ActualBottom + prevSibling.ActualBorderBottomWidth : 0); + + if (_incomingToken != null && ReferenceEquals(_incomingToken.Box, this)) + { + // Resuming this box's own interrupted child/content loop, not placing it fresh + // - css-break-3 §2 gives a box one inline position across all its fragments, so + // there is nothing to re-derive here; Location already holds it from the pass + // that placed this box originally. + top = Location.Y; + } + else if (_resumeTopOverride.HasValue) + { + // A break-before target an earlier pass already decided (see + // RequestedBreakBeforeTop's doc comment) - must not be re-derived. + top = _resumeTopOverride.Value; + } + else if (BlockFragmentation.TryGetForcedBreakTarget(this, prevSibling, baseTopWithoutMargin, out var breakSlot, out var breakTop)) + { + if (CanDeferToLaterPass()) + { + // A forced break-before/after applies and this is a genuinely fresh entry + // (no resume state of any kind) - defer this box (and everything after it + // in its parent's child loop) to a later pass entirely, rather than + // positioning it now. + RequestedBreakBeforeSlot = breakSlot; + RequestedBreakBeforeTop = breakTop; + return; + } + + // Deferring would never actually be resumed here (see CanDeferToLaterPass) - + // place immediately at the target instead, matching how forced breaks worked + // before real pass-based deferral existed. Not ideal (this content doesn't + // get a fresh fragmentainer pass the way top-level content does), but correct + // rather than silently measured-but-never-positioned. + top = breakTop; + } + else + { + top = BlockFragmentation.ResolveBlockTop(this, prevSibling, baseTopWithoutMargin); + } + Location = new RPoint(left, top); ActualBottom = top; @@ -830,12 +939,63 @@ protected virtual void PerformLayoutImp(RGraphics g) { ActualBottom = Location.Y; CssLayoutEngine.CreateLineBoxes(g, this); //This will automatically set the bottom of this block + InlineFragmentation.ApplyLineBreaking(this); } else if (_boxes.Count > 0) { - foreach (var childBox in Boxes) + // Resuming our OWN child loop (as opposed to a fresh entry) if the incoming token + // names this box - ResumeChildIndex says which child to pick back up at; every + // child before it already has a finished fragment from an earlier pass and is + // never touched again. + var resumeToken = _incomingToken as BlockBreakToken; + var resumingHere = resumeToken != null && ReferenceEquals(resumeToken.Box, this); + var startIndex = resumingHere ? resumeToken.ResumeChildIndex : 0; + + for (var i = startIndex; i < Boxes.Count; i++) { + var childBox = Boxes[i]; + + if (i == startIndex && resumingHere) + { + if (resumeToken.IsBreakBefore) + childBox.ResumeAt(null, resumeToken.ResumeTopOverride); + else + childBox.ResumeAt(resumeToken.ChildToken); + } + childBox.PerformLayout(g); + + if (childBox.RequestedBreakBeforeTop.HasValue) + { + // Child declined to be placed this pass at all - stop here too, so this + // box's own parent bubbles the same fact upward (see PendingBreakToken's + // doc comment for how this reaches HtmlContainerInt's pass loop). + PendingBreakToken = new BlockBreakToken( + this, childBox.RequestedBreakBeforeSlot, i, null, true, childBox.RequestedBreakBeforeTop); + return; + } + + // Checked BEFORE RelocateIfNeeded, not after: a child whose own child loop + // stopped mid-way (a nested forced break) never reached its epilogue, so its + // ActualBottom/Location only reflect a partial pass - RelocateIfNeeded's + // straddle test would read meaningless geometry if run on it. + if (BubbleChildPendingToken(childBox, i)) + return; + + BlockFragmentation.RelocateIfNeeded(g, childBox); + + // RelocateIfNeeded's own relayout (see its doc comment) can itself surface a + // break nested inside the relocated child's subtree - e.g. a forced break + // inside a break-inside:avoid container - so check again. + if (BubbleChildPendingToken(childBox, i)) + return; + + BlockFragmentation.EnforceKeepWithNext(g, childBox); + + // Same reasoning as above: EnforceKeepWithNext's own relayout of childBox can + // itself surface a nested break. + if (BubbleChildPendingToken(childBox, i)) + return; } ActualRight = CalculateActualRight(); @@ -875,6 +1035,23 @@ protected virtual void PerformLayoutImp(RGraphics g) } } + /// + /// If stopped somewhere inside its own content/child loop this pass, + /// wraps its token in a link naming this box (at ) and sets it as + /// this box's own , for the caller to stop laying out any further + /// siblings and return. See 's doc comment for how this bubbling + /// reaches 's pass loop. + /// + private bool BubbleChildPendingToken(CssBox childBox, int childIndex) + { + if (childBox.PendingBreakToken == null) + return false; + + PendingBreakToken = new BlockBreakToken( + this, childBox.PendingBreakToken.ResumeSlotIndex, childIndex, childBox.PendingBreakToken, false, null); + return true; + } + /// /// Assigns words its width and height /// @@ -1264,7 +1441,7 @@ internal bool HasJustInlineSiblings() /// /// the previous box under the same parent /// Resulting top margin - protected double MarginTopCollapse(CssBoxProperties prevSibling) + internal double MarginTopCollapse(CssBoxProperties prevSibling) { double value; if (prevSibling != null) @@ -1290,26 +1467,6 @@ protected double MarginTopCollapse(CssBoxProperties prevSibling) return value; } - public bool BreakPage() - { - var container = this.HtmlContainer; - - if (this.Size.Height >= container.PageSize.Height) - return false; - - var remTop = (this.Location.Y - container.MarginTop) % container.PageSize.Height; - var remBottom = (this.ActualBottom - container.MarginTop) % container.PageSize.Height; - - if (remTop > remBottom) - { - var diff = container.PageSize.Height - remTop; - this.Location = new RPoint(this.Location.X, this.Location.Y + diff + 1); - return true; - } - - return false; - } - /// /// Calculate the actual right of the box by the actual right of the child boxes if this box actual right is not set. /// @@ -1357,6 +1514,19 @@ private double MarginBottomCollapse() /// Deeply offsets the top of the box and its contents /// /// + /// + /// A real gap found while auditing this port's fragmentation engine against PeachPDF a second + /// time: this box's own entry for a line was kept in sync, but the + /// line's OWN mirror of the same value (, keyed the other way + /// around) was not - the two are separate dictionaries updated by separate call sites + /// ( keeps both in sync when a line-level shift initiates the + /// move; this method didn't when a box-level shift does). / + /// LineBottom - and therefore for any inline-only box, since it + /// reads them - went stale after this method ran, even though (this + /// method's own last statement) was correctly updated. Confirmed by directly inspecting both + /// dictionaries after a real EnforceKeepWithNext run-shift: Location.Y reflected the + /// new position while EffectiveTop still reported the old one. + /// internal void OffsetTop(double amount) { List lines = new List(); @@ -1366,7 +1536,9 @@ internal void OffsetTop(double amount) foreach (CssLineBox line in lines) { RRect r = Rectangles[line]; - Rectangles[line] = new RRect(r.X, r.Y + amount, r.Width, r.Height); + var shifted = new RRect(r.X, r.Y + amount, r.Width, r.Height); + Rectangles[line] = shifted; + line.Rectangles[this] = shifted; } foreach (CssRect word in Words) @@ -1385,89 +1557,6 @@ internal void OffsetTop(double amount) Location = new RPoint(Location.X, Location.Y + amount); } - /// - /// Paints the fragment - /// - /// the device to draw to - protected virtual void PaintImp(RGraphics g) - { - if (Display != CssConstants.None && (Display != CssConstants.TableCell || EmptyCells != CssConstants.Hide || !IsSpaceOrEmpty)) - { - var clipped = RenderUtils.ClipGraphicsByOverflow(g, this); - - var areas = Rectangles.Count == 0 ? new List(new[] { Bounds }) : new List(Rectangles.Values); - var clip = g.GetClip(); - RRect[] rects = areas.ToArray(); - RPoint offset = RPoint.Empty; - if (!IsFixed) - { - offset = HtmlContainer.ScrollOffset; - } - - for (int i = 0; i < rects.Length; i++) - { - var actualRect = rects[i]; - actualRect.Offset(offset); - - if (IsRectVisible(actualRect, clip)) - { - PaintBackground(g, actualRect, i == 0, i == rects.Length - 1); - BordersDrawHandler.DrawBoxBorders(g, this, actualRect, i == 0, i == rects.Length - 1); - } - } - - PaintWords(g, offset); - - for (int i = 0; i < rects.Length; i++) - { - var actualRect = rects[i]; - actualRect.Offset(offset); - - if (IsRectVisible(actualRect, clip)) - { - PaintDecoration(g, actualRect, i == 0, i == rects.Length - 1); - } - } - - // split paint to handle z-order - foreach (CssBox b in Boxes) - { - if (b.Position != CssConstants.Absolute && !b.IsFixed) - b.Paint(g); - } - foreach (CssBox b in Boxes) - { - if (b.Position == CssConstants.Absolute) - b.Paint(g); - } - foreach (CssBox b in Boxes) - { - if (b.IsFixed) - b.Paint(g); - } - - if (clipped) - g.PopClip(); - - if (_listItemBox != null) - { - _listItemBox.Paint(g); - } - } - } - - private bool IsRectVisible(RRect rect, RRect clip) - { - rect.X -= 2; - rect.Width += 2; - clip.Intersect(rect); - - if (clip != RRect.Empty) - return true; - - return false; - } - /// /// Paints the background of the box /// @@ -1475,7 +1564,7 @@ private bool IsRectVisible(RRect rect, RRect clip) /// the bounding rectangle to draw in /// is it the first rectangle of the element /// is it the last rectangle of the element - protected void PaintBackground(RGraphics g, RRect rect, bool isFirst, bool isLast) + internal void PaintBackground(RGraphics g, RRect rect, bool isFirst, bool isLast) { if (rect.Width > 0 && rect.Height > 0) { @@ -1535,61 +1624,54 @@ protected void PaintBackground(RGraphics g, RRect rect, bool isFirst, bool isLas } /// - /// Paint all the words in the box. + /// Paints one word at , its final paint position - the caller decides + /// where that is (fragment-tree-local geometry, offset the same way 's + /// line rects already are; there is no live-tree geometry read here, only style/selection state). /// /// the device to draw into - /// the current scroll offset to offset the words - private void PaintWords(RGraphics g, RPoint offset) + /// the word to paint + /// the word's final paint rectangle + internal void PaintWord(RGraphics g, CssRect word, RRect wordRect) { - if (Width.Length > 0) + if (word.IsLineBreak) + return; + + var clip = g.GetClip(); + clip.Intersect(wordRect); + if (clip == RRect.Empty) + return; + + var isRtl = Direction == CssConstants.Rtl; + var wordPoint = new RPoint(wordRect.X, wordRect.Y); + if (word.Selected) { - var isRtl = Direction == CssConstants.Rtl; - foreach (var word in Words) - { - if (!word.IsLineBreak) - { - var clip = g.GetClip(); - var wordRect = word.Rectangle; - wordRect.Offset(offset); - clip.Intersect(wordRect); + // handle paint selected word background and with partial word selection + var wordLine = DomUtils.GetCssLineBoxByWord(word); + var left = word.SelectedStartOffset > -1 ? word.SelectedStartOffset : (wordLine.Words[0] != word && word.HasSpaceBefore ? -ActualWordSpacing : 0); + var padWordRight = word.HasSpaceAfter && !wordLine.IsLastSelectedWord(word); + var width = word.SelectedEndOffset > -1 ? word.SelectedEndOffset : word.Width + (padWordRight ? ActualWordSpacing : 0); + var rect = new RRect(wordRect.X + left, wordRect.Y, width - left, wordLine.LineHeight); - if (clip != RRect.Empty) - { - var wordPoint = new RPoint(word.Left + offset.X, word.Top + offset.Y); - if (word.Selected) - { - // handle paint selected word background and with partial word selection - var wordLine = DomUtils.GetCssLineBoxByWord(word); - var left = word.SelectedStartOffset > -1 ? word.SelectedStartOffset : (wordLine.Words[0] != word && word.HasSpaceBefore ? -ActualWordSpacing : 0); - var padWordRight = word.HasSpaceAfter && !wordLine.IsLastSelectedWord(word); - var width = word.SelectedEndOffset > -1 ? word.SelectedEndOffset : word.Width + (padWordRight ? ActualWordSpacing : 0); - var rect = new RRect(word.Left + offset.X + left, word.Top + offset.Y, width - left, wordLine.LineHeight); - - g.DrawRectangle(GetSelectionBackBrush(g, false), rect.X, rect.Y, rect.Width, rect.Height); - - if (HtmlContainer.SelectionForeColor != RColor.Empty && (word.SelectedStartOffset > 0 || word.SelectedEndIndexOffset > -1)) - { - g.PushClipExclude(rect); - g.DrawString(word.Text, ActualFont, ActualColor, wordPoint, new RSize(word.Width, word.Height), isRtl); - g.PopClip(); - g.PushClip(rect); - g.DrawString(word.Text, ActualFont, GetSelectionForeBrush(), wordPoint, new RSize(word.Width, word.Height), isRtl); - g.PopClip(); - } - else - { - g.DrawString(word.Text, ActualFont, GetSelectionForeBrush(), wordPoint, new RSize(word.Width, word.Height), isRtl); - } - } - else - { - // g.DrawRectangle(HtmlContainer.Adapter.GetPen(RColor.Black), wordPoint.X, wordPoint.Y, word.Width - 1, word.Height - 1); - g.DrawString(word.Text, ActualFont, ActualColor, wordPoint, new RSize(word.Width, word.Height), isRtl); - } - } - } + g.DrawRectangle(GetSelectionBackBrush(g, false), rect.X, rect.Y, rect.Width, rect.Height); + + if (HtmlContainer.SelectionForeColor != RColor.Empty && (word.SelectedStartOffset > 0 || word.SelectedEndIndexOffset > -1)) + { + g.PushClipExclude(rect); + g.DrawString(word.Text, ActualFont, ActualColor, wordPoint, new RSize(word.Width, word.Height), isRtl); + g.PopClip(); + g.PushClip(rect); + g.DrawString(word.Text, ActualFont, GetSelectionForeBrush(), wordPoint, new RSize(word.Width, word.Height), isRtl); + g.PopClip(); + } + else + { + g.DrawString(word.Text, ActualFont, GetSelectionForeBrush(), wordPoint, new RSize(word.Width, word.Height), isRtl); } } + else + { + g.DrawString(word.Text, ActualFont, ActualColor, wordPoint, new RSize(word.Width, word.Height), isRtl); + } } /// @@ -1599,7 +1681,7 @@ private void PaintWords(RGraphics g, RPoint offset) /// /// /// - protected void PaintDecoration(RGraphics g, RRect rectangle, bool isFirst, bool isLast) + internal void PaintDecoration(RGraphics g, RRect rectangle, bool isFirst, bool isLast) { if (string.IsNullOrEmpty(TextDecoration) || TextDecoration == CssConstants.None) return; diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs b/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs index ecebb9223..05fc4a437 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxFrame.cs @@ -407,28 +407,26 @@ private void HandlePostApiCall() } /// - /// Paints the fragment + /// Starts loading the video thumbnail if the video API call resolved a thumbnail URL and loading + /// hasn't started already - the same paint-time trigger pattern as , see + /// its for why this can't move to measure time. + /// Called by . /// - /// the device to draw to - protected override void PaintImp(RGraphics g) + internal void EnsureVideoImageLoadStarted() { if (_videoImageUrl != null && _imageLoadHandler == null) { _imageLoadHandler = new ImageLoadHandler(HtmlContainer, OnLoadImageComplete); _imageLoadHandler.LoadImage(_videoImageUrl, HtmlTag != null ? HtmlTag.Attributes : null); } + } - var rects = CommonUtils.GetFirstValueOrDefault(Rectangles); - - RPoint offset = (HtmlContainer != null && !IsFixed) ? HtmlContainer.ScrollOffset : RPoint.Empty; - rects.Offset(offset); - - var clipped = RenderUtils.ClipGraphicsByOverflow(g, this); - - PaintBackground(g, rects, true, true); - - BordersDrawHandler.DrawBoxBorders(g, this, rects, true, true); - + /// + /// Draws the video thumbnail/title/play-button chrome at , leaving + /// background/border painting to the caller (). + /// + internal void DrawFrameContent(RGraphics g, RPoint offset) + { var word = Words[0]; var tmpRect = word.Rectangle; tmpRect.Offset(offset); @@ -443,9 +441,6 @@ protected override void PaintImp(RGraphics g) DrawTitle(g, rect); DrawPlay(g, rect); - - if (clipped) - g.PopClip(); } /// diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs b/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs index 8280f47c3..ad44a68df 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxHr.cs @@ -90,14 +90,13 @@ protected override void PerformLayoutImp(RGraphics g) } /// - /// Paints the fragment + /// Draws the rule itself at (already offset) - an <hr> has + /// no separate background/border step shared with other replaced elements (it draws each border + /// edge itself, not via ). Called by + /// . /// - /// the device to draw to - protected override void PaintImp(RGraphics g) + internal void DrawHrContent(RGraphics g, RRect rect) { - var offset = (HtmlContainer != null && !IsFixed) ? HtmlContainer.ScrollOffset : RPoint.Empty; - var rect = new RRect(Bounds.X + offset.X, Bounds.Y + offset.Y, Bounds.Width, Bounds.Height); - if (rect.Height > 2 && RenderUtils.IsColorVisible(ActualBackgroundColor)) { g.DrawRectangle(g.GetSolidBrush(ActualBackgroundColor), rect.X, rect.Y, rect.Width, rect.Height); diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs b/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs index 8322416ab..e849da63a 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxImage.cs @@ -67,31 +67,27 @@ public RImage Image } /// - /// Paints the fragment + /// Starts loading the image if it hasn't started already. This is the primary load trigger for + /// the common async case (/ + /// both false) - + /// only starts loading when one of those flags is set, so paint is where loading normally begins. + /// Called by . /// - /// the device to draw to - protected override void PaintImp(RGraphics g) + internal void EnsureImageLoadStarted() { - // load image if it is in visible rectangle if (_imageLoadHandler == null) { _imageLoadHandler = new ImageLoadHandler(HtmlContainer, OnLoadImageComplete); _imageLoadHandler.LoadImage(GetImageSource(), HtmlTag != null ? HtmlTag.Attributes : null); } + } - var rect = CommonUtils.GetFirstValueOrDefault(Rectangles); - RPoint offset = RPoint.Empty; - - if (!IsFixed) - offset = HtmlContainer.ScrollOffset; - - rect.Offset(offset); - - var clipped = RenderUtils.ClipGraphicsByOverflow(g, this); - - PaintBackground(g, rect, true, true); - BordersDrawHandler.DrawBoxBorders(g, this, rect, true, true); - + /// + /// Draws the image itself (or its error/loading placeholder) at , + /// leaving background/border painting to the caller (). + /// + internal void DrawImageContent(RGraphics g, RPoint offset) + { RRect r = _imageWord.Rectangle; r.Offset(offset); r.Height -= ActualBorderTopWidth + ActualBorderBottomWidth + ActualPaddingTop + ActualPaddingBottom; @@ -129,9 +125,6 @@ protected override void PaintImp(RGraphics g) g.DrawRectangle(g.GetPen(RColor.LightGray), r.X, r.Y, r.Width, r.Height); } } - - if (clipped) - g.PopClip(); } /// diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 79c1e6987..0a8a5bedd 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -90,6 +90,11 @@ internal abstract class CssBoxProperties private string _paddingRight = "0"; private string _paddingTop = "0"; private string _pageBreakInside = CssConstants.Auto; + private string _breakBefore = CssConstants.Auto; + private string _breakAfter = CssConstants.Auto; + private string _widows = "2"; + private string _orphans = "2"; + private string _pageName = CssConstants.Auto; private string _right; private string _textAlign = string.Empty; private string _textDecoration = string.Empty; @@ -455,6 +460,112 @@ public string PageBreakInside } } + /// + /// CSS Fragmentation "break-inside". Shares a backing field with the legacy "page-break-inside" + /// () so fragmentation code has one canonical value to consult + /// regardless of which property name an author used. + /// + public string BreakInside + { + get { return _pageBreakInside; } + set { _pageBreakInside = value; } + } + + /// + /// CSS Fragmentation "break-before". Shares a backing field with the legacy "page-break-before" + /// (). + /// + public string BreakBefore + { + get { return _breakBefore; } + set { _breakBefore = value; } + } + + /// + /// Legacy CSS2.1 "page-break-before". Shares a backing field with . + /// + public string PageBreakBefore + { + get { return _breakBefore; } + set { _breakBefore = value; } + } + + /// + /// CSS Fragmentation "break-after". Shares a backing field with the legacy "page-break-after" + /// (). + /// + public string BreakAfter + { + get { return _breakAfter; } + set { _breakAfter = value; } + } + + /// + /// Legacy CSS2.1 "page-break-after". Shares a backing field with . + /// + public string PageBreakAfter + { + get { return _breakAfter; } + set { _breakAfter = value; } + } + + /// + /// CSS Fragmentation "widows" - the minimum number of lines of a block left on the top of a page. + /// + public string Widows + { + get { return _widows; } + set { _widows = value; } + } + + /// + /// The resolved value, defaulting to the CSS initial value of 2 when unset + /// or unparsable. + /// + public int ActualWidows + { + get + { + int result; + return int.TryParse(_widows, NumberStyles.Integer, CultureInfo.InvariantCulture, out result) && result > 0 + ? result + : 2; + } + } + + /// + /// CSS Fragmentation "orphans" - the minimum number of lines of a block left at the bottom of a page. + /// + public string Orphans + { + get { return _orphans; } + set { _orphans = value; } + } + + /// + /// The resolved value, defaulting to the CSS initial value of 2 when unset + /// or unparsable. + /// + public int ActualOrphans + { + get + { + int result; + return int.TryParse(_orphans, NumberStyles.Integer, CultureInfo.InvariantCulture, out result) && result > 0 + ? result + : 2; + } + } + + /// + /// CSS Paged Media "page" - the named page this box's containing fragmentainer should use. + /// + public string PageName + { + get { return _pageName; } + set { _pageName = value; } + } + public string Left { get { return _left; } @@ -1759,6 +1870,8 @@ protected void InheritStyle(CssBox p, bool everything) _lineHeight = p._lineHeight; _wordBreak = p.WordBreak; _direction = p._direction; + _widows = p._widows; + _orphans = p._orphans; if (everything) { diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs index 4c7139ea2..c1ebb75d9 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngine.cs @@ -505,11 +505,6 @@ private static void FlowBox(RGraphics g, CssBox blockbox, CssBox box, double lim word.Left = curx; word.Top = cury; - if (!box.IsFixed) - { - word.BreakPage(); - } - curx = word.Left + word.FullWidth; maxRight = Math.Max(maxRight, word.Right); diff --git a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs index 79627161a..ab4a2d9d7 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLayoutEngineTable.cs @@ -15,6 +15,7 @@ using TheArtOfDev.HtmlRenderer.Adapters; using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Core.Fragmentation; using TheArtOfDev.HtmlRenderer.Core.Parse; using TheArtOfDev.HtmlRenderer.Core.Utils; @@ -624,12 +625,69 @@ private void LayoutCells(RGraphics g) _tableBox.Location = new RPoint(startx - _tableBox.ActualBorderLeftWidth - _tableBox.ActualPaddingLeft - GetHorizontalSpacing(), _tableBox.Location.Y); } + // css-tables-3 6.2: a repeats on every page the table's body/footer spans, where + // the group carries an avoiding break-inside (the UA default stylesheet sets this). + // Reserving the room here, before the first row of each continuation page is positioned, + // is what keeps that row from being drawn underneath the repeated header instead of below + // it - a fragment-tree-only repeat (no reservation) would just overlap real content. + // + // KNOWN LIMITATION (confirmed via direct testing, not yet fixed - fragmentation-engine-parity + // plan's R8 stage): this check runs once per ROW (below, gated on `i`), reading `cury`'s slot + // only at that row's own start. A row whose own cell content spans MULTIPLE pages by itself + // (one cell vastly longer than its siblings) only gets a repeat inserted for the FIRST page + // it crosses onto - the header does not repeat on further intermediate pages that same row's + // content continues to span, only reappearing once a LATER row's own start advances the slot + // again. Not data loss or a crash, just a missing header repeat on some pages of a fairly + // exotic table shape. A real fix needs to know how many pages a row spans before deciding how + // much room to reserve for it, which this single-pass-per-row model doesn't have without + // relaying the row out a second time once its true span is known - tractable, but out of + // scope for now given how rare the shape is (the far more common case - many ordinary rows, + // table spans many pages - already repeats correctly, verified by ThreadRepeatsOnEveryPageTheTableSpans). + var pageGridContainer = _tableBox.HtmlContainer; + var repeatsHeader = pageGridContainer != null && pageGridContainer.HasRealPageGrid + && _headerBox != null && BreakValues.AvoidsBreak(_headerBox.BreakInside); + var headerRowCount = _headerBox?.Boxes.Count ?? 0; + double headerHeight = 0; + int? lastRepeatSlot = null; + _tableBox.RepeatedHeaderRows = null; + for (int i = 0; i < _allRows.Count; i++) { + if (repeatsHeader && i == headerRowCount) + { + // The header's own rows (i = 0..headerRowCount-1) just finished; maxBottom is + // still theirs. Its own page is never itself a "repeat" - the header is already + // there once, in flow. + headerHeight = maxBottom - starty; + lastRepeatSlot = pageGridContainer.PageIndexOf(starty); + } + + if (repeatsHeader && i >= headerRowCount && lastRepeatSlot.HasValue) + { + var slot = pageGridContainer.PageIndexOf(cury); + if (slot > lastRepeatSlot.Value) + { + var pageTop = pageGridContainer.PageTopOf(slot); + cury = pageTop + headerHeight; + lastRepeatSlot = slot; + + _tableBox.RepeatedHeaderRows ??= new List(); + for (var hi = 0; hi < headerRowCount; hi++) + { + var sourceRow = _allRows[hi]; + // A box's own Location is never assigned by this row loop (only its + // cells' is) - the first cell is the real reference point for "where this + // header row actually renders". + var sourceRenderedTop = sourceRow.Boxes.Count > 0 ? sourceRow.Boxes[0].Location.Y : starty; + var targetTop = pageTop + (sourceRenderedTop - starty); + _tableBox.RepeatedHeaderRows.Add(TableHeaderRepeat.CloneAndPosition(sourceRow, sourceRenderedTop, targetTop)); + } + } + } + var row = _allRows[i]; double curx = startx; int curCol = 0; - bool breakPage = false; for (int j = 0; j < row.Boxes.Count; j++) { @@ -676,30 +734,53 @@ private void LayoutCells(RGraphics g) spacer.ExtendedBox.ActualBottom = maxBottom; CssLayoutEngine.ApplyCellVerticalAlignment(g, spacer.ExtendedBox); } + } - // If one cell crosses page borders then don't need to check other cells in the row - if (_tableBox.PageBreakInside == CssConstants.Avoid) + // css-tables-3 §6.1: "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 UA-default + // requirement, not something an author has to opt into. If this row straddles a page + // boundary and isn't "freely fragmentable" by that rule, shift the whole row - not just + // one cell - down to the next page's content top. The table's own break-inside:avoid + // still forces the attempt even for an otherwise-freely-fragmentable row (an author's + // explicit, stronger request), matching this port's existing behavior for that case. + if (pageGridContainer != null && pageGridContainer.HasRealPageGrid && maxBottom > cury) + { + var topSlot = pageGridContainer.PageIndexOf(cury); + var bottomSlot = pageGridContainer.PageIndexOf(Math.Max(cury, maxBottom - 0.01)); + var rowHeight = maxBottom - cury; + var freelyFragmentable = RowHasCellSpanningIntoSubsequentRow(row, currentrow) + || rowHeight >= pageGridContainer.PageSize.Height / 2 + || rowHeight >= pageGridContainer.PageSize.Width / 2; + var shouldPreserve = !freelyFragmentable || BreakValues.AvoidsBreak(_tableBox.BreakInside); + + if (bottomSlot > topSlot && shouldPreserve && rowHeight < pageGridContainer.PageSize.Height) { - breakPage = cell.BreakPage(); - if (breakPage) + var delta = pageGridContainer.PageTopOf(topSlot + 1) - cury; + foreach (CssBox cell in row.Boxes) { - cury = cell.Location.Y; - break; + // A rowspan-crossing cell's real content lives on CssSpacingBox.ExtendedBox, + // not on the placeholder itself (Display:none, no children/words/rectangles - + // OffsetTop on it was a silent no-op, leaving the spanning cell's actual + // bottom edge stale while the rest of the row moved on). Unlike an ordinary + // cell, the spanning cell's own top and content are already anchored to + // whichever earlier row it started in (laid out there, unaffected by this + // row's shift) - so rather than OffsetTop-ing the whole subtree (which would + // incorrectly drag its top and content away from that row too), only its + // bottom edge is extended to cover the gap this row's move just opened up. + if (cell is CssSpacingBox spacer) + { + spacer.ExtendedBox.ActualBottom += delta; + } + else + { + cell.OffsetTop(delta); + } } + maxBottom += delta; } } - if (breakPage) // go back to move the whole row to the next page - { - if (i == 1) // do not leave single row in previous page - i = -1; // Start layout from the first row on new page - else - i--; - - maxBottom = 0; - continue; - } - cury = maxBottom + GetVerticalSpacing(); currentrow++; @@ -803,6 +884,31 @@ private static int GetRowSpan(CssBox b) return rowspan; } + /// + /// css-tables-3 §6.1's "the cells spanning the row do not span any subsequent row" test: true + /// if any cell in - real or the placeholder + /// standing in for one that started earlier - continues into a row after + /// , meaning this row cannot be preserved unfragmented on its own + /// without also pulling along content that belongs to a row not yet reached. + /// + private static bool RowHasCellSpanningIntoSubsequentRow(CssBox row, int currentrow) + { + foreach (CssBox cell in row.Boxes) + { + if (cell is CssSpacingBox spacer) + { + if (spacer.EndRow > currentrow) + return true; + } + else if (GetRowSpan(cell) > 1) + { + return true; + } + } + + return false; + } + /// /// Recursively measures words inside the box /// diff --git a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs index dd2db8925..39e037756 100644 --- a/Source/HtmlRenderer/Core/Dom/CssLineBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssLineBox.cs @@ -113,6 +113,43 @@ public double LineBottom } } + /// + /// Get the top of this box line (the min top of all its rectangles). + /// + internal double LineTop + { + get + { + double top = double.MaxValue; + foreach (var rect in _rects) + { + top = Math.Min(top, rect.Value.Top); + } + return top == double.MaxValue ? 0 : top; + } + } + + /// + /// Shifts every word and per-box rectangle on this line down by - used + /// by to push a line (css-break-3 4.1: a line box + /// is monolithic - the whole of it moves, never just the words that don't fit) to the next page. + /// + internal void ShiftLine(double delta) + { + foreach (var word in _words) + { + word.Top += delta; + } + + var boxes = new List(_rects.Keys); + foreach (var box in boxes) + { + var r = _rects[box]; + _rects[box] = new RRect(r.X, r.Y + delta, r.Width, r.Height); + box.OffsetRectangle(this, delta); + } + } + /// /// Lets the linebox add the word an its box to their lists if necessary. /// diff --git a/Source/HtmlRenderer/Core/Dom/CssRect.cs b/Source/HtmlRenderer/Core/Dom/CssRect.cs index d7ff14ac1..183987ef9 100644 --- a/Source/HtmlRenderer/Core/Dom/CssRect.cs +++ b/Source/HtmlRenderer/Core/Dom/CssRect.cs @@ -269,23 +269,5 @@ public override string ToString() return string.Format("{0} ({1} char{2})", Text.Replace(' ', '-').Replace("\n", "\\n"), Text.Length, Text.Length != 1 ? "s" : string.Empty); } - public bool BreakPage() - { - var container = this.OwnerBox.HtmlContainer; - - if (this.Height >= container.PageSize.Height) - return false; - - var remTop = (this.Top - container.MarginTop) % container.PageSize.Height; - var remBottom = (this.Bottom - container.MarginTop) % container.PageSize.Height; - - if (remTop > remBottom) - { - this.Top += container.PageSize.Height - remTop + 1; - return true; - } - - return false; - } } } \ No newline at end of file diff --git a/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs new file mode 100644 index 000000000..8853083a8 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/BlockFragmentation.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// Block-level page-break decisions. Being replaced, stage by stage, with real resumable-pass-loop + /// equivalents matching PeachPDF's architecture (see the fragmentation-engine-parity plan): forced + /// breaks (, plan R1) go through CssBox's real pass loop + /// across fragmentainers; break-inside:avoid/monolithic relocation (, + /// plan R3) and keep-with-next (, plan R4) both relay the affected + /// box out fresh at its target position within the SAME pass, rather than shifting already-finished + /// geometry - real relayout, but not yet a cross-pass token, since nothing downstream has been touched + /// yet when either fires. Margin truncation () remains the older + /// pre-placement arithmetic correction, since it needs no relayout at all - it's already applied + /// before a box is ever positioned, the same timing uses. + /// + internal static class BlockFragmentation + { + /// + /// Resolves a block box's document-space top, applying css-break-3 §5.2 margin truncation at + /// unforced breaks. Forced break-before/break-after: page is handled earlier, by + /// and CssBox's own pass loop - a box this method is + /// reached for has already been confirmed not to have a forced break pending. + /// is the position before this box's own collapsed top + /// margin is added (the containing block's content top, or the previous sibling's border-box + /// bottom). + /// + internal static double ResolveBlockTop(CssBox box, CssBox prevSibling, double baseTopWithoutMargin) + { + var naturalTop = baseTopWithoutMargin + box.MarginTopCollapse(prevSibling); + + var container = box.HtmlContainer; + if (container == null || !container.HasRealPageGrid) + return naturalTop; + + // css-break-3 §5.2: a collapsed margin that, by itself, pushes content across one or more + // page boundaries is truncated to zero - content starts flush at the next page instead of + // paginating through blank vertical space. + var baseSlot = container.PageIndexOf(baseTopWithoutMargin); + var naturalSlot = container.PageIndexOf(naturalTop); + return naturalSlot > baseSlot ? container.PageTopOf(baseSlot + 1) : naturalTop; + } + + /// + /// Whether has a forced page break before it (its own break-before, + /// or 's break-after - including the legacy always + /// value) that isn't already satisfied by its natural top landing flush at a page top - and if so, + /// the pagination slot/document-Y it must be deferred to. A box with a forced break pending is not + /// placed this pass at all (see CssBox.RequestedBreakBeforeTop); its parent's child loop + /// stops and the pass ends, resuming with this box placed fresh at . + /// + /// + /// Suppressed when there's no previous sibling: css-break-3 §3.1 propagation says the break point + /// before a container's first in-flow child IS the break point before the container itself - so a + /// forced break here would really belong to an ancestor (and ultimately, if that ancestor also has + /// no previous sibling, to the fragmentation root, where it's inherently inert - there's no earlier + /// page to break away from). Full cross-ancestor propagation is out of scope for this port; + /// suppressing at the box's own level is what keeps a heading that merely happens to be first on + /// the page from forcing a spurious leading blank page - the common case this UA default + /// (`h1 { page-break-before: always }`) exists for is a heading that starts a new section partway + /// through a document, not one. + /// + internal static bool TryGetForcedBreakTarget(CssBox box, CssBox prevSibling, double baseTopWithoutMargin, out int slot, out double targetTop) + { + slot = 0; + targetTop = 0; + + var container = box.HtmlContainer; + if (container == null || !container.HasRealPageGrid || prevSibling == null) + return false; + + if (!BreakValues.IsForcedBreak(box.BreakBefore) && !BreakValues.IsForcedBreak(prevSibling.BreakAfter)) + return false; + + var naturalTop = baseTopWithoutMargin + box.MarginTopCollapse(prevSibling); + var naturalSlot = container.PageIndexOf(naturalTop); + var pageTop = container.PageTopOf(naturalSlot); + if (naturalTop <= pageTop + 0.01) + return false; // Already flush at a fresh page's top - a forced break here does not skip a page. + + slot = naturalSlot + 1; + targetTop = container.PageTopOf(slot); + return true; + } + + /// + /// Called by a block container's child loop right after (and its whole + /// subtree) has finished laying out this pass. If the child straddles a page boundary and either + /// asks not to be broken (break-inside: avoid) or may not be broken at all (a replaced + /// element, a scroll container), and it fits within a single page's height, the child is relaid + /// out fresh at the next page's content top. Does not itself consider whether this leaves a + /// preceding sibling stranded - , called right after this in the + /// same loop iteration, catches that uniformly for every trigger (this one included). + /// + /// + /// The child is genuinely relaid out (ResumeAt + PerformLayout), not + /// OffsetTop-shifted: nothing after this child in its parent's loop has been touched yet + /// this pass, so re-entering its own layout at the new top is cheap, and it is also more correct + /// than a flat shift - any of the child's OWN descendants that themselves have + /// break-inside:avoid or a nested forced break get to make their own decision relative to + /// the real page boundaries at the new position, rather than blindly carrying whatever decision + /// they made at the old one. + /// + /// + /// A real gap found while auditing this port's fragmentation engine against PeachPDF a second + /// time: css-break-3 §3.1's break-point propagation was only ever applied to forced breaks (see + /// 's own remark), never to this kind of relocation. A child + /// moved by this method while it's its parent's first in-flow child - a plain wrapper with no + /// content before it - left the parent spanning from its original page to the child's new one, its + /// own background/border painted as a stub-then-continuation for no reason a CSS author would + /// expect (e.g. a card/panel div wrapping a single table or figure). + /// fixes this by climbing the first-in-flow-child chain and shifting each such ancestor's own top + /// by the same delta, rather than leaving it behind. + /// + internal static void RelocateIfNeeded(RGraphics g, CssBox child) + { + var container = child.HtmlContainer; + if (container == null || !container.HasRealPageGrid || child.IsOutOfFlow) + return; + + var top = child.EffectiveTop; + var bottom = child.ActualBottom; + if (bottom <= top) + return; + + var topSlot = container.PageIndexOf(top); + // Bottom-edge convention: a bottom landing exactly on a boundary belongs to the band above it. + var bottomSlot = container.PageIndexOf(Math.Max(top, bottom - 0.01)); + if (bottomSlot <= topSlot) + return; + + if (!BreakValues.AvoidsBreak(child.BreakInside) && !MonolithicContent.IsMonolithic(child)) + return; + + var height = bottom - top; + if (height >= container.PageSize.Height) + return; // Fits on no single page - left in place rather than moved somewhere it also won't fit. + + var target = container.PageTopOf(topSlot + 1); + child.ResumeAt(null, target); + child.PerformLayout(g); + + PropagateContainerRelocation(child, child.EffectiveTop - top); + } + + /// + /// Called by a block container's child loop right after has finished + /// laying out (and, if applicable, been relocated by ) this pass. If + /// a page break actually falls between and its immediately preceding + /// in-flow sibling, and either of them asks it not to (break-after/break-before: avoid, + /// keep-with-next, css-break-3 §3.1), the whole preceding run chained to that sibling is pulled + /// down to join 's page instead of leaving it stranded on the page it just + /// left - then itself is relaid out fresh, since its own natural top + /// depends on the now-shifted sibling's new bottom. + /// + /// + /// A real gap found while building this: the pre-existing keep-with-next code only ever ran as a side effect + /// of relocating itself - so it only ever + /// fired when was ALSO break-inside:avoid or monolithic. The + /// ordinary case (an unremarkable paragraph that simply doesn't fit after a keep-with-next-chained + /// heading) never triggered it at all: the heading was left stranded on the page it started on + /// while the paragraph moved on alone. This method is the general fix - checked unconditionally, + /// not only after a relocation - and 's own preceding-run handling + /// was removed as redundant once this covers it too (after a relocation moves the child, the + /// preceding sibling is exactly as "left behind" as in the ordinary case, and this method treats + /// both identically). + /// + /// + /// A second real bug found while investigating the fragmentation-engine-parity plan's R9 stage: + /// an earlier version of this method always pulled the WHOLE preceding run to 's + /// page, without checking whether the run (which can be arbitrarily tall - a long chain of + /// break-after:avoid siblings) then fit there at all. This did not just mis-place content - + /// it corrupted layout outright: when a run too tall for one page got pulled, its own later + /// members remained just as likely to trigger their own keep-with-next check against the now + /// artificially-stretched-out run, each firing its own unconditional pull and compounding + /// shifts on the same earlier boxes without bound (observed + /// empirically reaching a box position around 8.6e11 for a 60-member chain on a short page). The + /// fix is css-break-3 §4.3's actual staged relaxation: trim the run from its front (the earliest, + /// least-important-to-keep members) until what remains actually fits the target page alongside + /// ("RunTrimmed"), or leave the run in place entirely if even its last + /// member doesn't fit there ("RunDropped") - never pull a run that can't actually fit. + /// + internal static void EnforceKeepWithNext(RGraphics g, CssBox child) + { + var container = child.HtmlContainer; + if (container == null || !container.HasRealPageGrid || child.IsOutOfFlow) + return; + + var prevSibling = DomUtils.GetPreviousSibling(child); + if (prevSibling == null || prevSibling.IsOutOfFlow) + return; + + if (!BreakValues.AvoidsBreak(prevSibling.BreakAfter) && !BreakValues.AvoidsBreak(child.BreakBefore)) + return; + + var prevBottomSlot = container.PageIndexOf(Math.Max(prevSibling.EffectiveTop, prevSibling.ActualBottom - 0.01)); + var childTopBeforeRelayout = child.EffectiveTop; + var childBottomBeforeRelayout = child.ActualBottom; + var childTopSlot = container.PageIndexOf(childTopBeforeRelayout); + if (childTopSlot <= prevBottomSlot) + return; // No break actually falls between them - nothing to enforce. + + var run = CollectPrecedingKeepWithNextRun(prevSibling); + run.Add(prevSibling); + + // Trim from the front (earliest members) until what remains fits alongside child on the + // target page - see the second remarks block above for why pulling an oversized run + // unconditionally is not just suboptimal but actively corrupts layout. + var childHeight = childBottomBeforeRelayout - childTopBeforeRelayout; + var pageHeight = container.PageSize.Height; + var start = 0; + while (start < run.Count) + { + var runHeight = run[run.Count - 1].ActualBottom - run[start].EffectiveTop; + if (runHeight + childHeight <= pageHeight) + break; + start++; + } + + if (start >= run.Count) + return; // RunDropped - not even the run's last member fits alongside child; leave everything in place. + + var originalGroupTop = run[start].EffectiveTop; // captured before OffsetTop below moves it + var delta = container.PageTopOf(childTopSlot) - originalGroupTop; + if (delta <= 0) + return; // Defensive - a positive shift is the only sensible outcome here. + + for (var i = start; i < run.Count; i++) + { + run[i].OffsetTop(delta); + } + + child.ResumeAt(null, null); + child.PerformLayout(g); + + // css-break-3 §3.1 propagation (see PropagateContainerRelocation and RelocateIfNeeded's own + // remark on the same gap): run[start] is the run's earliest member - if it's also its + // parent's first in-flow child, the parent's own top should follow it up by the same delta. + PropagateContainerRelocation(run[start], delta); + } + + /// + /// Walks backward through already-positioned preceding in-flow siblings chained to + /// by break-after/break-before: avoid (css-break-3 §3.1), + /// so a heading is never left stranded on the page its content just moved off of. + /// + private static List CollectPrecedingKeepWithNextRun(CssBox box) + { + var run = new List(); + var next = box; + var current = DomUtils.GetPreviousSibling(box); + + while (current != null && + (BreakValues.AvoidsBreak(current.BreakAfter) || BreakValues.AvoidsBreak(next.BreakBefore))) + { + run.Insert(0, current); + next = current; + current = DomUtils.GetPreviousSibling(current); + } + + return run; + } + + /// + /// css-break-3 §3.1's break-point propagation applied to relocation, not just to forced breaks + /// (see 's own "no previous sibling" check, which tests the + /// same condition): while is its parent's first in-flow child, the + /// parent's own top has no meaning independent of it - so the parent's + /// is shifted by the same , and the check repeats one level further up + /// (the parent, now itself "the thing that moved"). + /// + /// + /// Deliberately touches only the parent's top, never its bottom/: + /// a container's bottom is independently, correctly computed from its LAST child once that child + /// finishes its own layout (ordinary block flow, unaffected by an EARLIER sibling moving) - only + /// the top, decided once before any child is laid out and never revisited otherwise, needs this + /// correction. This also means the check doesn't need "does the parent have any OTHER content" at + /// all: a later sibling that hasn't been laid out yet (or moved by a different amount) has no + /// bearing on whether the FIRST child's own top should still anchor the parent's. + /// + /// + /// Deliberately narrower than PeachPDF's actual anchor-climbing (which participates in the same + /// call-stack-unwind bubbling every break decision does): this port has no such bubbling for + /// RelocateIfNeeded/EnforceKeepWithNext/InlineFragmentation's relocations (each fires and completes + /// within its own parent's child loop, several stack frames below any grandparent that might also + /// need to react), so climbing further and actually re-laying out an ancestor from underneath its + /// own in-progress layout call would be reentrant and unsafe. This version only ever adjusts the + /// parent's own directly - never a subtree-wide + /// ( has already been repositioned; shifting it again would double-count + /// it) and never a relayout. + /// + /// + /// A third audit pass raised a plausible-sounding concern worth recording as a non-issue: does a + /// list-item marker () go stale here the way it would after a raw + /// change elsewhere? No - confirmed empirically (a diagnostic test + /// showed identical marker positions with and without an explicit marker shift added here). + /// CssBox.CreateListItemBox recomputes the marker's position from its owner's CURRENT + /// Location unconditionally on every PerformLayoutImp call (not only once, at + /// creation) - and every ancestor this method climbs is, by construction, still mid-PerformLayoutImp + /// when it runs (this method is only ever called from deep within that same call's own child-loop + /// or line-breaking step), so CreateListItemBox always re-fires afterward with the + /// already-corrected Location. No explicit marker handling needed here. + /// + internal static void PropagateContainerRelocation(CssBox movedBox, double delta) + { + if (delta == 0) + return; + + var current = movedBox; + var parent = current.ParentBox; + while (parent != null && DomUtils.GetPreviousSibling(current) == null) + { + parent.Location = new RPoint(parent.Location.X, parent.Location.Y + delta); + current = parent; + parent = parent.ParentBox; + } + } + } +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs new file mode 100644 index 000000000..9008d037b --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakToken.cs @@ -0,0 +1,57 @@ +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// A resumption record: where layout stopped in one fragmentainer, so the next one can pick up from + /// exactly that point (https://www.w3.org/TR/css-break-3/#breaking-controls, CSS Fragmentation Level 3 + /// §2/§4.4). Ported from PeachPDF's BreakToken, reduced to what this port's driver loop + /// actually needs: only forced break-before/break-after: page ever produces a real + /// cross-pass token here (see ) - overflow, + /// break-inside:avoid, keep-with-next, widows/orphans, and table-row breaks all turned out to + /// be same-pass local corrections instead (confirmed empirically stage by stage while investigating + /// the fragmentation-engine-parity plan's R2-R9), so PeachPDF's inline and table token kinds - and its + /// per-token FanOutContinuations "parallel flows" mechanism, which only those kinds ever used - + /// have no counterpart in this port and were never added. + /// + /// + /// Tokens form a chain, one link per ancestor between the fragmentation-context root and the box that + /// actually stopped: each link names a box and where inside it to resume, and points at the deeper + /// link for its own child. The driver hands the chain back to the root, which walks it down, so every + /// ancestor on the path re-enters mid-flight while boxes off the path are untouched. A token records + /// where to resume, never geometry: the box tree still holds the coordinates. + /// + /// the box this link of the chain resumes into + /// + /// the pagination slot to resume in. Derived from where the break actually fell, never from "the pass + /// after this one": a box can be placed far down the document, so the fragmentainer it overflows is + /// not in general the one after the fragmentainer the pass nominally started in. + /// + internal abstract record BreakToken(CssBox Box, int ResumeSlotIndex); + + /// A block container stopped part-way through its in-flow children. + /// the block container to resume + /// the pagination slot the resumed pass fills + /// the index into to resume the child loop at + /// + /// how to resume that child, or null when the child has not been entered at all (). + /// + /// + /// whether the break falls before the child rather than inside it. A break before a box means the box + /// was never entered, so it has no geometry in the earlier fragmentainer and produces no fragment + /// there, as opposed to a box that was partially laid out and continues. A break-before child runs its + /// full prologue on resume; a partially laid-out one must not. + /// + /// + /// the document Y to place a break-before child at, when it is not simply the next fragmentainer's + /// band top. Set by the margin-truncation and keep-with-next paths, which have already computed an + /// adjusted target and must not have it re-derived. + /// + internal sealed record BlockBreakToken( + CssBox Box, + int ResumeSlotIndex, + int ResumeChildIndex, + BreakToken ChildToken, + bool IsBreakBefore, + double? ResumeTopOverride) : BreakToken(Box, ResumeSlotIndex); +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs new file mode 100644 index 000000000..daf1b6eb2 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/BreakValues.cs @@ -0,0 +1,34 @@ +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// Classifies a cascaded break-before/break-after/break-inside value, per + /// https://www.w3.org/TR/css-break-3/#break-between (CSS Fragmentation Level 3 §3.1/§3.2). Ported from + /// PeachPDF's BreakValues, reduced to this port's scope: pages only (no multi-column, so no + /// column/avoid-column handling) and no directional breaks (no left/right/ + /// recto/verso/@page :left/:right matching - see the plan's scope decision). + /// + /// + /// One home for every question layout asks about a break value, so a future widening of the accepted + /// value set only has to change one place. + /// + internal static class BreakValues + { + /// + /// Whether forces a page break: page, or the legacy + /// page-break-before/page-break-after: always value, which HTML-Renderer's CSS + /// engine accepts directly on the modern properties too (see BreakMode) rather than + /// normalizing it away at parse time - so both spellings are classified here. + /// + internal static bool IsForcedBreak(string value) => + value is CssConstants.Page or CssConstants.Always; + + /// + /// Whether forbids a break - avoid (both break-inside and + /// the legacy page-break-inside use it) or avoid-page. + /// + internal static bool AvoidsBreak(string value) => + value is CssConstants.Avoid or CssConstants.AvoidPage; + } +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs new file mode 100644 index 000000000..7c42286b5 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// Collects layout's output into the immutable . Layout (see + /// ) already positions every box correctly across however many + /// pages the document spans, in one continuous top-down pass with local relocation corrections - + /// so unlike PeachPDF's pass-based emitter, this one does not need to collect per-pass output over + /// multiple EmitPass calls. Its job is simpler: walk the already-finished box tree once per + /// page band and bucket each box's rectangles into whichever band(s) they fall in, splitting a box + /// that spans multiple pages into one per page it appears on. + /// + internal sealed class FragmentEmitter + { + private readonly HtmlContainerInt _container; + + internal FragmentEmitter(HtmlContainerInt container) + { + _container = container; + } + + /// + /// Materializes the immutable from the box tree as it stands right + /// now. Layout must have already finished - this reads geometry, it does not compute any. + /// + internal FragmentTree Finish() + { + var root = _container.Root; + if (root == null || _container.ActualSize.Height <= 0) + return new FragmentTree(new List(0)); + + if (!_container.HasRealPageGrid) + { + // No bounded page grid (WinForms/WPF's continuous-scroll convention, or any container + // that never set a real PageSize) - the whole document is one fragmentainer. + var rect = new RRect(RPoint.Empty, _container.ActualSize); + var band = new PageBand(0, rect.Height); + var geometry = new PageBandGeometry(0, rect.Height, _container.MarginTop, _container.MarginRight, _container.MarginBottom, _container.MarginLeft); + var rootFragment = BuildBoxFragment(root, 0, band); + var fragmentainer = new FragmentainerFragment(rect, 0, geometry, 0, rootFragment); + return new FragmentTree(new List { fragmentainer }); + } + + // root.ActualBottom (Location.Y + Size.Height), not _container.ActualSize.Height: the + // latter is document height *excluding* the root's own top offset (ActualSize.Height = + // ActualBottom - Root.Location.Y, set at the end of CssBox.PerformLayoutImp/Epilogue), so + // using it directly here as an absolute Y would double-subtract MarginTop inside + // PageIndexOf and under-report the last slot whenever content's true bottom lands just + // past a page boundary that ActualSize.Height alone doesn't yet cross. + var lastSlot = _container.PageIndexOf(Math.Max(0, root.ActualBottom - Epsilon)); + var fragmentainers = new List(); + + // css-position-3, paged media: a fixed box's containing block is each page's own page area, + // and it "is thus replicated on every page". Collected once - each fixed box's own Location + // is already page-relative (CssBox never runs it through normal top-computing flow; see + // CssBox.PerformLayoutImp's own Position==Fixed branch), so building its fragment against a + // band starting at Y=0 (rather than this slot's real band top) localizes it to exactly that + // same relative position on every page, unchanged. + var fixedRoots = new List(); + CollectFixedRoots(root, fixedRoots); + var fixedBand = new PageBand(0, _container.PageSize.Height); + + for (var slot = 0; slot <= lastSlot; slot++) + { + var bandTop = _container.PageTopOf(slot); + var bandBottom = _container.PageBottomOf(slot); + var band = new PageBand(bandTop, bandBottom); + + // CSS Paged Media 3 3.2: a page-slot no box has any content in is never materialized - + // this falls out of the walk rather than being special-cased, since a box only gets + // built into this fragmentainer at all when HasContentInBand finds something. Fixed + // content deliberately does not itself justify materializing an otherwise content-empty + // slot - matches this port's existing blank-page-skipping scope. + if (!HasContentInBand(root, band)) + continue; + + var rootFragment = BuildBoxFragment(root, slot, band); + if (fixedRoots.Count > 0) + { + var fixedFragments = fixedRoots + .Where(fixedRoot => HasContentInBand(fixedRoot, fixedBand)) + .Select(fixedRoot => BuildBoxFragment(fixedRoot, slot, fixedBand)) + .ToList(); + if (fixedFragments.Count > 0) + rootFragment = rootFragment with { Children = rootFragment.Children.Concat(fixedFragments).ToList() }; + } + + var rect = new RRect(0, 0, _container.PageSize.Width, band.Height); + var geometry = new PageBandGeometry(bandTop, band.Height, _container.MarginTop, _container.MarginRight, _container.MarginBottom, _container.MarginLeft); + fragmentainers.Add(new FragmentainerFragment(rect, slot, geometry, bandTop, rootFragment)); + } + + return new FragmentTree(fragmentainers); + } + + /// + /// Finds every position:fixed box in the tree, at any nesting depth - each one gets its + /// own independent repeat-per-page treatment in , regardless of whether it's + /// nested inside another fixed box (rare, but each still resolves its own page-relative position + /// independently per css-position-3, so neither should be folded into the other's subtree). + /// + private static void CollectFixedRoots(CssBox box, List into) + { + foreach (var child in box.Boxes) + { + if (child.Position == CssConstants.Fixed) + into.Add(child); + CollectFixedRoots(child, into); + } + } + + /// + /// Whether or any descendant has some rectangle (its own decoration + /// rects, a word, or a child's) overlapping - used both to decide + /// whether a page-slot is content-empty (skip it) and whether a child belongs in this band's + /// fragment at all. + /// + private bool HasContentInBand(CssBox box, PageBand band) + { + if (box.Rectangles.Count == 0) + { + if (Overlaps(box.Bounds, band)) return true; + } + else + { + foreach (var rect in box.Rectangles.Values) + { + if (Overlaps(rect, band)) return true; + } + } + + foreach (var word in box.Words) + { + if (Overlaps(word.Rectangle, band)) return true; + } + + foreach (var child in box.Boxes) + { + // A fixed box is handled separately when there's a real page grid (see + // CollectFixedRoots/Finish) - it repeats identically on every page rather than + // belonging to whichever band its own (page-relative, not absolute) coordinates would + // otherwise overlap. Without a real page grid (WinForms/WPF continuous-scroll, one + // fragmentainer for the whole document) it stays in the normal walk unchanged - "stays + // put" there is a paint-time scroll-offset suppression, not a repeat-per-page concern. + if (_container.HasRealPageGrid && child.Position == CssConstants.Fixed) continue; + if (HasContentInBand(child, band)) return true; + } + + if (box.ListItemBox != null && HasContentInBand(box.ListItemBox, band)) return true; + + if (box.RepeatedHeaderRows != null) + { + foreach (var repeatedRow in box.RepeatedHeaderRows) + { + if (HasContentInBand(repeatedRow, band)) return true; + } + } + + return false; + } + + /// + /// Builds one for the portion of falling in + /// , recursively, for every descendant with content there. Coordinates + /// are made fragmentainer-local (document Y - .Top) throughout. + /// + private BoxFragment BuildBoxFragment(CssBox box, int fragmentainerIndex, PageBand band) + { + var lines = new List(); + if (box.Rectangles.Count == 0) + { + if (Overlaps(box.Bounds, band)) + { + var clipped = ToLocal(Clip(box.Bounds, band), band); + lines.Add(new LineFragment(clipped, null, TrivialSlice(clipped))); + } + } + else + { + foreach (var pair in box.Rectangles) + { + if (!Overlaps(pair.Value, band)) continue; + var clipped = ToLocal(Clip(pair.Value, band), band); + lines.Add(new LineFragment(clipped, pair.Key, TrivialSlice(clipped))); + } + } + + var words = new List(); + foreach (var word in box.Words) + { + // A word is monolithic (css-break-3 4.1) - never sliced, only localized. + if (Overlaps(word.Rectangle, band)) + words.Add(new TextFragment(ToLocal(word.Rectangle, band), word)); + } + + var children = new List(); + foreach (var child in box.Boxes) + { + // See the matching check/comment in HasContentInBand. + if (_container.HasRealPageGrid && child.Position == CssConstants.Fixed) continue; + if (HasContentInBand(child, band)) + children.Add(BuildBoxFragment(child, fragmentainerIndex, band)); + } + + if (box.RepeatedHeaderRows != null) + { + foreach (var repeatedRow in box.RepeatedHeaderRows) + { + if (HasContentInBand(repeatedRow, band)) + children.Add(BuildBoxFragment(repeatedRow, fragmentainerIndex, band)); + } + } + + BoxFragment markerFragment = null; + if (box.ListItemBox != null && HasContentInBand(box.ListItemBox, band)) + markerFragment = BuildBoxFragment(box.ListItemBox, fragmentainerIndex, band); + + var rect = ToLocal(Clip(box.Bounds, band), band); + var wholeBoxRect = ToLocal(box.Bounds, band); + + var topSlot = _container.PageIndexOf(box.Location.Y); + var bottomSlot = _container.PageIndexOf(Math.Max(box.Location.Y, box.ActualBottom - Epsilon)); + var thisSlot = _container.PageIndexOf(band.Top); + var isFirstFragment = thisSlot <= topSlot; + var isLastFragment = thisSlot >= bottomSlot; + + return new BoxFragment( + rect, + box, + fragmentainerIndex, + OriginY: box.Location.Y, + WholeBoxRect: wholeBoxRect, + IsFixed: box.IsFixed, + IsFirstFragment: isFirstFragment, + IsLastFragment: isLastFragment, + IsMonolithic: MonolithicContent.IsMonolithic(box), + lines, + words, + children, + markerFragment, + OverflowClip: null); + } + + private const double Epsilon = 0.01; + + private static bool Overlaps(RRect rect, PageBand band) => rect.Top < band.Bottom && rect.Bottom > band.Top; + + private static RRect Clip(RRect rect, PageBand band) + { + var top = Math.Max(rect.Top, band.Top); + var bottom = Math.Min(rect.Bottom, band.Bottom); + return new RRect(rect.X, top, rect.Width, Math.Max(0, bottom - top)); + } + + private static RRect ToLocal(RRect rect, PageBand band) => new RRect(rect.X, rect.Y - band.Top, rect.Width, rect.Height); + + /// + /// A no-op - every edge is treated as a real box edge, since real + /// box-decoration-break slicing (distinguishing a genuine break edge from a real box edge) is + /// deferred until paint needs to draw a spanning box's borders correctly (Stage E2). + /// + private static SliceGeometry TrivialSlice(RRect rect) => new(rect, rect, HasLeftEdge: true, HasRightEdge: true); + } +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs new file mode 100644 index 000000000..b8edaacda --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// Inline-flow page-break corrections, applied the same way as : + /// as local shifts to lines has already computed, not + /// via a resumable re-entry into word measurement/line breaking. A line box is monolithic + /// (css-break-3 4.1) and never straddles a page boundary; where the whole run of already-laid-out + /// lines from the last break point would otherwise have too few lines before it (orphans) or + /// leave too few after (widows), the break point moves instead of the line count. + /// + internal static class InlineFragmentation + { + /// + /// Called right after finishes for + /// : pushes any line that would land on a later page than its run's + /// break down to that page's content top - and, honoring orphans/widows, the lines + /// around it - then updates to match. + /// + /// + /// Two phases, deliberately kept separate. Phase 1 decides every break index using each line's + /// own NATURAL (never-shifted) position - a run's total height is preserved under a uniform + /// shift, so "does a candidate run fit on one page" (and therefore where the next break falls) + /// can be decided without knowing where the run will actually land. This is what lets widows + /// cascade backward across more than one earlier break when needed (by removing entries from the + /// decided break list) without having to undo a shift already applied to specific lines - an + /// earlier single-pass version of this method shifted lines incrementally as it went, which + /// couldn't cleanly support that. It also had a subtler failure mode worth recording: once a + /// shift happens to land a run's lines in perfect page-boundary alignment (uniform line heights + /// make this common), no line ever straddles again, so a single-pass method driven purely by "did + /// this line straddle" silently stopped checking orphans/widows for every later page transition - + /// found via a paragraph long enough to span dozens of pages, whose final page ended up with + /// fewer lines than widows required and was never corrected. Phase 1's height-cumulative + /// natural-position test has no such blind spot, since it never depends on whether a straddle was + /// observed. Phase 2 applies the decided breaks as cumulative shifts to the real line boxes, in + /// one forward pass - no decisions left to make there, just arithmetic. + /// + internal static void ApplyLineBreaking(CssBox blockBox) + { + var container = blockBox.HtmlContainer; + // A fixed box (css-position-3, paged media) is repeated identically on every page and its + // own coordinates are page-relative, not absolute document-Y (see FragmentEmitter's + // CollectFixedRoots) - unlike a float or an absolutely-positioned box, which stay in normal + // document flow and must still paginate like anything else, the UA "must not paginate the + // content of fixed-positioned boxes" (css-position-3), so this correction does not apply. + if (container == null || !container.HasRealPageGrid || blockBox.Position == CssConstants.Fixed) + return; + + var lines = blockBox.LineBoxes; + if (lines.Count == 0) + return; + + // Captured before any shifting, for BlockFragmentation.PropagateContainerRelocation at the + // end - see that method's own remarks for why css-break-3 §3.1 propagation applies here too, + // not only to BlockFragmentation's own relocations: a box whose orphans violation pushes its + // whole first run to a fresh page (below) moves its own EffectiveTop exactly the way + // RelocateIfNeeded's block-level relocation does, and a parent that starts with this box + // needs its own top to follow just the same. + var originalTop = lines[0].LineTop; + + var orphans = blockBox.ActualOrphans; + var widows = blockBox.ActualWidows; + var pageHeight = container.PageSize.Height; + + // The first run starts wherever CreateLineBoxes naturally placed line 0 - not necessarily a + // page's top (this box may start partway down a page, after preceding sibling content) - so + // its capacity is only whatever room remains on that page, not a full page height the way + // every later run (which always starts fresh at a page's top, by construction) gets. + var firstPageIndex = container.PageIndexOf(lines[0].LineTop); + var firstRunCapacity = container.PageBottomOf(firstPageIndex) - lines[0].LineTop; + + // How many lines actually fit in the room remaining on the page this box starts on - a + // run's total height measured from line 0 is invariant under a uniform shift (see the + // two-phase remark above), so this natural-position count is valid regardless of where the + // run ends up landing. + var firstRunLineCount = 0; + while (firstRunLineCount < lines.Count && lines[firstRunLineCount].LineBottom - lines[0].LineTop <= firstRunCapacity) + firstRunLineCount++; + + // Orphans (css-break-3 §5.4) applies to the box's very first run exactly like every later + // one: a paragraph starting close enough to a page's bottom that fewer than `orphans` lines + // fit there must move in its ENTIRETY to the next page, not leave a too-small first fragment + // behind. The main loop below cannot fix this on its own - its merge-back correction only + // ever runs once at least one earlier break already exists (`breaks.Count > 1`), which is + // never true while still deciding the first run, so an otherwise-identical violation at the + // very start of a paragraph was silently exempt. Folding it into where the first run begins + // (the same mechanism already used for a single first line taller than the remaining room) + // fixes it without needing a special case in the main loop. Subsumes that single-line case + // too - it is just the `orphans` violation that can never be waived (0 lines fitting is + // always fewer than any orphans value of at least 1). + var firstRunMovedToFreshPage = firstRunLineCount < lines.Count && firstRunLineCount < orphans; + if (firstRunMovedToFreshPage) + { + firstPageIndex++; + firstRunCapacity = pageHeight; + } + + var breaks = new List { 0 }; + + for (var i = 1; i < lines.Count; i++) + { + var runStart = breaks[breaks.Count - 1]; + var capacity = runStart == 0 ? firstRunCapacity : pageHeight; + if (lines[i].LineBottom - lines[runStart].LineTop <= capacity) + continue; // line i still fits in the run that started at runStart + + var linesBefore = i - runStart; + if (linesBefore > 0 && linesBefore < orphans && breaks.Count > 1) + { + // Too few lines to justify breaking here - the attempted run merges into the + // previous page's run instead of leaving a near-empty fragment behind. Re-test this + // same line against the now-earlier run start (cascades further back if needed). + breaks.RemoveAt(breaks.Count - 1); + i--; + } + else + { + breaks.Add(i); + } + } + + // Widows: the run after the LAST break must have at least `widows` lines - if not, merge + // break points backward (as many as needed) until it does, or until only one run is left, or + // until merging further would make the run taller than a page can hold - honoring widows by + // creating a run that can never fit isn't honoring it, it's trading one violation for a worse + // one, so this is where the relaxation gives up rather than forcing it (css-break-3 §4.3's + // own "some constraints can't always be satisfied" philosophy). + while (breaks.Count > 1 && lines.Count - breaks[breaks.Count - 1] < widows) + { + var candidateStart = breaks[breaks.Count - 2]; + var candidateCapacity = candidateStart == 0 ? firstRunCapacity : pageHeight; + if (lines[lines.Count - 1].LineBottom - lines[candidateStart].LineTop > candidateCapacity) + break; + + breaks.RemoveAt(breaks.Count - 1); + } + + // Phase 2: apply the decided breaks as cumulative shifts, in one forward pass. The first + // run's own delta is seeded up front (zero unless firstRunMovedToFreshPage moved it) since + // the loop below only assigns a fresh delta when it crosses breaks[1] onward. + var delta = firstRunMovedToFreshPage ? container.PageTopOf(firstPageIndex) - lines[0].LineTop : 0.0; + var breakOrdinal = 0; + + for (var i = 0; i < lines.Count; i++) + { + if (breakOrdinal + 1 < breaks.Count && i == breaks[breakOrdinal + 1]) + { + breakOrdinal++; + var target = container.PageTopOf(firstPageIndex + breakOrdinal); + delta = target - lines[i].LineTop; // lines[i] not yet shifted this pass + } + + if (delta != 0) + lines[i].ShiftLine(delta); + } + + var maxBottom = 0.0; + foreach (var line in lines) + { + maxBottom = Math.Max(maxBottom, line.LineBottom); + } + + if (maxBottom > 0) + { + blockBox.ActualBottom = maxBottom + blockBox.ActualPaddingBottom + blockBox.ActualBorderBottomWidth; + } + + BlockFragmentation.PropagateContainerRelocation(blockBox, lines[0].LineTop - originalTop); + } + } +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs b/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs new file mode 100644 index 000000000..765f3ab84 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/MonolithicContent.cs @@ -0,0 +1,97 @@ +using System; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// Classifies content that cannot be broken, per + /// https://www.w3.org/TR/css-break-3/#monolithic (CSS Fragmentation Level 3 §2). Ported from + /// PeachPDF's MonolithicContent, reduced to what this port's scope needs: no flex/grid/columns + /// (so narrows to "is a table"), no vertical writing mode, no + /// box-decoration-break clone insets, and HTML-Renderer's own (smaller) set of replaced element types. + /// + /// + /// is css-break-3 §2's own set: a property of the content, which no user + /// agent may break. is an implementation constraint - the table + /// engine fragments its own subtree, so the driver must not hand it a half-laid-out one. Keeping the + /// two apart is the point of this file, exactly as in PeachPDF. + /// + internal static class MonolithicContent + { + /// Whether §2 forbids breaking inside . + internal static bool IsMonolithic(CssBox box) => IsReplaced(box) || IsScrollContainer(box); + + /// + /// Whether is a replaced element, whose content the UA cannot fragment + /// because it has no fragmentable inner structure. HTML-Renderer's replaced-element set is + /// smaller than PeachPDF's - no <object>/<video>, inline SVG, or form-field widgets. + /// + internal static bool IsReplaced(CssBox box) => box is CssBoxImage or CssBoxFrame; + + /// + /// Whether is a scroll container - §2's "elements with overflow + /// other than visible or clip". The root element is excluded (its overflow + /// propagates to the viewport rather than making it a scroll container, CSS Overflow 3 §3.3); the + /// body is excluded only while the root's own overflow is still visible, per the same + /// section's propagation rule. + /// + internal static bool IsScrollContainer(CssBox box) => + box.Overflow != CssConstants.Visible && !IsViewportPropagationSource(box); + + private static bool IsViewportPropagationSource(CssBox box) + { + if (IsRootElement(box)) return true; + + if (!IsNamed(box, "body") || box.ParentBox is not { } parent || !IsRootElement(parent)) + return false; + + return parent.Overflow == CssConstants.Visible; + } + + private static bool IsRootElement(CssBox box) => box.ParentBox == null || IsNamed(box, "html"); + + private static bool IsNamed(CssBox box, string name) => + string.Equals(box.HtmlTag?.Name, name, StringComparison.OrdinalIgnoreCase); + + /// + /// Whether runs a layout engine that fragments its own subtree. In + /// PeachPDF this covers flex, grid, table and multi-column; none of the first three exist in + /// HTML-Renderer, so this narrows to table/inline-table. + /// + internal static bool PaginatesItsOwnContent(CssBox box) => RunsAnEngineOfItsOwn(box.Display); + + /// + /// The display-value half of . Kept as its own method (rather + /// than inlined) so a future engine addition only has to widen this one place, mirroring PeachPDF's + /// shape even though it currently names only one display value. + /// + internal static bool RunsAnEngineOfItsOwn(string display) => + display is CssConstants.Table or CssConstants.InlineTable; + + /// + /// Whether must be treated as an indivisible unit by its parent's own + /// fragmentation. In PeachPDF this also covers unresumable vertical-writing-mode content; that + /// doesn't exist in HTML-Renderer, so this is currently the same set as . + /// Kept as a separate name (rather than inlined at call sites) so a future reason can be added here + /// without touching every caller. + /// + internal static bool IsMonolithicForFragmentation(CssBox box) => IsMonolithic(box); + + /// + /// Whether content tall fits in no fragmentainer at all - §2's + /// overflow-rather-than-slice rule. Content with nowhere to fit must not be treated as breakable: + /// moving it only repeats the question on the next fragmentainer. + /// + internal static bool FitsNoFragmentainer(double height, HtmlContainerInt container) => + height >= container.PageSize.Height; + + /// + /// Whether content tall fits inside a content band + /// tall. Not the negation of : this asks "will it fit there?" about + /// one specific band, where an exact fit fits; that one asks "could this ever fit anywhere?" and + /// treats an exact fit as not fitting. + /// + internal static bool FitsInBand(double height, double bandHeight) => height <= bandHeight; + } +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/PageBand.cs b/Source/HtmlRenderer/Core/Fragmentation/PageBand.cs new file mode 100644 index 000000000..257689194 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/PageBand.cs @@ -0,0 +1,24 @@ +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// A fragmentainer's block-axis extent: the coordinates its content may occupy. The value form of the + /// band exposes, so "which fragmentainer is this coordinate in" can + /// be asked of the page grid without a live to hand - which matters + /// because a box being laid out is not always inside the fragmentainer currently being filled + /// (monolithic content, a box below a tall margin). + /// + internal readonly struct PageBand + { + public PageBand(double top, double bottom) + { + Top = top; + Bottom = bottom; + } + + public double Top { get; } + public double Bottom { get; } + public double Height => Bottom - Top; + + public bool Contains(double y) => y >= Top && y < Bottom; + } +} diff --git a/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs b/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs new file mode 100644 index 000000000..96444f152 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragmentation/TableHeaderRepeat.cs @@ -0,0 +1,75 @@ +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation +{ + /// + /// Builds the detached row clones holds - css-tables-3 + /// 6.2's repeated <thead> content, one clone per continuation page a table's body spans. + /// + /// + /// Unlike PeachPDF's proxy-based approach (a shared source subtree re-emitted at each page's + /// position purely at the fragment-tree level), this clones real, laid-out + /// instances. That's a deliberate simplification for this port: it makes the repeat visible + /// through both the existing scroll-offset PDF pipeline and the new fragment tree without + /// teaching two different rendering paths about "one source, several positions" - at the cost of + /// only reproducing what a clone can cheaply carry over (a header's own decoration and words; + /// multi-line per-box decoration rectangles inside a header cell are not reproduced, since that + /// needs cloning CssLineBox instances too - an accepted gap for the common single-line-header + /// case this feature targets). + /// + internal static class TableHeaderRepeat + { + /// + /// Clones (a <thead> row, recursively with its cells and their + /// content) and shifts the clone so the row's rendered top - , + /// the caller's own reference, since a <tr> box's own Location is never assigned by table + /// layout (only its cells' is - see 's row loop) - lands + /// at . The clone is fully detached - not part of any box's + /// - so re-running table layout can never mistake it for real content. + /// + internal static CssBox CloneAndPosition(CssBox source, double sourceRenderedTop, double targetTop) + { + var clone = CloneSubtree(source, null); + var delta = targetTop - sourceRenderedTop; + if (delta != 0) + clone.OffsetTop(delta); + return clone; + } + + private static CssBox CloneSubtree(CssBox source, CssBox newParent) + { + var clone = new CssBox(newParent, source.HtmlTag); + clone.InheritStyle(source, everything: true); + clone.HtmlContainer = source.HtmlContainer; + clone.Location = source.Location; + clone.Size = source.Size; + clone.ActualBottom = source.ActualBottom; + clone.ActualRight = source.ActualRight; + + if (source.Words.Count > 0) + { + clone.Text = source.Text; + clone.ParseToWords(); + + // Reuses the source's already-measured word geometry rather than re-measuring - the + // clone's tokenization matches the source's own (same Text, same ParseToWords), so a + // positional pairing is safe here. + var count = clone.Words.Count < source.Words.Count ? clone.Words.Count : source.Words.Count; + for (var i = 0; i < count; i++) + { + clone.Words[i].Left = source.Words[i].Left; + clone.Words[i].Top = source.Words[i].Top; + clone.Words[i].Width = source.Words[i].Width; + clone.Words[i].Height = source.Words[i].Height; + } + } + + foreach (var child in source.Boxes) + { + CloneSubtree(child, clone); + } + + return clone; + } + } +} diff --git a/Source/HtmlRenderer/Core/Fragments/Fragment.cs b/Source/HtmlRenderer/Core/Fragments/Fragment.cs new file mode 100644 index 000000000..a6e20ac01 --- /dev/null +++ b/Source/HtmlRenderer/Core/Fragments/Fragment.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace TheArtOfDev.HtmlRenderer.Core.Fragments +{ + /// + /// The immutable output of layout - a "box fragment" per CSS Fragmentation Module Level 3 §2 + /// (https://www.w3.org/TR/css-break-3/#fragment). Layout produces this tree exactly once, at the end + /// of ; paint consumes it and must not read geometry off + /// the mutable tree. + /// + /// + /// A owns geometry only - style and paint-handler dispatch are reached through + /// (a live back-reference), so fragments stay cheap + /// and style keeps one home. Coordinates are fragmentainer-local: local.Y = documentY - + /// fragmentainer.LocalOriginY, X unchanged (a page's horizontal margin is applied by the painter's own + /// translate, not by layout). + /// + internal abstract record Fragment(RRect Rect); + + /// + /// Tells a decoration rectangle whether each of its four physical edges is a real box edge or a + /// fragmentation break, for CSS box-decoration-break (css-break-3 §6.2). Not a + /// itself - it's carried by a . is what a + /// slice value resolves against; is what clone resolves against. + /// + internal sealed record SliceGeometry( + RRect UnbrokenStrip, + RRect FragmentRect, + bool HasLeftEdge, + bool HasRightEdge, + bool HasTopEdge = true, + bool HasBottomEdge = true); + + /// + /// One line box's decoration rectangle - or, for a block-level box with no lines of its own, one rect + /// covering the whole border box, with null. The fragment-tree analog of a single + /// entry in a box's per-line paint rectangles. + /// + internal sealed record LineFragment(RRect Rect, CssLineBox Line, SliceGeometry Slice) : Fragment(Rect); + + /// One laid-out word (or inline replaced run). Words are monolithic - one maps to exactly one . + internal sealed record TextFragment(RRect Rect, CssRect Word) : Fragment(Rect); + + /// + /// The portion of one living in one fragmentainer. A box spanning a page boundary + /// produces one per page. // + /// mirror what the old live-tree paint walk painted, in the same order: own decoration rects, own words, + /// then stacking-ordered child box fragments. (a list item's marker, if any) + /// is kept separate from rather than folded in, matching the old live-tree + /// walk's own paint order - the marker paints last, after this fragment's own overflow clip is popped, since a + /// list-style-position: outside marker can legitimately hang outside the content box's clip. + /// + internal sealed record BoxFragment( + RRect Rect, + CssBox Box, + int FragmentainerIndex, + double OriginY, + RRect WholeBoxRect, + bool IsFixed, + bool IsFirstFragment, + bool IsLastFragment, + bool IsMonolithic, + IReadOnlyList Lines, + IReadOnlyList Words, + IReadOnlyList Children, + BoxFragment MarkerFragment, + RRect? OverflowClip) : Fragment(Rect) + { + /// The rect a replaced element paints its background/border over: the first line's rect, else this fragment's own rect. + public RRect PrimaryRect => Lines.Count > 0 ? Lines[0].Rect : Rect; + + /// Reference-equality lookup of a word's fragment rect within this box fragment. + public bool TryGetWordRect(CssRect word, out RRect rect) + { + foreach (var text in Words) + { + if (ReferenceEquals(text.Word, word)) + { + rect = text.Rect; + return true; + } + } + + rect = default; + return false; + } + } + + /// + /// One page - one materialized fragmentainer. is the pagination-slot index this + /// occupies; slot indices are not contiguous across , since a + /// content-empty slot is never materialized (CSS Paged Media 3 §3.2). is the document + /// root's for this page. + /// + internal sealed record FragmentainerFragment( + RRect Rect, + int SlotIndex, + PageBandGeometry Geometry, + double LocalOriginY, + BoxFragment Root) : Fragment(Rect); + + /// The complete immutable result of laying out one document - fragmentainers in page order. + internal sealed record FragmentTree(IReadOnlyList Fragmentainers); +} diff --git a/Source/HtmlRenderer/Core/HtmlContainerInt.cs b/Source/HtmlRenderer/Core/HtmlContainerInt.cs index bb9a20cc8..ab9e3e7d8 100644 --- a/Source/HtmlRenderer/Core/HtmlContainerInt.cs +++ b/Source/HtmlRenderer/Core/HtmlContainerInt.cs @@ -18,6 +18,8 @@ using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core.Dom; using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Core.Fragmentation; +using TheArtOfDev.HtmlRenderer.Core.Fragments; using TheArtOfDev.HtmlRenderer.Core.Handlers; using TheArtOfDev.HtmlRenderer.Core.Parse; using TheArtOfDev.HtmlRenderer.Core.Utils; @@ -444,7 +446,41 @@ public bool HasFloatedBoxes public RSize PageSize { get; set; } /// - /// the top margin between the page start and the text + /// Whether this container is paginating against a real, bounded page grid, as opposed to an + /// effectively unbounded single "page" (WinForms/WPF's continuous-scroll convention, which sets + /// to a large sentinel - see HtmlContainer.PageSize in the WinForms/ + /// WPF projects). Fragmentation corrections (forced breaks, break-inside:avoid relocation, margin + /// truncation) only make sense, and only run, when this is true. + /// + internal bool HasRealPageGrid + { + get { return PageSize.Height > 0 && PageSize.Height < 90999; } + } + + /// + /// The zero-based pagination slot document-space coordinate falls in - the + /// top-edge convention (a coordinate exactly on a page boundary belongs to the page that starts + /// there). Only meaningful when . + /// + internal int PageIndexOf(double y) + { + return (int)Math.Floor((y - MarginTop) / PageSize.Height); + } + + /// Document-space Y of the top of pagination slot 's content band. + internal double PageTopOf(int slot) + { + return MarginTop + slot * PageSize.Height; + } + + /// Document-space Y of the bottom of pagination slot 's content band. + internal double PageBottomOf(int slot) + { + return PageTopOf(slot) + PageSize.Height; + } + + /// + /// The top margin between the page start and the text /// public int MarginTop { @@ -529,6 +565,13 @@ internal CssBox Root get { return _root; } } + /// + /// The immutable fragment tree layout produced from the box tree on the last + /// call - the result paint reads from, rather than walking the mutable box tree directly. Null + /// before the first layout, or when there is nothing to lay out. + /// + internal FragmentTree FragmentTree { get; private set; } + /// /// the text fore color use for selected text /// @@ -710,7 +753,7 @@ public void PerformLayout(RGraphics g) _root.Size = new RSize(_maxSize.Width > 0 ? _maxSize.Width : 99999, 0); _root.Location = _location; _hasFloatedBoxes = ComputeHasFloatedBoxes(_root); - _root.PerformLayout(g); + DriveLayoutPasses(g); if (_maxSize.Width <= 0.1) { @@ -718,7 +761,7 @@ public void PerformLayout(RGraphics g) _root.Size = new RSize((int)Math.Ceiling(_actualSize.Width), 0); _actualSize = RSize.Empty; _hasFloatedBoxes = ComputeHasFloatedBoxes(_root); - _root.PerformLayout(g); + DriveLayoutPasses(g); } if (!_loadComplete) @@ -729,6 +772,46 @@ public void PerformLayout(RGraphics g) handler(this, EventArgs.Empty); } } + + FragmentTree = new FragmentEmitter(this).Finish(); + } + + /// + /// The resumable per-fragmentainer pass loop (matching PeachPDF's LayoutDocument): lay the + /// whole document out once; if stopped partway through (its own + /// is set - see that property's doc comment for how a break + /// discovered arbitrarily deep in the tree reaches it), resume from exactly that point and lay out + /// again; repeat until nothing is left pending. For a container with no real page grid (WinForms/ + /// WPF's continuous-scroll convention), or a document with no forced breaks at all, this runs + /// exactly once - 's default (no token, no override) is indistinguishable + /// from this engine's original single unbounded pass. + /// + private void DriveLayoutPasses(RGraphics g) + { + if (!HasRealPageGrid) + { + _root.PerformLayout(g); + return; + } + + // A backstop, not a real budget (matching PeachPDF's own sentinel) - a real document can only + // exhaust this many passes if something is genuinely wrong (a break token that never resolves + // forward), not from ordinary content length, since R1's scope (forced breaks only) resumes + // at most once per forced break in the whole document. + const int maxPasses = 100_000; + + BreakToken token = null; + for (var pass = 0; pass < maxPasses; pass++) + { + _root.ResumeAt(token); + _root.PerformLayout(g); + + var next = _root.PendingBreakToken; + if (next == null) + break; + + token = next; + } } /// @@ -765,14 +848,51 @@ public void PerformPaint(RGraphics g) g.PushClip(new RRect(MarginLeft, MarginTop, PageSize.Width, PageSize.Height)); } - if (_root != null) + // Every fragmentainer, painted onto this one continuous surface, each translated back to its + // real document-Y band top - exactly what the old live-tree walk (_root.Paint(g), removed + // once this replaced it) did by construction, since box geometry there was always absolute. + // For every caller of this overload today (WinForms/WPF's continuous single-surface + // rendering, any other HasRealPageGrid=false container) there is exactly one fragmentainer + // whose LocalOriginY is already 0, so this loop runs once with a no-op page origin - a direct + // multi-page-grid caller of this overload (bypassing PdfGenerator's real per-fragmentainer + // loop below) is the only case where more than one iteration, or a non-zero origin, happens. + if (FragmentTree != null) { - _root.Paint(g); + foreach (var fragmentainer in FragmentTree.Fragmentainers) + { + var pageOrigin = new RPoint(0, fragmentainer.LocalOriginY); + new Paint.FragmentPainter(this, pageOrigin).Paint(g, fragmentainer); + } } g.PopClip(); } + /// + /// Render one fragmentainer using the given device, reading from the immutable fragment tree + /// rather than walking the mutable box tree directly. + /// + /// the device to use to render + /// the fragmentainer to paint + internal void PerformPaint(RGraphics g, Fragments.FragmentainerFragment fragmentainer) + { + ArgChecker.AssertArgNotNull(g, "g"); + ArgChecker.AssertArgNotNull(fragmentainer, "fragmentainer"); + + if (MaxSize.Height > 0) + { + g.PushClip(new RRect(_location.X, _location.Y, Math.Min(_maxSize.Width, PageSize.Width), Math.Min(_maxSize.Height, PageSize.Height))); + } + else + { + g.PushClip(new RRect(MarginLeft, MarginTop, PageSize.Width, PageSize.Height)); + } + + new Paint.FragmentPainter(this).Paint(g, fragmentainer); + + g.PopClip(); + } + /// /// Handle mouse down to handle selection. /// diff --git a/Source/HtmlRenderer/Core/PageBandGeometry.cs b/Source/HtmlRenderer/Core/PageBandGeometry.cs new file mode 100644 index 000000000..2fc5c45d4 --- /dev/null +++ b/Source/HtmlRenderer/Core/PageBandGeometry.cs @@ -0,0 +1,35 @@ +namespace TheArtOfDev.HtmlRenderer.Core +{ + /// + /// The resolved block-axis band and margins one fragmentainer (page) occupies, in true output units. + /// HTML-Renderer keeps a single fixed page size/margins per document (no per-page @page overrides, + /// unlike PeachPDF's variable-geometry PageGeometryTable), so this is a plain value computed once + /// from the container's and margins rather than a table. + /// + internal readonly struct PageBandGeometry + { + public PageBandGeometry(double top, double height, double marginTop, double marginRight, double marginBottom, double marginLeft) + { + Top = top; + Height = height; + MarginTop = marginTop; + MarginRight = marginRight; + MarginBottom = marginBottom; + MarginLeft = marginLeft; + } + + /// Document-space Y of the top of this fragmentainer's content band. + public double Top { get; } + + /// The content band's block-axis extent. + public double Height { get; } + + public double MarginTop { get; } + public double MarginRight { get; } + public double MarginBottom { get; } + public double MarginLeft { get; } + + /// Document-space Y of the bottom of this fragmentainer's content band. + public double Bottom => Top + Height; + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/FragmentContentPainters.cs b/Source/HtmlRenderer/Core/Paint/Content/FragmentContentPainters.cs new file mode 100644 index 000000000..784ff6599 --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/Content/FragmentContentPainters.cs @@ -0,0 +1,28 @@ +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content +{ + /// + /// Dispatches a box to its , matching PeachPDF's + /// FragmentContentPainters.For. A null result tells the generic + /// box-fragment path (background/border per line, words, decoration, stacking-ordered children) + /// applies instead - every leaf/replaced type with its own paint shape is listed here explicitly. + /// + internal static class FragmentContentPainters + { + internal static IFragmentContentPainter For(CssBox box) + { + switch (box) + { + case CssBoxImage: + return ImageFragmentPainter.Instance; + case CssBoxHr: + return HrFragmentPainter.Instance; + case CssBoxFrame: + return FrameFragmentPainter.Instance; + default: + return null; + } + } + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/FrameFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/FrameFragmentPainter.cs new file mode 100644 index 000000000..804a38ba9 --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/Content/FrameFragmentPainter.cs @@ -0,0 +1,24 @@ +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Fragments; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content +{ + /// Paints an <iframe> fragment - the YouTube/Vimeo video thumbnail/title/play chrome. + internal sealed class FrameFragmentPainter : ReplacedFragmentPainter + { + internal static readonly FrameFragmentPainter Instance = new FrameFragmentPainter(); + + private FrameFragmentPainter() + { + } + + protected override void DrawContent(RGraphics g, BoxFragment fragment, RPoint offset) + { + var box = (CssBoxFrame)fragment.Box; + box.EnsureVideoImageLoadStarted(); + box.DrawFrameContent(g, offset); + } + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs new file mode 100644 index 000000000..adc9e8037 --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs @@ -0,0 +1,30 @@ +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Fragments; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content +{ + /// + /// Paints an <hr> fragment. Not a - a rule draws + /// each border edge itself rather than going through the shared background+DrawBoxBorders step + /// (see ), matching PeachPDF's HrFragmentPainter. + /// + internal sealed class HrFragmentPainter : IFragmentContentPainter + { + internal static readonly HrFragmentPainter Instance = new HrFragmentPainter(); + + private HrFragmentPainter() + { + } + + public void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment) + { + var box = (CssBoxHr)fragment.Box; + var offset = painter.FragmentLocalOffset(box.IsFixed); + var rect = fragment.PrimaryRect; + rect.Offset(offset); + box.DrawHrContent(g, rect); + } + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/IFragmentContentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/IFragmentContentPainter.cs new file mode 100644 index 000000000..443ffbef4 --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/Content/IFragmentContentPainter.cs @@ -0,0 +1,15 @@ +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Core.Fragments; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content +{ + /// + /// Paints one box fragment's own replaced/leaf content - the per-type half of + /// 's dispatch (see ), matching + /// PeachPDF's IFragmentContentPainter shape. Implementations are stateless singletons. + /// + internal interface IFragmentContentPainter + { + void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment); + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/ImageFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/ImageFragmentPainter.cs new file mode 100644 index 000000000..915f2bfcc --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/Content/ImageFragmentPainter.cs @@ -0,0 +1,24 @@ +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Fragments; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content +{ + /// Paints an <img> fragment - image, or error/loading placeholder. + internal sealed class ImageFragmentPainter : ReplacedFragmentPainter + { + internal static readonly ImageFragmentPainter Instance = new ImageFragmentPainter(); + + private ImageFragmentPainter() + { + } + + protected override void DrawContent(RGraphics g, BoxFragment fragment, RPoint offset) + { + var box = (CssBoxImage)fragment.Box; + box.EnsureImageLoadStarted(); + box.DrawImageContent(g, offset); + } + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs new file mode 100644 index 000000000..00396d457 --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs @@ -0,0 +1,43 @@ +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.Core.Handlers; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content +{ + /// + /// Shared clip/background/border sequence for replaced leaf elements (, + /// ) - both paint the same way (clip by overflow, then + /// , then ) before + /// their own type-specific content, matching PeachPDF's ReplacedFragmentPainter base. Uses + /// rather than CssBox.Rectangles directly since replaced + /// elements are monolithic (one fragment always covers the whole box, css-break-3 4.1). + /// + internal abstract class ReplacedFragmentPainter : IFragmentContentPainter + { + public void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment) + { + var box = fragment.Box; + + // fragment.PrimaryRect is fragment-local; the image/video word rect DrawContent's + // implementations read is off the live tree (still absolute document-Y) - each needs its own + // offset flavor, see FragmentPainter.FragmentLocalOffset/LiveTreeOffset's doc comments. + var rect = fragment.PrimaryRect; + rect.Offset(painter.FragmentLocalOffset(box.IsFixed)); + + var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, painter.LiveTreeExtraOffset(box.IsFixed)); + + box.PaintBackground(g, rect, true, true); + BordersDrawHandler.DrawBoxBorders(g, box, rect, true, true); + + DrawContent(g, fragment, painter.LiveTreeOffset(box.IsFixed)); + + if (clipped) + g.PopClip(); + } + + protected abstract void DrawContent(RGraphics g, BoxFragment fragment, RPoint offset); + } +} diff --git a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs new file mode 100644 index 000000000..3c2cdfef9 --- /dev/null +++ b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs @@ -0,0 +1,265 @@ +using System; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.Core.Handlers; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Paint +{ + /// + /// Paints a fragmentainer from the immutable fragment tree - the sole paint path now that the old + /// live-tree walk (formerly CssBox.Paint/PaintImp) has been deleted. Every geometric + /// decision reads from the being painted, including text: each word paints + /// at its own , not CssRect.Rectangle read off the live box. + /// The box back-reference () is consulted only for computed style and + /// paint primitives themselves (// + /// - widened from protected/private to internal + /// rather than duplicated here, so this stays a faithful re-shaping of the existing, tested paint code + /// rather than a parallel reimplementation). + /// + /// + /// Leaf/replaced types dispatch to their own (matching + /// PeachPDF's IFragmentContentPainter/FragmentContentPainters shape, see + /// ); everything else uses the generic box-fragment + /// path below. Stacking-context paint order and box-decoration-break slicing are follow-on + /// work once real fragmentation (multiple fragments per box) exists for them to matter. + /// + internal sealed class FragmentPainter + { + private readonly HtmlContainerInt _container; + + /// + /// Added to every painted rect on top of - zero for + /// every ordinary caller (one fragmentainer already in its own native coordinate system: a PDF + /// page's own XGraphics, or the single always-page-local-zero fragmentainer WinForms/WPF's + /// continuous document produces). Non-zero only when + /// paints several fragmentainers onto one continuous surface (its multi-fragmentainer branch) - + /// there each fragmentainer's content is fragment-tree-local (translated so the band's own top is + /// Y=0) and must be translated back by the band's real document-Y top to land in the right place + /// on the shared surface, matching what painting the old, unfragmented box tree once did directly. + /// + private readonly RPoint _pageOrigin; + + /// + /// The real document-Y top of the fragmentainer currently being painted (), + /// set once per call. Geometry sourced from the fragment tree (/ + /// /) is already local to this band + /// ( subtracts it at build time) and needs no further + /// adjustment for it. Geometry read straight off the live tree instead + /// ('s image-word rect, the visibility cull below) is + /// still absolute document-Y and must have this subtracted to land in the same target frame - + /// missing this distinction for text was a real bug (found while building the continuous-surface + /// paint path this field supports, since fixed by moving word painting onto the fragment tree + /// entirely rather than reconciling it): every page after the first silently painted zero text, + /// since a fresh per-page surface's origin is this band's top, not the document's. + /// + private double _bandTop; + + internal FragmentPainter(HtmlContainerInt container, RPoint pageOrigin = default) + { + _container = container; + _pageOrigin = pageOrigin; + } + + /// + /// The offset to apply to a box's fragment-local rect (already local to the fragmentainer being + /// painted) to reach its paint position: scroll offset (suppressed for a fixed-position box, + /// matching the old live-tree walk's behavior) plus (applies regardless + /// of - the old, single continuous-surface paint path this replaced + /// never gave "fixed" boxes special treatment with respect to which page's content they belonged + /// to, only whether scroll offset applied to them). + /// + internal RPoint FragmentLocalOffset(bool isFixed) + { + var scroll = isFixed ? RPoint.Empty : _container.ScrollOffset; + return new RPoint(scroll.X + _pageOrigin.X, scroll.Y + _pageOrigin.Y); + } + + /// + /// The offset to apply to a rect read straight off the live tree (still + /// absolute document-Y, unlike fragment-tree geometry) to reach the same paint position + /// gives fragment-local geometry: undoes + /// - except for a fixed (or fixed-ancestor) box, whose live geometry is already page-relative + /// (see the remark below), where undoing this painter's current band top would double-subtract + /// it, pushing the box far outside every page except the one whose band top happens to equal its + /// own small top offset. + /// + /// + /// A real bug found while confirming 's fixed-position repeat-per-page + /// support through actual PDF output: CssBox.PerformLayoutImp never routes a + /// Position==Fixed box through normal top-computing flow at all (its Left/Top + /// property setters assign Location directly, from GetActualLocation, resolved + /// against the page size) - so unlike ordinary content, whose live Location genuinely is an + /// absolute document-Y this painter's current band top needs undoing from, a fixed box's live + /// Location already IS the small, page-relative offset the fragment tree also uses. This + /// only affected the containing-block visibility/overflow-clip checks below ('s + /// own check, and via ) + /// - the fragment tree's own already-correct geometry (fragment.Lines/fragment.Words, + /// via alone) was never affected, which is why the fixed content + /// was confirmed correctly PRESENT in the fragment tree on every page before this was found - it + /// was being computed correctly and then clipped away on every page except one. + /// + internal RPoint LiveTreeOffset(bool isFixed) + { + var offset = FragmentLocalOffset(isFixed); + return isFixed ? offset : new RPoint(offset.X, offset.Y - _bandTop); + } + + /// + /// The portion of that + /// doesn't already add itself (it applies gating + /// internally) - pass as its extraOffset parameter. See 's own + /// remark for why must gate the band-top term here too. + /// + internal RPoint LiveTreeExtraOffset(bool isFixed) => + isFixed ? new RPoint(_pageOrigin.X, _pageOrigin.Y) : new RPoint(_pageOrigin.X, _pageOrigin.Y - _bandTop); + + internal void Paint(RGraphics g, FragmentainerFragment fragmentainer) + { + _bandTop = fragmentainer.LocalOriginY; + PaintFragment(g, fragmentainer.Root); + } + + /// + /// Paints one box fragment: display/visibility gate, fixed-position clip suspension, and the + /// same "is this rect actually in the visible area" cull the old live-tree walk used, before + /// handing off to the box's own content. + /// + private void PaintFragment(RGraphics g, BoxFragment fragment) + { + var box = fragment.Box; + try + { + if (box.Display == CssConstants.None || box.Visibility != CssConstants.Visible) + return; + + // Only this box's own Position, not IsFixed's ancestor-aware sense - matching the old live-tree walk. + var suspendsClip = box.Position == CssConstants.Fixed; + if (suspendsClip) + g.SuspendClipping(); + + var visible = box.Rectangles.Count == 0; + if (!visible) + { + // box.ContainingBlock.ClientRectangle is read off the live box tree - still absolute + // document-Y, unlike fragment-tree geometry, so this needs LiveTreeOffset (not just + // ScrollOffset) to land in this painter's target frame. + var clip = g.GetClip(); + var rect = box.ContainingBlock.ClientRectangle; + rect.X -= 2; + rect.Width += 2; + rect.Offset(LiveTreeOffset(box.IsFixed)); + clip.Intersect(rect); + visible = clip != RRect.Empty; + } + + if (visible) + PaintFragmentContent(g, fragment); + + if (suspendsClip) + g.ResumeClipping(); + } + catch (Exception ex) + { + _container.ReportError(HtmlRenderErrorType.Paint, "Exception in fragment paint", ex); + } + } + + /// + /// Paints one box fragment's own decorations, words, and children. + /// + private void PaintFragmentContent(RGraphics g, BoxFragment fragment) + { + var box = fragment.Box; + + var contentPainter = Content.FragmentContentPainters.For(box); + if (contentPainter != null) + { + contentPainter.Paint(this, g, fragment); + return; + } + + if (box.Display == CssConstants.None || + (box.Display == CssConstants.TableCell && box.EmptyCells == CssConstants.Hide && box.IsSpaceOrEmpty)) + { + return; + } + + var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, LiveTreeExtraOffset(box.IsFixed)); + var clip = g.GetClip(); + // fragment.Lines/fragment.Words are already fragment-local (FragmentEmitter subtracted the + // band top at build time) - only FragmentLocalOffset (scroll + page-origin) applies to either. + var offset = FragmentLocalOffset(box.IsFixed); + var lines = fragment.Lines; + + for (var i = 0; i < lines.Count; i++) + { + var actualRect = lines[i].Rect; + actualRect.Offset(offset); + if (IsRectVisible(actualRect, clip)) + { + box.PaintBackground(g, actualRect, i == 0, i == lines.Count - 1); + BordersDrawHandler.DrawBoxBorders(g, box, actualRect, i == 0, i == lines.Count - 1); + } + } + + // Width.Length > 0 gate matches CssBox's own former PaintWords guard - preserved here since + // it's the caller's job now that word painting reads the fragment tree, not the live box. + if (box.Width.Length > 0) + { + foreach (var wordFragment in fragment.Words) + { + var wordRect = wordFragment.Rect; + wordRect.Offset(offset); + box.PaintWord(g, wordFragment.Word, wordRect); + } + } + + for (var i = 0; i < lines.Count; i++) + { + var actualRect = lines[i].Rect; + actualRect.Offset(offset); + if (IsRectVisible(actualRect, clip)) + { + box.PaintDecoration(g, actualRect, i == 0, i == lines.Count - 1); + } + } + + // Split to match the old live-tree walk's z-order: normal flow, then absolute, then fixed. + foreach (var child in fragment.Children) + { + if (child.Box.Position != CssConstants.Absolute && !child.Box.IsFixed) + PaintFragment(g, child); + } + foreach (var child in fragment.Children) + { + if (child.Box.Position == CssConstants.Absolute) + PaintFragment(g, child); + } + foreach (var child in fragment.Children) + { + if (child.Box.IsFixed) + PaintFragment(g, child); + } + + if (clipped) + g.PopClip(); + + // Marker paints last, after this fragment's own overflow clip is popped - see + // BoxFragment.MarkerFragment's doc comment for why it's kept separate from Children. + if (fragment.MarkerFragment != null) + PaintFragment(g, fragment.MarkerFragment); + } + + private static bool IsRectVisible(RRect rect, RRect clip) + { + rect.X -= 2; + rect.Width += 2; + clip.Intersect(rect); + return clip != RRect.Empty; + } + } +} diff --git a/Source/HtmlRenderer/Core/Utils/CssConstants.cs b/Source/HtmlRenderer/Core/Utils/CssConstants.cs index 06fe0aa02..923f3a9c1 100644 --- a/Source/HtmlRenderer/Core/Utils/CssConstants.cs +++ b/Source/HtmlRenderer/Core/Utils/CssConstants.cs @@ -89,6 +89,9 @@ internal static class CssConstants public const string Oblique = "oblique"; public const string Outset = "outset"; public const string Overline = "overline"; + public const string Page = "page"; + public const string Always = "always"; + public const string AvoidPage = "avoid-page"; public const string Pre = "pre"; public const string PreWrap = "pre-wrap"; public const string PreLine = "pre-line"; diff --git a/Source/HtmlRenderer/Core/Utils/CssUtils.cs b/Source/HtmlRenderer/Core/Utils/CssUtils.cs index df9fe7dc7..412260e32 100644 --- a/Source/HtmlRenderer/Core/Utils/CssUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CssUtils.cs @@ -47,7 +47,9 @@ internal static class CssUtils "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius", "margin-bottom", "margin-left", "margin-right", "margin-top", "padding-bottom", "padding-left", "padding-right", "padding-top", - "page-break-inside", "left", "top", "width", "max-width", "height", "min-height", "max-height", + "page-break-inside", "break-inside", "break-before", "break-after", "page-break-before", "page-break-after", + "widows", "orphans", "page", + "left", "top", "width", "max-width", "height", "min-height", "max-height", "background-color", "background-image", "background-position", "background-repeat", "content", "color", "display", "direction", "empty-cells", "float", "clear", "box-sizing", "position", "line-height", "vertical-align", "text-indent", "text-align", "text-decoration-line", @@ -149,6 +151,22 @@ public static string GetPropertyValue(CssBox cssBox, string propName) return cssBox.PaddingTop; case "page-break-inside": return cssBox.PageBreakInside; + case "break-inside": + return cssBox.BreakInside; + case "break-before": + return cssBox.BreakBefore; + case "break-after": + return cssBox.BreakAfter; + case "page-break-before": + return cssBox.PageBreakBefore; + case "page-break-after": + return cssBox.PageBreakAfter; + case "widows": + return cssBox.Widows; + case "orphans": + return cssBox.Orphans; + case "page": + return cssBox.PageName; case "left": return cssBox.Left; case "top": @@ -328,6 +346,30 @@ public static void SetPropertyValue(CssBox cssBox, string propName, string value case "page-break-inside": cssBox.PageBreakInside = value; break; + case "break-inside": + cssBox.BreakInside = value; + break; + case "break-before": + cssBox.BreakBefore = value; + break; + case "break-after": + cssBox.BreakAfter = value; + break; + case "page-break-before": + cssBox.PageBreakBefore = value; + break; + case "page-break-after": + cssBox.PageBreakAfter = value; + break; + case "widows": + cssBox.Widows = value; + break; + case "orphans": + cssBox.Orphans = value; + break; + case "page": + cssBox.PageName = value; + break; case "left": cssBox.Left = value; break; diff --git a/Source/HtmlRenderer/Core/Utils/RenderUtils.cs b/Source/HtmlRenderer/Core/Utils/RenderUtils.cs index 091c75191..4239f569c 100644 --- a/Source/HtmlRenderer/Core/Utils/RenderUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/RenderUtils.cs @@ -39,7 +39,15 @@ public static bool IsColorVisible(RColor color) /// the graphics to clip /// the box that is rendered to get containing blocks /// true - was clipped, false - not clipped - public static bool ClipGraphicsByOverflow(RGraphics g, CssBox box) + /// + /// Added unconditionally (regardless of ) on top of the usual + /// scroll-offset handling below - passes its + /// to additionally undo the current + /// fragmentainer's band top, since .ContainingBlock's client rectangle is + /// read straight off the live box tree (still absolute document-Y) while the caller may be + /// painting into a page-local or page-origin-translated surface. + /// + public static bool ClipGraphicsByOverflow(RGraphics g, CssBox box, RPoint extraOffset = default) { var containingBlock = box.ContainingBlock; while (true) @@ -53,6 +61,7 @@ public static bool ClipGraphicsByOverflow(RGraphics g, CssBox box) if (!box.IsFixed) rect.Offset(box.HtmlContainer.ScrollOffset); + rect.Offset(extraOffset); rect.Intersect(prevClip); g.PushClip(rect); diff --git a/Source/HtmlRenderer/HtmlRenderer.csproj b/Source/HtmlRenderer/HtmlRenderer.csproj index e21f42282..4d786fe8e 100644 --- a/Source/HtmlRenderer/HtmlRenderer.csproj +++ b/Source/HtmlRenderer/HtmlRenderer.csproj @@ -26,12 +26,16 @@ For existing implementations see: HtmlRenderer.WinForms, HtmlRenderer.WPF and Ht - + + diff --git a/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs new file mode 100644 index 000000000..7541f020f --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindKeepWithNextTest.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies the same css-break-3 §3.1 propagation gap as , applied +/// to EnforceKeepWithNext's run-pull instead of RelocateIfNeeded's relocation: a heading +/// pulled onto a paragraph's page (because they're chained by break-after:avoid) is also the +/// section wrapping both of them's first in-flow child - the section's own top needs to follow the +/// heading up, or the section is left spanning from its original page to the pulled-together pair's new +/// one. +/// +/// +/// Diagnosing this surfaced a SECOND, more fundamental bug along the way: CssBox.OffsetTop (what +/// EnforceKeepWithNext uses to pull the run) kept the box's own Rectangles dictionary in +/// sync but never the corresponding CssLineBox.Rectangles entry (a separate dictionary, keyed the +/// other way, that CssLineBox.LineTop/LineBottom - and therefore CssBox.EffectiveTop +/// for any inline-only box - read from). Location.Y (this method's own last statement) was +/// correctly updated while EffectiveTop silently kept reporting the pre-shift position - confirmed +/// by inspecting both dictionaries directly on a real shifted heading before the fix. Fixed by having +/// OffsetTop also update the line's own mirror entry for each line it touches. +/// +[TestClass] +[DoNotParallelize] +public sealed class ContainerLeftBehindKeepWithNextTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable Walk(CssBox box) + { + yield return box; + foreach (var b in box.Boxes) + foreach (var d in Walk(b)) + yield return d; + } + + [TestMethod] + public async Task SectionWrappingHeadingAndParagraph_MovesWithThePulledHeading_NeverSpansBothPages() + { + var checkedAnyPull = false; + + for (var fillerCount = 1; fillerCount < 60; fillerCount++) + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line

", fillerCount)); + await wrapper.SetHtml( + $""" + + {filler} +
+

SectionHeading WordTwo WordThree WordFour WordFive WordSix WordSeven WordEight WordNine WordTen

+

SectionParagraph

+
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(300, 0); + + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var allBoxes = Walk(container.Root).ToList(); + var section = allBoxes.FirstOrDefault(b => b.HtmlTag?.Name == "div" && b.GetAttribute("class") == "section"); + var heading = allBoxes.FirstOrDefault(b => b.HtmlTag?.Name == "h2"); + if (section == null || heading == null) + continue; + + var sectionSlot = container.PageIndexOf(section.Location.Y); + var headingSlot = container.PageIndexOf(heading.EffectiveTop); + + // Only meaningful once the heading has actually been pulled forward (flush at a fresh page + // top) - otherwise there's no run-pull for the section to have gotten left behind by. + if (System.Math.Abs(heading.EffectiveTop - container.PageTopOf(headingSlot)) > 0.5) + continue; + + checkedAnyPull = true; + + Assert.AreEqual(headingSlot, sectionSlot, + $"at fillerCount={fillerCount}, the section wrapper is on page slot {sectionSlot} but its heading was pulled to slot {headingSlot}"); + } + + Assert.IsTrue(checkedAnyPull, "no filler count in range actually exercised a keep-with-next pull - test is not meaningful as written"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs new file mode 100644 index 000000000..7e8f828a2 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/ContainerLeftBehindTest.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine +/// against PeachPDF a second time (a later, separate pass over what remained after the R0-R10 plan +/// completed): css-break-3 §3.1's break-point propagation was only ever applied to forced breaks +/// ('s +/// own "no previous sibling" check), never to break-inside:avoid/monolithic relocation +/// (RelocateIfNeeded). A box moved by that method while it's its parent's first (and here, only) +/// in-flow child - a plain wrapper div with no content before it - left the parent spanning from its +/// original page to the child's new one, its own background/border rendered as a stub-then-continuation +/// for no reason a CSS author would expect (e.g. a card/panel div wrapping a single table or figure). +/// Confirmed by temporarily reverting the fix: card and table reliably landed on different page slots at +/// several filler counts. +/// +[TestClass] +[DoNotParallelize] +public sealed class ContainerLeftBehindTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable Walk(CssBox box) + { + yield return box; + foreach (var b in box.Boxes) + foreach (var d in Walk(b)) + yield return d; + } + + [TestMethod] + public async Task WrapperDivWithOneAvoidBreakChild_MovesWithIt_NeverSpansBothPages() + { + var checkedAnyRelocation = false; + + // Sweep filler counts - the exact boundary where the relocation fires depends on font-metric + // arithmetic (this session's established testing lesson: never hardcode a "just barely + // straddles" calibration). + for (var fillerCount = 1; fillerCount < 60; fillerCount++) + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line

", fillerCount)); + await wrapper.SetHtml( + $""" + + {filler} +
+ + + +
CellOne
CellTwo
+
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(300, 0); + + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var allBoxes = Walk(container.Root).ToList(); + var card = allBoxes.FirstOrDefault(b => b.HtmlTag?.Name == "div" && b.GetAttribute("class") == "card"); + var table = allBoxes.FirstOrDefault(b => b.HtmlTag?.Name == "table"); + if (card == null || table == null) + continue; + + var cardSlot = container.PageIndexOf(card.Location.Y); + var tableSlot = container.PageIndexOf(table.Location.Y); + + // Only meaningful once the table has actually been relocated (flush - within border-rounding + // slack - at a fresh page top) - otherwise there's nothing for the card to have gotten left + // behind by in the first place. + if (System.Math.Abs(table.Location.Y - container.PageTopOf(tableSlot)) > 2.0) + continue; + + checkedAnyRelocation = true; + Assert.AreEqual(tableSlot, cardSlot, + $"at fillerCount={fillerCount}, the card wrapper is on page slot {cardSlot} but its sole break-inside:avoid child moved to slot {tableSlot}"); + } + + Assert.IsTrue(checkedAnyRelocation, "no filler count in range actually exercised a relocation - test is not meaningful as written"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs new file mode 100644 index 000000000..1b6734598 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/FixedPositionRepeatsPerPageTest.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real, spec-confirmed missing feature found while auditing this port's fragmentation engine +/// against PeachPDF a third time, then checking the actual W3C text directly +/// (css-position-3): "in paged media, the page +/// area of each page; fixed positioned boxes are thus replicated on every page", and user agents "must +/// not paginate the content of fixed-positioned boxes". A position:fixed element (a print +/// header/watermark - bottom/right anchoring is 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, is out of scope here) previously rendered on exactly one page - wherever its +/// top/left offset happened to be interpreted as an absolute document coordinate - instead +/// of being replicated identically on every page. +/// +[TestClass] +[DoNotParallelize] +public sealed class FixedPositionRepeatsPerPageTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable<(string Text, double Top)> AllWords(BoxFragment f) + { + foreach (var w in f.Words) + if (!w.Word.IsLineBreak) + yield return (w.Word.Text, w.Rect.Top); + foreach (var c in f.Children) + foreach (var x in AllWords(c)) + yield return x; + } + + [TestMethod] + public async Task TopLeftFixedElement_RepeatsIdenticallyOnEveryPage() + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line of body text

", 60)); + await wrapper.SetHtml( + $""" + +
PageHeaderMarker
+ {filler} + + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(300, 0); + + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsGreaterThan(1, tree.Fragmentainers.Count, "the filler content must genuinely span multiple pages for this test to be meaningful"); + + for (var i = 0; i < tree.Fragmentainers.Count; i++) + { + var markerHits = AllWords(tree.Fragmentainers[i].Root).Where(w => w.Text == "PageHeaderMarker").ToList(); + Assert.AreEqual(1, markerHits.Count, $"page {i} should show the fixed marker exactly once - not zero (missing) and not more than one (duplicated by both the repeat mechanism and the normal walk)"); + Assert.AreEqual(5.0, markerHits[0].Top, 0.5, $"page {i}'s marker must be at the same page-relative offset (top:5px) as every other page"); + } + } + + [TestMethod] + public async Task FixedElement_StillRendersOnce_WithoutARealPageGrid() + { + // WinForms/WPF's continuous-scroll convention (no PageSize set - HasRealPageGrid=false): the + // repeat-per-page mechanism must not apply here at all, since "stays put" for that viewport is a + // paint-time scroll-offset suppression (CssBox.IsFixed), not a per-page repeat concern - confirms + // the new exclusion in FragmentEmitter is correctly gated on HasRealPageGrid. + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line of body text

", 60)); + await wrapper.SetHtml( + $""" + +
PageHeaderMarker
+ {filler} + + """); + + wrapper.MaxSize = new SizeF(300, 0); + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var container = GetInternal(wrapper); + Assert.IsFalse(container.HasRealPageGrid); + var tree = container.FragmentTree; + Assert.AreEqual(1, tree.Fragmentainers.Count); + + var markerHits = AllWords(tree.Fragmentainers[0].Root).Where(w => w.Text == "PageHeaderMarker").ToList(); + Assert.AreEqual(1, markerHits.Count, "the fixed element must still render exactly once via the normal walk when there's no real page grid"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs new file mode 100644 index 000000000..281e11369 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/OffsetTopLineTopSyncTest.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real, previously-undocumented bug found while investigating +/// : CssBox.OffsetTop kept the box's own +/// Rectangles dictionary in sync with a shift, but never the corresponding entry in the line's OWN +/// mirror dictionary (CssLineBox.Rectangles, keyed the other way around) that +/// CssLineBox.LineTop/LineBottom - and therefore CssBox.EffectiveTop for any +/// inline-only box - read from. Location.Y (updated by OffsetTop's own last statement) was +/// correct immediately after the call, while EffectiveTop silently kept reporting the pre-shift +/// position - confirmed directly by inspecting both dictionaries on a real shifted heading before the fix. +/// Exercised directly via reflection here (rather than only through whichever fragmentation mechanism +/// happens to call OffsetTop at a given filler count - EnforceKeepWithNext's run-pull and +/// InlineFragmentation's own orphans-driven push are both live callers, and only the former uses +/// OffsetTop, so a test gated only on "the heading visibly moved" can't reliably tell which path it +/// hit) since OffsetTop's own contract - keep every derived position getter consistent after a +/// shift - should hold regardless of which caller invokes it. +/// +[TestClass] +[DoNotParallelize] +public sealed class OffsetTopLineTopSyncTest +{ + [TestMethod] + public async Task EffectiveTop_MatchesLocation_AfterOffsetTopOnAMultiLineBox() + { + using var wrapper = new HtmlContainer(); + await wrapper.SetHtml( + """ + +

Heading WordTwo WordThree WordFour WordFive WordSix WordSeven WordEight WordNine WordTen

+ + """); + + wrapper.MaxSize = new SizeF(300, 0); + using var bitmap = new Bitmap(300, 2000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + var containerInt = (HtmlContainerInt)prop.GetValue(wrapper)!; + + CssBox? Walk(CssBox box) => + box.HtmlTag?.Name == "h2" ? box : box.Boxes.Select(Walk).FirstOrDefault(r => r != null); + + var heading = Walk(containerInt.Root); + Assert.IsNotNull(heading, "expected an

box in the laid-out tree"); + + var effectiveTopProp = typeof(CssBox).GetProperty("EffectiveTop", BindingFlags.NonPublic | BindingFlags.Instance)!; + var offsetTopMethod = typeof(CssBox).GetMethod("OffsetTop", BindingFlags.NonPublic | BindingFlags.Instance)!; + + var beforeLocation = heading!.Location.Y; + var beforeEffectiveTop = (double)effectiveTopProp.GetValue(heading)!; + Assert.AreEqual(beforeLocation, beforeEffectiveTop, 0.01, "precondition: Location.Y and EffectiveTop must agree before any shift"); + Assert.IsGreaterThan(1, heading.LineBoxes.Count, "the heading must genuinely wrap to more than one line for this test to be meaningful"); + + offsetTopMethod.Invoke(heading, new object[] { 50.0 }); + + var afterLocation = heading.Location.Y; + var afterEffectiveTop = (double)effectiveTopProp.GetValue(heading)!; + + Assert.AreEqual(beforeLocation + 50.0, afterLocation, 0.01, "OffsetTop must move Location.Y by the given amount"); + Assert.AreEqual(afterLocation, afterEffectiveTop, 0.01, + "EffectiveTop must match Location.Y after OffsetTop - the line-side rectangle mirror must not go stale"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs new file mode 100644 index 000000000..7be86fd85 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/OrphansOnFirstRunTest.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine +/// against PeachPDF a second time (the first audit produced the R0-R10 plan; this is a later, separate +/// pass over what remained): 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 a paragraph's very 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, uncorrected. Confirmed by temporarily reverting the fix and re-running this exact test: +/// it reliably reproduced a 1-line first page against orphans:2 at several filler counts (13, 28, +/// 43, 58 - the same ~15-count period the page-height/line-height ratio produces). +/// +[TestClass] +[DoNotParallelize] +public sealed class OrphansOnFirstRunTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable<(string Text, double Top)> AllTargetWords(BoxFragment f) + { + foreach (var w in f.Words) + if (!w.Word.IsLineBreak && w.Word.Text.StartsWith("TargetLine")) + yield return (w.Word.Text, w.Rect.Top); + foreach (var c in f.Children) + foreach (var x in AllTargetWords(c)) + yield return x; + } + + [TestMethod] + public async Task ParagraphStartingNearPageBottom_NeverStrandsFewerThanOrphansLines() + { + // Sweep filler counts rather than hardcoding one - this is a "just barely fits" calibration + // (see this session's own established testing lesson), and the exact boundary depends on + // font-metric arithmetic other changes are expected to keep touching. + for (var fillerCount = 1; fillerCount < 60; fillerCount++) + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line

", fillerCount)); + await wrapper.SetHtml( + $""" + + {filler} +

TargetLineOne TargetLineTwo TargetLineThree TargetLineFour TargetLineFive TargetLineSix TargetLineSeven TargetLineEight TargetLineNine TargetLineTen

+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(200, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(200, 0); + + using var bitmap = new Bitmap(200, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + + var wordInfo = new List<(string Text, int Page, double Top)>(); + for (var pi = 0; pi < tree.Fragmentainers.Count; pi++) + foreach (var w in AllTargetWords(tree.Fragmentainers[pi].Root)) + wordInfo.Add((w.Text, pi, System.Math.Round(w.Top, 1))); + + if (wordInfo.Count == 0) + continue; // paragraph didn't appear in this bitmap height at this filler count - try the next + + var firstPage = wordInfo[0].Page; + var linesOnFirstPage = wordInfo.Where(w => w.Page == firstPage).Select(w => w.Top).Distinct().Count(); + + Assert.IsGreaterThanOrEqualTo(2, linesOnFirstPage, + $"at fillerCount={fillerCount}, the paragraph's first page-fragment kept only {linesOnFirstPage} line(s), fewer than orphans:2 requires"); + } + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs new file mode 100644 index 000000000..7c575d2fd --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/RowspanCellShiftTest.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real, previously-undocumented gap found while auditing this port's fragmentation engine +/// against PeachPDF a second time: CssLayoutEngineTable.LayoutCells's break-inside:avoid +/// row-shift correction did foreach (CssBox 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 relative to the rest of the row, which moved on to the next page. Confirmed by temporarily +/// reverting the fix and re-running this exact test: it reliably reproduced the spanning cell's bottom +/// edge lagging behind its sibling's at several filler counts. +/// +[TestClass] +[DoNotParallelize] +public sealed class RowspanCellShiftTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable Walk(CssBox box) + { + yield return box; + foreach (var b in box.Boxes) + foreach (var d in Walk(b)) + yield return d; + } + + [TestMethod] + public async Task RowspanCellSpanningAShiftedRow_BottomTracksTheShift_NotLeftStale() + { + // Sweep filler counts - the exact boundary where the row-shift fires depends on font-metric + // arithmetic (see this session's established testing lesson: never hardcode a "just barely + // straddles" calibration). + var checkedAnyShift = false; + + for (var fillerCount = 1; fillerCount < 60; fillerCount++) + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line

", fillerCount)); + // Many extra rows before the rowspan pair push the table's own total height well past one + // page, so RelocateIfNeeded's table-level relocation (which requires the whole table to fit + // within one page) declines, leaving CssLayoutEngineTable's own row-level shift as the ONLY + // mechanism that can act on the straddling row - otherwise a small table gets moved wholesale + // and never exercises this bug at all. + var extraRows = string.Concat(Enumerable.Range(0, 40).Select(i => $"Extra{i}AExtra{i}B")); + await wrapper.SetHtml( + $""" + + {filler} + + {extraRows} + + +
SpanCellContentRow1Cell2
Row2Cell2
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(300, 0); + + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var allBoxes = Walk(container.Root).ToList(); + + CssBox? FindEnclosingTd(string text) + { + var wordBox = allBoxes.FirstOrDefault(b => b.Words.Any(w => w.Text.Contains(text))); + for (var b = wordBox; b != null; b = b.ParentBox) + if (b.HtmlTag?.Name == "td") + return b; + return null; + } + + var spanCell = FindEnclosingTd("SpanCellContent"); + var row2Cell = FindEnclosingTd("Row2Cell2"); + if (spanCell == null || row2Cell == null) + continue; + + // Only meaningful once the shift has actually fired for this row (row2Cell flush at a fresh + // page top) - otherwise there's nothing to have gotten stale in the first place. + if (System.Math.Abs(row2Cell.Location.Y - container.PageTopOf(container.PageIndexOf(row2Cell.Location.Y))) > 0.5) + continue; + + checkedAnyShift = true; + Assert.IsGreaterThanOrEqualTo(row2Cell.ActualBottom - 0.5, spanCell.ActualBottom, + $"at fillerCount={fillerCount}, the rowspan cell's bottom ({spanCell.ActualBottom:F1}) fell short of its sibling's ({row2Cell.ActualBottom:F1}) after the row-shift"); + } + + Assert.IsTrue(checkedAnyShift, "no filler count in range actually exercised the row-shift - test is not meaningful as written"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs new file mode 100644 index 000000000..2337554ee --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD2FragmentBucketingSmokeTest.cs @@ -0,0 +1,84 @@ +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +// This assembly parallelizes at the method level (MSTestSettings.cs); HtmlContainerInt's underlying +// adapter singletons (font/brush caches, etc.) aren't safe against that for tests that drive full +// layout passes directly - HtmlRenderingRegressionTests already opts out for the same reason. +[TestClass] +[DoNotParallelize] +public sealed class StageD2FragmentBucketingSmokeTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + [TestMethod] + public async Task MultiPageDocument_ProducesOneFragmentainerPerPage_WithSplitBoxFragments() + { + using var wrapper = new HtmlContainer(); + var paragraphs = string.Concat(Enumerable.Repeat("

filler line of text for pagination

", 80)); + await wrapper.SetHtml($"{paragraphs}"); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + Assert.IsTrue(tree.Fragmentainers.Count > 1, $"expected multiple fragmentainers, got {tree.Fragmentainers.Count}"); + + // Slot indices are ascending and each fragmentainer's band matches its slot. + for (var i = 0; i < tree.Fragmentainers.Count; i++) + { + var f = tree.Fragmentainers[i]; + Assert.AreEqual(f.SlotIndex, i, "no blank slots expected in this dense document"); + } + + // The document root CssBox (which spans the whole document) must produce a distinct + // BoxFragment per fragmentainer - the same underlying box, multiple fragments. + Assert.AreEqual(tree.Fragmentainers.Count, tree.Fragmentainers.Select(f => f.Root).Distinct().Count()); + + // Every fragmentainer's root should trace back to the same document root CssBox. + foreach (var f in tree.Fragmentainers) + { + Assert.AreSame(container.Root, f.Root.Box); + } + } + + [TestMethod] + public async Task HugeMargin_SkipsBlankFragmentainers() + { + using var wrapper = new HtmlContainer(); + await wrapper.SetHtml("
content
"); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + + // The margin is truncated (D2), so content should land on an early page, not one 3000px down - + // this also implicitly confirms no run of ~4 blank fragmentainers was materialized for the gap. + Assert.IsTrue(tree.Fragmentainers.Count <= 2, $"expected at most 2 fragmentainers, got {tree.Fragmentainers.Count}"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs new file mode 100644 index 000000000..9d1584af0 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD3PrecisionTest.cs @@ -0,0 +1,114 @@ +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +// See StageD2FragmentBucketingSmokeTest for why this opts out of this assembly's default +// method-level parallelization (MSTestSettings.cs). +[TestClass] +[DoNotParallelize] +public sealed class StageD3PrecisionTest +{ + private static HtmlContainerInt Layout(string html, int pageWidth, int pageHeight, out HtmlContainer wrapper, out Bitmap bitmap) + { + wrapper = new HtmlContainer(); + wrapper.SetHtml(html).GetAwaiter().GetResult(); + + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + var container = (HtmlContainerInt)prop.GetValue(wrapper)!; + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(pageWidth, pageHeight); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(pageWidth, 0); + + bitmap = new Bitmap(pageWidth, 8000); + var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + g.Dispose(); + + return container; + } + + [TestMethod] + public void NoLine_EverStraddlesAPageBoundary() + { + var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; + var html = $"

{string.Concat(Enumerable.Repeat(sentence, 60))}

"; + + var container = Layout(html, 500, 700, out var wrapper, out var bitmap); + try + { + var p = DomUtils.GetBoxByTagName(container.Root, "p"); + Assert.IsTrue(p.LineBoxes.Count > 5, "expected many lines to make this test meaningful"); + + foreach (var line in p.LineBoxes) + { + var top = line.LineTop; + var bottom = line.LineBottom; + if (bottom <= top) continue; + + var topSlot = container.PageIndexOf(top); + var bottomSlot = container.PageIndexOf(System.Math.Max(top, bottom - 0.01)); + Assert.AreEqual(topSlot, bottomSlot, $"line [{top:F1},{bottom:F1}) straddles a page boundary"); + } + } + finally + { + bitmap.Dispose(); + wrapper.Dispose(); + } + } + + [TestMethod] + public void Widows_NeverLeavesFewerThanMinimumLinesAtTopOfPage() + { + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 53)); + var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; + var html = $""" + + {filler} +

{string.Concat(Enumerable.Repeat(sentence, 6))}

+ + """; + + var container = Layout(html, 500, 700, out var wrapper, out var bitmap); + try + { + // Find the widowed

specifically (the last

, since filler

s come first). + var body = DomUtils.GetBoxByTagName(container.Root, "body"); + var target = body.Boxes[body.Boxes.Count - 1]; + + AssertNoStraddleAndWidowsHonored(container, target, minWidows: 3); + } + finally + { + bitmap.Dispose(); + wrapper.Dispose(); + } + } + + private static void AssertNoStraddleAndWidowsHonored(HtmlContainerInt container, CssBox box, int minWidows) + { + var lines = box.LineBoxes; + var breakLineIndex = -1; + for (var i = 1; i < lines.Count; i++) + { + if (container.PageIndexOf(lines[i].LineTop) != container.PageIndexOf(lines[i - 1].LineTop)) + { + breakLineIndex = i; + break; + } + } + + if (breakLineIndex < 0) return; // whole box fit on one page - nothing to check + + var linesAfterBreak = lines.Count - breakLineIndex; + Assert.IsTrue(linesAfterBreak >= minWidows, + $"only {linesAfterBreak} lines after the break, expected at least {minWidows}"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs new file mode 100644 index 000000000..f9ddb1022 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageD4RepeatedHeaderTest.cs @@ -0,0 +1,83 @@ +using System.Drawing; +using System.Reflection; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Utils; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +// See StageD2FragmentBucketingSmokeTest for why this opts out of this assembly's default +// method-level parallelization (MSTestSettings.cs). +[TestClass] +[DoNotParallelize] +public sealed class StageD4RepeatedHeaderTest +{ + [TestMethod] + public void ThreadRepeatsOnEveryPageTheTableSpans() + { + // break-inside: avoid is explicit here rather than relied on from the UA default stylesheet's + // "@media print { thead, tfoot { break-inside: avoid } }" - this test renders via WinForms, + // whose adapter reports a "screen" media type, so that print-scoped rule never matches here + // (confirmed intentional: only PdfSharpAdapter overrides DefaultMediaType to "print"). + var sb = new StringBuilder(""); + for (var i = 0; i < 60; i++) + { + sb.Append($""); + } + sb.Append("
Col ACol B
row {i} arow {i} b
"); + + using var wrapper = new HtmlContainer(); + wrapper.SetHtml(sb.ToString()).GetAwaiter().GetResult(); + + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + var container = (HtmlContainerInt)prop.GetValue(wrapper)!; + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var table = DomUtils.GetBoxByTagName(container.Root, "table"); + Assert.IsNotNull(table); + + // The table must genuinely span multiple pages for this test to be meaningful. + Assert.IsTrue(container.PageIndexOf(table.ActualBottom - 0.01) > container.PageIndexOf(table.Location.Y), + "expected the table to span more than one page"); + + Assert.IsNotNull(table.RepeatedHeaderRows, "expected at least one repeated header row set"); + Assert.IsTrue(table.RepeatedHeaderRows.Count > 0); + + // Each repeated row's text content should match the original header's text (Col A / Col B). + var headerRow = table.RepeatedHeaderRows[0]; + var text = string.Join(" ", CollectWords(headerRow)); + StringAssert.Contains(text, "Col A"); + StringAssert.Contains(text, "Col B"); + + // Every repeated header must land at the top of a page slot the table's body actually spans, + // and must not be positioned on the table's own first page (it's already there once, in flow). + // The row itself carries no Location (only its cells do - see CssLayoutEngineTable's row loop), + // so the first cell is the reference point. + var firstSlot = container.PageIndexOf(table.Location.Y); + var slot = container.PageIndexOf(headerRow.Boxes[0].Location.Y); + Assert.IsTrue(slot > firstSlot, "repeated header should not land back on the table's own first page"); + Assert.AreEqual(container.PageTopOf(slot), headerRow.Boxes[0].Location.Y, 0.5, "repeated header should sit flush at its page's content top"); + } + + private static System.Collections.Generic.IEnumerable CollectWords(TheArtOfDev.HtmlRenderer.Core.Dom.CssBox box) + { + foreach (var word in box.Words) + { + if (!string.IsNullOrWhiteSpace(word.Text)) + yield return word.Text; + } + foreach (var child in box.Boxes) + { + foreach (var w in CollectWords(child)) + yield return w; + } + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs new file mode 100644 index 000000000..f74201377 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR1DriverLoopTest.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +///

+/// Verifies the R1 stage of the fragmentation-engine-parity plan: forced page breaks now go through a +/// real resumable pass loop ('s per-fragmentainer driver, CssBox's +/// ResumeAt/PendingBreakToken child-loop bubbling) instead of a single-pass local +/// correction. These tests exercise the loop across multiple passes specifically, which the existing +/// single-forced-break tests (StageD2VerificationTest) don't - a bug in child-index bookkeeping +/// across repeated resumes wouldn't necessarily show up with only one break in the document. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR1DriverLoopTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + [TestMethod] + public async Task TwoForcedBreaksInSequence_EachStartsANewPageWithCorrectContent() + { + using var wrapper = new HtmlContainer(); + await wrapper.SetHtml( + """ + +
First page content.
+
Second page content.
+
Third page content.
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + Assert.AreEqual(3, tree.Fragmentainers.Count, "each forced break should land its own div on its own page"); + + // Each page's fragmentainer must be flush at its own band top (no leftover offset carried + // across the second break from the first, which an off-by-one in ResumeChildIndex would produce). + for (var slot = 0; slot < 3; slot++) + { + var fragmentainer = tree.Fragmentainers[slot]; + Assert.AreEqual(slot, fragmentainer.SlotIndex); + } + + // The three divs resolve to three distinct, correctly-ordered per-page fragments - proves the + // second break resumed the child loop at the right index rather than re-processing or skipping + // a sibling. + StringAssert.Contains(AllText(tree.Fragmentainers[0].Root), "First"); + StringAssert.Contains(AllText(tree.Fragmentainers[1].Root), "Second"); + StringAssert.Contains(AllText(tree.Fragmentainers[2].Root), "Third"); + } + + private static string AllText(BoxFragment fragment) + { + var words = new List(); + Collect(fragment, words); + return string.Join(" ", words); + + static void Collect(BoxFragment f, List into) + { + foreach (var word in f.Words) + into.Add(word.Word.Text); + foreach (var child in f.Children) + Collect(child, into); + } + } + + [TestMethod] + public async Task ManyForcedBreaksInSequence_TerminatesPromptlyWithOnePagePerBreak() + { + using var wrapper = new HtmlContainer(); + var divs = string.Concat(Enumerable.Range(0, 50).Select(i => + $"
Section {i}
")); + await wrapper.SetHtml($"{divs}"); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + // 50 divs, every one but the first forcing its own break: 50 pages. A hang or a runaway pass + // count would fail this test by timeout rather than by assertion - that's the point of covering + // the pass loop's backstop with a large-but-realistic case rather than only single/double breaks. + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + Assert.AreEqual(50, tree.Fragmentainers.Count); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs new file mode 100644 index 000000000..22392cf61 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR3RelocationTest.cs @@ -0,0 +1,56 @@ +using System.Drawing; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies the R3 stage of the fragmentation-engine-parity plan: break-inside:avoid/monolithic +/// relocation now relays the child out fresh at its target position (CssBox.ResumeAt + a second +/// PerformLayout call within the same pass) instead of shifting already-finished geometry with +/// OffsetTop. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR3RelocationTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + [TestMethod] + public async Task MonolithicContentTallerThanOnePage_IsLeftInPlace_NotMoved() + { + using var wrapper = new HtmlContainer(); + // A scroll container (overflow:hidden, MonolithicContent.IsScrollContainer) taller than the + // 700px page - RelocateIfNeeded's "fits on no single page" guard must leave it straddling the + // boundary in place rather than moving it (nowhere to move it TO would help) or looping. + await wrapper.SetHtml( + """ + +
filler
+
monolithic content taller than one page
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + // Straddles the one boundary it naturally crosses (250 + 900 = 1150, past the 700px mark) and + // stops there - not moved to a later page (which would still not fit it whole) and not spun + // into extra pages by a mistaken relocation attempt. + Assert.AreEqual(2, tree.Fragmentainers.Count); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs new file mode 100644 index 000000000..f516db0a0 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR4KeepWithNextTest.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies the R4 stage of the fragmentation-engine-parity plan: keep-with-next +/// (BlockFragmentation.EnforceKeepWithNext) now fires for the ordinary case, not just as a side +/// effect of the following box also being break-inside:avoid/monolithic. +/// +/// +/// The pre-existing KeepWithNext_HeadingStaysWithFollowingParagraph PDF test (still passing, still +/// kept) only ever asserted a page COUNT of 2 - which is also exactly what you get if the heading is left +/// stranded alone at the bottom of page 1 while the paragraph moves to page 2 by itself (2 pages either +/// way). It never actually proved the heading and paragraph land on the SAME page. This test does, using +/// the fragment tree directly: filler content is calibrated so the heading provably fits alone on page 0 +/// in isolation (confirmed by a companion assertion with no trailing paragraph), then, with the paragraph +/// present, both must appear in the SAME fragmentainer. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR4KeepWithNextTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static async Task LayoutAsync(string bodyHtml) + { + using var wrapper = new HtmlContainer(); + await wrapper.SetHtml($"{bodyHtml}"); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(595, 800); + container.MarginTop = 20; + wrapper.MaxSize = new SizeF(595, 0); + + using var bitmap = new Bitmap(595, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + return container.FragmentTree; + } + + private static string AllText(BoxFragment fragment) + { + var words = new List(); + Collect(fragment, words); + return string.Join(" ", words); + + static void Collect(BoxFragment f, List into) + { + foreach (var word in f.Words) + into.Add(word.Word.Text); + foreach (var child in f.Children) + Collect(child, into); + } + } + + private static string Filler(int count) => + string.Concat(Enumerable.Repeat("

filler line of text

", count)); + + // WinForms reports media type "screen", not "print" - the UA stylesheet's h1-h6 { break-after: avoid } + // rule lives under @media print (see PdfSharpAdapter vs RAdapter.DefaultMediaType) and never applies + // to this IntegrationTest project's WinForms-based HtmlContainer. Set it explicitly rather than + // relying on the UA default. + private const string HeadingStyle = "margin:0; break-after: avoid;"; + + /// + /// Finds, by direct search rather than a hardcoded magic number, a filler count where the heading + /// fits alone on page 0 but heading+paragraph together do not - the exact boundary this stage's real + /// test needs. Hardcoding the count made this test fragile to unrelated, still-correct changes + /// elsewhere in the pagination arithmetic (this happened once already, when InlineFragmentation's + /// algorithm was rewritten for an unrelated widows bug and shifted the boundary by one filler). + /// + private static async Task FindBoundaryFillerCountAsync() + { + for (var count = 20; count < 80; count++) + { + var headingAlone = await LayoutAsync($"{Filler(count)}

Section heading

"); + var headingFitsAlone = StringContains(AllText(headingAlone.Fragmentainers[0].Root), "Section heading"); + if (!headingFitsAlone) + continue; + + var withParagraph = await LayoutAsync( + $"{Filler(count)}

Section heading

Paragraph right after the heading.

"); + var bothFitOnPageZero = withParagraph.Fragmentainers.Count >= 1 + && StringContains(AllText(withParagraph.Fragmentainers[0].Root), "Paragraph right after the heading."); + if (!bothFitOnPageZero) + return count; // heading alone fits; heading+paragraph together doesn't - the boundary. + } + + Assert.Fail("could not find a filler count where the heading fits alone but not with its paragraph"); + return -1; + } + + private static bool StringContains(string haystack, string needle) => haystack.Contains(needle); + + [TestMethod] + public async Task HeadingAndParagraph_LandOnTheSamePage_NotStranded() + { + var count = await FindBoundaryFillerCountAsync(); + + var tree = await LayoutAsync( + $"{Filler(count)}

Section heading

Paragraph right after the heading.

"); + + // Page 0's own text must NOT contain the heading - it should have been pulled forward to join + // the paragraph, not left stranded where the boundary search shows it would otherwise fit alone. + var pageZeroText = AllText(tree.Fragmentainers[0].Root); + StringAssert.DoesNotMatch(pageZeroText, new System.Text.RegularExpressions.Regex("Section heading")); + + var withHeading = tree.Fragmentainers.Select(f => AllText(f.Root)).FirstOrDefault(t => t.Contains("Section heading")); + Assert.IsNotNull(withHeading, "heading should appear on some page"); + StringAssert.Contains(withHeading, "Paragraph right after the heading."); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs new file mode 100644 index 000000000..5eb2dfa50 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR5WidowsMultiPageTest.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real bug found while investigating the fragmentation-engine-parity plan's R5/R6 stages +/// (inline resumption, widows as a driver-level rewind): the investigation concluded neither stage needed +/// new resumption machinery after all - CreateLineBoxes already computes an entire paragraph's +/// lines in one unbounded, side-effect-free call, so there is never a point where a later pass reveals +/// information the same-shot correction didn't already have. What it DID find was a real bug in that +/// same-shot correction's own cascading logic. +/// +/// +/// The old single-pass version of InlineFragmentation.ApplyLineBreaking shifted lines +/// incrementally as it walked them, driven by "did this line straddle a page boundary". Once a shift +/// happened to land a run of lines in perfect page-boundary alignment (very common with uniform line +/// heights), no line ever straddled again for the rest of the paragraph - so widows was silently never +/// re-checked for any later page transition. A paragraph long enough to span dozens of pages could end +/// with a final page far short of its `widows` minimum and nothing would catch it. The rewritten version +/// computes every break point up front from each line's own natural (never-shifted) position, which has +/// no such blind spot, and applies the decided breaks in a single separate pass. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR5WidowsMultiPageTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static int CountWords(BoxFragment f) + { + var n = f.Words.Count(w => !w.Word.IsLineBreak); + foreach (var c in f.Children) + n += CountWords(c); + return n; + } + + private static void CollectWordTops(BoxFragment f, List into) + { + foreach (var w in f.Words) + if (!w.Word.IsLineBreak) + into.Add(w.Rect.Top); + foreach (var c in f.Children) + CollectWordTops(c, into); + } + + [TestMethod] + public async Task LongParagraph_PullsBackAcrossMultipleEarlierPages_WhenTheFirstDoesNotHaveRoom() + { + using var wrapper = new HtmlContainer(); + // A deliberately non-round page height relative to the line height (100 vs a 24-tall line: 4 + // lines is 96, leaving 4 units of slack; a straight single-page-back merge for widows:3 needs to + // reach past that slack into the page before it too) - this is exactly the shape the old + // single-pass algorithm's "stops checking after perfect alignment" blind spot could miss, and + // the shape the two-phase rewrite's break-list (rather than incremental-shift) design exists to + // handle: cascading the merge across more than one earlier break by removing list entries, + // without needing to undo a shift already applied to specific lines. + var sentence = "Alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey "; + var paragraph = string.Concat(Enumerable.Repeat(sentence, 40)); + await wrapper.SetHtml($"

{paragraph}

"); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(220, 100); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(220, 0); + + using var bitmap = new Bitmap(220, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsGreaterThan(3, tree.Fragmentainers.Count, "test content should span several pages for this to be meaningful"); + + var lastPageWords = CountWords(tree.Fragmentainers[tree.Fragmentainers.Count - 1].Root); + Assert.IsGreaterThanOrEqualTo(3, lastPageWords, + $"the final page has only {lastPageWords} line(s), fewer than widows:3 - the paragraph's own last line was left stranded"); + } + + [TestMethod] + public async Task LongParagraph_DeclinesGracefully_WhenSatisfyingWidowsWouldOverflowAPage() + { + using var wrapper = new HtmlContainer(); + // Deliberately degenerate: a single repeated word gives every line identical height, so pages + // pack to exactly the same capacity throughout - satisfying widows:3 on the trailing page would + // require merging in lines from an already-full preceding page, producing a run taller than any + // page can hold. This must not overflow, crash, or loop - it must simply leave the shorter final + // page as the best achievable result (css-break-3 4.3's "some constraints can't always be + // satisfied" relaxation philosophy). + var words = string.Concat(Enumerable.Repeat("word ", 300)); + await wrapper.SetHtml($"

{words}

"); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(60, 100); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(60, 0); + + using var bitmap = new Bitmap(60, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsGreaterThan(3, tree.Fragmentainers.Count); + + // No page's own words may span more vertical room than the page itself has - the real + // regression this guards against is a "fix" that satisfies widows by producing a run that + // silently overflows its fragmentainer (word rects are already fragmentainer-local, so a span + // near or under one page height is the correct expectation regardless of scroll/margin setup). + foreach (var fragmentainer in tree.Fragmentainers) + { + var tops = new List(); + CollectWordTops(fragmentainer.Root, tops); + if (tops.Count == 0) + continue; + + var span = tops.Max() - tops.Min(); + Assert.IsLessThanOrEqualTo(container.PageSize.Height, span, + $"fragmentainer at slot {fragmentainer.SlotIndex} holds words spanning more than one page's height"); + } + + // The total word count must be conserved - nothing dropped, nothing duplicated, across however + // many pages the graceful-decline path produced. + var total = tree.Fragmentainers.Sum(f => CountWords(f.Root)); + Assert.AreEqual(300, total); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableCellForcedBreakTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableCellForcedBreakTest.cs new file mode 100644 index 000000000..9ac047a82 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableCellForcedBreakTest.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real regression found while investigating the fragmentation-engine-parity plan's R7 stage +/// (table resumption), introduced by R1's forced-break deferral: CssLayoutEngineTable's row loop +/// calls cell.PerformLayout directly and does not participate in the PendingBreakToken +/// bubbling protocol an ordinary block-child loop does. A forced break nested inside a table cell (e.g. a +/// <div style="break-before:page"> inside a <td>) would request deferral to a +/// later pass exactly like any other box - but nothing ever reads that request or resumes it, since a +/// table row is not itself laid out via the block-child loop. The deferred content's own layout returned +/// before ever calling CreateLineBoxes, yet its words had already been measured (unconditional, +/// at the top of every PerformLayoutImp call) - so it ended up rendered at a stale/default (0,0) +/// position, silently overlapping whatever else was there, rather than being lost outright or correctly +/// paginated. Confirmed by direct fragment-tree inspection before the fix: the word appeared, but at the +/// wrong position, with no new page created for it. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR7TableCellForcedBreakTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + /// + /// Reconstructs each word's ABSOLUTE document-Y (fragment rects are page-band-local, so comparing + /// raw Rect.Top values across different fragmentainers is meaningless - a word at local Y=0 + /// on page 2 is not "above" a word at local Y=10 on page 1). + /// + private static void CollectWordsWithAbsoluteY(BoxFragment f, double bandTop, List<(string Text, double AbsoluteTop)> into) + { + foreach (var w in f.Words) + if (!w.Word.IsLineBreak) + into.Add((w.Word.Text, w.Rect.Top + bandTop)); + foreach (var c in f.Children) + CollectWordsWithAbsoluteY(c, bandTop, into); + } + + [TestMethod] + public async Task ForcedBreakInsideTableCell_DoesNotOverlapOrLoseContent() + { + using var wrapper = new HtmlContainer(); + await wrapper.SetHtml( + """ + +
+
BeforeMarker
+
AfterMarker
+
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(500, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(500, 0); + + using var bitmap = new Bitmap(500, 5000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + + var words = new List<(string Text, double AbsoluteTop)>(); + foreach (var f in tree.Fragmentainers) + CollectWordsWithAbsoluteY(f.Root, f.LocalOriginY, words); + + var before = words.Find(w => w.Text == "BeforeMarker"); + var after = words.Find(w => w.Text == "AfterMarker"); + + Assert.IsNotNull(before.Text, "BeforeMarker must still be present"); + Assert.IsNotNull(after.Text, "AfterMarker must still be present - not silently dropped"); + + // The real regression: AfterMarker rendered at the SAME position as BeforeMarker (or at a + // stale/default position near zero) rather than being placed below it in normal document flow. + Assert.IsGreaterThan(before.AbsoluteTop, after.AbsoluteTop, + $"AfterMarker (absoluteTop={after.AbsoluteTop}) must render below BeforeMarker (absoluteTop={before.AbsoluteTop}), not overlapping it"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs new file mode 100644 index 000000000..fbea9344b --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR7TableMultiPageCellTest.cs @@ -0,0 +1,86 @@ +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies the fragmentation-engine-parity plan's R7 investigation finding: a table cell whose own +/// content spans several pages by itself (the content routes through the same, already-fixed +/// CssBox.PerformLayoutImp/CreateLineBoxes/ApplyLineBreaking machinery as any other +/// box) is preserved intact and subsequent rows correctly continue after it - no TableBreakToken/ +/// TableRowCursor machinery needed for this case, matching the R2/R5/R6 finding that this port's +/// architecture rarely needs what it looks like it needs at first glance. +/// +/// +/// Does NOT cover repeated-header behavior for this shape - a row whose own content spans multiple +/// pages by itself only gets a header repeat inserted for the first page it crosses onto, not further +/// intermediate pages that same row continues to span (see the KNOWN LIMITATION comment beside +/// CssLayoutEngineTable.LayoutCells's repeat-check). Confirmed via direct testing, not fixed - the far +/// more common shape (many ordinary rows, table spans many pages) already repeats correctly per +/// StageD4RepeatedHeaderTest.ThreadRepeatsOnEveryPageTheTableSpans. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR7TableMultiPageCellTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static string AllText(BoxFragment f) + { + var words = new System.Collections.Generic.List(); + void Collect(BoxFragment x) + { + foreach (var w in x.Words) + if (!w.Word.IsLineBreak) + words.Add(w.Word.Text); + foreach (var c in x.Children) + Collect(c); + } + Collect(f); + return string.Join(" ", words); + } + + [TestMethod] + public async Task RowAfterAMultiPageSpanningCell_IsNotLost() + { + using var wrapper = new HtmlContainer(); + var sentence = "one two three four five six seven eight nine ten "; + var longCell = string.Concat(Enumerable.Repeat(sentence, 100)); + await wrapper.SetHtml( + $""" + + + + +
{longCell}short
RowTwoCellOneRowTwoCellTwo
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(400, 700); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(400, 0); + + using var bitmap = new Bitmap(400, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + Assert.IsGreaterThan(3, tree.Fragmentainers.Count, "the long cell should genuinely span several pages for this to be meaningful"); + + var allText = string.Join(" ", tree.Fragmentainers.Select(f => AllText(f.Root))); + StringAssert.Contains(allText, "short"); + StringAssert.Contains(allText, "RowTwoCellOne"); + StringAssert.Contains(allText, "RowTwoCellTwo"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs new file mode 100644 index 000000000..081e61487 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR9KeepWithNextAcrossForcedBreakTest.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies the fragmentation-engine-parity plan's R9 investigation finding: PeachPDF's "keep-with-next +/// run-pull rewind across an already-frozen fragmentainer" does not have a counterpart problem in this +/// port's architecture, so no new rewind machinery is needed - the existing same-pass +/// (R4) +/// already covers it. +/// +/// +/// PeachPDF needs a real cross-pass rewind because ordinary overflow-driven pagination is itself a real +/// pass boundary there - a keep-with-next violation discovered while laying out page N+1 may need to +/// reach back into page N's content, which was already committed via that pass's own EmitPass. +/// In this port, only a FORCED break (break-before/after: page) ever creates a real pass boundary +/// in HtmlContainerInt.DriveLayoutPasses - ordinary overflow and break-inside:avoid are both +/// same-pass local corrections (R2/R3), and FragmentEmitter runs once, only after every pass has +/// settled, so nothing is ever truly "frozen" mid-layout the way PeachPDF's per-pass emit makes it. +/// A keep-with-next run is therefore always laid out - and checked by EnforceKeepWithNext - within +/// the SAME pass as the sibling it's chained to, even immediately after resuming from an unrelated forced +/// break earlier in the document, as this test confirms directly against the fragment tree. And a run +/// could never need to be pulled across a forced break itself either way: the forced break is the +/// intentional separator keep-with-next exists to avoid accidentally recreating, not an obstacle to undo. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR9KeepWithNextAcrossForcedBreakTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static string AllText(BoxFragment f) + { + var words = new List(); + void Collect(BoxFragment x) + { + foreach (var w in x.Words) + if (!w.Word.IsLineBreak) + words.Add(w.Word.Text); + foreach (var c in x.Children) + Collect(c); + } + Collect(f); + return string.Join(" ", words); + } + + [TestMethod] + public async Task KeepWithNextPairRightAfterAForcedBreak_StaysTogether_OnTheResumedPage() + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 39)); + await wrapper.SetHtml( + $""" + +
ForcedBreakMarker
+ {filler} +

Section heading

+

Paragraph right after the heading.

+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(595, 800); + container.MarginTop = 20; + wrapper.MaxSize = new SizeF(595, 0); + + using var bitmap = new Bitmap(595, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + Assert.IsGreaterThanOrEqualTo(2, tree.Fragmentainers.Count, "the forced break must actually introduce a real pass boundary for this test to be meaningful"); + + var pageOfHeading = -1; + var pageOfParagraph = -1; + for (var i = 0; i < tree.Fragmentainers.Count; i++) + { + var text = AllText(tree.Fragmentainers[i].Root); + if (text.Contains("Section heading")) pageOfHeading = i; + if (text.Contains("Paragraph right after the heading.")) pageOfParagraph = i; + } + + Assert.AreNotEqual(-1, pageOfHeading, "heading must not be lost"); + Assert.AreNotEqual(-1, pageOfParagraph, "paragraph must not be lost"); + Assert.AreEqual(pageOfHeading, pageOfParagraph, "break-after:avoid must keep the heading with its paragraph even immediately after resuming from an unrelated forced break"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs new file mode 100644 index 000000000..7d44e2376 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/StageR9OversizedKeepWithNextRunTest.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real bug found while investigating the fragmentation-engine-parity plan's R9 stage: an +/// earlier version of +/// always pulled the WHOLE preceding break-after:avoid-chained run to a child's page without +/// checking whether the run then fit there. For a long chain (taller than one page combined), this did +/// not just mis-place content - it corrupted layout outright: each subsequent chained sibling's own +/// keep-with-next check re-fired against the now artificially-stretched-out run, compounding +/// CssBox.OffsetTop shifts on the same earlier boxes without bound (observed reaching a box +/// position of roughly 8.6e11 for a 60-member chain on a short page, before the fix). The fix implements +/// css-break-3 §4.3's actual staged relaxation - trim the run from its front until what remains fits, or +/// drop it entirely rather than pulling something that can't fit. +/// +[TestClass] +[DoNotParallelize] +public sealed class StageR9OversizedKeepWithNextRunTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable AllWords(BoxFragment f) + { + foreach (var w in f.Words) + if (!w.Word.IsLineBreak) + yield return w.Word.Text; + foreach (var c in f.Children) + foreach (var w in AllWords(c)) + yield return w; + } + + [TestMethod] + public async Task LongAvoidChainTallerThanOnePage_NeverCorruptsGeometry_AndLosesNothing() + { + using var wrapper = new HtmlContainer(); + var runMembers = string.Concat(Enumerable.Range(0, 60).Select(i => + $"

RunMember{i} filler filler filler filler filler

")); + await wrapper.SetHtml( + $""" + +
TopMarker
+ {runMembers} +

FinalParagraph

+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(595, 400); + container.MarginTop = 20; + wrapper.MaxSize = new SizeF(595, 0); + + using var bitmap = new Bitmap(595, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tree = container.FragmentTree; + Assert.IsNotNull(tree); + + // The real bug produced an ActualSize.Height in the hundreds of billions and zero fragmentainers + // (FragmentEmitter could not bucket geometry that far out of range) - a sane document is nowhere + // close to that regardless of exact page count, which depends on font metrics. + Assert.IsLessThan(100_000.0, wrapper.ActualSize.Height, "document height must stay sane - not blow up from compounding OffsetTop shifts"); + Assert.IsGreaterThan(0, tree.Fragmentainers.Count); + + var allWords = tree.Fragmentainers.SelectMany(f => AllWords(f.Root)).ToList(); + var expected = Enumerable.Range(0, 60).Select(i => $"RunMember{i}").Append("FinalParagraph").Append("TopMarker"); + foreach (var e in expected) + { + Assert.AreEqual(1, allWords.Count(w => w == e), $"'{e}' must appear exactly once - not lost or duplicated"); + } + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs b/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs new file mode 100644 index 000000000..cc95bfd63 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/TableRowDefaultAtomicityTest.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TheArtOfDev.HtmlRenderer.Core; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.WinForms; + +namespace TheArtOfDev.HtmlRenderer.IntegrationTest; + +/// +/// Verifies a real, spec-confirmed default-behavior gap found while auditing this port's fragmentation +/// engine against PeachPDF a third time, then checking the actual W3C text directly +/// (css-tables-3 §6.1, current +/// Editor's Draft): "When fragmenting a table, user agents must attempt to preserve the table rows +/// unfragmented if the cells spanning the row do not span any subsequent row, and their height is at +/// least twice smaller than both the fragmentainer height and width. Other rows are said freely +/// fragmentable." This is phrased as a required UA default, not something an author opts into - +/// CssLayoutEngineTable.LayoutCells previously only preserved a row when the TABLE had explicit +/// break-inside:avoid, meaning an ordinary multi-page table with no special markup at all rendered +/// rows split across page boundaries by default, which the spec does not permit as the default. +/// +[TestClass] +[DoNotParallelize] +public sealed class TableRowDefaultAtomicityTest +{ + private static HtmlContainerInt GetInternal(HtmlContainer wrapper) + { + var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (HtmlContainerInt)prop.GetValue(wrapper)!; + } + + private static IEnumerable Walk(CssBox box) + { + yield return box; + foreach (var b in box.Boxes) + foreach (var d in Walk(b)) + yield return d; + } + + [TestMethod] + public async Task OrdinaryRowWithNoBreakInsideAvoid_IsStillPreservedUnfragmented_ByDefault() + { + var checkedAnyStraddleCandidate = false; + + for (var fillerCount = 1; fillerCount < 60; fillerCount++) + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line

", fillerCount)); + // Deliberately no break-inside:avoid anywhere - this is the plain, no-special-markup case + // css-tables-3 §6.1 says every conformant UA must handle this way by default. + await wrapper.SetHtml( + $""" + + {filler} + + + +
RowOneCell
TargetRowCellText with several words giving it real, non-trivial height
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(300, 0); + + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var tds = Walk(container.Root).Where(b => b.HtmlTag?.Name == "td").ToList(); + if (tds.Count < 2) + continue; + var targetCell = tds[1]; + + var topSlot = container.PageIndexOf(targetCell.Location.Y); + var bottomSlot = container.PageIndexOf(System.Math.Max(targetCell.Location.Y, targetCell.ActualBottom - 0.01)); + + checkedAnyStraddleCandidate = true; + Assert.AreEqual(topSlot, bottomSlot, + $"at fillerCount={fillerCount}, the second row straddles page slots {topSlot}->{bottomSlot} with no break-inside:avoid anywhere - css-tables-3 6.1 requires it stay whole by default"); + } + + Assert.IsTrue(checkedAnyStraddleCandidate, "no filler count in range produced a target cell - test is not meaningful as written"); + } + + [TestMethod] + public async Task RowSpanningIntoASubsequentRow_RemainsFreelyFragmentable() + { + // css-tables-3 6.1's own carve-out: a row a rowspan cell only STARTS in (spanning further rows) + // is explicitly excluded from the "preserve unfragmented" default - confirming the new default + // atomicity doesn't overreach into content the spec says must stay freely fragmentable. + var foundAStraddle = false; + + for (var fillerCount = 1; fillerCount < 30; fillerCount++) + { + using var wrapper = new HtmlContainer(); + var filler = string.Concat(Enumerable.Repeat("

filler line

", fillerCount)); + await wrapper.SetHtml( + $""" + + {filler} + + + +
SpanCellRow1Cell2 with enough words to make this row meaningfully tall for the straddle test to matter
Row2Cell
+ + """); + + var container = GetInternal(wrapper); + container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300); + container.MarginTop = 0; + wrapper.MaxSize = new SizeF(300, 0); + + using var bitmap = new Bitmap(300, 20000); + using var g = Graphics.FromImage(bitmap); + wrapper.PerformLayout(g); + + var row1Cell2 = Walk(container.Root) + .FirstOrDefault(b => b.Words.Any(w => w.Text.Contains("Row1Cell2"))) + ?.ParentBox; + if (row1Cell2 == null) + continue; + + var topSlot = container.PageIndexOf(row1Cell2.Location.Y); + var bottomSlot = container.PageIndexOf(System.Math.Max(row1Cell2.Location.Y, row1Cell2.ActualBottom - 0.01)); + if (topSlot != bottomSlot) + foundAStraddle = true; + } + + Assert.IsTrue(foundAStraddle, "expected at least one filler count where the rowspan-starting row straddles a page boundary - if none do, this test isn't exercising the carve-out"); + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionRepeatsPerPdfPageTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionRepeatsPerPdfPageTest.cs new file mode 100644 index 000000000..b0a61632b --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/FixedPositionRepeatsPerPdfPageTest.cs @@ -0,0 +1,55 @@ +using System.Text; +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +/// +/// End-to-end confirmation, through the real path, of the fragment-tree-level +/// fix verified in FixedPositionRepeatsPerPageTest (HtmlRenderer.IntegrationTest): a +/// position:fixed element (css-position-3, paged media - "fixed positioned boxes are thus +/// replicated on every page") must show up in every generated PDF page, not just the page its +/// top/left offset happened to land on when misinterpreted as an absolute document +/// coordinate. +/// +/// +/// Verified by a RELATIVE Tj-operator-count comparison (with the fixed header vs. without, same filler +/// content otherwise), not a literal-text search: PdfSharp draws through a Type0/CID font here, so a +/// page's content stream holds hex glyph-index strings (<0037004B...> Tj), never the source +/// text itself - the same reality MultiPageTextVisibilityTest works around by checking only for a +/// Tj operator's presence, not its content. A page with genuinely one extra line of fixed content +/// drawn on it gets exactly one extra Tj versus the same page without that content. +/// +[TestClass] +[DoNotParallelize] +public sealed class FixedPositionRepeatsPerPdfPageTest +{ + private static int CountTj(byte[] streamBytes) => + Encoding.Latin1.GetString(streamBytes).Split("Tj").Length - 1; + + [TestMethod] + public async Task FixedHeaderMarker_AddsOneExtraTextOperatorToEveryGeneratedPage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. "; + var body = $"

{string.Concat(Enumerable.Repeat(sentence, 200))}

"; + + using var withFixed = await PdfGenerator.GeneratePdf( + $"""
FixedHeaderMarkerText
{body}""", + config); + using var withoutFixed = await PdfGenerator.GeneratePdf($"{body}", config); + + Assert.IsGreaterThanOrEqualTo(3, withoutFixed.Pages.Count, "test content should span at least 3 pages for this to be meaningful"); + Assert.AreEqual(withoutFixed.Pages.Count, withFixed.Pages.Count, "adding a fixed header should not itself change how many pages the body content needs"); + + for (var i = 0; i < withFixed.Pages.Count; i++) + { + var withCount = CountTj(withFixed.Pages[i].Contents.Elements.GetDictionary(0)!.Stream.Value); + var withoutCount = CountTj(withoutFixed.Pages[i].Contents.Elements.GetDictionary(0)!.Stream.Value); + Assert.AreEqual(withoutCount + 1, withCount, + $"page {i} should have exactly one extra text-drawing operator for the repeated fixed header (with={withCount}, without={withoutCount})"); + } + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs new file mode 100644 index 000000000..a6daa971b --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/MultiPageTextVisibilityTest.cs @@ -0,0 +1,49 @@ +using System.Text; +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +/// +/// Regression coverage for a real bug found while giving FragmentPainter a page-origin translate +/// (so HtmlContainerInt.PerformPaint(RGraphics)'s multi-fragmentainer fallback could stop +/// depending on CssBox.Paint): FragmentPainter.PaintFragmentContent painted line +/// backgrounds/borders from the fragment tree's already page-local rects, but painted the actual text via +/// CssBox.PaintWords, which reads CssRect.Rectangle straight off the live box tree - still +/// absolute document-Y - offset only by ScrollOffset (always zero for PDF generation). Every page +/// after the first got a content stream with zero text-draw operators, since a fresh per-page +/// XGraphics's origin is that page's own band top, not the document's. Existing tests only ever +/// asserted page *count*, never that a page's content stream actually contains text - this would have +/// stayed silently broken indefinitely otherwise. +/// +// Concurrent full-layout-pass tests race on shared adapter singleton state (same MSTest ClassLevel +// parallelism issue documented for HtmlRenderer.IntegrationTest) - reproduced here: this test passes +// reliably alone but intermittently reports a missing Tj on page 0 when run alongside the rest of the +// suite. +[TestClass] +[DoNotParallelize] +public sealed class MultiPageTextVisibilityTest +{ + [TestMethod] + public async Task EveryPage_HasRealTextDrawingOperators() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // Sized well past what fits on one A4 page, so every page has genuine paragraph content, not + // just a trailing sliver. + var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. "; + var html = $"

{string.Concat(Enumerable.Repeat(sentence, 200))}

"; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThanOrEqualTo(3, document.Pages.Count, "Test content should span at least 3 pages for this to be a meaningful check."); + + for (var i = 0; i < document.Pages.Count; i++) + { + var content = document.Pages[i].Contents.Elements.GetDictionary(0); + var text = Encoding.Latin1.GetString(content!.Stream.Value); + StringAssert.Contains(text, "Tj", $"Page {i} has no text-drawing operators - its content is invisible."); + } + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs new file mode 100644 index 000000000..544e5bbd2 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD2VerificationTest.cs @@ -0,0 +1,143 @@ +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +[TestClass] +public sealed class StageD2VerificationTest +{ + [TestMethod] + public async Task ForcedBreakBefore_Page_StartsNewPage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + const string html = """ + +

Page one content.

+
Page two content.
+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.AreEqual(2, document.Pages.Count); + } + + [TestMethod] + public async Task NoForcedBreak_SmallContent_StaysOnOnePage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + const string html = "

Title

Body text.

"; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.AreEqual(1, document.Pages.Count); + } + + [TestMethod] + public async Task LegacyPageBreakBefore_Always_StartsNewPage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + const string html = """ + +

Page one content.

+
Page two content.
+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.AreEqual(2, document.Pages.Count); + } + + [TestMethod] + public async Task BreakInsideAvoid_KeepsBlockTogether_OnOnePage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // Enough filler to span several pages regardless of exactly which font ends up resolving on + // whatever machine runs this (a small, precisely-calibrated filler count is fragile to font + // substitution - CI's non-Windows runners fall back to an embedded font with different metrics + // than Windows' real "Times New Roman", so a boundary tuned for one silently misses the other; + // see this project's own established testing lesson about hardcoded "just barely" magic + // numbers). Precise per-page content verification lives in HtmlRenderer.IntegrationTest's + // ContainerLeftBehindTest/StageR3RelocationTest, which read the fragment tree directly instead + // of inferring behavior from a PDF's total page count - this is only a regression-style guard + // that the avoid-block relocation doesn't crash or misbehave outright. + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 150)); + var html = $""" + + {filler} +
+

first

second

third

+
+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count); + } + + [TestMethod] + public async Task ManyParagraphs_FlowAcrossMultiplePages() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + var paragraphs = string.Concat(Enumerable.Repeat( + "

A reasonably long paragraph of filler text used to force real multi-page pagination in this test.

", + 120)); + var html = $"{paragraphs}"; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThan(1, document.Pages.Count); + } + + [TestMethod] + public async Task HugeMargin_DoesNotProduceRunawayBlankPages() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // A margin far taller than a single page - margin truncation (css-break-3 5.2) must + // discard it rather than paginating through blank vertical space. + const string html = "
content
"; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsLessThanOrEqualTo(2, document.Pages.Count); + } + + [TestMethod] + public async Task KeepWithNext_HeadingStaysWithFollowingParagraph() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // h4 has UA break-after: avoid. Generous filler (see BreakInsideAvoid_KeepsBlockTogether_OnOnePage's + // own remark on why a precisely-calibrated boundary is fragile to font substitution across CI + // platforms) - this is a regression-style guard that the pair doesn't blow up across an + // unreasonable number of pages, not a precise "did they move together" check (that lives at the + // fragment-tree level, in HtmlRenderer.IntegrationTest's ContainerLeftBehindKeepWithNextTest). + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 150)); + var html = $""" + + {filler} +

Section heading

+

Paragraph right after the heading.

+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count); + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs new file mode 100644 index 000000000..e9a808854 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD3VerificationTest.cs @@ -0,0 +1,76 @@ +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +[TestClass] +public sealed class StageD3VerificationTest +{ + [TestMethod] + public async Task LongParagraph_SpansPagesWithoutError() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // Generous repeat count - a precisely-calibrated boundary is fragile to font substitution + // across CI platforms (non-Windows runners fall back to an embedded font with different metrics + // than Windows' real "Times New Roman"); this only needs to comfortably exceed one page + // regardless of exactly which font resolves. + var sentence = "This is a moderately long sentence used to build a paragraph that will wrap across many lines and, eventually, across more than one page. "; + var html = $"

{string.Concat(Enumerable.Repeat(sentence, 100))}

"; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThan(1, document.Pages.Count); + } + + [TestMethod] + public async Task Widows_PullsMinimumLinesToNextPage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // Generous filler, not precisely calibrated to a specific boundary - see + // StageD2VerificationTest.BreakInsideAvoid_KeepsBlockTogether_OnOnePage's remark on why a tight + // "just barely" filler count is fragile to font substitution across CI platforms. Precise + // per-page widows verification lives in HtmlRenderer.IntegrationTest's StageR5WidowsMultiPageTest, + // which reads the fragment tree directly. + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 150)); + var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; + var html = $""" + + {filler} +

{string.Concat(Enumerable.Repeat(sentence, 6))}

+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + // The widowed paragraph must not leave fewer than 3 of its lines alone at the top of a page - + // this is a structural/behavioral guard (page count is stable and small) rather than pixel + // inspection, matching the other D2/D3 verification tests in this project. + Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count); + } + + [TestMethod] + public async Task Orphans_KeepsMinimumLinesOnFirstPage() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // Generous filler - see Widows_PullsMinimumLinesToNextPage's own remark on why a tight "just + // barely" filler count is fragile to font substitution across CI platforms. + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 150)); + var sentence = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty "; + var html = $""" + + {filler} +

{string.Concat(Enumerable.Repeat(sentence, 6))}

+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThanOrEqualTo(2, document.Pages.Count); + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs new file mode 100644 index 000000000..2a4950485 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageD4VerificationTest.cs @@ -0,0 +1,30 @@ +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +[TestClass] +public sealed class StageD4VerificationTest +{ + [TestMethod] + public async Task LargeTableWithHeader_SpansMultiplePagesWithoutError() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + var rows = string.Concat(Enumerable.Range(0, 60) + .Select(i => $"row {i} arow {i} b")); + var html = $""" + + + + {rows} +
Column AColumn B
+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThan(1, document.Pages.Count); + } +} diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs new file mode 100644 index 000000000..33cacb7f5 --- /dev/null +++ b/Source/Test/HtmlRenderer.PdfSharp.Test/StageF1VerificationTest.cs @@ -0,0 +1,61 @@ +using PdfSharp; +using TheArtOfDev.HtmlRenderer.PdfSharp; + +namespace HtmlRenderer.PdfSharp.Test; + +[TestClass] +public sealed class StageF1VerificationTest +{ + [TestMethod] + public async Task WebLinkAndAnchorLink_AcrossPages_DoNotThrow() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + // Generous filler, not a precisely-calibrated boundary - a tight "just barely" filler count is + // fragile to font substitution across CI platforms (non-Windows runners fall back to an + // embedded font with different metrics than Windows' real "Times New Roman"). + var filler = string.Concat(Enumerable.Repeat("

filler line of text

", 150)); + var html = $""" + + external link on page one + jump to anchor + {filler} +

anchor target, on a later page

+ + """; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + Assert.IsGreaterThan(1, document.Pages.Count); + + // At least one page carries some link annotation (either the web link or the document link) - + // this is a smoke check that HandleLinks' new slot-to-page mapping runs without throwing and + // actually attaches annotations, not a check of which exact page holds which link. + var anyLinks = false; + for (var i = 0; i < document.PageCount; i++) + { + if (document.Pages[i].Annotations.Count > 0) + { + anyLinks = true; + break; + } + } + Assert.IsTrue(anyLinks, "expected at least one page to carry a link annotation"); + } + + [TestMethod] + public async Task HugeMargin_ProducesNoBlankPages() + { + var config = new PdfGenerateConfig { PageSize = PageSize.A4 }; + config.SetMargins(20); + + const string html = "
content
"; + + using var document = await PdfGenerator.GeneratePdf(html, config); + + // css-break-3 5.2 margin truncation (D2) keeps this on very few pages; blank-page skipping + // (F1's page-per-fragmentainer loop) means whatever pages exist are never content-empty. + Assert.IsLessThanOrEqualTo(2, document.Pages.Count); + } +}