Recursive AI

Convert HTML to PDF

All onerender recipes

Turn an HTML page into a tagged PDF in C# with OneRender, with no browser: set the page size and margins, write metadata, stream the file to a response, and keep links working.

C#
using OneRender.Pdf;
 
string html = """
<!DOCTYPE html>
<html lang="en">
<head>
<title>Packing slip 5521</title>
<style>
  body { font: 11pt/1.4 sans-serif; margin: 0 }
  h1 { font-size: 16pt; margin: 0 0 8px }
  ul { padding-left: 18px }
</style>
</head>
<body>
  <h1>Packing slip 5521</h1>
  <p>Ship to: Harbor Street Books, 14 Harbor Street</p>
  <ul><li>Shelf labels × 400</li><li>Reading lamps × 6</li></ul>
  <p><a href="https://example.com/track/5521">Track this order</a> · <a href="#returns">Returns</a></p>
  <h2 id="returns" style="break-before: page">Returns</h2>
  <p>Return anything within 30 days.</p>
</body>
</html>
""";
 
// 4 by 6 inches, a common label size, with quarter-inch margins. Units are CSS px: 96 per inch.
var label = new PdfPageSetup(width: 4 * 96, height: 6 * 96, marginTop: 24, marginRight: 24, marginBottom: 24, marginLeft: 24);
 
var options = new HtmlPdfOptions
{
    Page = label,
    Output = new PdfOutputOptions
    {
        Author = "Harbor Street Books",
        Subject = "Order 5521",
        CreationDate = "D:20261027090000Z",   // a PDF date string; the default is none
    },
};
 
byte[] bytes = HtmlPdf.ToPdf(html, options);
File.WriteAllBytes("slip.pdf", bytes);
Console.WriteLine($"slip.pdf: {bytes.Length:N0} bytes");
 
// In a web endpoint, write straight to the response body instead: pages stream out as they are finished.
var response = new MemoryStream();
HtmlPdfResult result = HtmlPdf.ToPdf(html, response, options);
Console.WriteLine($"{result.PageCount} pages, same bytes: {response.ToArray().AsSpan().SequenceEqual(bytes)}");
Text
slip.pdf: 17,149 bytes
2 pages, same bytes: True

The file is 4 by 6 inches, two pages long, titled “Packing slip 5521” in English, with the author and subject set. The link to the tracking page is a link annotation, and “Returns” jumps to the heading on page 2.

What goes into the file

  • Structure. The PDF is tagged from the HTML: headings, paragraphs, lists, tables with header scopes, links, and images with their alt text. Turn it off with Tagged = false in PdfOutputOptions.
  • Title and language come from <title> and <html lang>, unless Title or Language is set in the options.
  • Text is real text in subsetted fonts, so it can be selected, searched and extracted, including ligatures and right-to-left runs.
  • Vector output. Backgrounds, borders, gradients, shadows without blur, transforms and clips are written as PDF drawing operations. Effects PDF can’t express, such as blurred shadows and filters, are rasterized at RasterDpi (192 by default) for just that area.
  • The same bytes every time. Nothing reads the clock: with no CreationDate, the same input gives the same file on every platform, which makes PDFs easy to compare in tests.

Good to know

  • Pass a stream to write page by page. A PdfProfile other than None (see Accessible PDF) builds the file in memory first, then writes it.
  • PdfPageSetup.A4 and PdfPageSetup.Letter have 48 px (half-inch) margins. A @page rule in the document wins over the options: see Headers, footers and page breaks.
  • Style sheets, images and fonts come through BaseUrl and Resources, as in Getting started. Scripts don’t run, so render the final HTML on the server, for example from a Razor component.
  • MaxPages (100,000 by default) stops a runaway document with a PdfRenderException.

Every rule of the writer is in PDF output.