Skip to content

Commit f78c97d

Browse files
committed
Implement step 7.4.2: Column flow
1 parent 6e2bc17 commit f78c97d

7 files changed

Lines changed: 279 additions & 22 deletions

File tree

PanoramicData.Render.Test/PageBuilderTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@ public void Paginate_BlocksExceedPageHeight_SpillToSecondPage()
8686
result[1].Blocks.Should().ContainSingle();
8787
}
8888

89+
[Fact]
90+
public void Paginate_TwoColumnSection_UsesSecondColumnBeforeCreatingNewPage()
91+
{
92+
var section = new SectionInfo { ColumnCount = 2 };
93+
var blocks = new[]
94+
{
95+
MakeBlock(7000f),
96+
MakeBlock(7000f),
97+
MakeBlock(7000f),
98+
};
99+
100+
var result = PageBuilder.Paginate(blocks, section);
101+
102+
result.Should().HaveCount(2);
103+
result[0].Blocks.Should().HaveCount(2);
104+
result[1].Blocks.Should().ContainSingle();
105+
}
106+
89107
[Fact]
90108
public void Paginate_ManyBlocks_CorrectPageCount()
91109
{

PanoramicData.Render.Test/RenderCommandEmitterTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,40 @@ public void EmitPage_TablePlaceholderBlock_EmitsDrawRectCommand()
142142
target.DrawRectCalls[0].Rect.HeightTwips.Should().Be(800f);
143143
}
144144

145+
[Fact]
146+
public void EmitPage_WithBlockPlacements_UsesPlacementCoordinates()
147+
{
148+
var firstBlock = new LayoutBlock(new ParagraphBlock
149+
{
150+
SourceElement = new Paragraph(new Run(new Text("First")))
151+
}, 300f);
152+
var secondBlock = new LayoutBlock(new ParagraphBlock
153+
{
154+
SourceElement = new Paragraph(new Run(new Text("Second")))
155+
}, 300f);
156+
var page = new LayoutPage
157+
{
158+
Section = new SectionInfo { MarginLeft = 1440, MarginRight = 1440, PageWidth = 12240 },
159+
PageNumber = 1,
160+
ContentTopTwips = 1440,
161+
Blocks = [firstBlock, secondBlock],
162+
BlockPlacements =
163+
[
164+
new LayoutBlockPlacement(firstBlock, 1440f, 1440f, 4320f, 0),
165+
new LayoutBlockPlacement(secondBlock, 6480f, 1440f, 4320f, 1)
166+
]
167+
};
168+
var target = new FakeRenderTarget();
169+
170+
RenderCommandEmitter.EmitPage(page, target);
171+
172+
target.DrawTextCalls.Should().HaveCount(2);
173+
target.DrawTextCalls[0].Text.Should().Be("First");
174+
target.DrawTextCalls[0].BaselineXTwips.Should().Be(1440f);
175+
target.DrawTextCalls[1].Text.Should().Be("Second");
176+
target.DrawTextCalls[1].BaselineXTwips.Should().Be(6480f);
177+
}
178+
145179
[Fact]
146180
public void EmitDocument_MultiplePages_EmitsCommandsAcrossPages()
147181
{

PanoramicData.Render/LayoutPage.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ internal sealed class LayoutPage
2020
/// </summary>
2121
public required IReadOnlyList<LayoutBlock> Blocks { get; init; }
2222

23+
/// <summary>
24+
/// Gets the positioned block placements for this page.
25+
/// When empty, blocks are rendered as a single top-to-bottom stream.
26+
/// </summary>
27+
public IReadOnlyList<LayoutBlockPlacement> BlockPlacements { get; init; } = [];
28+
2329
/// <summary>
2430
/// Gets the header layout blocks for this page, or <see langword="null"/> when no header applies.
2531
/// </summary>
@@ -59,3 +65,18 @@ internal sealed class LayoutPage
5965
/// </summary>
6066
public float FootnoteTopTwips { get; init; }
6167
}
68+
69+
/// <summary>
70+
/// Associates a layout block with its positioned content box on a page.
71+
/// </summary>
72+
/// <param name="Block">The block being placed.</param>
73+
/// <param name="XTwips">The X origin of the block's content region in twips.</param>
74+
/// <param name="YTwips">The Y origin of the block in twips.</param>
75+
/// <param name="ContentWidthTwips">The available content width for the block in twips.</param>
76+
/// <param name="ColumnIndex">The zero-based page column index containing the block.</param>
77+
internal readonly record struct LayoutBlockPlacement(
78+
LayoutBlock Block,
79+
float XTwips,
80+
float YTwips,
81+
float ContentWidthTwips,
82+
int ColumnIndex);

PanoramicData.Render/PageBuilder.cs

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ private static IReadOnlyList<LayoutPage> PaginateStartingAt(
195195
return [];
196196
}
197197

198+
if (section.ColumnCount > 1)
199+
{
200+
return PaginateAcrossColumnsStartingAt(blocks, section, startPageNumber, headerHeight, footerHeight, footnoteHeight);
201+
}
202+
198203
var availableHeight = ComputeAvailableContentHeight(section, headerHeight, footerHeight, footnoteHeight);
199204
var pages = new List<LayoutPage>();
200205
var currentPageBlocks = new List<LayoutBlock>();
@@ -241,7 +246,7 @@ private static IReadOnlyList<LayoutPage> PaginateStartingAt(
241246
currentPageBlocks.RemoveRange(keepStart, pullBackCount);
242247
currentHeight -= pulledBack.Sum(b => b.HeightTwips);
243248

244-
pages.Add(CreatePage(section, pageNumber, currentPageBlocks));
249+
pages.Add(CreatePage(section, pageNumber, currentPageBlocks, headerHeight: headerHeight, footerHeight: footerHeight, footnoteHeight: footnoteHeight));
245250
pageNumber++;
246251
currentPageBlocks = new List<LayoutBlock>(pulledBack);
247252
currentHeight = pulledBack.Sum(b => b.HeightTwips);
@@ -260,7 +265,7 @@ private static IReadOnlyList<LayoutPage> PaginateStartingAt(
260265
{
261266
// Place the first part on the current page, queue the second part.
262267
currentPageBlocks.Add(split.Value.First);
263-
pages.Add(CreatePage(section, pageNumber, currentPageBlocks));
268+
pages.Add(CreatePage(section, pageNumber, currentPageBlocks, headerHeight: headerHeight, footerHeight: footerHeight, footnoteHeight: footnoteHeight));
264269
pageNumber++;
265270
currentPageBlocks = [];
266271
currentHeight = 0f;
@@ -269,7 +274,7 @@ private static IReadOnlyList<LayoutPage> PaginateStartingAt(
269274
else if (currentPageBlocks.Count > 0)
270275
{
271276
// Cannot split and page has content. Finalize page and retry on a fresh page.
272-
pages.Add(CreatePage(section, pageNumber, currentPageBlocks));
277+
pages.Add(CreatePage(section, pageNumber, currentPageBlocks, headerHeight: headerHeight, footerHeight: footerHeight, footnoteHeight: footnoteHeight));
273278
pageNumber++;
274279
currentPageBlocks = [];
275280
currentHeight = 0f;
@@ -287,12 +292,134 @@ private static IReadOnlyList<LayoutPage> PaginateStartingAt(
287292
// Finalize the last page.
288293
if (currentPageBlocks.Count > 0)
289294
{
290-
pages.Add(CreatePage(section, pageNumber, currentPageBlocks));
295+
pages.Add(CreatePage(section, pageNumber, currentPageBlocks, headerHeight: headerHeight, footerHeight: footerHeight, footnoteHeight: footnoteHeight));
291296
}
292297

293298
return pages;
294299
}
295300

301+
private static IReadOnlyList<LayoutPage> PaginateAcrossColumnsStartingAt(
302+
IReadOnlyList<LayoutBlock> blocks,
303+
SectionInfo section,
304+
int startPageNumber,
305+
float headerHeight = 0f,
306+
float footerHeight = 0f,
307+
float footnoteHeight = 0f)
308+
{
309+
var availableHeight = ComputeAvailableContentHeight(section, headerHeight, footerHeight, footnoteHeight);
310+
var contentTop = ComputeContentTop(section, headerHeight);
311+
var columns = ComputeColumnRegions(section);
312+
var pages = new List<LayoutPage>();
313+
var currentPageBlocks = new List<LayoutBlock>();
314+
var currentPlacements = new List<LayoutBlockPlacement>();
315+
var currentHeight = 0f;
316+
var currentColumnIndex = 0;
317+
var currentColumnHasBlocks = false;
318+
var pageNumber = startPageNumber;
319+
320+
var index = 0;
321+
LayoutBlock? pending = null;
322+
323+
while (index < blocks.Count || pending is not null)
324+
{
325+
var block = pending ?? blocks[index];
326+
if (pending is null)
327+
{
328+
index++;
329+
}
330+
331+
pending = null;
332+
333+
if (block.ForcePageBreakBefore && currentPageBlocks.Count > 0)
334+
{
335+
FinalizeCurrentPage();
336+
pending = block;
337+
continue;
338+
}
339+
340+
if (currentHeight + block.HeightTwips <= availableHeight)
341+
{
342+
AddCurrentBlock(block);
343+
continue;
344+
}
345+
346+
var remainingSpace = currentColumnHasBlocks
347+
? availableHeight - currentHeight
348+
: availableHeight;
349+
350+
var split = TrySplitBlock(block, remainingSpace);
351+
352+
if (split is not null)
353+
{
354+
AddCurrentBlock(split.Value.First);
355+
pending = split.Value.Second;
356+
AdvanceColumnOrPage();
357+
continue;
358+
}
359+
360+
if (currentColumnHasBlocks)
361+
{
362+
pending = block;
363+
AdvanceColumnOrPage();
364+
continue;
365+
}
366+
367+
AddCurrentBlock(block);
368+
}
369+
370+
if (currentPageBlocks.Count > 0)
371+
{
372+
FinalizeCurrentPage();
373+
}
374+
375+
return pages;
376+
377+
void AddCurrentBlock(LayoutBlock block)
378+
{
379+
var column = columns[currentColumnIndex];
380+
currentPageBlocks.Add(block);
381+
currentPlacements.Add(new LayoutBlockPlacement(
382+
block,
383+
column.XTwips,
384+
contentTop + currentHeight,
385+
column.WidthTwips,
386+
currentColumnIndex));
387+
currentHeight += block.HeightTwips;
388+
currentColumnHasBlocks = true;
389+
}
390+
391+
void AdvanceColumnOrPage()
392+
{
393+
if (currentColumnIndex + 1 < columns.Count)
394+
{
395+
currentColumnIndex++;
396+
currentHeight = 0f;
397+
currentColumnHasBlocks = false;
398+
return;
399+
}
400+
401+
FinalizeCurrentPage();
402+
}
403+
404+
void FinalizeCurrentPage()
405+
{
406+
pages.Add(CreatePage(
407+
section,
408+
pageNumber,
409+
currentPageBlocks,
410+
currentPlacements,
411+
headerHeight,
412+
footerHeight,
413+
footnoteHeight));
414+
pageNumber++;
415+
currentPageBlocks = [];
416+
currentPlacements = [];
417+
currentColumnIndex = 0;
418+
currentHeight = 0f;
419+
currentColumnHasBlocks = false;
420+
}
421+
}
422+
296423
/// <summary>
297424
/// The default minimum number of lines for widow/orphan control.
298425
/// </summary>
@@ -463,11 +590,20 @@ internal static IReadOnlyList<LayoutBlock> CreateTableRowLayoutBlocks(
463590
private static LayoutPage CreatePage(
464591
SectionInfo section,
465592
int pageNumber,
466-
List<LayoutBlock> blocks) => new()
593+
List<LayoutBlock> blocks,
594+
IReadOnlyList<LayoutBlockPlacement>? blockPlacements = null,
595+
float headerHeight = 0f,
596+
float footerHeight = 0f,
597+
float footnoteHeight = 0f) => new()
467598
{
468599
Section = section,
469600
PageNumber = pageNumber,
470-
Blocks = blocks.ToArray()
601+
Blocks = blocks.ToArray(),
602+
BlockPlacements = blockPlacements?.ToArray() ?? [],
603+
HeaderTopTwips = ComputeHeaderTop(section),
604+
ContentTopTwips = ComputeContentTop(section, headerHeight),
605+
FooterTopTwips = ComputeFooterTop(section, footerHeight),
606+
FootnoteTopTwips = ComputeFootnoteTop(section, footerHeight, footnoteHeight)
471607
};
472608

473609
/// <summary>
@@ -576,9 +712,37 @@ private static int ApplySectionBreak(
576712
{
577713
Section = section,
578714
PageNumber = pageNumber,
579-
Blocks = []
715+
Blocks = [],
716+
HeaderTopTwips = ComputeHeaderTop(section),
717+
ContentTopTwips = ComputeContentTop(section),
718+
FooterTopTwips = ComputeFooterTop(section),
719+
FootnoteTopTwips = ComputeFootnoteTop(section)
580720
};
581721

722+
private static IReadOnlyList<ColumnRegion> ComputeColumnRegions(SectionInfo section)
723+
{
724+
var contentLeft = section.MarginLeft;
725+
var contentWidth = MathF.Max(0f, section.PageWidth - section.MarginLeft - section.MarginRight);
726+
var columnCount = Math.Max(1, section.ColumnCount);
727+
if (columnCount == 1)
728+
{
729+
return [new ColumnRegion(contentLeft, contentWidth)];
730+
}
731+
732+
var spacing = MathF.Max(0f, section.ColumnSpacingTwips);
733+
var totalSpacing = spacing * (columnCount - 1);
734+
var columnWidth = MathF.Max(0f, (contentWidth - totalSpacing) / columnCount);
735+
var result = new ColumnRegion[columnCount];
736+
var currentX = (float)contentLeft;
737+
for (var i = 0; i < columnCount; i++)
738+
{
739+
result[i] = new ColumnRegion(currentX, columnWidth);
740+
currentX += columnWidth + spacing;
741+
}
742+
743+
return result;
744+
}
745+
582746
/// <summary>
583747
/// Computes the available content height for body text, accounting for page dimensions,
584748
/// margins, header/footer content heights, and footnote space.
@@ -649,4 +813,6 @@ internal static float ComputeFootnoteTop(SectionInfo section, float footerHeight
649813

650814
private static float ComputeEffectiveBottomMargin(SectionInfo section, float footerHeight)
651815
=> Math.Max(section.MarginBottom, section.MarginFooter + footerHeight);
816+
817+
private readonly record struct ColumnRegion(float XTwips, float WidthTwips);
652818
}

0 commit comments

Comments
 (0)