Convert HTML to PDF in C#: install and first conversion
DocToolkit converts HTML into Word documents and PDFs, and reads and edits DOCX, XLSX and PPTX
files. It is pure managed code: no native binaries, no headless browser, no LibreOffice, no Office
interop. dotnet restore is the whole install, and nothing it does at runtime touches the network
unless you explicitly ask it to.
Install
dotnet add package Ank.DocToolkit
That is the library. Everything in it is a static class, so there is nothing to register and no container to configure. If you are in ASP.NET Core or a worker service and would rather inject interfaces, add the companion package as well — see Dependency injection.
dotnet add package Ank.DocToolkit.Extensions.DependencyInjection
Both target net8.0 and net10.0, and both are MIT licensed.
Your first conversion
byte[] docx = await HtmlToDocxConverter.ConvertAsync(Html);
byte[] pdf = await HtmlToPdfConverter.ConvertAsync(Html);
byte[] rendered = DocxToPdfConverter.Convert(docx);
Three things worth noticing in those three lines.
HTML → PDF pivots through DOCX. There is no direct HTML renderer here, because every free one
is a browser and a browser is a native binary. HtmlToPdfConverter builds a Word document and
renders that. This is why the PDF from HtmlToPdfConverter and the PDF from DocxToPdfConverter
above are the same size — they are the same document.
Nothing was written to disk. The default overloads take and return byte[], which is usually
what a web handler wants. File and Stream overloads exist for when it isn't — see
Getting the bytes out immediately below.
Nothing reached the network. An <img src="https://…"> in that HTML would have been dropped,
not fetched. See Remote images for how to opt
in for specific hosts.
Getting the bytes out
A byte[] is where every conversion stops, because this library does not decide where your
document goes. Turning one into a file is a single line:
// 1. You have the bytes and want a file.
string docxPath = Path.Join(AppContext.BaseDirectory, "invoice.docx");
await File.WriteAllBytesAsync(docxPath, docx);
// 2. You want a file and never needed the array: write straight to any Stream.
string pdfPath = Path.Join(AppContext.BaseDirectory, "invoice.pdf");
await using (var file = File.Create(pdfPath))
{
await HtmlToPdfConverter.ConvertAsync(Html, file);
}
// 3. Both ends are already paths - no stream, no array.
DocxToPdfConverter.ConvertFile(docxPath, Path.Join(AppContext.BaseDirectory, "from-docx.pdf"));
Three forms, and which one you want depends on what you already have:
| you have | you want | use |
|---|---|---|
a byte[] |
a file | File.WriteAllBytes(path, bytes) — plain .NET, nothing from this library |
| HTML or a document | a file | the Stream overload, writing to File.Create(path) |
| a path | a path | ConvertFile(in, out) or …ToFileAsync(in, out, ct) |
The second form saves you from holding the array, which is not quite the same as nobody holding it. The destination can be a file, a socket or an HTTP response body — forward-only and write-only are both fine — and it is never disposed, closed or sought. It stays yours.
It is not a memory optimisation, and the numbers say so: the same edit costs 238 MB through the
Stream overload against 233 MB through the byte[] one, because the source is drained into a
buffer either way. On the PDF paths the library also renders the document whole before writing a
byte — deliberately, since a repair that retries a failed render cannot un-write bytes already sent.
The upside of that is worth knowing: a failed conversion leaves your destination untouched
rather than carrying half a PDF.
A byte[] is not only a file-in-waiting. The same array is what you return from a web
endpoint, put in a blob store or a database column, or hand straight back to this library:
byte[] docx = await HtmlToDocxConverter.ConvertAsync(html);
string text = DocxEditor.ExtractText(docx); // read it back
byte[] pdf = DocxToPdfConverter.Convert(docx); // convert it onward
byte[] locked = DocxEditor.Protect(docx, "s3cret"); // or protect it
return File(pdf, "application/pdf", "invoice.pdf"); // ASP.NET, no temp file anywhere
Nothing about the result is a file until you make it one.
The shape of the API
Every type follows the same three conventions, so learning one teaches you the rest.
| You have | You want | Use |
|---|---|---|
A byte[] |
A byte[] |
Convert(bytes) — synchronous, no allocation surprises |
A Stream |
A Stream |
ConvertAsync(source, destination, ct) |
| A path | A path | ConvertFile(in, out) or …ToFileAsync(in, out, ct) |
The Stream overloads are not merely wrappers that buffer into an array — they exist so a large
document never has to be resident in memory twice. What they do and do not guarantee is covered in
Running in production.
Producers — anything that creates a document rather than reading one — take an optional
PageSetup. Left out, they lay out on A4. PageSetup is immutable, so Landscape()
and WithMargins() return a new value instead of changing the shared default:
PageSetup wide = PageSetup.Letter.Landscape().WithMargins(36);
byte[] landscape = await HtmlToDocxConverter.ConvertAsync(Html, wide);
byte[] landscapePdf = await HtmlToPdfConverter.ConvertAsync(Html, wide);
More sizes, margins and orientations: Page size and margins.
When something goes wrong
Everything the library raises on your behalf arrives as a single exception type,
DocumentConversionException, so a caller needs one catch rather than one per
underlying library. The original failure is preserved as InnerException, which is what you want
in a log.
byte[] notADocx = "This is a text file, not a Word document."u8.ToArray();
try
{
DocxToPdfConverter.Convert(notADocx);
}
catch (DocumentConversionException ex)
{
Console.WriteLine($"\nRejected : {ex.Message}");
Console.WriteLine($"Inner cause : {ex.InnerException?.GetType().Name ?? "(none)"}");
}
Rejected : Failed to render DOCX to PDF. See the inner exception for details.
Inner cause : FileFormatException
A caller that hands user-supplied bytes to any reader should expect this: "is this really a DOCX" is not a question you can answer from a filename or a content type.
Where to go next
- What it can convert — the complete grid, generated from the shipped API
- HTML to Word and PDF — page setup, remote images, the network guard
- Markdown, and conversion loss — Markdown in and out, and
ConvertWithReport - Word documents — fill a template, build one from scratch, export it again
- Spreadsheets and presentations — XLSX and PPTX
- Dependency injection —
AddDocToolkit()and the injectable interfaces - Running in production — streaming, containers, trimming, telemetry, limits
Most code blocks in these guides are pulled from a runnable sample that CI compiles against the published package on Linux, Windows and macOS — if a snippet here is wrong, the build is red. A few examples show API that has not reached the published package yet; those are marked with a note at the point they appear.