Document Generation — PDF & Excel from Templates
Why a document generation pipeline?
Section titled “Why a document generation pipeline?”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.
Package structure
Section titled “Package structure”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
| Package | Role | Depends on |
|---|---|---|
Granit.DocumentGeneration | IDocumentGenerator facade, IDocumentRenderer abstraction | Granit.Templating |
Granit.DocumentGeneration.Pdf | PuppeteerSharp PDF renderer, IPdfAConverter | Granit.DocumentGeneration |
Granit.DocumentGeneration.Excel | ClosedXML ITemplateEngine (direct binary output) | Granit.Templating |
[DependsOn( typeof(GranitDocumentGenerationPdfModule), typeof(GranitDocumentGenerationExcelModule), typeof(GranitTemplatingScribanModule))]public class AppModule : GranitModule { }Document generation pipeline
Section titled “Document generation pipeline”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:
| Path | Engine output | Next step | Example |
|---|---|---|---|
| HTML-based | TextRenderedContent | IDocumentRenderer converts HTML to binary | Invoice PDF (Scriban HTML → Chromium PDF) |
| Native binary | BinaryRenderedContent | Direct output, no renderer needed | Excel report (ClosedXML XLSX) |
Declaring a document template type
Section titled “Declaring a document template type”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);Generating a PDF document
Section titled “Generating a PDF document”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; }}Generating an Excel document
Section titled “Generating an Excel document”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.
PDF/A-3b conversion
Section titled “PDF/A-3b conversion”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);}Configuration reference
Section titled “Configuration reference”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 } }}| Property | Default | Description |
|---|---|---|
PaperFormat | "A4" | Paper size ("A4", "A5", "Letter") |
Landscape | false | Landscape orientation |
MarginTop / Bottom / Left / Right | "10mm" | Margins in CSS units |
HeaderTemplate | null | HTML header (supports pageNumber, totalPages classes) |
FooterTemplate | null | HTML footer (same classes as header) |
PrintBackground | true | Print background graphics |
ChromiumExecutablePath | null | Custom Chromium path; validated at startup (auto-download if null) |
RenderTimeoutMs | 30000 | Maximum rendering time in ms (1 000—300 000) |
MaxConcurrentPages | 4 | Max parallel Chromium tabs (1—32) |
DisableSandbox | false | Disable Chromium OS sandbox (see Security below) |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitDocumentGenerationModule, GranitDocumentGenerationPdfModule, GranitDocumentGenerationExcelModule | — |
| Keys | DocumentTemplateType<TData>, DocumentFormat | Granit.DocumentGeneration |
| Generator | IDocumentGenerator, IDocumentRenderer, DocumentResult | Granit.DocumentGeneration |
PdfRenderOptions, IPdfAConverter, PdfAConversionOptions | Granit.DocumentGeneration.Pdf | |
| Diagnostics | DocumentGenerationMetrics (meter: Granit.DocumentGeneration) | Granit.DocumentGeneration |
| Extensions | AddGranitDocumentGeneration(), AddGranitDocumentGenerationPdf(), AddGranitDocumentGenerationExcel() | — |
Security
Section titled “Security”The document generation pipeline includes several hardening measures:
Chromium sandbox (PDF)
Section titled “Chromium sandbox (PDF)”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 } }}SSRF prevention (PDF)
Section titled “SSRF prevention (PDF)”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 disabled (PDF)
Section titled “JavaScript disabled (PDF)”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.
Rendering timeout (PDF)
Section titled “Rendering timeout (PDF)”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.
Formula injection protection (Excel)
Section titled “Formula injection protection (Excel)”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).
Nesting depth limit (Excel)
Section titled “Nesting depth limit (Excel)”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.
Observability
Section titled “Observability”DocumentGenerationMetrics (meter Granit.DocumentGeneration) records three
instruments for every generation call:
| Metric | Type | Tags |
|---|---|---|
granit.document_generation.document.generated | Counter | tenant_id, template_type, format |
granit.document_generation.document.failed | Counter | tenant_id, template_type, format |
granit.document_generation.document.duration | Histogram (s) | tenant_id, template_type, format |
See also
Section titled “See also”- Templating module — Scriban pipeline, enrichers, resolvers, template lifecycle
- Background Jobs module — Schedule document generation as background jobs
- Wolverine module — Durable messaging for async document generation