Recursive AI

Create a PDF in C#

Create PDF files in C# from code with OnePdf.Layout: text, tables that break across pages, SVG logos, page X of Y, ASP.NET Core downloads and PDF/A. No HTML.

To create a PDF in C#, add the RecursiveAI.OnePdf.Layout package, describe the document with LayoutDocument.Compose and call Save. OnePdf.Layout is a code-first PDF generator: you build pages from text, rows, columns, tables, images and SVG, and it handles line breaking, page breaks, repeated table headers and page numbers. It writes the file page by page. It is pure C# with no browser and no native binaries, licensed MIT OR Apache-2.0. This page builds a first PDF, a multi-page invoice, an ASP.NET Core download and a PDF/A archive copy. Every sample was compiled and run against OnePDF 1.11.0 on .NET 10.

Create your first PDF

TerminalNuGet
dotnet new console -o PdfCreate
cd PdfCreate
dotnet add package RecursiveAI.OnePdf.Layout --version 1.11.0
Program.csOnePDF 1.11.0 · .NET 10
using OnePdf.Layout;
 
LayoutDocument.Compose(doc =>
{
    doc.Title("Hello").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.Letter);
        page.Margin(72);
        page.Content().Text("Hello, PDF").FontSize(24);
    });
}).Save("hello.pdf");

This writes a one-page US Letter hello.pdf. A few things happen without being asked:

  • The text is set in Liberation Sans, which comes with the package, and only the glyphs used are embedded. System fonts are never used unless you add them, so the output is the same on Windows, Linux, macOS and in containers.
  • The PDF is tagged: headings, paragraphs, tables and figures are marked for screen readers. Title and Language set the document’s title and language.
  • Nothing is laid out until Save, which writes the file one page at a time, so long documents don’t build up in memory.

Units are points (1/72 inch). PageSize has the usual sizes (Letter, A4 and others), and 10.Mm(), 2.Cm() and 1.Inch() convert.

Generate an invoice with a table that breaks across pages

A real document needs a header on every page, a table that continues onto the next page with its header row repeated, and “Page 1 of 2” in the footer. This invoice has 45 lines, a vector logo and a total:

Program.csOnePDF 1.11.0 · .NET 10
using System.Globalization;
using OnePdf.Layout;
 
var us = CultureInfo.GetCultureInfo("en-US");
 
// 45 line items, enough to run onto a second page.
var items = Enumerable.Range(1, 45)
    .Select(i => (Description: $"Support hours, ticket #{4100 + i}", Hours: 1 + i % 4, Rate: 95m))
    .ToList();
decimal total = items.Sum(x => x.Hours * x.Rate);
 
const string logo = """
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
      <title>Northwind Supply</title>
      <rect width="40" height="40" rx="8" fill="#2F5D8A"/>
      <path d="M10 30 V10 L30 30 V10" stroke="#FFFFFF" stroke-width="4" fill="none"/>
    </svg>
    """;
 
LayoutDocument.Compose(doc =>
{
    doc.Title("Invoice 2026-118").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.Letter);
        page.Margin(54);
 
        page.Header().PaddingBottom(16).Row(row =>
        {
            row.Spacing(10);
            row.AlignMiddle();                             // center the items of the row vertically
            row.ConstantItem(40).Svg(logo);                // vector logo, 40 pt wide
            row.RelativeItem().Text("Northwind Supply").FontSize(14).Bold();
            row.RelativeItem().AlignEnd().Text("Invoice 2026-118\nOctober 26, 2026");
        });
 
        page.Content().Column(col =>
        {
            col.Spacing(12);
            col.Item().Text("Bill to: Example Co., 100 Main Street, Springfield");
            col.Item().Table(table =>
            {
                table.Columns(c => { c.Relative(1); c.Constant(50); c.Constant(70); c.Constant(80); });
                table.CellPadding(4);
                table.CellBorder(0.5, Colors.LightGrey);
                table.RowBackground(i => i % 2 == 1 ? LayoutColor.FromHex("#F3F6F9") : null);
 
                table.HeaderRow(r =>                       // repeated at the top of every page
                {
                    r.Cell().Text("Description").Bold();
                    r.Cell().AlignEnd().Text("Hours").Bold();
                    r.Cell().AlignEnd().Text("Rate").Bold();
                    r.Cell().AlignEnd().Text("Amount").Bold();
                });
                foreach (var item in items)
                {
                    table.Row(r =>
                    {
                        r.Cell().Text(item.Description);
                        r.Cell().AlignEnd().Text(item.Hours.ToString(us));
                        r.Cell().AlignEnd().Text(item.Rate.ToString("C", us));
                        r.Cell().AlignEnd().Text((item.Hours * item.Rate).ToString("C", us));
                    });
                }
            });
            col.Item().AlignEnd().Text($"Total due: {total.ToString("C", us)}").FontSize(13).Bold();
        });
 
        page.Footer().AlignCenter().Text(t =>
        {
            t.FontSize(9);
            t.Span("Page ");
            t.CurrentPageNumber();
            t.Span(" of ");
            t.TotalPages().ReservedDigits(1);              // room for the total, which is known only at the end
        });
    });
}).Save("invoice.pdf");

The result is a two-page US Letter PDF of about 30 KB. The logo and company header sit at the top of both pages, and the table runs from page 1 to page 2 with the “Description, Hours, Rate, Amount” row repeated. The footer reads “Page 1 of 2” and “Page 2 of 2”, and page 2 ends with “Total due: $10,640.00”.

Page 1 of the generated invoice: a blue logo and “Northwind Supply” at the top left, the invoice number and date at the top right, the bill-to line, and a striped table of support hours with Description, Hours, Rate and Amount columns, ending with “Page 1 of 2” at the bottom

How the pieces fit:

  • Slots. Header, Content and Footer are slots, and every slot takes one thing: Text, Column, Row, Table, Image, Svg and so on. Decorators such as PaddingBottom, AlignEnd or Width go in front of it.
  • Rows and columns. ConstantItem(points) has a fixed width and RelativeItem() shares what is left. Table columns work the same way with Constant and Relative.
  • Tables. HeaderRow repeats at the top of every page the table reaches. FooterRow repeats at the bottom, and body rows split between pages. RowBackground stripes the rows. See generate a PDF with tables for spans, footers and column-width rules.
  • SVG. Svg draws the logo as vector paths, with its <title> as alternative text. A PNG or JPEG goes in with Image(path) instead. SVG in layout lists what is supported.
  • Page numbers. The total page count isn’t known until the end, so ReservedDigits sets aside room for it. Reserve as many digits as the count can have. Extra room shows as a gap before the number.
  • Vertical alignment. Put AlignMiddle() on the row, as above, and not on an item. On a slot, AlignMiddle makes it as tall as the space offered, and in a header that is the whole page.

For content that repeats, implement IComponent and place it with Component(...). Running totals per page (“carried forward”), tables of contents, lists, links and bookmarks are in the layout guide.

Return a PDF from ASP.NET Core

ToBytes() gives you the finished file as a byte array, ready for Results.File. Create the project with dotnet new web and add the package:

Program.csOnePDF 1.11.0 · ASP.NET Core 10
using OnePdf.Layout;
 
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
app.MapGet("/invoices/{number}", (string number) =>
{
    byte[] pdf = LayoutDocument.Compose(doc =>
    {
        doc.Title($"Invoice {number}").Language("en-US");
        doc.Page(page =>
        {
            page.Size(PageSize.Letter);
            page.Margin(54);
            page.Content().Text($"Invoice {number}").FontSize(24).Bold();
        });
    }).ToBytes();
 
    return Results.File(pdf, "application/pdf", $"invoice-{number}.pdf");
});
 
app.Run();

A request to /invoices/2026-118 returns 200 OK with Content-Type: application/pdf and Content-Disposition: attachment; filename=invoice-2026-118.pdf, and the body is a one-page PDF titled “Invoice 2026-118”. To show the PDF in the browser instead of downloading it, leave out the file name. The same Compose and ToBytes calls work anywhere .NET 10 runs, such as a Blazor Server app or a background service.

Layout objects are independent, so one server can generate many documents at once. In the project’s benchmark, 32 invoices generated concurrently took 9.99 ms in total (see the numbers below).

Fonts, Arabic, Hindi and other scripts

Register your own TrueType or OpenType fonts on doc.Fonts with doc.Fonts.Add(File.ReadAllBytes("Brand-Regular.ttf"), "Brand"), then select them with FontFamily("Brand"). OnePdf.Layout shapes text through the OneFont engine, so kerning and ligatures are applied, and Arabic is joined right to left and Devanagari conjuncts form. Fallback families cover characters your main font lacks. The Arabic and Hindi recipe is a complete program. For hyphenation in 26 language variants, add RecursiveAI.OnePdf.Hyphenation and opt in with Hyphenator(Hyphenators.Auto()).

Save as PDF/A for archiving

The same document can be saved as PDF/A-2b, a profile of PDF for long-term archiving. Save(stream, PdfSaveOptions) lays the document out and then writes it with the conformance options:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Layout;
 
LayoutDocument report = LayoutDocument.Compose(doc =>
{
    doc.Title("Archive copy").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.Letter);
        page.Margin(72);
        page.Content().Text("This copy is saved as PDF/A-2b for long-term archiving.");
    });
});
 
using FileStream file = File.Create("archive.pdf");
report.Save(file, new PdfSaveOptions
{
    PdfA = new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.B },
});

We checked archive.pdf with the veraPDF 1.30.3 validator, and it passes as PDF/A-2b. OnePDF embeds the fonts, writes the XMP metadata and adds an sRGB output intent itself. If a document can’t conform, the save is refused with every violation listed. Because layout output is tagged, the same approach gives PDF/UA for accessibility: see create PDF/UA and create PDF/A, including PDF/A-3 with attached files.

What about HTML to PDF?

OnePDF has no HTML engine, so it doesn’t convert HTML or Razor views to PDF. If your templates are already HTML and CSS, you need a library with a browser engine inside: IronPDF and iText’s pdfHTML add-on are two, both commercial or AGPL (see choosing a C# PDF library for their terms). The code-first approach above needs no browser: page breaks, repeated headers and page numbers are part of the layout model.

OnePdf.Layout, QuestPDF and MigraDoc

OnePdf.Layout uses the same nested-delegate style as QuestPDF. If you know QuestPDF, migrating from QuestPDF maps the calls. The differences that usually decide are the license and the dependencies:

  • QuestPDF is free under its Community License for eligible users, including organizations under USD 1 million in annual gross revenue. Others need a paid license from $1,999 (pricing). The 2026.9.1 package carries native Skia and qpdf libraries for eight runtimes.
  • MigraDoc 6.5.0 is AGPL or commercial (€750, pricing). Version 6.2.4 and earlier are MIT.
  • OnePdf.Layout is MIT OR Apache-2.0 with no native code. It depends on RecursiveAI.OnePdf, RecursiveAI.OnePdf.Fonts and RecursiveAI.OneFont, all from the same project. It targets .NET 10 only and supports NativeAOT.

The project’s published benchmark generated the same documents with OnePdf.Layout and MigraDoc 6.2.4, on an AMD Ryzen 9 7950X3D under Linux with .NET 10.0.13. Times are BenchmarkDotNet means.

DocumentOnePdf.LayoutMigraDoc 6.2.4
Invoice, 1 page1.01 ms1.30 ms
Report, 50 pages (tables, images, headers, page X of Y)37.1 ms175 ms
Table, 10,000 rows (about 250 pages, repeated header)362 ms1,191 ms
Book, 500 pages, justified317 ms496 ms
32 invoices concurrently9.99 ms51.7 ms

Published as NativeAOT, OnePDF generates its first invoice in a fresh process in 4 ms with a 12 MiB peak. That matters for serverless functions and command-line tools, where every run is a cold start.

Where next