Recursive AI

Render a Razor component to PDF

All onerender recipes

Render a Razor component to a tagged PDF from an ASP.NET Core endpoint in one call, with its style sheet served from wwwroot, using RecursiveAI.OneRender.Razor.

Terminal
dotnet new web -o InvoiceWeb
cd InvoiceWeb
dotnet add package RecursiveAI.OneRender.Razor --version 1.0.0

The component, Components/Invoice.razor, is a whole HTML document with parameters:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Invoice @Number</title>
  <link rel="stylesheet" href="css/invoice.css">
</head>
<body>
  <h1>Invoice @Number</h1>
  <p class="to">Billed to @Customer</p>
  <table>
    <thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead>
    <tbody>
      @foreach (var line in Lines)
      {
        <tr><td>@line.Item</td><td>@line.Qty</td><td>@line.Amount.ToString("C", System.Globalization.CultureInfo.GetCultureInfo("en-US"))</td></tr>
      }
    </tbody>
  </table>
  <p class="total">Total @Lines.Sum(l => l.Amount).ToString("C", System.Globalization.CultureInfo.GetCultureInfo("en-US"))</p>
</body>
</html>
 
@code {
    [Parameter] public string Number { get; set; } = "";
    [Parameter] public string Customer { get; set; } = "";
    [Parameter] public IReadOnlyList<InvoiceLine> Lines { get; set; } = [];
 
    public record InvoiceLine(string Item, int Qty, decimal Amount);
}

Components/_Imports.razor holds @using Microsoft.AspNetCore.Components, and the style sheet is an ordinary file, wwwroot/css/invoice.css:

CSS
body { font: 15px/1.5 Arial, sans-serif; color: #222 }
h1 { font-family: Georgia, serif; font-weight: normal; margin: 0 }
.to { color: #555 }
table { width: 100%; border-collapse: collapse }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #ddd }
.total { text-align: right; font-weight: bold }

Program.cs registers the renderer and maps the endpoint:

C#
using InvoiceWeb.Components;
using OneRender.Pdf;
using OneRender.Razor;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOneRender();
var app = builder.Build();
 
app.MapGet("/invoices/{number}.pdf", async (string number, RazorRenderer renderer, CancellationToken ct) =>
{
    var parameters = new Dictionary<string, object?>
    {
        ["Number"] = number,
        ["Customer"] = "Harbor Street Books",
        ["Lines"] = new Invoice.InvoiceLine[] { new("Shelf labels", 400, 120m), new("Reading lamps", 6, 354m) },
    };
    RazorPdfResult pdf = await renderer.RenderPdfAsync<Invoice>(parameters,
        new HtmlPdfOptions { Page = PdfPageSetup.Letter }, ct);
    foreach (string missing in pdf.MissingAssets)
        app.Logger.LogWarning("Missing asset {Path}", missing);
    return Results.File(pdf.Bytes!, "application/pdf", $"invoice-{number}.pdf");
});
 
app.Run();

Run it and request /invoices/INV-1042.pdf: the response is a one-page Letter PDF titled “Invoice INV-1042”, tagged, styled by invoice.css, with no missing assets logged.

How it works

  • AddOneRender() registers a singleton RazorRenderer over the app’s web root. Each call renders the component in a fresh service scope, so scoped services behave as they do in a request, and components may await.
  • Assets (<link>, <img>, @font-face) are served from wwwroot and the static web assets of referenced libraries (_content/<Library>/...). data: addresses work, and .. segments are refused. An asset that isn’t found never throws: it is listed in MissingAssets.
  • Fragments. A component that renders only a fragment, with no <html> element, is wrapped in a document using RazorRenderOptions.Language (en by default), Title and Stylesheets. Set those in AddOneRender(o => ...) so a tagged PDF still gets a title and language.
  • Options pass through. HtmlPdfOptions sets the page size and output, such as a PDF/UA profile. Its BaseUrl and Resources are replaced by the web root, which is asked first.

Images and HTML

RenderImageAsync<T> returns a RazorImageResult with ToPng(), taking HtmlRenderOptions for the viewport:

C#
RazorImageResult img = await renderer.RenderImageAsync<Invoice>(parameters, new HtmlRenderOptions { Width = 800, Height = 600 }, ct);
byte[] png = img.ToPng();

RenderHtmlAsync<T> returns the markup alone, and RenderPdfAsync<T>(stream, ...) writes to your stream, such as the response body, and returns the page count with Bytes null.

The package needs the ASP.NET Core shared framework. Everything it does goes through the public HtmlPdf and HtmlRender APIs. The details are in Razor components.