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.

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 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

View this page's source on GitHub →