Skip to content

Commit 736529b

Browse files
committed
Repeat position:fixed content on every page, per css-position-3
Verified against the actual W3C spec text (css-position-3): "in paged media, the page area of each page; fixed positioned boxes are thus replicated on every page", and UAs "must not paginate the content of fixed-positioned boxes". Scoped to top/left-anchored fixed content only (a page header/watermark) - bottom/right are a separate, pre-existing gap: neither property is parsed for absolute/fixed positioning at all, so a bottom-anchored footer, the more common real print pattern, needs that fixed first. FragmentEmitter now collects every position:fixed box in the tree (CollectFixedRoots, handling arbitrary nesting depth) and, for each materialized page, builds a fresh fragment for it against a page-local band (top=0) rather than the page's real band top - a fixed box's own Location is already page-relative (CssBox.PerformLayoutImp's Position==Fixed branch never routes it through normal absolute-Y-computing flow at all), so this reuses the same geometry unchanged on every page. Excluded from the normal per-page walk to avoid a duplicate/misplaced render on whichever page its raw offset would otherwise land on. InlineFragmentation.ApplyLineBreaking now also skips fixed boxes outright - their content must not paginate. Confirming this through actual PDF output (not just the fragment tree) surfaced a second, real, pre-existing bug: FragmentPainter.LiveTreeOffset/ LiveTreeExtraOffset unconditionally undid the current page's band top from live-tree geometry, on the documented assumption that "band membership is orthogonal to scroll-offset suppression" for fixed content. That assumption was true when fixed content only ever appeared on one page (wherever its raw offset landed) but breaks now that it's intentionally repeated: a fixed box's live geometry is already page-relative, so subtracting a nonzero band top pushes its containing-block visibility/overflow-clip check far outside every page except the one whose band top happens to equal its own small offset - confirmed via a real generated PDF, where the header only rendered on page 0. Fixed by making both offsets skip the band-top term entirely for fixed (or fixed-ancestor) content.
1 parent 68fbb66 commit 736529b

6 files changed

Lines changed: 248 additions & 12 deletions

File tree

Source/HtmlRenderer/Core/Fragmentation/FragmentEmitter.cs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using TheArtOfDev.HtmlRenderer.Adapters.Entities;
45
using TheArtOfDev.HtmlRenderer.Core.Dom;
56
using TheArtOfDev.HtmlRenderer.Core.Fragments;
7+
using TheArtOfDev.HtmlRenderer.Core.Utils;
68

79
namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation
810
{
@@ -55,6 +57,16 @@ internal FragmentTree Finish()
5557
var lastSlot = _container.PageIndexOf(Math.Max(0, root.ActualBottom - Epsilon));
5658
var fragmentainers = new List<FragmentainerFragment>();
5759

60+
// css-position-3, paged media: a fixed box's containing block is each page's own page area,
61+
// and it "is thus replicated on every page". Collected once - each fixed box's own Location
62+
// is already page-relative (CssBox never runs it through normal top-computing flow; see
63+
// CssBox.PerformLayoutImp's own Position==Fixed branch), so building its fragment against a
64+
// band starting at Y=0 (rather than this slot's real band top) localizes it to exactly that
65+
// same relative position on every page, unchanged.
66+
var fixedRoots = new List<CssBox>();
67+
CollectFixedRoots(root, fixedRoots);
68+
var fixedBand = new PageBand(0, _container.PageSize.Height);
69+
5870
for (var slot = 0; slot <= lastSlot; slot++)
5971
{
6072
var bandTop = _container.PageTopOf(slot);
@@ -63,11 +75,23 @@ internal FragmentTree Finish()
6375

6476
// CSS Paged Media 3 3.2: a page-slot no box has any content in is never materialized -
6577
// this falls out of the walk rather than being special-cased, since a box only gets
66-
// built into this fragmentainer at all when HasContentInBand finds something.
78+
// built into this fragmentainer at all when HasContentInBand finds something. Fixed
79+
// content deliberately does not itself justify materializing an otherwise content-empty
80+
// slot - matches this port's existing blank-page-skipping scope.
6781
if (!HasContentInBand(root, band))
6882
continue;
6983

7084
var rootFragment = BuildBoxFragment(root, slot, band);
85+
if (fixedRoots.Count > 0)
86+
{
87+
var fixedFragments = fixedRoots
88+
.Where(fixedRoot => HasContentInBand(fixedRoot, fixedBand))
89+
.Select(fixedRoot => BuildBoxFragment(fixedRoot, slot, fixedBand))
90+
.ToList();
91+
if (fixedFragments.Count > 0)
92+
rootFragment = rootFragment with { Children = rootFragment.Children.Concat(fixedFragments).ToList() };
93+
}
94+
7195
var rect = new RRect(0, 0, _container.PageSize.Width, band.Height);
7296
var geometry = new PageBandGeometry(bandTop, band.Height, _container.MarginTop, _container.MarginRight, _container.MarginBottom, _container.MarginLeft);
7397
fragmentainers.Add(new FragmentainerFragment(rect, slot, geometry, bandTop, rootFragment));
@@ -76,13 +100,29 @@ internal FragmentTree Finish()
76100
return new FragmentTree(fragmentainers);
77101
}
78102

103+
/// <summary>
104+
/// Finds every <c>position:fixed</c> box in the tree, at any nesting depth - each one gets its
105+
/// own independent repeat-per-page treatment in <see cref="Finish"/>, regardless of whether it's
106+
/// nested inside another fixed box (rare, but each still resolves its own page-relative position
107+
/// independently per css-position-3, so neither should be folded into the other's subtree).
108+
/// </summary>
109+
private static void CollectFixedRoots(CssBox box, List<CssBox> into)
110+
{
111+
foreach (var child in box.Boxes)
112+
{
113+
if (child.Position == CssConstants.Fixed)
114+
into.Add(child);
115+
CollectFixedRoots(child, into);
116+
}
117+
}
118+
79119
/// <summary>
80120
/// Whether <paramref name="box"/> or any descendant has some rectangle (its own decoration
81121
/// rects, a word, or a child's) overlapping <paramref name="band"/> - used both to decide
82122
/// whether a page-slot is content-empty (skip it) and whether a child belongs in this band's
83123
/// fragment at all.
84124
/// </summary>
85-
private static bool HasContentInBand(CssBox box, PageBand band)
125+
private bool HasContentInBand(CssBox box, PageBand band)
86126
{
87127
if (box.Rectangles.Count == 0)
88128
{
@@ -103,6 +143,13 @@ private static bool HasContentInBand(CssBox box, PageBand band)
103143

104144
foreach (var child in box.Boxes)
105145
{
146+
// A fixed box is handled separately when there's a real page grid (see
147+
// CollectFixedRoots/Finish) - it repeats identically on every page rather than
148+
// belonging to whichever band its own (page-relative, not absolute) coordinates would
149+
// otherwise overlap. Without a real page grid (WinForms/WPF continuous-scroll, one
150+
// fragmentainer for the whole document) it stays in the normal walk unchanged - "stays
151+
// put" there is a paint-time scroll-offset suppression, not a repeat-per-page concern.
152+
if (_container.HasRealPageGrid && child.Position == CssConstants.Fixed) continue;
106153
if (HasContentInBand(child, band)) return true;
107154
}
108155

@@ -156,6 +203,8 @@ private BoxFragment BuildBoxFragment(CssBox box, int fragmentainerIndex, PageBan
156203
var children = new List<BoxFragment>();
157204
foreach (var child in box.Boxes)
158205
{
206+
// See the matching check/comment in HasContentInBand.
207+
if (_container.HasRealPageGrid && child.Position == CssConstants.Fixed) continue;
159208
if (HasContentInBand(child, band))
160209
children.Add(BuildBoxFragment(child, fragmentainerIndex, band));
161210
}

Source/HtmlRenderer/Core/Fragmentation/InlineFragmentation.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using TheArtOfDev.HtmlRenderer.Core.Dom;
4+
using TheArtOfDev.HtmlRenderer.Core.Utils;
45

56
namespace TheArtOfDev.HtmlRenderer.Core.Fragmentation
67
{
@@ -41,7 +42,12 @@ internal static class InlineFragmentation
4142
internal static void ApplyLineBreaking(CssBox blockBox)
4243
{
4344
var container = blockBox.HtmlContainer;
44-
if (container == null || !container.HasRealPageGrid)
45+
// A fixed box (css-position-3, paged media) is repeated identically on every page and its
46+
// own coordinates are page-relative, not absolute document-Y (see FragmentEmitter's
47+
// CollectFixedRoots) - unlike a float or an absolutely-positioned box, which stay in normal
48+
// document flow and must still paginate like anything else, the UA "must not paginate the
49+
// content of fixed-positioned boxes" (css-position-3), so this correction does not apply.
50+
if (container == null || !container.HasRealPageGrid || blockBox.Position == CssConstants.Fixed)
4551
return;
4652

4753
var lines = blockBox.LineBoxes;

Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment)
2727
var rect = fragment.PrimaryRect;
2828
rect.Offset(painter.FragmentLocalOffset(box.IsFixed));
2929

30-
var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, painter.LiveTreeExtraOffset);
30+
var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, painter.LiveTreeExtraOffset(box.IsFixed));
3131

3232
box.PaintBackground(g, rect, true, true);
3333
BordersDrawHandler.DrawBoxBorders(g, box, rect, true, true);

Source/HtmlRenderer/Core/Paint/FragmentPainter.cs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,22 +81,41 @@ internal RPoint FragmentLocalOffset(bool isFixed)
8181
/// <summary>
8282
/// The offset to apply to a rect read straight off the live <see cref="CssBox"/> tree (still
8383
/// absolute document-Y, unlike fragment-tree geometry) to reach the same paint position
84-
/// <see cref="FragmentLocalOffset"/> gives fragment-local geometry: additionally undoes
85-
/// <see cref="_bandTop"/>, regardless of <paramref name="isFixed"/> (band membership is
86-
/// orthogonal to scroll-offset suppression).
84+
/// <see cref="FragmentLocalOffset"/> gives fragment-local geometry: undoes <see cref="_bandTop"/>
85+
/// - except for a fixed (or fixed-ancestor) box, whose live geometry is already page-relative
86+
/// (see the remark below), where undoing this painter's current band top would double-subtract
87+
/// it, pushing the box far outside every page except the one whose band top happens to equal its
88+
/// own small top offset.
8789
/// </summary>
90+
/// <remarks>
91+
/// A real bug found while confirming <see cref="FragmentEmitter"/>'s fixed-position repeat-per-page
92+
/// support through actual PDF output: <c>CssBox.PerformLayoutImp</c> never routes a
93+
/// <c>Position==Fixed</c> box through normal top-computing flow at all (its <c>Left</c>/<c>Top</c>
94+
/// property setters assign <c>Location</c> directly, from <c>GetActualLocation</c>, resolved
95+
/// against the page size) - so unlike ordinary content, whose live <c>Location</c> genuinely is an
96+
/// absolute document-Y this painter's current band top needs undoing from, a fixed box's live
97+
/// <c>Location</c> already IS the small, page-relative offset the fragment tree also uses. This
98+
/// only affected the containing-block visibility/overflow-clip checks below (<see cref="PaintFragment"/>'s
99+
/// own check, and <see cref="RenderUtils.ClipGraphicsByOverflow"/> via <see cref="LiveTreeExtraOffset"/>)
100+
/// - the fragment tree's own already-correct geometry (<c>fragment.Lines</c>/<c>fragment.Words</c>,
101+
/// via <see cref="FragmentLocalOffset"/> alone) was never affected, which is why the fixed content
102+
/// was confirmed correctly PRESENT in the fragment tree on every page before this was found - it
103+
/// was being computed correctly and then clipped away on every page except one.
104+
/// </remarks>
88105
internal RPoint LiveTreeOffset(bool isFixed)
89106
{
90107
var offset = FragmentLocalOffset(isFixed);
91-
return new RPoint(offset.X, offset.Y - _bandTop);
108+
return isFixed ? offset : new RPoint(offset.X, offset.Y - _bandTop);
92109
}
93110

94111
/// <summary>
95112
/// The portion of <see cref="LiveTreeOffset"/> that <see cref="RenderUtils.ClipGraphicsByOverflow"/>
96-
/// doesn't already add itself (it applies <see cref="HtmlContainerInt.ScrollOffset"/>/<c>IsFixed</c>
97-
/// gating internally) - pass as its <c>extraOffset</c> parameter.
113+
/// doesn't already add itself (it applies <see cref="HtmlContainerInt.ScrollOffset"/> gating
114+
/// internally) - pass as its <c>extraOffset</c> parameter. See <see cref="LiveTreeOffset"/>'s own
115+
/// remark for why <paramref name="isFixed"/> must gate the band-top term here too.
98116
/// </summary>
99-
internal RPoint LiveTreeExtraOffset => new RPoint(_pageOrigin.X, _pageOrigin.Y - _bandTop);
117+
internal RPoint LiveTreeExtraOffset(bool isFixed) =>
118+
isFixed ? new RPoint(_pageOrigin.X, _pageOrigin.Y) : new RPoint(_pageOrigin.X, _pageOrigin.Y - _bandTop);
100119

101120
internal void Paint(RGraphics g, FragmentainerFragment fragmentainer)
102121
{
@@ -169,7 +188,7 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment)
169188
return;
170189
}
171190

172-
var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, LiveTreeExtraOffset);
191+
var clipped = RenderUtils.ClipGraphicsByOverflow(g, box, LiveTreeExtraOffset(box.IsFixed));
173192
var clip = g.GetClip();
174193
// fragment.Lines/fragment.Words are already fragment-local (FragmentEmitter subtracted the
175194
// band top at build time) - only FragmentLocalOffset (scroll + page-origin) applies to either.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System.Collections.Generic;
2+
using System.Drawing;
3+
using System.Linq;
4+
using System.Reflection;
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
using TheArtOfDev.HtmlRenderer.Core;
7+
using TheArtOfDev.HtmlRenderer.Core.Fragments;
8+
using TheArtOfDev.HtmlRenderer.WinForms;
9+
10+
namespace TheArtOfDev.HtmlRenderer.IntegrationTest;
11+
12+
/// <summary>
13+
/// Verifies a real, spec-confirmed missing feature found while auditing this port's fragmentation engine
14+
/// against PeachPDF a third time, then checking the actual W3C text directly
15+
/// (<see href="https://www.w3.org/TR/css-position-3/">css-position-3</see>): "in paged media, the page
16+
/// area of each page; fixed positioned boxes are thus replicated on every page", and user agents "must
17+
/// not paginate the content of fixed-positioned boxes". A <c>position:fixed</c> element (a print
18+
/// header/watermark - <c>bottom</c>/<c>right</c> anchoring is a separate, pre-existing gap: neither
19+
/// property is parsed for absolute/fixed positioning at all, so a bottom-anchored footer, the more common
20+
/// real print pattern, is out of scope here) previously rendered on exactly one page - wherever its
21+
/// <c>top</c>/<c>left</c> offset happened to be interpreted as an absolute document coordinate - instead
22+
/// of being replicated identically on every page.
23+
/// </summary>
24+
[TestClass]
25+
[DoNotParallelize]
26+
public sealed class FixedPositionRepeatsPerPageTest
27+
{
28+
private static HtmlContainerInt GetInternal(HtmlContainer wrapper)
29+
{
30+
var prop = typeof(HtmlContainer).GetProperty("HtmlContainerInt", BindingFlags.NonPublic | BindingFlags.Instance)!;
31+
return (HtmlContainerInt)prop.GetValue(wrapper)!;
32+
}
33+
34+
private static IEnumerable<(string Text, double Top)> AllWords(BoxFragment f)
35+
{
36+
foreach (var w in f.Words)
37+
if (!w.Word.IsLineBreak)
38+
yield return (w.Word.Text, w.Rect.Top);
39+
foreach (var c in f.Children)
40+
foreach (var x in AllWords(c))
41+
yield return x;
42+
}
43+
44+
[TestMethod]
45+
public async Task TopLeftFixedElement_RepeatsIdenticallyOnEveryPage()
46+
{
47+
using var wrapper = new HtmlContainer();
48+
var filler = string.Concat(Enumerable.Repeat("<p style='margin:0;'>filler line of body text</p>", 60));
49+
await wrapper.SetHtml(
50+
$"""
51+
<html><body>
52+
<div style="position:fixed; top:5px; left:5px;">PageHeaderMarker</div>
53+
{filler}
54+
</body></html>
55+
""");
56+
57+
var container = GetInternal(wrapper);
58+
container.PageSize = new TheArtOfDev.HtmlRenderer.Adapters.Entities.RSize(300, 300);
59+
container.MarginTop = 0;
60+
wrapper.MaxSize = new SizeF(300, 0);
61+
62+
using var bitmap = new Bitmap(300, 20000);
63+
using var g = Graphics.FromImage(bitmap);
64+
wrapper.PerformLayout(g);
65+
66+
var tree = container.FragmentTree;
67+
Assert.IsGreaterThan(1, tree.Fragmentainers.Count, "the filler content must genuinely span multiple pages for this test to be meaningful");
68+
69+
for (var i = 0; i < tree.Fragmentainers.Count; i++)
70+
{
71+
var markerHits = AllWords(tree.Fragmentainers[i].Root).Where(w => w.Text == "PageHeaderMarker").ToList();
72+
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)");
73+
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");
74+
}
75+
}
76+
77+
[TestMethod]
78+
public async Task FixedElement_StillRendersOnce_WithoutARealPageGrid()
79+
{
80+
// WinForms/WPF's continuous-scroll convention (no PageSize set - HasRealPageGrid=false): the
81+
// repeat-per-page mechanism must not apply here at all, since "stays put" for that viewport is a
82+
// paint-time scroll-offset suppression (CssBox.IsFixed), not a per-page repeat concern - confirms
83+
// the new exclusion in FragmentEmitter is correctly gated on HasRealPageGrid.
84+
using var wrapper = new HtmlContainer();
85+
var filler = string.Concat(Enumerable.Repeat("<p style='margin:0;'>filler line of body text</p>", 60));
86+
await wrapper.SetHtml(
87+
$"""
88+
<html><body>
89+
<div style="position:fixed; top:5px; left:5px;">PageHeaderMarker</div>
90+
{filler}
91+
</body></html>
92+
""");
93+
94+
wrapper.MaxSize = new SizeF(300, 0);
95+
using var bitmap = new Bitmap(300, 20000);
96+
using var g = Graphics.FromImage(bitmap);
97+
wrapper.PerformLayout(g);
98+
99+
var container = GetInternal(wrapper);
100+
Assert.IsFalse(container.HasRealPageGrid);
101+
var tree = container.FragmentTree;
102+
Assert.AreEqual(1, tree.Fragmentainers.Count);
103+
104+
var markerHits = AllWords(tree.Fragmentainers[0].Root).Where(w => w.Text == "PageHeaderMarker").ToList();
105+
Assert.AreEqual(1, markerHits.Count, "the fixed element must still render exactly once via the normal walk when there's no real page grid");
106+
}
107+
}

0 commit comments

Comments
 (0)