DEV Community

Cover image for Turn LLM-Generated HTML into Images Using PuppeteerSharp with .NET
David Au Yeung
David Au Yeung

Posted on

Turn LLM-Generated HTML into Images Using PuppeteerSharp with .NET

Introduction

Large-language models (LLMs) love to "draw" with HTML and CSS.
That is wonderfully flexible which you can restyle, localize, or animate the markup. However, most chats, slide decks, and PDF reports still expect PNG/JPEG images.

How can we turn raw, LLM-generated HTML into real images with nothing but .NET?

This article walks through a lightweight .NET 8 console tool that converts HTML (file, URL, or stdin) into screenshots using PuppeteerSharp.
You'll get auto-sizing, full-page capture, high-DPR rendering, and clean logs. It is ready for batch jobs or CI pipelines.

Building the Converter with .NET & PuppeteerSharp

Key Features

  • Auto width/height: no hard-coded viewports.
  • Full-page or viewport-only capture.
  • Device-scale factor (DPR) for Retina-quality text.
  • Opinionated I/O folders (Input/ and Output/) but fully overrideable.
  • Timeouts and timestamped logs so you know what's happening.
  • Chromium auto-download on first run: no global installs.

Project Structure

Html2Image/
├─ Input/                ← put your HTML files here
├─ Output/               ← screenshots appear here
├─ Program.cs            ← CLI + argument parsing
└─ Utils/
   └─ HTMLConverter.cs   ← PuppeteerSharp orchestration
Enter fullscreen mode Exit fullscreen mode

Step 1: Create the Console Project

dotnet new console -n Html2Image
cd Html2Image
dotnet add package PuppeteerSharp --version 20.*
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement the CLI (Program.cs)

using Html2Image.Utils;
using System.Globalization;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var cfg = ParseArgs(args);

        // ----- defaults -----
        var root   = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
        var inDir  = Get(cfg, "input-dir",  Path.Combine(root, "Input"));
        var outDir = Get(cfg, "output-dir", Path.Combine(root, "Output"));

        var input   = Get(cfg, "Input",  "triangle.html");
        var output  = Get(cfg, "Output", "output.jpg");
        var width   = GetInt(cfg, "width");
        var height  = GetInt(cfg, "height");
        var dpr     = double.Parse(Get(cfg, "dpr", "2"), CultureInfo.InvariantCulture);
        var quality = int.Parse(Get(cfg, "quality", "85"), CultureInfo.InvariantCulture);
        var full    = GetBool(cfg, "fullpage", true);
        var delay   = int.Parse(Get(cfg, "delay", "500"), CultureInfo.InvariantCulture);
        var stdin   = GetBool(cfg, "from-stdin", false);

        Log("=== Html2Image ===");
        Log($"Input   : {input}");
        Log($"Output  : {output}");
        Log($"Viewport: {width ?? 0}×{height ?? 0} @DPR {dpr}");
        Log($"FullPage: {full}, Delay: {delay} ms, FromStdin: {stdin}");

        // Resolve paths …
        // (omitted for brevity – see full source below)

        await HTMLConverter.ConvertHtmlToJpegAsync(
            input, output, width, height, dpr, quality, full, delay, stdin);

        return 0;
    }

    // -------- helpers --------
    // … ParseArgs / Get / GetBool / GetInt / Log …
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement the Converter (Utils/HTMLConverter.cs)

using System.Diagnostics;
using PuppeteerSharp;

namespace Html2Image.Utils;

public static class HTMLConverter
{
    private const int MAX_DIM = 16_384;   // Chrome hard-limit

    public static async Task ConvertHtmlToJpegAsync(
        string input,
        string output,
        int?   width,
        int?   height,
        double deviceScaleFactor,
        int    quality,
        bool   fullPage,
        int    delayMs,
        bool   fromStdin)
    {
        var sw = Stopwatch.StartNew();
        Log("Starting converter…");

        // 1) Ensure Chromium
        var fetcher = new BrowserFetcher();
        await fetcher.DownloadAsync();
        var exe = fetcher.GetInstalledBrowsers().Last().GetExecutablePath();

        // 2) Launch headless Chrome
        var browser = await Puppeteer.LaunchAsync(new LaunchOptions
        {
            ExecutablePath = exe,
            Headless       = true,
            Args           = new[]
            {
                "--disable-gpu",
                "--no-sandbox",
                "--hide-scrollbars",
                "--allow-file-access-from-files"
            }
        });
        var page = await browser.NewPageAsync();

        // 3) Initial viewport
        int vw = width  ?? 1200;
        int vh = height ?? 800;

        await page.SetViewportAsync(new ViewPortOptions
        {
            Width  = vw,
            Height = vh,
            DeviceScaleFactor = deviceScaleFactor
        });

        // 4) Load content
        if (fromStdin)
        {
            var html = await Console.In.ReadToEndAsync();
            await page.SetContentAsync(html);
        }
        else if (input.StartsWith("http", StringComparison.OrdinalIgnoreCase))
        {
            await page.GoToAsync(input, WaitUntilNavigation.Networkidle0);
        }
        else
        {
            var uri = new Uri(Path.GetFullPath(input)).AbsoluteUri;
            await page.GoToAsync(uri, WaitUntilNavigation.Networkidle0);
        }

        // 5) Optional delay
        if (delayMs > 0) await Task.Delay(delayMs);

        // 6) Auto-size if needed
        if (width == null || height == null)
        {
            var size = await page.EvaluateFunctionAsync<dynamic>(@"
                () => ({ w: document.documentElement.scrollWidth,
                           h: document.documentElement.scrollHeight })");
            vw = width  ?? Math.Min((int)size.w, MAX_DIM);
            vh = height ?? Math.Min((int)size.h, MAX_DIM);

            await page.SetViewportAsync(new ViewPortOptions
            {
                Width  = vw,
                Height = vh,
                DeviceScaleFactor = deviceScaleFactor
            });
        }

        // 7) Screenshot
        Directory.CreateDirectory(Path.GetDirectoryName(output)!);
        await page.ScreenshotAsync(output, new ScreenshotOptions
        {
            Type      = ScreenshotType.Jpeg,
            Quality   = quality,
            FullPage  = fullPage
        });
        Log($"Saved ➜ {Path.GetFullPath(output)}  ({sw.Elapsed.TotalSeconds:F1}s)");

        await browser.CloseAsync();
    }

    private static void Log(string msg) =>
        Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {msg}");
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Run the Tool

# Full-page, auto-size
dotnet run -- --Input triangle.html --Output triangle.jpg --fullpage true

# Mobile viewport only (375px wide, DPR 3)
dotnet run -- --Input triangle.html \
            --Output triangle-mobile.jpg \
            --width 375 --dpr 3 --fullpage false

# Remote URL
dotnet run -- --Input https://example.com --Output site.jpg --width 1440 --dpr 2

# Pipe HTML from stdin
type Input\triangle.html | dotnet run -- --from-stdin true --Output tri-stdin.jpg
Enter fullscreen mode Exit fullscreen mode

Default Folders
Input : /Input
Output : /Output

Result

The command

dotnet run -- --Input triangle.html --Output triangle.jpg --fullpage true
Enter fullscreen mode Exit fullscreen mode

output.jpg

where triangle.html

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>三角形內角和:示意題目</title>
  <style>
    :root {
      color-scheme: light dark;
      --bg: #ffffff;
      --fg: #222222;
      --muted: #666666;
      --accent: #2563eb;
      --card: #f7f7fb;
      --stroke: #111111;
    }
    @media (prefers-color-scheme: dark) {
      :root {
        --bg: #0b0e14;
        --fg: #e6e6e6;
        --muted: #9aa4b2;
        --accent: #60a5fa;
        --card: #111827;
        --stroke: #e5e7eb;
      }
    }
    html, body {
      margin: 0;
      background: var(--bg);
      color: var(--fg);
      font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans TC", Roboto, "PingFang TC", "Microsoft JhengHei", Arial, sans-serif;
      line-height: 1.6;
    }
    .wrap {
      max-width: 720px;
      padding: 18px;
      margin: 0 auto;
    }
    .card {
      background: var(--card);
      border-radius: 14px;
      padding: 16px;
      box-shadow: 0 6px 20px rgba(0,0,0,0.08);
    }
    h1 {
      font-size: clamp(20px, 3.6vw, 28px);
      margin: 0 0 10px;
      line-height: 1.3;
    }
    p, li {
      font-size: clamp(14px, 2.6vw, 16px);
      margin: 6px 0;
    }
    .figure {
      margin: 10px 0 6px;
      display: flex;
      justify-content: center;
    }
    svg {
      width: 100%;
      max-width: 520px;
      height: auto;
      display: block;
    }
    .caption {
      text-align: center;
      color: var(--muted);
      font-size: clamp(12px, 2.4vw, 14px);
      margin-top: 6px;
    }
    details {
      background: transparent;
      border: 1px dashed var(--muted);
      border-radius: 10px;
      padding: 10px 12px;
      margin-top: 10px;
    }
    summary {
      cursor: pointer;
      font-weight: 600;
      color: var(--accent);
      outline: none;
    }
    .hint {
      color: var(--muted);
      font-size: 0.95em;
    }
    .badge {
      display: inline-block;
      padding: 2px 8px;
      background: color-mix(in oklab, var(--accent) 18%, transparent);
      border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
      color: var(--accent);
      border-radius: 999px;
      font-size: 0.85em;
      margin-left: 6px;
      vertical-align: middle;
    }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="card">
      <h1>三角形內角和題目 <span class="badge">示意圖</span></h1>
      <p><strong>題目:</strong>在三角形 ABC 中,已知 ∠A = 52°,∠B = 63°,求 ∠C 的度數。</p>

      <div class="figure" aria-label="一個標示 A、B、C 的三角形,A 角標為 52°,B 角標為 63°,C 角以 x° 表示。">
        <!-- 自適應 SVG 圖(無外部資源) -->
        <svg viewBox="0 0 320 250" role="img">
          <!-- 邊 -->
          <g fill="none" stroke="var(--stroke)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
            <!-- 三角形邊 AB, BC, CA -->
            <path d="M 36 210 L 284 210 L 120 34 Z" />
          </g>

          <!-- 頂點標籤 -->
          <g font-family="inherit" font-size="14" fill="var(--fg)">
            <text x="28" y="222">A</text>
            <text x="286" y="222">B</text>
            <text x="112" y="28">C</text>
          </g>

          <!-- 角度文字標示 -->
          <g font-family="inherit" fill="var(--accent)" font-weight="600">
            <text x="56" y="202">52°</text>
            <text x="252" y="202">63°</text>
            <text x="128" y="56">x°</text>
          </g>

          <!-- 裝飾用的簡單角弧(不依賴外部計算,近似展示) -->
          <g fill="none" stroke="var(--accent)" stroke-width="2">
            <!-- 在 A 附近的小弧 -->
            <path d="M 60 206 A 18 18 0 0 1 76 206" />
            <!-- 在 B 附近的小弧 -->
            <path d="M 262 206 A 16 16 0 0 0 276 206" />
            <!-- 在 C 附近的小弧 -->
            <path d="M 128 58 A 18 18 0 0 1 114 50" />
          </g>
        </svg>
      </div>
      <div class="caption">圖:三角形 ABC,兩個角的度數已知,第三個角以 x° 表示。</div>

      <p class="hint">提示:三角形內角和為 180°。</p>

      <details>
        <summary>顯示解答</summary>
        <p>由三角形內角和可知:∠A + ∠B + ∠C = 180°。</p>
        <p>帶入已知:52° + 63° + x° = 180° → x° = 180° − 115° = <strong>65°</strong>。</p>
      </details>
    </div>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

How the Conversion Works

1.Chromium Auto-Download
PuppeteerSharp fetches a compatible headless Chromium on first run.

2.Viewport Setup
The tool starts with a safe default viewport, then measures the page to compute the ideal width/height when “auto” is requested.

3.High-DPI Rendering
By setting deviceScaleFactor, screenshots look sharp on Retina/4K displays.

4.Full-Page vs. Viewport Capture
The fullPage flag toggles between scrolling screenshots and fixed-viewport images.

5.Safe Limits
Width/height are clamped to 16,384px, the largest Chrome will allow without crashes.

Best Practices & Troubleshooting

1.Quality vs. File Size
--quality 80-90 is a good JPEG sweet spot. Use PNG when transparency is required.

2.First Run Seems Slow
Chromium download can take ~1 min. Subsequent runs launch instantly.

3.Timeouts
All await calls use generous but finite WaitAsync limits, tweak as needed for heavy pages.

4.Very Long Pages
For pages taller than 16,384 px consider splitting content or using PDF output.

Real-World Applications

  • Chatbots: Convert HTML charts or cards into inline images for Slack/Teams.
  • Documentation Generators: Produce snapshots of interactive demos.
  • CI Pipelines: Visual regression tests by comparing screenshots.
  • Teaching: Turn math-heavy HTML answers (like MathJax) into slides.

Next Steps

  • PNG & WebP output options
  • PDF rendering for multi-page reports
  • Batch mode: process an entire folder in one command

Conclusion

When your LLM outputs HTML instead of pixels, this tiny console app bridges the gap: no external tools, no manual steps, just .NET + PuppeteerSharp. Drop it into your tool-box and keep your AI workflows fully automated and cross-platform.

Love C# & AI!

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.