Generate PDF from HTML: Node and Python in 2026
The code to generate a PDF from HTML is four lines. The reason your first PDF comes out with white boxes where your colors should be takes a bit longer.
To generate a PDF from HTML you render the page in a real browser engine and print it. In Node, that's Puppeteer or Playwright driving headless Chromium; in Python, it's WeasyPrint, a pure-Python engine with no browser but also no JavaScript. Pick Chromium when your page needs JS to render; pick WeasyPrint for static HTML and CSS when you'd rather not run a browser. Or POST the HTML to a hosted API and skip the whole question.
I build propzapi, a hosted image and PDF API, so the examples lean on it at the end. But the Node and Python code here is the real thing you'd run yourself, and I'll flag when self-hosting is the right call.
How do you generate a PDF from HTML in Node?
Launch headless Chromium with Puppeteer, set your HTML, wait for resources, and call page.pdf(). Playwright works the same way. The one thing that trips up every first attempt: printBackground defaults to false, per the Puppeteer PDFOptions docs, so your backgrounds and colors are dropped unless you set it true. Set the format, the margins, and printBackground, and you have a real PDF.
Here's the whole thing, with the two waits that matter.
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" }); // let CSS + images load
await page.evaluateHandle("document.fonts.ready"); // let web fonts load
await page.emulateMediaType("screen"); // keep your screen styles
const pdf = await page.pdf({
format: "A4",
printBackground: true, // without this, Chromium strips your backgrounds and colors
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
});
await browser.close(); The networkidle0 wait lets external CSS and images finish loading before the print, which matters because setting HTML on a dynamic page can otherwise skip CSS resources. The fonts wait stops Chromium from printing before your web font is ready. Puppeteer now waits for fonts by default, but the wait can miss on a throttled tab, so the explicit document.fonts.ready is cheap insurance.
Playwright is nearly identical, with one hard limit worth knowing. Its page.pdf() only works in headless Chromium. The docs are blunt: "PDF generation is only supported for Headless Chromium", so Firefox and WebKit are out.
Puppeteer or Playwright for PDFs?
For PDF specifically it's close to a coin flip, because both drive the same headless Chromium and expose the same page.pdf() options, so your output looks identical either way. Puppeteer has the deeper PDF-focused community and more of the gotcha fixes you'll search for. Playwright's strengths (better auto-waiting, cleaner multi-page handling, real cross-browser support) apply to everything except the PDF call, which stays Chromium-only. Use whichever your project already has.
Under the hood, the bytes come out of the same Chrome print path, so the PDF is identical. What differs is everything around it.
Puppeteer is the older and more PDF-centric of the two, which mostly means the Stack Overflow answer for whatever breaks was probably written against Puppeteer. Playwright wins on the rest of the automation story, with stronger automatic waiting and a nicer API when you're driving several pages at once. Just don't switch to Playwright for the PDF, since page.pdf() won't run in its Firefox or WebKit engines anyway.
So decide on the rest of your stack. If a repo already has one of them installed for tests, that's your answer, and adding PDF generation is a few lines either way.
Why is my Puppeteer PDF missing backgrounds or colors?
Because Chromium prints with print media by default, not screen media. Playwright's docs say it plainly: page.pdf() "generates a pdf with modified colors for printing." So any @media screen styling and most background fills drop out. The fix is two calls: printBackground: true in the pdf options, and page.emulateMediaType('screen') before you print, so Chromium keeps the styles you actually designed.
This is the single most common "why does my PDF look broken" complaint, and it shows up all over the Puppeteer tracker: "unable to set background colour on PDF", "the background is incomplete", and more.
There's a CSS half to the fix too. Even with printBackground on, browsers can strip colors they think are wasteful ink. Adding print-color-adjust: exact tells the browser your colors are intentional; per MDN, it signals the content "should not be changed" except at the user's request.
One edge case to know: even with everything set, a background image larger than the page can still drop out on some renders. It's a known Chromium quirk, not something you did wrong.
How do you generate a PDF from HTML in Python without a browser?
Use WeasyPrint. It's a pure-Python engine that turns HTML and CSS into a PDF with no browser and no JavaScript engine. Its own docs describe it as "not based on a full rendering engine like WebKit or Gecko," with a CSS layout engine written in Python for pagination. You call HTML(string=html).write_pdf("out.pdf") and you get a PDF. The one hard limit: it runs zero JavaScript.
For a huge share of PDF jobs, that limit doesn't matter. Invoices, receipts, statements, certificates: those are static HTML and CSS, and WeasyPrint renders them cleanly and fast, with none of the browser-management overhead.
from weasyprint import HTML
# pure Python, no browser to run — but no JavaScript either
HTML(string=html, base_url="https://example.com").write_pdf("invoice.pdf")
# call write_pdf() with no filename to get the PDF bytes back in memory WeasyPrint is actively maintained with regular releases, and it installs into a Lambda without a 100MB browser. Pass a base_url so relative image and CSS paths resolve.
The line where WeasyPrint stops is JavaScript. If your PDF needs a chart drawn by Chart.js, a table built by a framework at runtime, or anything the page assembles after load, WeasyPrint sees an empty container. That's the moment you need Chromium, either self-hosted with Playwright/pyppeteer or through a hosted API.
Is wkhtmltopdf (and pdfkit) still safe to use?
No. Skip it for new work. The wkhtmltopdf repository was archived in January 2023, its last release was 0.12.6 back in 2020, and it runs on a frozen Qt WebKit fork with no modern CSS. Worse, it carries CVE-2022-35583, an unpatched critical SSRF flaw scored 9.8, that upstream declined to fix. The popular Python pdfkit library is a thin wrapper around it, so it inherits every one of these problems.
You'll still find wkhtmltopdf recommended in old tutorials and even some 2026 roundups, usually with no mention of the CVE. Don't build new pipelines on it.
If you're on wkhtmltopdf today, WeasyPrint is the closest drop-in for static documents, and a Chromium-based renderer covers anything that needs JavaScript or the newest CSS.
How do you set page size, margins, and page breaks?
Set the page box in CSS with @page, not by hard-coding a body width. @page { size: A4; margin: 20mm } gives you an A4 page with even margins; A4 is 794×1123px and US Letter is 816×1056px at 96dpi. Control breaks with break-before: page and break-inside: avoid, but test them, because Chromium honors break-inside unevenly, especially across table rows.
Sizing through @page instead of a fixed body width matters. If you pin width: 794px on the body, it fights the page box and the whole document scales oddly. Let the page rule own the size.
@page { size: A4; margin: 20mm; } /* A4 is 794 x 1123px at 96dpi */
.keep-together { break-inside: avoid; } /* Chromium honors this unevenly */
thead { display: table-header-group; } /* repeat table headers on every page */
.brand { print-color-adjust: exact; } /* force the brand colour to actually print */ Page breaks are the genuinely hard part. Chromium's support for break-inside: avoid is uneven, a long-standing open issue that bites hardest inside tables. Two habits help: keep the blocks you don't want split small, and repeat table headers on each page with thead { display: table-header-group }. For a 40-page contract with perfect pagination, a dedicated paged-media engine like Prince is still stronger than a browser.
Page numbers and running headers are their own thing. Puppeteer and Playwright both take a displayHeaderFooter option with header and footer templates, and the templates support built-in classes for the current page and total page count, so you can print "Page 3 of 12" on every page. The catch is that those templates render at a tiny default size and ignore your document's CSS, so they need their own inline styles. It's fiddly, but it's the only way to get real running footers out of Chromium.
How do you run Puppeteer PDF generation on AWS Lambda?
Drop the full puppeteer package and use puppeteer-core plus @sparticuz/chromium. The full package bundles a Chromium of roughly 170MB, which blows past AWS Lambda's 250MB unzipped limit. The @sparticuz/chromium build is 130.62 MiB uncompressed but ships Brotli-compressed at about 33 MiB and unpacks to /tmp at cold start, which fits.
This is the tax nobody warns you about until the deploy fails. Once you're on the slimmed build, give the function at least 512MB of memory and expect a multi-second cold start while Chromium unpacks and launches.
WeasyPrint sidesteps this entirely. With no browser to ship, it fits in a Lambda with room to spare, which is a real point in its favor if your documents don't need JavaScript.
Should you self-host or use an HTML to PDF API?
Self-host when PDF generation is core to your product and the volume is high and steady enough to justify running a browser fleet. Use a hosted API when PDFs are a feature, not the business, and you'd rather not patch Chromium every two weeks, reap zombie processes, and fight the Lambda size limit. Below serious volume, the API is usually cheaper once you price your own time.
Here's the trade-off, laid out across the options.
| Approach | Engine | JavaScript | CSS fidelity | Serverless | Upkeep |
|---|---|---|---|---|---|
| Puppeteer / Playwright (Node) | Headless Chromium | Yes | Highest | Hard (250MB) | You patch the browser |
| WeasyPrint (Python) | Pure Python, no browser | No | Good print CSS | Easy | Light, but no JS |
| wkhtmltopdf / pdfkit | Qt WebKit (~2012) | Limited | Low, frozen | Risky | Abandoned, 9.8 CVE |
| Hosted HTML to PDF API | Vendor Chromium | Yes | Highest | N/A (a call) | Vendor patches it |
With propzapi, generating a PDF is a template plus your data and one line different from an image: set format to pdf. It renders in headless Chromium and, importantly, emulates screen media by default, so the backgrounds gotcha above doesn't bite.
curl https://api.propzapi.com/v1/images \
-H "X-API-Key: pk_live_…" -H "Content-Type: application/json" \
-d '{"template":"invoice","format":"pdf",
"modifications":{"number":"1042","total":"$4,200.00"}}'
# → { "url": "https://images.propzapi.com/img_2ef7….pdf", "format": "pdf", "bytes": 6679 }
# X-Credits-Cost: 1 (a failed render costs 0) A failed render costs nothing, the exact charge comes back in a header, and there's no browser for you to run. The full option list is on the HTML to PDF API page, with a 50-render free trial and no card. Where it isn't the right tool: long multi-page documents that need running headers and footnotes across dozens of pages, which is paged-media work for an engine like Prince.
Frequently asked questions
- Why is my Puppeteer PDF missing backgrounds or colors?
- Two reasons, usually together. Puppeteer's printBackground option defaults to false, so background graphics and colors get dropped unless you set it true. And Chromium renders the PDF with print media by default, so screen-only styles vanish. Set printBackground: true and call page.emulateMediaType('screen') before page.pdf(), and add print-color-adjust: exact in your CSS.
- How do I add page breaks when converting HTML to PDF?
- Use CSS: break-before: page to force a new page, and break-inside: avoid to keep a block together. Fair warning: Chromium honors break-inside: avoid unevenly, especially inside tables, which is a long-standing open issue. Keep the blocks you don't want split small, and repeat table headers with thead { display: table-header-group }.
- WeasyPrint or Puppeteer — which should I use?
- If your page needs JavaScript to render, like a charting library or a JS-built table, you need Puppeteer or Playwright, because they run real Chromium. If it's static HTML and CSS and you already work in Python, WeasyPrint is lighter, has no browser to manage, and installs cleanly into a Lambda. WeasyPrint's catch is that it runs no JavaScript at all.
- Is wkhtmltopdf still safe to use?
- No. The wkhtmltopdf repository was archived in January 2023, the last release was 0.12.6 in 2020, and it runs on an end-of-life Qt WebKit fork with no CSS updates coming. It also carries CVE-2022-35583, an unpatched critical (CVSS 9.8) SSRF flaw that upstream declined to fix. Treat it, and the pdfkit wrapper around it, as abandonware.
- How do I run Puppeteer PDF generation on AWS Lambda?
- Drop the full puppeteer package, whose bundled Chromium (~170MB) blows past Lambda's 250MB unzipped limit, and use puppeteer-core plus @sparticuz/chromium. Its Chromium is 130.62 MiB uncompressed but ships Brotli-compressed at about 33 MiB and unpacks to /tmp at cold start. Give the function at least 512MB of memory.
- Why are my custom fonts not loading in the generated PDF?
- The PDF fires before the web font finishes downloading, so Chromium falls back to a system font. Wait for the fonts explicitly with await page.evaluateHandle('document.fonts.ready') before you call page.pdf(). Newer Puppeteer versions wait for fonts by default, but the wait can miss on backgrounded or throttled pages, so keep the explicit call.
Pick the engine that fits your page
If your HTML needs JavaScript, generate the PDF with Puppeteer or Playwright and set printBackground plus emulateMediaType('screen'). If it's static, reach for WeasyPrint in Python and skip the browser. Either way, avoid wkhtmltopdf for new work. And if you'd rather not run Chromium at all, POST your HTML to an API: grab a free key and render your first PDF with one call.