Skip to content

Run PdfNetStack on Azure Functions

Since 1.1View as Markdown

Convert HTML to PDF and optimize PDFs in Azure Functions, on the .NET isolated worker.

Package the fonts your HTML uses with the app, load them once, and turn installed fonts off. Create the options once: every invocation the worker runs shares them, concurrent ones included. Publish ready to run.

// Created once per instance and shared by every invocation, concurrent ones included.
internal static class Pdfs
{
private static readonly HtmlConverterOptions Options = new()
{
Fonts = LoadFonts(),
UseSystemFonts = false, // the host has no fonts, or ones that can change
MaxPages = 200,
};
public static byte[] Convert(string html) => HtmlConverter.ConvertToPdf(html, Options);
// The fonts deployed with the function, in fonts/ next to the executable.
private static PdfFontCollection LoadFonts()
{
var fonts = new PdfFontCollection();
fonts.AddDirectory(Path.Combine(AppContext.BaseDirectory, "fonts"));
return fonts;
}
}

A complete app with an HTTP trigger for HTML and an Event Grid blob trigger for optimization is in samples/serverless/azure-functions.

Function hosts have few fonts installed or none, and what is there can change with the image. With UseSystemFonts = false the output depends only on the HTML and the fonts you packaged. CSS families that are not packaged fall back to the standard 14 fonts. A loaded font is thread safe and shared by concurrent conversions; variable font instances made for a conversion are released once nothing uses them. See Fonts and assets.

Without a resource loader, the converter fetches nothing from the network: put images and style sheets inline.

The optimizer reads its input stream to the end and holds the document in memory. Write the result to the temporary directory and upload it from there:

internal static class Optimizer
{
public static async Task<PdfOptimizationResult> OptimizeAsync(Stream input, Stream destination, CancellationToken cancellationToken)
{
// /tmp on AWS Lambda, the instance's local storage on Azure Functions; deleted on close.
string temp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pdf");
await using var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None,
1 << 16, FileOptions.Asynchronous | FileOptions.DeleteOnClose);
var options = new PdfOptimizerOptions
{
Streaming = true, // flat memory; about 3x the file in the temp folder
MaxInputBytes = 600L << 20, // larger inputs pass through unchanged
MaxDegreeOfParallelism = Environment.ProcessorCount, // the default; vCPUs grow with memory on Lambda
};
PdfOptimizationResult result = await PdfOptimizer.OptimizeAsync(input, output, options, cancellationToken);
output.Position = 0;
await output.CopyToAsync(destination, cancellationToken); // or hand it to the storage SDK's upload
return result;
}
}

Path.GetTempPath() is the instance’s local storage. It is limited and differs by plan, so cap the input size. The library itself writes nothing to disk unless you give it a path or a stream.

  • Flex Consumption runs instances of 512 MB, 2,048 MB or 4,096 MB, and runs several invocations on one instance. Size memory for the concurrent work. With Streaming, each document being optimized takes a few hundred MB whatever its size, and about three times its size in the temporary directory; without it, about ten times the file size in memory.
  • The Consumption plan caps an instance at 1.5 GB for all its concurrent invocations.
  • maxDegreeOfParallelism under extensions.blobs in host.json bounds concurrent blobs per instance; MaxDegreeOfParallelism in PdfOptimizerOptions bounds the threads of one document.

Publish ready to run (-p:PublishReadyToRun=true with a runtime identifier), so less code is compiled on the first request. On a development machine, in a fresh process each time, with tools/measure_cold_start.py:

First callReady to runJIT
HTML to PDF (UseSystemFonts = false)470 ms800 ms
Text extraction of three pages110 ms250 ms
A line of text in an embedded TrueType font330 ms570 ms

The machine was busy during this run, so compare the columns rather than the absolute times. The AWS Lambda guide has Native AOT figures.

  • The optimizer, ToArray and a full Save hold the whole document in memory.
  • The converter does not run JavaScript.

Questions

Which Azure Functions plan suits PDF work?
Flex Consumption, with 2,048 MB instances for optimization. With the streaming optimizer, memory stays flat whatever the file size, and the temporary directory holds about three times each file. The Consumption plan caps an instance at 1.5 GB.
How do I make the first request faster on Azure Functions?
Publish ready to run. On a development machine the first HTML conversion in a fresh process took about 470 ms ready to run against about 800 ms with the JIT alone.