Skip to content

Document Generation — PDF & Excel from Templates

Every business application eventually needs to produce documents — invoices, contracts, reports, export files. The common approach is to build PDF generation as a one-off utility using a low-level library, resulting in duplicated layout code, inconsistent branding, and no easy way for non-developers to update templates.

Granit.DocumentGeneration solves this with a two-stage pipeline: first, render a Scriban text template (HTML or data), then convert the output to a binary format (PDF or Excel). Templates are managed through the Templating system with full lifecycle and approval workflow, so business users control the content while developers control the rendering. PDF/A-3b support is built in for long-term archival and electronic invoicing (Factur-X / ZUGFeRD EN 16931) — a legal requirement in several EU countries.

Architecture decisions: see ADR-012: PuppeteerSharp for the PDF engine choice and ADR-011: ClosedXML for Excel generation.

  • DirectoryGranit.DocumentGeneration/ IDocumentGenerator facade, 2-stage pipeline (text render + binary conversion)
    • Granit.DocumentGeneration.Pdf PuppeteerSharp headless Chromium, PdfRenderOptions, PDF/A-3b
    • Granit.DocumentGeneration.Excel ClosedXML engine, Base64-encoded XLSX templates
PackageRoleDepends on
Granit.DocumentGenerationIDocumentGenerator facade, IDocumentRenderer abstractionGranit.Templating
Granit.DocumentGeneration.PdfPuppeteerSharp PDF renderer, IPdfAConverterGranit.DocumentGeneration
Granit.DocumentGeneration.ExcelClosedXML ITemplateEngine (direct binary output)Granit.Templating
[DependsOn(
typeof(GranitDocumentGenerationPdfModule),
typeof(GranitDocumentGenerationExcelModule),
typeof(GranitTemplatingScribanModule))]
public class AppModule : GranitModule { }

IDocumentGenerator extends the text pipeline with a binary conversion step:

flowchart LR
    subgraph "Text Pipeline"
        DATA[TData] --> ENRICH[Enrichers]
        ENRICH --> RESOLVE[Resolver Chain]
        RESOLVE --> ENGINE[ITemplateEngine]
    end
    ENGINE -->|TextRenderedContent| RENDERER["IDocumentRenderer<br/>(PuppeteerSharp)"]
    ENGINE -->|BinaryRenderedContent| RESULT
    RENDERER --> RESULT[DocumentResult]

Two paths through the pipeline:

PathEngine outputNext stepExample
HTML-basedTextRenderedContentIDocumentRenderer converts HTML to binaryInvoice PDF (Scriban HTML → Chromium PDF)
Native binaryBinaryRenderedContentDirect output, no renderer neededExcel report (ClosedXML XLSX)
public sealed class InvoiceTemplateType
: DocumentTemplateType<InvoiceDocumentData>
{
public override string Name => "Billing.Invoice";
public override DocumentFormat DefaultFormat => DocumentFormat.Pdf;
}
public sealed record InvoiceDocumentData(
string InvoiceNumber,
DateTimeOffset InvoiceDate,
string CustomerName,
string CustomerAddress,
IReadOnlyList<InvoiceLineItem> Lines,
decimal TotalExclVat,
decimal VatAmount,
decimal TotalInclVat,
string PaymentUrl,
string? PaymentQrCodeSvg = null);
public sealed class InvoiceService(IDocumentGenerator generator)
{
public async Task<DocumentResult> GenerateInvoicePdfAsync(
InvoiceDocumentData data,
CancellationToken cancellationToken)
{
DocumentResult result = await generator
.GenerateAsync(new InvoiceTemplateType(), data, cancellationToken: cancellationToken)
.ConfigureAwait(false);
// result.Content contains PDF bytes
// result.Format == DocumentFormat.Pdf
return result;
}
}

Excel templates use Base64-encoded XLSX workbooks stored as template content. Placeholders ({{model.property}}) in string cells are replaced with data values. Nested objects use dot notation ({{model.address.city}}), arrays use bracket notation ({{model.lines[0].amount}}).

public sealed class MonthlyReportTemplateType
: DocumentTemplateType<MonthlyReportData>
{
public override string Name => "Reporting.MonthlyReport";
public override DocumentFormat DefaultFormat => DocumentFormat.Excel;
}

The ClosedXmlTemplateEngine returns BinaryRenderedContent directly — no IDocumentRenderer is needed for Excel output.

For long-term archival and electronic invoicing (Factur-X / ZUGFeRD EN 16931), use IPdfAConverter to convert standard PDFs to PDF/A-3b:

public async Task<DocumentResult> GenerateArchivalInvoiceAsync(
InvoiceDocumentData data,
IPdfAConverter pdfAConverter,
IDocumentGenerator generator,
CancellationToken cancellationToken)
{
DocumentResult pdf = await generator
.GenerateAsync(new InvoiceTemplateType(), data, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return await pdfAConverter
.ConvertToPdfAAsync(pdf, new PdfAConversionOptions(), cancellationToken)
.ConfigureAwait(false);
}

Bound from configuration section DocumentGeneration:Pdf:

{
"DocumentGeneration": {
"Pdf": {
"PaperFormat": "A4",
"Landscape": false,
"MarginTop": "10mm",
"MarginBottom": "10mm",
"MarginLeft": "10mm",
"MarginRight": "10mm",
"HeaderTemplate": null,
"FooterTemplate": "<div style='font-size:9px;text-align:center;width:100%'><span class='pageNumber'></span>/<span class='totalPages'></span></div>",
"PrintBackground": true,
"ChromiumExecutablePath": null,
"RenderTimeoutMs": 30000,
"MaxConcurrentPages": 4,
"DisableSandbox": false
}
}
}
PropertyDefaultDescription
PaperFormat"A4"Paper size ("A4", "A5", "Letter")
LandscapefalseLandscape orientation
MarginTop / Bottom / Left / Right"10mm"Margins in CSS units
HeaderTemplatenullHTML header (supports pageNumber, totalPages classes)
FooterTemplatenullHTML footer (same classes as header)
PrintBackgroundtruePrint background graphics
ChromiumExecutablePathnullCustom Chromium path; validated at startup (auto-download if null)
RenderTimeoutMs30000Maximum rendering time in ms (1 000—300 000)
MaxConcurrentPages4Max parallel Chromium tabs (1—32)
DisableSandboxfalseDisable Chromium OS sandbox (see Security below)
CategoryKey typesPackage
ModuleGranitDocumentGenerationModule, GranitDocumentGenerationPdfModule, GranitDocumentGenerationExcelModule
KeysDocumentTemplateType<TData>, DocumentFormatGranit.DocumentGeneration
GeneratorIDocumentGenerator, IDocumentRenderer, DocumentResultGranit.DocumentGeneration
PDFPdfRenderOptions, IPdfAConverter, PdfAConversionOptionsGranit.DocumentGeneration.Pdf
DiagnosticsDocumentGenerationMetrics (meter: Granit.DocumentGeneration)Granit.DocumentGeneration
ExtensionsAddGranitDocumentGeneration(), AddGranitDocumentGenerationPdf(), AddGranitDocumentGenerationExcel()

The document generation pipeline includes several hardening measures:

The headless Chromium browser runs with OS-level process sandboxing enabled by default. This isolates the rendering process and prevents arbitrary code execution from malicious HTML content.

In containerized environments that cannot grant CAP_SYS_ADMIN or configure a seccomp profile for Chromium, set DisableSandbox: true explicitly:

{
"DocumentGeneration": {
"Pdf": {
"DisableSandbox": true
}
}
}

All outbound network requests from the Chromium rendering page are blocked via request interception. HTML content is injected directly via the Chrome DevTools Protocol without triggering network navigation. Templates must embed all resources inline (base64 images, inline CSS/fonts) rather than referencing external URLs.

JavaScript execution is explicitly disabled (SetJavaScriptEnabledAsync(false)) before HTML content injection. PDF rendering from HTML/CSS does not require JavaScript, and user-controlled <script> tags in templates could cause CPU or memory exhaustion. This is a defense-in-depth measure alongside SSRF prevention.

Both the HTML content injection and PDF byte generation are bounded by RenderTimeoutMs (default 30 seconds). This prevents denial-of-service from complex CSS layouts. The caller’s CancellationToken is also respected.

The ClosedXML engine detects values starting with formula trigger characters (=, +, -, @, tab, carriage return) and forces them through the rich-text API path, which guarantees text-only cell interpretation. This matches the protection applied in the CSV export module (CWE-1236 defense-in-depth).

The JSON flattening algorithm that converts TData into template substitution values enforces a maximum nesting depth of 32. Deeply nested data structures throw InvalidOperationException instead of causing a StackOverflowException.

DocumentGenerationMetrics (meter Granit.DocumentGeneration) records three instruments for every generation call:

MetricTypeTags
granit.document_generation.document.generatedCountertenant_id, template_type, format
granit.document_generation.document.failedCountertenant_id, template_type, format
granit.document_generation.document.durationHistogram (s)tenant_id, template_type, format