HTML to PDF skill for Codex
Print CSS and Chromium, no surprise blank pages. Codex renders HTML to PDF the way print designers expect: it writes print CSS with @page size, break rules and repeating table headers, waits for fonts and charts before calling page.pdf, adds page numbers through margin boxes or header templates, and enables tagged output. It then checks page counts, embedded fonts, the text layer and page thumbnails, and has a lookup table for the usual rendering failures.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- html-to-pdf Installs to ~/.agents/skills/html-to-pdf/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install html-to-pdf The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project html-to-pdf Installs to .agents/skills/html-to-pdf/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/html-to-pdf.zipunzip -o html-to-pdf.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) html-to-pdf PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $html-to-pdf, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Renders HTML and CSS to PDF with headless Chromium through Playwright (Node or Python) or Puppeteer, with print CSS (@page size and margins, margin boxes, page breaks, repeating table headers), header and footer templates with page numbers, web fonts, tagged output and file-size control. Use when asked to export a web page or HTML template to PDF, generate invoices or reports from HTML in a service, fix blank pages, cut-off content, missing backgrounds or fonts in PDF output, or add page numbers.
Use it when
- An HTML or web template must become a PDF that matches the design
- PDF output has blank pages, cut-off content, wrong paper size or missing fonts
- You need page numbers, running headers or repeating table headers
- PDFs are generated server-side from HTML in a web app or CI job
Use something else when
- You want a PDF built from code without a browser engine: use pdf-generation
- The PDF must meet PDF/UA and pass a formal accessibility audit: use pdf-accessibility
How to check the result
Check the page count, run pdffonts to confirm embedded fonts, extract text with pdftotext to confirm a real text layer, and inspect page thumbnails for bad breaks and blank pages.
The mistake it prevents
Leaving format set while also defining @page size, or forgetting printBackground, so the PDF comes out on Letter paper without any of the background colours.
Done well means the PDF matches the intended print design on every page: correct paper size and margins, no orphaned headings or split rows, fonts and backgrounds present, page numbers right, text selectable (not rasterized), and the same input gives the same output in CI as on a laptop.
Engine choice
| Need | Use |
|---|---|
| Modern CSS (grid, flex, web fonts), JS-rendered content, same look as the browser | Chromium via Playwright (default) |
| Already a Puppeteer codebase | Puppeteer page.pdf() (same engine and options) |
| No browser binary allowed, strong paged-media CSS (footnotes, running heads, cross-refs) | WeasyPrint or Typst: see pdf-generation |
| One-off conversion from the shell | chromium --headless --print-to-pdf |
Playwright needs its browser binary: npx playwright install chromium (or
python -m playwright install chromium) downloads it, so it needs network and may need
approval. In Docker, use the official Playwright image or install with --with-deps.
Workflow
- [ ] 1. Read the template and how data gets into it; check AGENTS.md for the stack
- [ ] 2. Write print CSS (@page, breaks, print-only rules)
- [ ] 3. Render with explicit options and wait for fonts/images
- [ ] 4. Add header/footer and page numbers
- [ ] 5. Verify page count, breaks, fonts, text layer
2. Print CSS
@page {
size: A4; /* or Letter, or 210mm 297mm, or A4 landscape */
margin: 18mm 16mm 20mm; /* leave room for header/footer */
}
@page :first { margin-top: 30mm; }
@media print {
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
nav, .no-print, button { display: none !important; }
a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.8em; }
}
h1, h2, h3 { break-after: avoid; }
figure, tr, .card, pre { break-inside: avoid; }
.chapter { break-before: page; }
p { orphans: 3; widows: 3; }
table { border-collapse: collapse; width: 100%; }
thead { display: table-header-group; } /* repeats on each page */
tfoot { display: table-footer-group; }
Chromium 131 and later supports page margin boxes, so running headers and page numbers can live in CSS instead of Playwright templates:
@page {
@top-left { content: "Quarterly report"; font: 9pt/1 Inter, sans-serif; color: #555; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt Inter, sans-serif; }
}
Use one approach, not both. If the deployed Chromium is older (check
npx playwright --version and the bundled browser), use headerTemplate/footerTemplate.
Layout rules for print:
- Size with
mm,ptor%, not viewport units;vhandvwrefer to the page box. - Avoid
position: fixedfor headers; it repeats unpredictably. Use margin boxes or templates. - Flex and grid containers do not fragment cleanly in every case; for long flows, put breakable content in normal block flow.
- Large
box-shadow,filterandbackdrop-filtercan force rasterization and bloat files.
3. Render (Node)
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" }); // or page.goto(url, { waitUntil: "networkidle" })
await page.evaluate(() => document.fonts.ready.then(() => true));
await page.emulateMedia({ media: "print" });
await page.pdf({
path: "out/report.pdf",
preferCSSPageSize: true, // honour @page size; otherwise format wins
printBackground: true, // backgrounds and colours are dropped without this
tagged: true, // structure tags for accessibility
outline: true, // bookmarks from headings
});
await browser.close();
Python equivalent:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(f"file://{html_path.resolve()}", wait_until="networkidle")
page.evaluate("() => document.fonts.ready.then(() => true)")
page.pdf(path="out/report.pdf", prefer_css_page_size=True, print_background=True,
tagged=True, outline=True)
browser.close()
Notes:
setContenthas no base URL, so relative<img src="logo.png">and<link href="style.css">fail. Eithergotoafile://URL, serve the folder, inline assets as data URIs, or add<base href="file:///abs/path/">.page.pdf()is Chromium-only; Firefox and WebKit in Playwright cannot print to PDF.- Charts drawn by JS: wait for a ready signal (
await page.waitForFunction(() => window.chartsReady)) instead of fixed sleeps. Disable chart animations for print. - Reuse one browser and open a new page or context per document in a service; launching a browser per request costs about a second and lots of memory. Cap concurrency.
- Rendering untrusted HTML in a service: block network with
page.route("**/*", r => r.abort())apart from your asset host, and never exposefile://access to user-supplied markup.
4. Header and footer templates
await page.pdf({
path: "out/report.pdf",
format: "A4",
printBackground: true,
displayHeaderFooter: true,
margin: { top: "22mm", bottom: "20mm", left: "16mm", right: "16mm" },
headerTemplate: `<div style="font-size:8px;width:100%;padding:0 16mm;color:#666;">
<span class="title"></span></div>`,
footerTemplate: `<div style="font-size:8px;width:100%;padding:0 16mm;text-align:right;color:#666;">
Page <span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
});
Template rules: they render in a separate context, so page CSS and web fonts do not
apply; set font-size explicitly (the default is tiny), inline all styles, and embed
images as data URIs. The available classes are date, title, url, pageNumber,
totalPages. The page margin must be large enough or the header overlaps content.
Pass an empty <span></span> as headerTemplate to suppress the default header.
Shell one-off
chromium --headless --no-pdf-header-footer --print-to-pdf=out.pdf file:///abs/path/page.html
The binary may be google-chrome, chromium or chrome. Older builds used
--print-to-pdf-no-header. There is no way to wait for JS readiness here; use Playwright
when the page renders data asynchronously.
5. Verify
qpdf --show-npages out/report.pdf
pdffonts out/report.pdf # poppler; every font "emb yes", no unexpected fallback (DejaVu, Liberation)
pdftotext -layout out/report.pdf - | head -40 # text layer exists and reads in order
pdftoppm -r 60 -png out/report.pdf review/page # thumbnails to inspect breaks
Check in the thumbnails: no heading alone at a page bottom, no split table rows, the
table header repeats, margins are equal, the last page is not blank. Add a regression
test for templates rendered in CI: assert page count and a few strings with
pdftotext, or compare page images against a baseline at low DPI with a tolerance
(fonts render slightly differently across OSes).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Backgrounds and colours missing | printBackground off | printBackground: true plus print-color-adjust: exact |
| Wrong paper size | format overrides @page | preferCSSPageSize: true or remove format |
| Trailing blank page | Element with height: 100vh or break-after: page on the last block | Remove the last break; avoid 100vh |
| Fallback font in PDF | Font not loaded before print, or not installed in the container | Wait for document.fonts.ready; ship fonts as files; install them in the image |
| Emoji or CJK show as boxes | No font with those glyphs in the container | Install Noto fonts in the image |
| Content cut at right edge | Fixed pixel widths wider than the page box | Use max-width: 100% and mm units |
| Huge file | Full-size photos, raster effects | Resize images to print size, drop filters/shadows |
| Different output in CI | Different Chromium version or fonts | Pin Playwright version; use the same container image locally |
| Header overlaps text | Margin smaller than template height | Increase top/bottom margin |
Gotchas
- Page size in
page.pdf()defaults to Letter; set it explicitly for A4 regions. page.pdf()uses print media by default; screen-only CSS (@media screen) silently disappears. UseemulateMedia({ media: "screen" })only when the design is screen-first.scaleshrinks everything including fonts; fix the layout instead of scaling.- Interactive elements (forms,
<details>) print as static; expand<details>before printing. - Untrusted templates with
{{ }}interpolation must HTML-escape values, or a customer name becomes markup in their invoice.
Output
## HTML to PDF: invoice template
- Engine: Playwright 1.x Chromium, preferCSSPageSize A4, tagged + outline
- Header/footer: CSS margin boxes, "Page n of N"
- Checks: 3 sample invoices rendered; page counts 1/2/4; fonts embedded (Inter);
table header repeats; no blank trailing pages; text layer extracted cleanly What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "HTML to PDF"
short_description: "Print CSS and Chromium, no surprise blank pages"
default_prompt: "$html-to-pdf Our invoice PDFs from Puppeteer lose their background colours and the table header doesn't repeat; fix it" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.