Add the OnePdf.Layout package, describe the document with LayoutDocument.Compose and call Save. The layout engine handles line breaks, page breaks and page numbers, and needs no browser or native code.
dotnet new console -o CreatePdf && cd CreatePdf dotnet add package RecursiveAI.OnePdf.Layout --version 1.14.0
Put this in Program.cs and run dotnet run:
using OnePdf.Layout; LayoutDocument.Compose(doc => { doc.Title("Hello").Language("en-US"); doc.Page(page => { page.Size(PageSize.Letter); page.Margin(72); // points: 72 = 1 inch page.Content().Text("Hello, PDF").FontSize(24); }); }).Save("hello.pdf"); Console.WriteLine($"hello.pdf: {new FileInfo("hello.pdf").Length:N0} bytes");
hello.pdf: 5,229 byteshello.pdf is one US Letter page. The text is set in Liberation Sans, which comes with the package, and only the letters used are embedded, so the file looks the same on every machine. The PDF is tagged for screen readers, with the title and language you set.
Generate a real document: an invoice
A header on every page, a table that continues onto the next page with its header row repeated, a vector logo, and “Page 1 of 2” in the footer:
using System.Globalization; using OnePdf.Layout; var us = CultureInfo.GetCultureInfo("en-US"); var items = Enumerable.Range(1, 45) // 45 lines: enough for two pages .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(); // 40 points wide, as vector paths row.ConstantItem(40).Svg(logo); 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.HeaderRow(r => // repeated on 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)); decimal amount = item.Hours * item.Rate; r.Cell().AlignEnd().Text(amount.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 "); // room for the total, known only at the end t.TotalPages().ReservedDigits(1); }); }); }).Save("invoice.pdf"); using var pdf = OnePdf.PdfDocument.Open("invoice.pdf"); Console.WriteLine($"invoice.pdf: {pdf.PageCount} pages"); string[] lastPage = pdf.Pages[^1].ExtractText().Text.Split('\n'); Console.WriteLine(lastPage.Last(line => line.StartsWith("Total")));
invoice.pdf: 2 pages Total due: $10,640.00
Both pages carry the logo and header, the table runs from page 1 onto page 2 with its header row repeated, and the footers read “Page 1 of 2” and “Page 2 of 2”.
How it fits together
- Slots.
Header,ContentandFootereach take one thing:Text,Column,Row,Table,Image,Svgand so on. Modifiers such asPaddingBottomorAlignEndgo in front. - Widths.
ConstantItem(points)andConstantare fixed;RelativeItem()andRelativeshare what is left. - Images.
Image(bytes)takes a PNG or JPEG;Svg(markup)draws vector art, its<title>becoming the alternative text. - In a web app,
ToBytes()gives the finished file:return Results.File(doc.ToBytes(), "application/pdf", "invoice.pdf"); - Long documents.
Savewrites page by page, so memory stays small.
Limits
- No HTML. OnePdf.Layout builds documents from code. To turn HTML into a PDF, use OneRender: see Save as PDF.
- Fonts. Liberation Sans is the default and system fonts are never used. Register your own with
doc.Fonts.Add(bytes, "Family"). Arabic, Hindi and other complex scripts need a font that covers them: see Arabic and Hindi text. - .NET 10. The packages target .NET 10 only, and support NativeAOT.
More tables in Generate a PDF table; every option in Layout. Coming from QuestPDF? The calls are nearly the same: see From QuestPDF. To compare libraries and licenses, see Choosing a C# PDF library.