To convert HTML to an image in C#, drive headless Chromium with PuppeteerSharp or Microsoft.Playwright: load the markup with SetContentAsync(html) and call ScreenshotAsync on the page or on one element. Both give you Chrome's real rendering — grid, gradients, web fonts — in about fifteen lines. The work is in deployment: Chromium is a few hundred megabytes with its own system dependencies and sandbox rules, which bites in containers. If you would rather not ship a browser, an HTML to image API takes the same job with one HttpClient call.
Every sample below was compiled and run on .NET 10: PuppeteerSharp 25.15.0 and Microsoft.Playwright 1.64.1 inside Microsoft's Playwright Noble image, the API call against a live server.
The HTML
// Card.cs
static class Card
{
public const string Html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; }
.card {
width: 1200px; height: 630px; box-sizing: border-box;
display: grid; align-content: center; gap: 16px; padding: 80px;
background: linear-gradient(135deg, #0f172a, #1e3a8a);
color: #f8fafc; font: 700 64px/1.15 system-ui, sans-serif;
}
.card small { font-size: 28px; font-weight: 400; color: #93c5fd; }
</style>
</head>
<body>
<div class="card">
<small>Release notes · v2.4</small>
HTML to image in C#, rendered by Chromium
</div>
</body>
</html>
""";
}
PuppeteerSharp
dotnet add package PuppeteerSharp
using PuppeteerSharp;
// Downloads a matching Chrome on first run, into the app's directory.
await new BrowserFetcher().DownloadAsync();
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
// Docker's default seccomp profile blocks Chrome's sandbox. Drop it only
// for HTML you wrote yourself; for anyone else's HTML, fix the sandbox.
Args = new[] { "--no-sandbox" },
});
await using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 630, DeviceScaleFactor = 2 });
await page.SetContentAsync(Card.Html, new SetContentOptions
{
WaitUntil = new[] { WaitUntilNavigation.Load },
Timeout = 15_000,
});
await page.EvaluateExpressionAsync("document.fonts.ready");
var card = await page.QuerySelectorAsync(".card");
await card.ScreenshotAsync("card.png"); // 2400×1260, the element only
byte[] jpeg = await page.ScreenshotDataAsync(new ScreenshotOptions
{
Type = ScreenshotType.Jpeg,
Quality = 85,
FullPage = true,
});
Two version notes. In PuppeteerSharp 25 the overload SetContentAsync(string, NavigationOptions) is obsolete — the compiler warns that "the networkidle0 and networkidle2 wait conditions never worked reliably with SetContent" — so use SetContentOptions as above. And BrowserFetcher saves the browser next to your binaries: on our first run it wrote about 670 MB (Chrome plus chrome-headless-shell) into the output directory and took about a minute. Download it at image build time, not on the first request. The project README shows the same fetch-launch-screenshot sequence.
Microsoft.Playwright
dotnet add package Microsoft.Playwright
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium # match your target framework
using Microsoft.Playwright;
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync(new()
{
ViewportSize = new() { Width = 1200, Height = 630 },
DeviceScaleFactor = 2,
});
await page.SetContentAsync(Card.Html, new() { WaitUntil = WaitUntilState.Load });
await page.Locator(".card").ScreenshotAsync(new() { Path = "card.png" });
byte[] png = await page.ScreenshotAsync(new()
{
Clip = new() { X = 0, Y = 0, Width = 600, Height = 315 },
OmitBackground = true, // transparent where the page paints nothing
});
Playwright needs no --no-sandbox, because its chromiumSandbox launch option defaults to false — which also means Chromium runs unsandboxed unless you turn it on. The browsers live outside your build output (playwright.ps1 install, see the .NET install docs).
Chromium in containers
PuppeteerSharp failed twice for us before it worked, in ways you will meet in production:
- Running as root. PuppeteerSharp in a container as root: "Running as root without --no-sandbox is not supported."
- No usable sandbox. As a normal user, Docker's default seccomp profile blocked Chrome's sandbox ("No usable sandbox!"). Microsoft's Playwright Docker guidance: for trusted pages root is acceptable; for pages you do not control, run as a separate user with a seccomp profile that enables the sandbox.
- Libraries and fonts. Chromium needs dozens of shared libraries and real fonts, including emoji and CJK if your text has them. Microsoft publishes
mcr.microsoft.com/playwright/dotnet:v1.64.1-noblewith everything installed; pin the tag to your package version, or Playwright cannot find its browser. Alpine is not supported (musl, not glibc). - Flags. The same page recommends
--ipc=host(Chromium can run out of shared memory) and--init(zombie processes).
Then the usual browser operations: keep one browser alive and open a page per render, cap concurrent pages, time out every render, restart the browser now and then. If users supply the HTML, block outbound requests (page.RouteAsync in Playwright, request interception in PuppeteerSharp) so the markup cannot reach internal addresses.
The same image from an API with HttpClient
An HTML to image API removes Chromium from your deployment. With Shareglint you save the HTML and CSS once as a template with named fields, then each image is one request with the values:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using var http = new HttpClient
{
BaseAddress = new Uri("https://shareglint.com/v1/"), // trailing slash matters
Timeout = TimeSpan.FromSeconds(60),
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("IMAGE_API_KEY"));
// 1. Save the template once (a deploy step or a migration).
var created = await http.PostAsJsonAsync("templates", new
{
name = "Article card",
slug = "article-card",
width = 1200,
height = 630,
fields = new[] { "kicker", "title", "author" },
html = """<div class="card"><p class="kicker">{{ kicker }}</p><div class="fitbox"><h1 class="fit" data-autofit>{{ title }}</h1></div><p class="by">{{ author }}</p></div>""",
css = ".card{height:100%;padding:72px;display:grid;grid-template-rows:auto 1fr auto;gap:24px;background:#0f172a;color:#f8fafc;font-family:Roboto,sans-serif} .kicker{color:#fbbf24;font-size:26px;text-transform:uppercase} h1{font-size:76px;line-height:1.08} .by{font-size:28px;color:#94a3b8}",
});
created.EnsureSuccessStatusCode();
// 2. One request per image.
var response = await http.PostAsJsonAsync("images?wait=true", new
{
template = "article-card",
format = "png",
modifications = new
{
kicker = "Engineering",
title = "How we cut our build time in half",
author = "Ada Lovelace",
},
});
response.EnsureSuccessStatusCode();
var render = await response.Content.ReadFromJsonAsync<Render>();
Console.WriteLine(render!.Url);
record Render(string Id, string Status, string? Url);
ReadFromJsonAsync uses System.Text.Json with web defaults, so the camel-case JSON fields bind to the Render record without attributes. In ASP.NET Core, register the client with IHttpClientFactory instead of creating it per call.
How the API behaves: step 1 runs once — a second run answers 422 because the slug is taken (update with PATCH /v1/templates/{id}), and built-in templates such as blog-og need no step 1. The same fields again return the existing file and use no quota. A 503 with an id means the synchronous wait ran out; the render continues and GET /v1/images/{id} collects it. The sandbox has a price: no scripts in templates, no external URLs in the HTML or CSS (images arrive through image fields) and fonts only from the installed list — Playwright or PuppeteerSharp have none of those limits. Free accounts get 100 images a month with a small watermark; Starter is $9 a month for 2,000 (pricing).
Which one to choose
- Microsoft.Playwright if you already ship browsers (end-to-end tests, a worker service) — official images make containers simplest.
- PuppeteerSharp if you want the browser download in code and Puppeteer's API.
- An API for Azure, serverless or slim containers where a few hundred megabytes of Chromium and its sandbox are not worth it.
The same task in Python, JavaScript and PHP, and which npm package renders what.