PowerShell has no HTML renderer of its own, so a script that turns HTML into a PDF either drives a browser that's already installed (Chrome or Edge, headless) or sends the HTML to an API. We ran both in PowerShell 7.6 with Chrome 155, on the same invoice, and measured what each default got wrong. Every script below was run exactly as shown.
| Option | Pick it when |
|---|---|
| Headless Chrome or Edge --print-to-pdf, nothing to install on Windows | The machine already has Chrome or Edge and you convert local files now and then. |
| An API Invoke-RestMethod, no browser | The script runs on a server, in a scheduled task or in Azure Automation, where you'd rather not install and patch a browser. |
Skip these: wkhtmltopdf (archived, no flexbox or grid: wkhtmltopdf alternative) and Word's COM object (Word.Application opening the HTML and saving as PDF), which needs Office installed, isn't supported in unattended server use by Microsoft, and lays out HTML like Word, not like a browser.
Save this as invoice.html next to your script. It asks for A4 in CSS and has a dark header bar:
<!doctype html>
<html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 20mm 15mm; }
body { font-family: sans-serif; }
.top { display: flex; justify-content: space-between; align-items: center;
background: #1e3a8a; color: #fff; padding: 12px; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
</style></head><body>
<div class="top"><h1>Invoice #1042</h1><p>Due: 2026-11-01</p></div>
<table><tr><th>Item</th><th>Price</th></tr>
<tr><td>Consulting (3h)</td><td>€450.00</td></tr>
<tr><td>Hosting, Oct.</td><td>€19.00</td></tr></table>
</body></html>
The one-liner most answers give is & $chrome --headless --print-to-pdf=invoice.pdf invoice.html. We ran it, and the PDF came out on A4 with the header bar, but with two lines we didn't ask for: the date and time (10/23/26, 9:31 AM) at the top, and the full local file path (file:///home/.../invoice.html) plus 1/1 at the bottom. Chrome prints its own header and footer unless you pass --no-pdf-header-footer; for a PDF you send to a customer, that leaks a path from your disk. This version fixes that and the other problems below:
# Chrome or Edge, already installed on most Windows machines
$chrome = "C:\Program Files\Google\Chrome\Application\chrome.exe"
# Edge: "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
if ($env:CHROME) { $chrome = $env:CHROME }
$in = (Resolve-Path "invoice.html").Path
$out = Join-Path (Get-Location) "invoice.pdf"
# file:///C:/... with spaces escaped (a plain [Uri] cast gives $null on Linux/macOS)
$uri = [System.Uri]::new($in, [System.UriKind]::Absolute).AbsoluteUri
$flags = @(
"--headless",
"--no-pdf-header-footer", # no date, title, file path, page number
"--virtual-time-budget=5000", # let scripts run before printing
"--print-to-pdf=`"$out`"",
$uri
)
if ($IsLinux) { $flags += "--no-sandbox" }
# Start-Process -Wait: on Windows, & $chrome returns before the PDF exists
Start-Process -FilePath $chrome -ArgumentList $flags -Wait -NoNewWindow
Write-Host "saved $out ($((Get-Item $out).Length) bytes)"
Result: a 1-page A4 PDF with the blue header bar and no date or path, in 2.9 s including Chrome's start. To convert a live page, set $uri to its URL. Our Chrome 155 kept background colors and the CSS page size from the command line, so there are no flags for those (unlike the DevTools print call, where both are off by default).
Why each line is there:
[System.Uri]::new($in, [System.UriKind]::Absolute), not the shorter ([System.Uri]$in).AbsoluteUri. On Linux and macOS the cast reads /home/... as a relative URI and .AbsoluteUri quietly returns nothing. Chrome then got no page to load and printed its New Tab page (US Letter, "Search Google or type a URL") as invoice.pdf, with no error and exit code 0. On Windows both forms give file:///C:/..., with spaces escaped as %20.--virtual-time-budget=5000. Chrome prints as soon as the page has loaded. A total filled in by a script 0.8 s later printed as "Total: pending" without the flag and as "Total: €469.00" with it. The budget is virtual time: Chrome fast-forwards timers, so it doesn't add 5 s to every run.Start-Process -Wait rather than & $chrome. On Windows, PowerShell doesn't wait for GUI programs such as chrome.exe started with &, so the next line can run before the PDF exists. (We ran the scripts on Linux, where & does wait; Start-Process -Wait behaves the same on both.)--no-sandbox on Linux only. Chrome for Testing on Ubuntu 23.10+ and Chrome running as root (Docker) won't start without it. Only render HTML you trust when the sandbox is off.Shotmint renders in the same Chrome on its servers, with backgrounds on, A4 by default and no header or footer unless you ask for one. Get a free key (25 renders a month, no signup) and put it in $env:SHOTMINT_KEY:
$key = $env:SHOTMINT_KEY
# -Raw reads one string; without it you get an array of lines
$html = Get-Content "invoice.html" -Raw -Encoding UTF8
$json = @{
html = $html # or: url = "https://example.com"
prefer_css_page_size = $true # use @page size/margins from the CSS
page_numbers = $true # "1 / 3" footer
} | ConvertTo-Json
# Send bytes: Windows PowerShell 5.1 doesn't send a string body as UTF-8
$body = [System.Text.Encoding]::UTF8.GetBytes($json)
try {
Invoke-RestMethod -Method Post -Uri "https://shotmint.creasework.com/v1/pdf" `
-Headers @{ "X-API-Key" = $key } `
-ContentType "application/json; charset=utf-8" `
-Body $body -OutFile "invoice.pdf" -TimeoutSec 60
Write-Host "saved invoice.pdf ($((Get-Item invoice.pdf).Length) bytes)"
} catch {
# errors come back as JSON with a message
Write-Error "render failed: $($_.Exception.Message) $($_.ErrorDetails.Message)"
exit 1
}
Result: the same 1-page A4 invoice with the header bar, € signs intact and a "1/1" footer, in 0.76 s for the whole script. A wrong key ends in the catch with 401 (Unauthorized) and the JSON message Missing or invalid API key, and the script exits with code 1, so a scheduled task shows it as failed. One PDF is one credit, however many pages it has.
Get-Content -Raw. Without -Raw, Get-Content returns an array of lines and ConvertTo-Json sends "html": [ ... ]. Our API answers 400 with html must be one string, but it is an array (14 items ...); other APIs may join the lines without newlines or reject it less clearly.charset=utf-8 in the content type. Then a € sign arrives as a € sign whichever PowerShell runs the script (we ran 7.6, not Windows PowerShell 5.1, which handles string bodies differently).-Encoding UTF8 on Get-Content: Windows PowerShell 5.1 reads files without a byte-order mark in the ANSI code page, which turns € into mojibake before the request is even sent. PowerShell 7 reads UTF-8 by default, so the flag is harmless there.url instead of html, format (A3, A4, A5, Letter, Legal, Tabloid), landscape, margin (with a unit, such as "15mm"), header_template and footer_template, wait_until. See the API reference.The same pattern takes a PNG of each URL in a list, for example to archive status pages or check a set of sites after a deploy:
$key = $env:SHOTMINT_KEY
$urls = @("https://example.com", "https://en.wikipedia.org/wiki/PowerShell")
New-Item -ItemType Directory -Force -Path "shots" | Out-Null
foreach ($url in $urls) {
$q = "url=" + [System.Uri]::EscapeDataString($url) + "&full_page=true"
$file = Join-Path "shots" (($url -replace '^https?://', '' -replace '[^\w.-]', '_') + ".png")
try {
Invoke-RestMethod -Uri "https://shotmint.creasework.com/v1/screenshot?$q" `
-Headers @{ "X-API-Key" = $key } -OutFile $file -TimeoutSec 90
Write-Host "ok $url -> $file"
} catch {
Write-Warning "fail $url : $($_.ErrorDetails.Message)"
}
}
Result: shots/example.com.png (1280 × 843) and a full-page capture of the Wikipedia article on PowerShell, capped at 16,000 px tall, in 16.6 s for both. A URL that fails prints a warning with the API's message and the loop moves on. For hundreds of URLs, see bulk website screenshots.
Related: HTML to PDF in C#, page numbers and footers, headless Chrome screenshots, background colors in PDFs, free HTML to PDF converter.
One PDF or screenshot = one credit. Free keys get 25 renders a month, no signup. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons