Rendering
Rendering is the second pass: it turns an already-paginated LayoutResult
(see Pagination and Layout) into HTML. By the
time a renderer runs, every page’s geometry - and therefore the total page
count - is already known, which is what lets header/footer elements resolve
{page}/{totalPages} tokens correctly.
The APIs on ReportDocument
You usually don’t call the layout/render stages separately - ReportDocument
exposes five convenience methods that do both:
| Method | Output | Use when… |
|---|---|---|
RenderHtml(renderer?, ct?) |
string (full document) |
You want the complete HTML in memory, e.g. to return from a web endpoint. |
RenderHtmlDocument(filePath, renderer?, ct?) |
writes to a file | You want to stream straight to disk without holding the full string in memory. |
RenderHtmlDocumentAsync(filePath, renderer?, ct?) |
Task, writes to a file |
Same as above, but doesn’t block a thread-pool thread on the final flush/dispose - see below. |
RenderFragment(renderer?, ct?) |
string (page <div>s only) |
You’re embedding the report inside an existing HTML page. |
All five accept an optional IHtmlReportRenderer (defaulting to
HtmlReportRenderer.Default) and a CancellationToken, checked periodically
during both pagination and rendering so a very large document can be
aborted - e.g. when the originating web request is canceled.
string html = report.RenderHtml();
await report.RenderHtmlDocumentAsync("report.html", cancellationToken: ct);
string fragment = report.RenderFragment(); // for embedding
Why is RenderHtmlDocumentAsync async at all if pagination/rendering are
synchronous CPU work? Because there’s no I/O to await until the final
flush to disk - the method exists so the flush, and the writer’s disposal, do
not block a thread-pool thread in an async call chain (e.g. inside an ASP.NET
request handler), not because layout or HTML generation themselves are
asynchronous.
IHtmlReportRenderer
IHtmlReportRenderer
is the renderer contract:
public interface IHtmlReportRenderer
{
string RenderDocument(LayoutResult layout);
string RenderFragment(LayoutResult layout);
void RenderDocumentTo(TextWriter writer, LayoutResult layout, CancellationToken cancellationToken = default);
void RenderFragmentTo(TextWriter writer, LayoutResult layout, CancellationToken cancellationToken = default);
}
HtmlReportRenderer
(accessible as the shared, stateless HtmlReportRenderer.Default) is the
only implementation shipped today. The ...To(TextWriter, ...) overloads
write one page at a time rather than building the whole document as a single
in-memory string first - for a report with many thousands of pages, this
bounds peak memory to roughly one page’s HTML rather than the entire
document’s. The non-streaming RenderDocument/RenderFragment overloads are
implemented as a thin wrapper: write to a StringWriter, then return its
contents.
See Extending the Library: Custom renderer if you want to implement this interface yourself (e.g. to emit a different page-wrapper structure, or PDF-specific markup).
What the HTML looks like
RenderDocumentTo produces a single self-contained document - inline
<style>, no external CSS or JS, no external image references (every
ReportImage is base64-encoded inline). The example below is for
PageSize.A4 (210mm x 297mm, converted to pixels at 96px/inch):
<!DOCTYPE html><html><head><meta charset="utf-8" /><title>Report</title>
<style>
@page { size: 793.701px 1122.52px; margin: 0; }
html, body { margin: 0; padding: 0; }
body { background: #e8e8e8; font-family: Segoe UI, Arial, sans-serif; }
.fhr-page { position: relative; width: 793.7px; height: 1122.52px; background: #ffffff;
overflow: hidden; margin: 0 auto 16px auto; box-shadow: 0 0 6px rgba(0,0,0,0.25);
page-break-after: always; break-after: page; }
.fhr-page:last-child { page-break-after: auto; break-after: auto; margin-bottom: 0; }
@media print { body { background: none; } .fhr-page { margin: 0; box-shadow: none; } }
</style>
</head><body>
<div class="fhr-page"> ... one absolutely-positioned element per placed fragment ... </div>
<div class="fhr-page"> ... </div>
</body></html>
Key points:
@page { size: ...; margin: 0; }sets the print page size to exactly match the document’sPageSize. Page margins are not expressed as@page margin- they’re baked into each element’s absoluteleft/topposition instead, so what you see on screen (a white page with a drop shadow) is pixel-identical to what prints.- One
.fhr-page<div>per page, sized exactly toPageSize, withoverflow:hiddenso any force-placed, overflowing content (seeLayoutWarning) is clipped visually rather than spilling into the next page’s div. page-break-after: always(plus the modernbreak-after: page) on every page except the last is a redundant signal for browsers that don’t fully honor@pagesizing during print - belt-and-suspenders, since the exact-pixel page divs are normally enough on their own.- Every element renders as its own absolutely-positioned tag (
<p>,<h1>-<h6>,<img>,<table>,<ul>/<ol>, or a styled<div>) at theleft/top/width/heightthe layout engine computed - the renderer has no pagination logic of its own; it only translates eachElementPlacementfrom section-relative to page-absolute coordinates (see Pagination and Layout: The output) and callsIReportElement.RenderHtml. - All CSS pixel values use the invariant culture (
123.45px, never123,45px) - seeCssFormat- so generated reports are correct regardless of the server’s locale. - All user-supplied text is HTML-encoded before being written (again via
CssFormat.Encode, i.e.WebUtility.HtmlEncode) - the one exception isRawHtml, which is emitted verbatim by design (see Content Elements: Raw HTML). <title>defaults toReport, or the document’s own title (HTML-encoded) whenReportDocumentBuilder.Title(...)was called - a customIHtmlReportRenderercan read it viaLayoutResult.Title.
RenderFragmentTo emits the page styles and page <div>s without the
<html>/<head>/<body> wrapper. Its stylesheet omits the document-level
html/body reset and background rules, so embedding a report does not
change the host page’s margins, background, or font.
Printing to PDF
Because the HTML is built around exact-pixel @page sizing and one <div>
per page, a browser’s native “Print to PDF” (or a headless-browser print API,
e.g. Playwright/Puppeteer) reproduces the same page breaks you see when
viewing the HTML directly - there’s no separate PDF-specific code path in
this library. If you need exact pixel-perfect text wrapping in that PDF, see
Text Measurement for why the bundled measurer alone
may not guarantee that.
Tested against real browsers
The pagination and rendering logic isn’t only checked with plain unit tests -
CI also runs PrintLayoutBrowserTests
against real, headless Chromium, Firefox, and WebKit via
Playwright, rendering a generated report and
asserting - under @media print, the same media browsers use for “Print to
PDF” - that every page’s getBoundingClientRect() matches the requested
PageSize exactly and that no element’s box overflows its page’s bounds. See
.github/workflows/ci.yml for the CI job that
installs and runs all three engines on every push and pull request. This is in
addition to, not instead of, the unit tests that exercise the pagination math
itself (see Pagination and Layout) - the
browser tests specifically guard the rendered HTML/CSS against real
browsers’ print-layout behavior, which a markup-only assertion cannot.
Where to go next
- Text Measurement for the seam that determines how accurately layout matches real browser rendering.
- Extending the Library for writing a custom
IHtmlReportRenderer. - Cookbook: Streaming a large report for a runnable example of the async file API.