---
title: "How to Convert HTML to an Image in C#"
description: "Render a shipment card as a PNG in C# using PuppeteerSharp or the typed HTML/CSS to Image client. Capture a page or element and wait for external assets."
published: "2026-10-13"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/convert-html-to-image-csharp"
---


To convert HTML to an image in C#, load it into headless Chromium with PuppeteerSharp and take a screenshot. Chromium handles the CSS, fonts and layout, so you never have to measure text or position elements yourself.

HCTI is mostly a .NET codebase, and we use PuppeteerSharp in our own code, so this is the version of the tutorial we have the strongest opinions about. Getting the first screenshot working is easy. The parts that cause trouble later are where the browser comes from, what happens when twenty requests want it at once, and why the server's output doesn't match your machine. We'll cover those too.

The example is a delivery update card showing a tracking number, destination and expected delivery date. It's built from a C# template and saved as a PNG. At the end, we'll render the same card through the typed HTML/CSS to Image client.

## 1. Set Up a .NET Console Project

The examples use a .NET 10 console app and C# raw string literals:

```bash
dotnet new console -n ShipmentImage -f net10.0
cd ShipmentImage
dotnet add package PuppeteerSharp
```

[PuppeteerSharp](https://www.puppeteersharp.com/){target=_blank} is a .NET port of Puppeteer. One thing that surprises people: the NuGet package doesn't include a browser. The code below uses `BrowserFetcher` to download Chromium on first run and then passes the executable path to the launcher.

## 2. Create the Shipment Card HTML

Add a file named `ShipmentTemplate.cs`. It takes plain application data and returns a complete HTML document:

```csharp
using System.Net;

public static class ShipmentTemplate
{
    public static string Create(
        string customer, string tracking, string destination, string delivery)
    {
        // These values are text, not HTML supplied by the customer.
        static string Encode(string value) => WebUtility.HtmlEncode(value);

        return $$"""
        <!doctype html>
        <html lang="en">
        <head>
          <meta charset="utf-8">
          <title>Shipment update</title>
          <style>
            * { box-sizing: border-box; }
            body { margin: 0; background: white; font-family: Arial, sans-serif; color: #172b3a; }
            #shipment-image { width: 600px; padding: 24px; background: #eaf1f5; }
            .card { padding: 32px; border-radius: 18px; background: white; }
            .brand { font-size: 13px; font-weight: bold; letter-spacing: 2px; }
            .status { display: inline-block; margin-top: 24px; padding: 7px 12px;
                      border-radius: 20px; background: #ddf4e7; color: #17613d;
                      font-size: 13px; font-weight: bold; }
            h1 { margin: 16px 0 8px; font-size: 30px; line-height: 1.2; }
            .intro { margin: 0; line-height: 1.5; color: #526575; }
            dl { margin: 28px 0 0; padding-top: 20px; border-top: 1px solid #e2e9ee; }
            .detail { display: flex; justify-content: space-between; gap: 24px;
                      padding: 8px 0; font-size: 14px; line-height: 1.4; }
            dt { color: #526575; }
            dd { margin: 0; font-weight: bold; text-align: right; overflow-wrap: anywhere; }
            .progress { display: flex; gap: 6px; margin-top: 24px; }
            .progress span { flex: 1; height: 6px; border-radius: 6px; background: #dce5eb; }
            .progress .done { background: #259764; }
          </style>
        </head>
        <body>
          <main id="shipment-image">
            <article class="card">
              <div class="brand">NORTHLINE GOODS</div>
              <div class="status">Out for delivery</div>
              <h1>Your package is on its way</h1>
              <p class="intro">{{Encode(customer)}}, here's the latest on your order.</p>
              <dl>
                <div class="detail"><dt>Tracking</dt><dd>{{Encode(tracking)}}</dd></div>
                <div class="detail"><dt>Destination</dt><dd>{{Encode(destination)}}</dd></div>
                <div class="detail"><dt>Expected delivery</dt><dd>{{Encode(delivery)}}</dd></div>
              </dl>
              <div class="progress" aria-label="Delivery stage 3 of 4">
                <span class="done"></span><span class="done"></span>
                <span class="done"></span><span></span>
              </div>
            </article>
          </main>
        </body>
        </html>
        """;
    }
}
```

The `$$"""` prefix is what makes this pleasant to write. Single braces in the CSS stay as they are, and interpolation uses double braces, like `{{Encode(tracking)}}`. Before raw string literals, this template would have been full of escaped braces.

[`WebUtility.HtmlEncode()`](https://learn.microsoft.com/en-us/dotnet/api/system.net.webutility.htmlencode){target=_blank} handles a customer name like `Jordan & Lee`. Encoding is enough for text values. If you ever insert markup that someone else wrote, you'll need a proper sanitizer.

The `#shipment-image` wrapper has a fixed width and its own pale blue background. We'll capture the wrapper rather than `.card`, so the 24 pixels of padding around the white card end up in the image. Here's what that looks like rendered with HCTI:

![A delivery status card with tracking number, destination and expected delivery date](https://content-images.htmlcsstoimage.com/media-assets/35b37a2d5c6cb3d3433249b45a539380bad28feb4962a20f96291b624ea7ee22/example-output.png)

If you want to compare your template's output against ours, [the generated HTML](https://content-images.htmlcsstoimage.com/media-assets/892a1224151cc6f6920a1a7734444ebd18a61dd73875e5cb1e69a32a03b5057a/shipment.html) is available to download.

## 3. Render the HTML to a PNG with PuppeteerSharp

Replace `Program.cs` with the following, and keep `ShipmentTemplate.cs` in the project:

```csharp
using PuppeteerSharp;

var html = ShipmentTemplate.Create(
    "Sam Rivera", "PKG-10482", "Portland, OR", "Friday, October 16");

var installedBrowser = await new BrowserFetcher().DownloadAsync();
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
    Headless = true,
    ExecutablePath = installedBrowser.GetExecutablePath()
});
await using var page = await browser.NewPageAsync();

await page.SetViewportAsync(new ViewPortOptions
{
    Width = 800,
    Height = 600,
    DeviceScaleFactor = 1
});

await page.SetContentAsync(html, new SetContentOptions
{
    WaitUntil = new[] { WaitUntilNavigation.Load },
    Timeout = 30_000
});

// Wait for fonts and <img> elements, with a separate upper limit.
await page.EvaluateFunctionAsync("""
    async () => {
      let timer;
      try {
        await Promise.race([
          Promise.all([
            document.fonts.ready,
            ...[...document.images].map(image => image.decode())
          ]),
          new Promise((_, reject) => {
            timer = setTimeout(() => reject(new Error("Assets timed out")), 30000);
          })
        ]);
      } finally {
        clearTimeout(timer);
      }
    }
    """);

var target = await page.WaitForSelectorAsync(
    "#shipment-image",
    new WaitForSelectorOptions { Visible = true, Timeout = 30_000 });

await target.ScreenshotAsync("shipment.png");

Console.WriteLine(Path.GetFullPath("shipment.png"));
```

Run it:

```bash
dotnet run
```

The first run is slower because [`BrowserFetcher.DownloadAsync()`](https://www.puppeteersharp.com/api/PuppeteerSharp.BrowserFetcher.html){target=_blank} has to download Chromium. After that it reuses the existing copy.

[`SetContentAsync()`](https://www.puppeteersharp.com/api/PuppeteerSharp.SetContentOptions.html){target=_blank} loads the HTML string directly, so there's no web server involved. The element's [`ScreenshotAsync()`](https://www.puppeteersharp.com/api/PuppeteerSharp.IElementHandle.html#PuppeteerSharp_IElementHandle_ScreenshotAsync_System_String_){target=_blank} crops to `#shipment-image`, which is why the PNG is 600 pixels wide even though the viewport is 800.

If you want a sharper image at the same size, set `DeviceScaleFactor = 2`. You'll get a 1,200-pixel-wide file with exactly the same layout. Changing the viewport width is a different thing. That can change how text wraps and which responsive styles apply.

## 4. Capture a Whole Page or Wait for Live Data

For the full document instead of just the card, swap the element screenshot for:

```csharp
await page.ScreenshotAsync("shipment-page.png", new ScreenshotOptions
{
    FullPage = true
});
```

Without `FullPage`, you get the viewport. For this card, both options leave a lot of empty white space, which is why the element capture makes more sense. [How to Take a Screenshot of a Specific HTML Element](/blog/screenshot-specific-html-element) goes deeper on crops and padding.

This card doesn't need anything external. If you add a logo or web font, remember that an HTML string loaded into a blank page has no base URL. `src="/images/logo.png"` won't resolve to your site, so use absolute URLs or embed the asset.

The readiness script above covers the two usual problems:

- [`document.fonts.ready`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready){target=_blank} waits for the fonts the page uses. It still resolves if a font fails and the browser falls back to another one, so check the output if the typeface matters.
- [`image.decode()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode){target=_blank} waits until each `<img>` can be drawn, and throws if one is broken. If your logo is required, that's what you want.

Neither one knows about data your page fetches after it loads. Say you're capturing a live tracking page that loads the delivery estimate from an API. Navigate to it, then wait for an attribute your app sets once the estimate is on screen:

```csharp
await page.GoToAsync("https://example.com/tracking/PKG-10482", new NavigationOptions
{
    WaitUntil = new[] { WaitUntilNavigation.DOMContentLoaded },
    Timeout = 30_000
});

await page.WaitForSelectorAsync(
    "[data-shipment-ready='true']",
    new WaitForSelectorOptions { Visible = true, Timeout = 30_000 });

// Run the font/image checks above, then capture #shipment-image.
```

Waiting for the card itself isn't enough, because the empty shell renders before the estimate arrives. [How to Wait for a Page to Finish Rendering](/blog/wait-for-page-before-screenshot) covers this pattern in more detail.

> [!TIP]
> Resist `Task.Delay(3000)`. It adds three seconds even when the card was ready in 200 ms, and it's still too short on the day an API is slow. The 30-second timeouts above are only upper limits. The code moves on as soon as the condition is met.

## 5. Running This Inside an ASP.NET Core App

The console app launches a browser, takes one screenshot and exits. That's fine for a script, but it falls apart quickly behind an endpoint. Here's what we'd change:

- **Download the browser at deploy time.** Run `BrowserFetcher` as part of your build or container image. If the first request after a deploy has to download Chromium, that user waits for it, and so does every request queued behind them. On Linux, you'll also need Chromium's system libraries in the image.
- **Launch one browser and keep it.** Register it as a singleton and create a new page for each render. Starting Chromium takes far longer than opening a page.
- **Limit how many renders run at once.** Every page uses real memory. A `SemaphoreSlim` around the render, or a background queue, keeps a burst of requests from exhausting the server.
- **Return bytes instead of writing files.** `ScreenshotDataAsync()` returns a `byte[]`, which you can send back with `Results.File(bytes, "image/png")`.
- **Ship your fonts.** The fonts installed on your Windows machine won't be on a Linux container. If the typeface matters, load it as a web font.

That's the work you take on when you run Chromium yourself. If you don't want it, the next section is the alternative.

## 6. Render the Same Card with the Typed HTML/CSS to Image Client

HTML/CSS to Image runs the browser for you and returns a hosted image URL. Add the official .NET package:

```bash
dotnet add package HtmlCssToImage
```

Set `HCTI_API_ID` and `HCTI_API_KEY` in your environment using the credentials from the [dashboard](https://htmlcsstoimage.com/dashboard){target=_blank}. Keep them on the server and out of anything that ships to the browser, including a Blazor WebAssembly app.

Replace `Program.cs` again. This version uses the same `ShipmentTemplate.cs`, without PuppeteerSharp:

```csharp
using HtmlCssToImage;
using HtmlCssToImage.Models;
using HtmlCssToImage.Models.Requests;

var options = HtmlCssToImageOptions.FromEnvironmentVariables();

using var apiHttp = new HttpClient();
var client = new HtmlCssToImageClient(apiHttp, options);

var request = new CreateHtmlCssImageRequest
{
    Html = ShipmentTemplate.Create(
        "Sam Rivera", "PKG-10482", "Portland, OR", "Friday, October 16"),
    Selector = "#shipment-image",
    ViewportWidth = 800,
    ViewportHeight = 600,
    DeviceScale = 1,
    Format = RenderImageFormat.PNG
};

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(60));
using var result = await client.CreateImageAsync(request, cancellation.Token);
if (!result.Success || result.Response is null)
{
    throw new InvalidOperationException(
        $"HCTI returned {result.StatusCode}: {result.ErrorDetails?.Message}");
}

Console.WriteLine(result.Response.Url);

// Pass --download to save a copy with a separate, unauthenticated client.
if (args.Contains("--download"))
{
    using var downloadHttp = new HttpClient();
    var png = await downloadHttp.GetByteArrayAsync(
        result.Response.Url, cancellation.Token);
    await File.WriteAllBytesAsync("shipment-hcti.png", png, cancellation.Token);
}
```

Run `dotnet run` to print the hosted URL, or `dotnet run -- --download` to save a local PNG too.

[`Selector`](https://docs.htmlcsstoimage.com/parameters/selector/){target=_blank}, the viewport and `DeviceScale` all match the PuppeteerSharp version, so the two images should be identical.

There are two details in that code worth knowing about:

- `using var result` is intentional. The typed result holds onto the HTTP response, so it needs disposing. The [client README](https://github.com/htmlcsstoimage/dotnet-client/blob/main/src/HtmlCssToImage/README.md){target=_blank} covers `Success`, `Response` and `ErrorDetails`.
- The download uses its own `HttpClient`. `HtmlCssToImageClient` adds your API credentials to the client you give it, and you don't want those sent along with unrelated requests.

In ASP.NET Core, register the client with dependency injection rather than creating `HttpClient`s by hand. The `HtmlCssToImage.DependencyInjection` package handles that registration.

If your template fetches data or draws a chart, set `RenderWhenReady = true` and call `ScreenshotReady()` from the page once it's done. The [`render_when_ready` documentation](https://docs.htmlcsstoimage.com/parameters/render_when_ready/){target=_blank} has the details, including the equivalent for URL screenshots.

## 7. What About wkhtmltoimage and PhantomJS?

You'll still find older .NET tutorials built on `wkhtmltoimage` or PhantomJS. `wkhtmltoimage` is part of wkhtmltopdf, and its [status page](https://wkhtmltopdf.org/status.html){target=_blank} explains how old its QtWebKit engine is. The [PhantomJS repository](https://github.com/ariya/phantomjs){target=_blank} says development is suspended. If one of them is already running in your app, it's not an emergency. For anything new, I'd start with PuppeteerSharp or an API.

## Frequently Asked Questions

#### Can I Convert a Local HTML File to an Image in C#?

Yes. Read it with `File.ReadAllTextAsync("shipment.html")` and pass the string to `SetContentAsync()` or the request's `Html` property. Relative paths won't resolve from a string, so inline your assets or use absolute URLs. Locally, you can instead navigate PuppeteerSharp to a `file:` URL so relative paths work. That won't help with the API, since it can't read your disk.

#### Can I Render a Razor View or Razor Component?

Yes, once it has been rendered to HTML. PuppeteerSharp and HCTI both take the finished markup, not the `.cshtml` or `.razor` source. You can also point PuppeteerSharp at a route in your running app. If you render a URL remotely, everything that page loads needs to be reachable from the internet.

For more on the HCTI side, see [HTML to PNG in C#](https://htmlcsstoimage.com/features/html-to-png/csharp){target=_blank}, the [C# and .NET API examples](https://docs.htmlcsstoimage.com/example-code/c/){target=_blank} and the [typed .NET client](https://github.com/htmlcsstoimage/dotnet-client){target=_blank}.
