Cookbook
Working recipes you can copy and adapt. Most are trimmed versions of the
scenarios in samples/TerraFluent.Html.Reporting.Sample/Scenarios -
run dotnet run --project samples/TerraFluent.Html.Reporting.Sample to generate the
full versions as HTML and open them in a browser.
A Multi-Page Report with Header, Footer, and a Table That Spans Pages
The “kitchen sink” example: a repeating header/footer with page numbers, a heading, a paragraph, an image, a 45-row table that spans several pages (header repeated, rows split correctly), and a numbered list.
using TerraFluent.Html.Reporting.Model;
using TerraFluent.Html.Reporting.Model.Elements;
var products = new[] { "Widget A", "Widget B", "Gadget C", "Gizmo D" };
var random = new Random(42);
var report = ReportDocument.Create(PageSize.A4, PageOrientation.Portrait)
.SetMargins(40, 40, 60, 60)
.Header(h => h.AddText("Monthly Sales Report").AlignCenter().Bold().FontSize(16))
.Footer(f => f.AddPageNumber("Page {page} of {totalPages}").AlignCenter())
.Content(c =>
{
c.AddHeading("Sales Summary", HeadingLevel.H1);
c.AddParagraph(
"This report summarizes sales activity across all regions for the current " +
"period. The table below spans multiple pages: the header row repeats on " +
"every continuation page, and a row too tall to fit is split mid-row.");
c.AddImage("logo.png", widthPx: 72);
c.AddRule();
c.AddHeading("Detailed Line Items", HeadingLevel.H2);
c.AddTable(table =>
{
table.AddColumns("Product", "Qty", "Revenue");
for (var i = 0; i < 45; i++)
{
var product = products[i % products.Length];
var qty = random.Next(10, 500);
var revenue = qty * (decimal)(5 + random.NextDouble() * 45);
table.AddRow(product, qty.ToString(), revenue.ToString("C2"));
}
});
c.AddSpacer(12);
c.AddHeading("Notes", HeadingLevel.H2);
c.AddList(ListStyle.Numbered, new[]
{
"Revenue figures are pre-tax.",
"Contact the finance team for a region-level breakdown.",
});
})
.Build();
string html = report.RenderHtml();
See Tables for the column-width and row-split rules at play,
and Pagination and Layout for why the header
row repeats automatically. Full source:
GettingStartedScenario.cs.
Comparing Table Row-Split Behaviors
Put the same long row through both TableStyle.RowSplitBehavior options
side by side, on a deliberately small custom page size so the row is forced
to overflow:
using TerraFluent.Html.Reporting.Model.Styling;
const string longNote =
"This note is deliberately long, and the column it sits in deliberately " +
"narrow, so it cannot fit in the remaining space and must be handled by " +
"the table's row-split behavior.";
var report = ReportDocument.Create(PageSize.FromPixels(650, 480))
.SetMargins(20)
.Content(c =>
{
c.AddHeading("AllowSplitWithContinuedHeader (the default)", HeadingLevel.H2);
c.AddTable(
table =>
{
table.AddColumn("Item", widthPx: 80);
table.AddColumn("Notes", widthPx: 220);
table.AddRow("Item 1", "Short note.");
table.AddRow("Item 2", longNote);
table.AddRow("Item 3", "Another short note.");
},
TableStyle.Default.With(rowSplitBehavior: RowSplitBehavior.AllowSplitWithContinuedHeader));
c.AddPageBreak();
c.AddHeading("KeepRowIntact", HeadingLevel.H2);
c.AddTable(
table =>
{
table.AddColumn("Item", widthPx: 80);
table.AddColumn("Notes", widthPx: 220);
table.AddRow("Item 1", "Short note.");
table.AddRow("Item 2", longNote);
table.AddRow("Item 3", "Another short note.");
},
TableStyle.Default.With(rowSplitBehavior: RowSplitBehavior.KeepRowIntact));
})
.Build();
With AllowSplitWithContinuedHeader, “Item 2”’s note is truncated mid-sentence
and continues on the next page under a header marked “(continued)”. With
KeepRowIntact, the whole row moves to the next page instead, leaving
trailing whitespace on the first page. See
Tables: Row splitting. Full
source: TableStylingScenario.cs.
Grouping Rows with RowSpan
A category cell spanning every line item beneath it, plus a ColSpan-based
label on the closing totals row:
c.AddTable(table =>
{
table.AddColumn("Category", widthPx: 110);
table.AddColumn("Item");
table.AddColumn("Qty", widthPx: 50);
table.AddColumn("Price", widthPx: 90);
table.AddRow(new TableCell[]
{
new TableCell("Electronics") { RowSpan = 3 }, // spans this row and the next two
"Wireless Mouse", "2", "$39.98",
});
table.AddRow(new TableCell[] { "Mechanical Keyboard", "1", "$89.00" }); // omits "Category" - covered above
table.AddRow(new TableCell[] { "USB-C Dock", "1", "$64.50" });
table.AddRow(new TableCell[]
{
new TableCell("Grand Total") { ColSpan = 3 }, // spans "Category", "Item", and "Qty"
"$193.48",
});
});
A row underneath a RowSpan cell must omit a cell for the column(s) it
covers - the Table constructor throws if a row supplies the wrong number of
cells once spans are accounted for. The rows linked by the RowSpan are
treated as one atomic unit during pagination: they move to the next page
together if they don’t all fit, even under AllowSplitWithContinuedHeader.
See Tables: Column and row spans for the
full rules. Full source:
TableSpansScenario.cs.
A Header and Footer with a Logo Row
A realistic invoice-style header (logo + company name side by side) and
footer (small logo + a line of fine print + page number), using AddRow in
both:
var report = ReportDocument.Create(PageSize.A4)
.SetMargins(40)
.Header(h =>
{
h.AddRow(row =>
{
row.AddColumn(48, col => col.AddImage("logo.png", widthPx: 40))
.Padding(topPx: 15, 0, 0, 0);
row.AddColumn(col =>
{
col.AddText("Acme Corporation").Bold().FontSize(20);
col.AddText("123 Market Street, Springfield, USA").FontSize(12);
});
});
h.AddRule();
})
.Footer(f =>
{
f.AddRule();
f.AddRow(row =>
{
row.AddColumn(32, col => col.AddImage("logo.png", widthPx: 24));
row.AddColumn(col => col.AddText("Payment is due within 30 days.").FontSize(10));
});
f.AddPageNumber().AlignCenter().FontSize(9);
})
.Content(c => { /* ... */ })
.Build();
The fixed-width logo column (48px/32px) leaves the company-name column to
auto-share the rest of the content width; RowVerticalAlignment.Middle (the
default) keeps the logo centered against the two-line text block next to it.
See Rows and Columns. Full source:
SalesInvoiceScenario.cs
(also see section A Complete Sales Invoice below for
the rest of this report).
A Barcode in the Header (Invoice Number)
A Code 128 barcode of the invoice number, pinned to the top-right corner of the header next to the company info, using a fixed-width right-aligned column:
const string invoiceNumber = "20264471";
var report = ReportDocument.Create(PageSize.A4)
.SetMargins(40)
.Header(h =>
{
h.AddRow(row =>
{
row.AddColumn(col =>
{
col.AddText("Acme Corporation").Bold().FontSize(20);
col.AddText("123 Market Street, Springfield, USA").FontSize(12);
});
row.AddColumn(300, col =>
{
col.AddBarcode(invoiceNumber, moduleWidthPx: 2, heightPx: 40).AlignRight();
col.AddText(invoiceNumber, TextStyle.Default.With(alignment: TextAlignment.Right, marginBottomPx: 0)).FontSize(10);
});
}, verticalAlignment: RowVerticalAlignment.Top);
h.AddRule();
})
.Content(c => { /* ... */ })
.Build();
The barcode column has a fixed 300px width so it doesn’t grow or shrink
with the company-info column, and .AlignRight() pins the barcode (and the
human-readable number printed underneath it) flush against the page’s right
margin. The column must be at least as wide as the barcode’s rendered
width (quietZoneModules * 2 + sum of per-character module widths, times
moduleWidthPx - see Content Elements: Barcode)
or the barcode overflows past the column - and past the page’s right margin -
instead of stopping at it; an 8-digit Code 128 value at the defaults shown
here renders to 286px wide, so 300px leaves a small safety margin.
RowVerticalAlignment.Top keeps both columns aligned to the top of the
header instead of centering the shorter one. See
Content Elements: Barcode for the encoding
rules and parameters. Full source:
InvoiceBarcodeScenario.cs.
Detecting Content That Doesn’t Fit (LayoutWarning)
An image taller than the entire page’s content area, and unsplittable by
definition, triggers a LayoutWarning instead of silently clipping or
throwing:
using TerraFluent.Html.Reporting.Layout;
var report = ReportDocument.Create(PageSize.FromPixels(400, 150))
.SetMargins(10)
.Content(c =>
{
c.AddHeading("Warnings", HeadingLevel.H2);
c.AddParagraph("The image below is taller than this page's entire content area.");
c.AddImage(oversizedImageBytes, "image/png", widthPx: 300, heightPx: 300);
})
.Build();
var layout = LayoutEngine.Paginate(report);
foreach (var warning in layout.Warnings)
{
Console.WriteLine(warning); // "Page 1: A ReportImage required 300px but only ... was available ..."
}
Check LayoutResult.Warnings after pagination - e.g. to log a warning or
reject the report before it reaches a user - rather than only discovering
clipped content by eyeballing the rendered HTML. See
Pagination and Layout: Warnings.
Full source: WarningsAndAsyncScenario.cs.
Streaming a Large Report to Disk Asynchronously
For a report large enough that holding the full HTML string in memory is
undesirable, render straight to a file with the async, streaming API instead
of RenderHtml():
await report.RenderHtmlDocumentAsync(
Path.Combine(outputDir, "report.html"),
cancellationToken: cancellationToken);
This writes one page’s HTML at a time rather than building the entire
document as a single in-memory string, and the async signature exists so
the final flush/dispose doesn’t block a thread-pool thread in an async call
chain (e.g. inside an ASP.NET request handler) - pagination and HTML
generation themselves are still synchronous, CPU-bound work. See
Rendering: The APIs on ReportDocument.
The sample project’s Program.cs
uses this API for every scenario it writes out.
A Landscape Certificate
A single-page, landscape-oriented certificate, built entirely from centered content with no header/footer:
var report = ReportDocument.Create(PageSize.Letter, PageOrientation.Landscape)
.SetMargins(50)
.Content(c =>
{
c.AddImage("logo.png", widthPx: 48);
c.AddSpacer(30);
c.AddHeading("Certificate of Completion", HeadingLevel.H1).AlignCenter();
c.AddSpacer(20);
c.AddParagraph("This certifies that").AlignCenter();
c.AddHeading("Jane Doe", HeadingLevel.H2).AlignCenter();
c.AddParagraph("has successfully completed the TerraFluent.Html.Reporting advanced training course.").AlignCenter();
c.AddSpacer(40);
c.AddRule();
c.AddParagraph("Issued June 23, 2026").AlignCenter();
})
.Build();
PageOrientation.Landscape swaps PageSize.Letter’s width/height (see
Core Concepts: Page geometry).
Full source: LandscapeCertificateScenario.cs.
Images: File, Bytes, and Base64, with Aspect-Ratio Sizing
// From a file path, explicit width and height (stretched to exactly 240x80):
c.AddImage("photo.png", widthPx: 240, heightPx: 80);
// From bytes, only width given - height derived from the source's aspect ratio:
c.AddImage(imageBytes, "image/png", widthPx: 360);
// From bytes, only height given - width derived from the source's aspect ratio:
c.AddImage(tallImageBytes, "image/png", heightPx: 200);
// From a data: URI or bare base64 payload (Content only):
c.AddImageFromBase64($"data:image/png;base64,{base64Source}", widthPx: 150, heightPx: 150);
See Content Elements: Image for how the
missing dimension is derived (sniffed from the image’s own header bytes) and
what happens if the format can’t be recognized. Full source:
ImagesScenario.cs.
A Numbered List Spanning Multiple Pages
c.AddParagraph(
"This list has enough items to split across a page boundary; numbering " +
"resumes correctly on the next page instead of restarting at 1.");
c.AddList(ListStyle.Numbered, Enumerable.Range(1, 60).Select(i => $"Numbered list entry #{i}"));
The list splits at item boundaries only (an item’s own wrapped lines are
never separated), and the continuation fragment’s StartIndex keeps the
<ol start="..."> numbering correct. See
Pagination and Layout: Paragraph splitting
for the related text-splitting rules. Full source:
ListsScenario.cs.
Forcing Chapter Breaks with AddPageBreak
c.AddHeading("Chapter 1: Introduction", HeadingLevel.H1);
c.AddParagraph("...");
c.AddPageBreak();
c.AddHeading("Chapter 2: Methodology", HeadingLevel.H1);
c.AddParagraph("...");
c.AddPageBreak();
Each chapter starts on a fresh page regardless of how much room was left on
the previous one. A page break with nothing yet placed on the page is a
no-op, so this never produces a blank page between chapters even if a
chapter happens to end exactly at a page boundary already. See
Content Elements: Page break. Full
source: PageBreaksScenario.cs.
Injecting Raw HTML for Custom Markup
c.AddRawHtml(
"<div style=\"border:2px dashed #2f4858;border-radius:8px;padding:16px;background:#eef3f6;\">" +
"<strong>Custom callout box</strong><br/>This entire block is raw HTML supplied by the " +
"caller, including its own inline styles.</div>",
heightPx: 110);
You supply the height because the layout engine cannot measure markup it
doesn’t understand; it treats the block as opaque and unsplittable, exactly
like an oversized image (see
section Detecting Content That Doesn’t Fit
above if it doesn’t fit). The HTML is emitted verbatim with no encoding -
don’t pass unsanitized end-user input here. See
Content Elements: Raw HTML. Full source:
RawHtmlScenario.cs.
A Complete Sales Invoice
Putting headers/footers, rows, right-aligned table columns via per-cell style overrides, and running totals together:
using TerraFluent.Html.Reporting.Model.Styling;
var rightAlign = TextStyle.Default.With(alignment: TextAlignment.Right, marginBottomPx: 0);
var report = ReportDocument.Create(PageSize.A4)
.SetMargins(40)
.Header(h => { /* logo + company name row, see above */ })
.Footer(f => { /* logo + fine print row + page number, see above */ })
.Content(c =>
{
c.AddHeading("INVOICE", HeadingLevel.H1).AlignCenter();
c.AddParagraph("Invoice #: INV-1042\nInvoice Date: June 23, 2026\nDue Date: July 23, 2026")
.AlignRight().FontSize(11);
c.AddSpacer(8);
c.AddHeading("Bill To", HeadingLevel.H3);
c.AddParagraph("Jane Doe\n456 Oak Avenue\nSpringfield, USA");
c.AddSpacer(20);
c.AddTable(table =>
{
table.AddColumn("Item");
table.AddColumn("Qty", widthPx: 50);
table.AddColumn("Unit Price", widthPx: 100);
table.AddColumn("Amount", widthPx: 100);
table.AddRow(new TableCell[]
{
"Website Redesign",
new TableCell("1", rightAlign),
new TableCell("$1,200.00", rightAlign),
new TableCell("$1,200.00", rightAlign),
});
// ... more rows ...
});
c.AddSpacer(12);
c.AddParagraph("Subtotal: $2,300.00").AlignRight();
c.AddParagraph("Tax (8%): $184.00").AlignRight();
c.AddRule();
c.AddParagraph("Total: $2,484.00").AlignRight().Bold().FontSize(16);
})
.Build();
Note the \n inside AddParagraph’s text - ITextMeasurer.Measure treats
explicit newlines as hard breaks, so a single Paragraph can hold multiple
visually distinct lines (an address block, here) without needing several
separate elements. See Tables: Per-cell style overrides
for the rightAlign pattern used on numeric columns. Full source:
SalesInvoiceScenario.cs.
Where to go next
- FAQ / Troubleshooting if something in your own report isn’t behaving like these recipes.
- Extending the Library if you need a capability none of these cover.