Skip to main content

Muonroi PDF Engine

Muonroi.Pdf is a pure-managed HTML/CSS → PDF renderer. It turns an HTML+CSS document into a deterministic, policy-enforced PDF in a single AddPdf() call — on any OS, with no native binary, no browser/Chromium engine, and no outbound network.

It is the managed replacement for wkhtmltopdf-style wrappers such as DinkToPdf: no libwkhtmltox native dependency to ship, no glibc/musl mismatch, no headless-Chrome process to babysit. See PDF Engine vs DinkToPdf for a feature + speed comparison.

Authoring templates? The supported markup is a bounded, print-safe subset of HTML/CSS — read Supported HTML / CSS / JS before writing a template. JavaScript is not executed.


Install & register

The engine ships in the Muonroi.Pdf package. Register the whole pipeline once at the composition root:

using Muonroi.Pdf.Extensions;

builder.Services.AddPdf(builder.Configuration);

AddPdf is idempotent (every registration uses TryAdd*) and binds PdfConfigs from the "PdfConfigs" configuration section with ValidateOnStart() — a non-positive limit fails fast at host build time. It also auto-wires Muonroi.Logging and an ISystemExecutionContextAccessor if not already present.

To override any pipeline component (e.g. a custom IFontResolver or IResourceResolver), register your implementation before calling AddPdf — the TryAdd contract means yours wins.


Render a document

Inject IMPdfService and call one of three overloads.

public sealed class InvoiceController(IMPdfService pdf) : ControllerBase
{
[HttpGet("invoice/{id}")]
public async Task<IActionResult> Get(string id, CancellationToken ct)
{
string html = await BuildInvoiceHtml(id);

var options = new PdfRenderOptions
{
PageSize = PdfPageSize.A4,
Orientation = PdfOrientation.Portrait,
Margins = PdfMargins.Uniform(12),
TemplateId = "invoice-v1", // emitted in telemetry (hashed, no content)
};

// Stream overload — recommended; does not buffer the whole PDF in memory.
Response.ContentType = "application/pdf";
await pdf.RenderAsync(html, Response.Body, options, ct);
return new EmptyResult();
}
}

IMPdfService API

MethodUse
RenderAsync(html, Stream destination, options, ct)Recommended. Writes the PDF straight to a caller-owned stream.
RenderToBytesAsync(html, options, ct)(byte[] Bytes, PdfRenderResult Metadata)Convenience; buffers the output. Prefer the stream overload in production.
RenderMultiPageAsync(IReadOnlyList<string> htmlPages, Stream destination, options, ct)Renders several HTML fragments into one PDF; each fragment starts on a new page.

All overloads return a PdfRenderResult:

PdfRenderResult result = await pdf.RenderAsync(html, stream, options, ct);
// result.PageCount, result.ByteCount, result.Elapsed,
// result.TemplateHash, result.PolicyId, result.PolicyViolations

The service is singleton-safe but resolves tenant context per call via ITenantContext; all internal caches are tenant-scoped to prevent cross-tenant leakage.


Render options

PdfRenderOptions is a per-call record — safe to vary per request. Do not thread a tenant id through it (tenancy flows ambiently via ITenantContext).

PropertyDefaultNotes
PageSizeA4A4, A5, A3, Letter, Legal.
OrientationPortraitPortrait / Landscape (swaps width/height).
Margins10 mm uniformPdfMargins.Uniform(mm) or new PdfMargins(top, right, bottom, left); clamped to [0, 100] mm. Default20mm / Zero presets available.
HeadernullFull-HTML running header — see below.
FooternullFull-HTML running footer.
UserStyleSheetnullCSS appended at author origin (correct cascade order, unlike wkhtmltopdf).
Policynull → DI defaultCSS subset gate. DI default is legacy-print-v1 (LegacyPrintPolicy).
FontResolvernull → DI defaultPer-call override of the registered IFontResolver.
ResourceResolvernull → DI defaultPer-call override of the registered IResourceResolver.
TemplateIdnullTelemetry tag (recommended).
CorrelationIdnullTelemetry correlation tag.

Page sizes (points, portrait)

SizeWidth × Height (pt)mm
A4595.28 × 841.89210 × 297
A5419.53 × 595.28148 × 210
A3841.89 × 1190.55297 × 420
Letter612 × 7928.5 × 11 in
Legal612 × 10088.5 × 14 in

PdfHeaderFooter renders full HTML in three columns (left / center / right). Each column is laid out as a real box tree with the same fonts as the body — so bold, color, font sizes, and even images (a logo) work. Page numbers use the CSS counters counter(page) and counter(pages).

var options = new PdfRenderOptions
{
Header = new PdfHeaderFooter(
LeftHtml: "<img src=\"data:image/png;base64,...\" style=\"width:64px;height:38px;\" />",
CenterHtml: "<b style=\"color:#0c6b6b;\">TÂN CẢNG SÀI GÒN</b>",
RightHtml: "Trang counter(page)/counter(pages)",
HeightMm: 20, // reserved band height; if larger than the page margin it pushes the body down
ShowLine: true), // draws a separator rule between the band and the body
Footer = new PdfHeaderFooter(
CenterHtml: "Tài liệu nội bộ — counter(page)/counter(pages)",
HeightMm: 12,
ShowLine: true),
};
FieldMeaning
LeftHtml / CenterHtml / RightHtmlHTML fragment per column. Each is aligned within its third (left / center / right).
HeightMmReserved band height. When greater than the corresponding page margin, the effective margin grows and the body is pushed below the header band / above the footer band (no overlap).
ShowLineDraws a thin separator rule between the header and the body (and between the body and the footer).

Only counter(page) / counter(pages) are supported as page tokens. wkhtmltopdf-style [page] / [topage] / [date] tokens are not recognized.


Custom resource loading (images, fonts)

The engine never reaches the network on its own. The default IResourceResolver is ThrowingResourceResolver — it rejects every non-inlined URL. Data-URI images (data:image/png;base64,...) always work without a resolver.

To load images from a URL, app store, or blob storage, register a resolver that returns bytes:

public sealed class BlobResourceResolver(IBlobStore store) : IResourceResolver
{
public async Task<ResourceResult?> ResolveAsync(Uri uri, string? contentTypeHint, CancellationToken ct)
{
byte[]? bytes = await store.TryGetAsync(uri.ToString(), ct);
return bytes is null ? null : new ResourceResult(bytes, contentTypeHint ?? "image/png");
}
}

// register BEFORE AddPdf to win the TryAdd:
builder.Services.AddSingleton<IResourceResolver, BlobResourceResolver>();
builder.Services.AddPdf(builder.Configuration);

Fonts follow the same pattern via IFontResolver (the default DefaultFontResolver reads PdfConfigs:FontResolver and bundles Liberation Serif/Sans/Mono as the canonical Times New Roman / Arial / Courier New fallbacks). Embedded @font-face (data-URI) is also supported and subsetted into the output PDF.


Configuration & limits

Bound from the "PdfConfigs" section. All limits are enforced and reject oversized or hostile input (PdfInputLimitException) rather than degrading silently.

{
"PdfConfigs": {
"Limits": {
"MaxHtmlBytes": 8388608,
"MaxDomDepth": 256,
"MaxElementCount": 100000,
"MaxImagePixels": 25000000,
"MaxPages": 1000,
"MaxRenderDurationMs": 15000,
"MaxFontFiles": 32
},
"Policy": {
"SoftDegradeUnknownDisplay": false
}
}
}
LimitDefaultBreach
MaxHtmlBytes8 MiBPdfInputLimitException("HTML-MAX-BYTES") before parsing
MaxDomDepth256policy reject
MaxElementCount100 000policy reject
MaxImagePixels25 000 000PdfInputLimitException("IMG-MAX-PIXELS")
MaxPages1 000PdfInputLimitException("PAGE-MAX-PAGES")
MaxRenderDurationMs15 000render is cancelled (OperationCanceledException)
MaxFontFiles32font resolution capped

Policy.SoftDegradeUnknownDisplay = true turns display:flex/grid violations into warnings (the element renders as display:block) instead of hard errors — useful when migrating legacy templates. Default is strict (fail-loud).


Error handling

ExceptionWhen
PdfInputLimitExceptionAn input limit was breached (HTML bytes, image pixels, page count).
PdfPolicyExceptionThe CSS subset policy rejected a forbidden feature; .Violations lists each one with rule id, selector, rejected value, and a suggested alternative.
OperationCanceledExceptionThe render exceeded MaxRenderDurationMs or the caller cancelled.
try
{
await pdf.RenderAsync(html, stream, options, ct);
}
catch (PdfPolicyException ex)
{
foreach (var v in ex.Violations)
logger.LogWarning("PDF policy: {Rule} on {Selector}: {Value} → {Fix}",
v.RuleId, v.CssSelector, v.RejectedValue, v.SuggestedAlternative);
throw;
}

Telemetry

AddPdf registers a telemetry descriptor discovered by OtelSetup. The engine emits a pdf.render activity span plus operation-count and page-count metrics, tagged with TemplateId (hashed) and the ambient tenant id.


See also