Reporting
Lay out a Module's report once and render it to a PDF, a CSV file or a spreadsheet in memory, with a logo, an accent colour and an embedded font.
PouryaKit.Reporting has one renderer, IReportRenderer. A Module builds a ReportLayout from what it read: a title, an introduction of headings and paragraphs, one table of text cells, and its branding, a logo and an accent colour. Render turns the layout into a PDF, a CSV file or a spreadsheet in memory and returns a Result with the file's bytes, content type and extension. The layout stays in the Module; the Kit supplies the primitives, the font, the branding and the formats (what each format holds).
It stores nothing. The caller stores the rendered bytes through File storage, and the Report Module runs that whole job in the background for you.
The Module formats every value
Every cell is text. A number, an amount or a date is formatted by the Module, in the culture it chooses, before it goes in the table. The embedded font holds no Persian or Arabic glyphs, and the PDF lays out no right-to-left text (Limits).
When to use it
- A Module produces a document for people to download, print or open in a spreadsheet.
- You brand a report with a logo and an accent colour.
- You store a rendered file, or need to know which layouts the renderer refuses.
- You test a rendered file with each format's standard reader.
How it works
Register the renderer in the Composition Root
ReportRenderer keeps no state, so one instance serves every caller: register it with TryAddSingleton, which lets several Modules do so. The package has no options.
Build the layout from what the Module read
A ReportLayout takes a title and a ReportTable of columns and rows; its introduction, page header and footer, and branding are optional. Each row has exactly one cell per column.
Render, then store, then record
Render(layout, format) refuses a layout it cannot draw with a validation Error and renders nothing. Upload the bytes under a fresh key and record the key afterwards, as File storage explains.
In the Kit's tests
A month's sales laid out once, with a heading and a paragraph of introduction, a page header and footer, and a logo and accent colour; the tests render it to every format and read each back:
private static readonly ReportLayout Sales = new(
"Monthly sales",
new ReportTable(
[
new("Product"),
new("Quantity", ReportAlignment.Right),
new("Amount", ReportAlignment.Right),
],
Rows
)
)
{
Introduction =
[
new ReportHeading("October 2026"),
new ReportParagraph("Every order the shop settled this month."),
],
PageHeader = "Acme Shop",
PageFooter = "Confidential",
Branding = new() { Logo = Logo, Accent = new(0x1F, 0x4E, 0x79) },
};Guarantees and failures
| You want to know | Read |
|---|---|
| How the renderer is registered, and its PDF font resolver | Registering |
| What a layout holds | A Module's layout |
What Render returns, and the Errors of a layout it refuses | Rendering and storing |
| What each format holds, and how a CSV cell is kept from running as a formula | The formats |
| What the renderer does not draw, and why its PDF library is pinned | Limits |
Next steps
Report Module
Report jobs requested over HTTP, rendered in the background and served by link.
File storage
Where a rendered file is stored.
Reference
PouryaKit.Reporting: one renderer, IReportRenderer, that renders a Module's ReportLayout to a PDF, a CSV file or a spreadsheet in memory and returns a Result. The layout stays in the Module; the Kit supplies the primitives, the font, the branding and the three formats. Optional, namespace PouryaKit.Reporting; it references PouryaKit.Domain only, and stores nothing: the caller stores the rendered bytes, through storage.md.
Registering
In the Composition Root of each Module that renders reports:
using Microsoft.Extensions.DependencyInjection.Extensions;
using PouryaKit.Reporting;
module.TryAddSingleton<IReportRenderer, ReportRenderer>();ReportRendererkeeps no state of its own, so one instance serves every caller at once;TryAddSingletonlets several Modules register it. The package has no registration call and no options.- The PDF writer sets PDFsharp's process-wide font resolver to the Roboto faces the package embeds, the first time a PDF renders, unless the Host set its own resolver before; that one then resolves the report's fonts.
A Module's layout
A layout is a value the Module builds from what it read: a title, an introduction of headings and paragraphs, one table of text cells, and the branding. The Module formats every value, such as a number, an amount or a date, as the text the report shows, in the culture it chooses:
using System.Globalization;
using PouryaKit.Reporting;
internal static class SalesReport
{
private static readonly ReportBranding Branding = new()
{
Logo = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Reports", "logo.png")),
Accent = new(0x1F, 0x4E, 0x79),
};
public static ReportLayout For(DateOnly month, IReadOnlyList<SalesLine> lines) =>
new(
"Monthly sales",
new ReportTable(
[new("Product"), new("Quantity", ReportAlignment.Right), new("Amount", ReportAlignment.Right)],
[
.. lines.Select(line => (IReadOnlyList<string>)
[
line.Product,
line.Quantity.ToString(CultureInfo.InvariantCulture),
line.Amount.ToString("N2", CultureInfo.InvariantCulture),
]),
]
)
)
{
Introduction =
[
new ReportHeading(month.ToString("MMMM yyyy", CultureInfo.InvariantCulture)),
new ReportParagraph("Every order settled this month."),
],
PageHeader = "Acme Shop",
PageFooter = "Confidential",
Branding = Branding,
};
}
internal sealed record SalesLine(string Product, int Quantity, decimal Amount);ReportLayout(Title, Table):Introduction(ReportHeadingandReportParagraph, in order),PageHeader,PageFooterandBrandingare optional.ReportTable(Columns, Rows): eachReportColumnhas a header and an alignment,Leftby default; each row has exactly one cell per column.ReportBranding:Logo, a PNG or JPEG image, andAccent, aReportColor, dark grey by default, for the title and the table's header row.
Rendering and storing
Render(layout, format) returns a RenderedReport with the file's Content, its ContentType and its FileExtension, ready for IFileStorage.UploadAsync. Store first, then record the key, as storage.md says:
var rendered = renderer.Render(SalesReport.For(command.Month, lines), ReportFormat.Pdf);
if (rendered.IsFailure)
{
return rendered;
}
var report = rendered.Value;
var key = $"reports/sales/{Guid.CreateVersion7()}{report.FileExtension}";
var stored = await files.UploadAsync(key, new MemoryStream(report.Content), report.ContentType, cancellationToken);Render refuses a layout it cannot draw, renders nothing, and returns an ErrorKind.Validation Error:
ReportErrors.TableShape(Reporting.TableShape, targetTable): the table has no column, or a row whose number of cells differs from its number of columns.ReportErrors.LogoFormat(Reporting.LogoFormat, targetLogo): the logo is not a PNG or JPEG image PDFsharp can load, its signature checked, then its header and dimensions read, so a file cut short is refused, where MigraDoc would have drawn a placeholder in the PDF. A JPEG cut inside its image data still loads, since a PDF carries a JPEG as it is.
Rendering is synchronous and in memory, so the whole file is one byte array; render a large report from a background command, as the report Module does (reporting-module.md).
The three formats
ReportFormat | Content type and extension | Holds |
|---|---|---|
Pdf | application/pdf, .pdf | An A4 portrait document: the logo, the title, the introduction and the table, whose header row repeats on every page, with the page header, and the page footer before page / pages, on every page. MigraDoc and PDFsharp, the Roboto font embedded, so the file renders where no font is installed, the Linux CI runner included. |
Csv | text/csv, .csv | The table alone, headers first: RFC 4180 through CsvHelper with the invariant culture, in UTF-8 with a byte order mark, so a spreadsheet application reads non-ASCII text as written. A cell that starts with =, +, -, @, a tab or a carriage return is written with a leading apostrophe ('-4+cmd), so a spreadsheet application never runs it as a formula, unless the whole cell is a number in the invariant culture (decimal.TryParse with NumberStyles.Number), such as -4 or +1,250.50, which stays as written and computable. An empty field is quoted (""), so the empty row of a one-column table survives a reader that skips blank lines. |
Spreadsheet | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, .xlsx | The table alone on one worksheet named Report, headers first, every cell an inline string exactly as the Module formatted it: text, numbers included, never a formula. A literal _xHHHH_ is written _x005F_xHHHH_ and a character XML cannot hold, such as a control character, as _xHHHH_, so a spreadsheet application reads each cell back verbatim. The Open XML SDK. |
Limits
- One table per report, of text cells; no charts, images in cells, merged cells or cell styles.
- One font, Roboto 2.138 regular and bold, with italic simulated: it holds Latin, Greek and Cyrillic glyphs, not Persian or Arabic, and the PDF writer lays out no right-to-left text.
- PDFsharp is pinned at 6.2.4, its last MIT release: 6.5.0 and later are AGPL-3.0-only, and the Kit takes no reciprocally licensed package.
- QuestPDF and ClosedXML are not used (licence), and no second file library.
Testing
The Kit's tests render one layout to each format and read it back with that format's standard reader: the PDF with PDFsharp's PdfReader (its header, page count, logo and each page's fonts embedded with their font program), the spreadsheet with the Open XML SDK's SpreadsheetDocument.Open and its OpenXmlValidator (the cells as rows, escape-like text and control characters read back verbatim), and the CSV with CsvHelper's CsvReader (the rows, the escaped formulas, the numbers kept, the byte order mark and a one-column table's empty rows). A Module's own report test renders its layout the same way: build the layout from fixed rows, render it, and read back the cells it promises.
Realtime
Push server-sent events to browsers from a Notification Handler: a push reaches every instance's clients once its delivery commits, only within its tenant and on the Channels the endpoint admits.
Report Module
The Kit's report Module: a Host adds it once, implements two ports for its report kinds, and its callers request report jobs over HTTP, then download each finished file by link.