HTML to PDF skill for Claude Code
Print CSS that breaks pages where you meant. Claude chooses the rendering engine from the job: WeasyPrint for templated business documents, Playwright when JavaScript or browser fidelity matters, Paged.js for book layouts. Templates use @page rules, margin-box counters, named pages and break controls from the start, assets are local so fonts embed, and every PDF is checked with pdffonts and page images.
- name
html-to-pdf - size183 lines · ≈2,000 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Generates PDFs from HTML and CSS with Playwright/Chromium, WeasyPrint, or Paged.js - engine choice, print CSS (@page size and margins, margin boxes with page counters, named pages, break-before/break-inside, repeating table headers), header and footer templates, web fonts and images that actually load, PDF/A and tagged output, and a render-and-inspect verification loop. Use when building invoices, reports, certificates, or documents as PDF from templates, page breaks or headers are wrong, fonts or images are missing in the PDF, or choosing between Puppeteer/Playwright, WeasyPrint, and Paged.js. Try asking
- Generate monthly statement PDFs from this Jinja template with page numbers
- Our Puppeteer PDFs split table rows across pages and lose the logo
- Should we use WeasyPrint or headless Chrome for 50k invoices a night?
Use it when
- Producing PDFs from HTML templates or existing web pages
- Page breaks, headers, footers, or page numbers come out wrong
- Fonts or images are missing in generated PDFs
- Choosing a PDF rendering engine for a service
Skip it when
- You need to read, merge, or fill existing PDFs - use pdf-toolkit
- Your source is Markdown and pandoc fits - use markdown-publishing
How to check the result
pdffonts reports every font embedded, pdfinfo shows the right page size, and page images for short, typical and longest fixtures show no split rows or stray blank pages.
The common mistake it prevents
Rendering in a container without the template's fonts installed or bundled, so the PDF silently falls back to another font, reflows, and changes page count.
A generated PDF is done when it renders identically on every run, page breaks never split a row, heading, or signature block, every page shows the right header/footer and page number, fonts are embedded (no fallbacks), text is selectable, and someone has looked at the rendered pages, not only the HTML.
Engine choice
| Need | Engine |
|---|---|
| Page has JavaScript (charts, React) or must match the browser exactly | Playwright/Puppeteer with Chromium page.pdf() |
| Server-side Python, no browser, strong CSS Paged Media (running headers, string-set, footnotes) | WeasyPrint (no JavaScript execution) |
| Book-like layout in a browser (running heads from content, cross-reference page numbers) | Paged.js polyfill rendered in Chromium |
| Commercial-grade typesetting, complex print requirements | Prince or DocRaptor (paid) - mention, do not assume |
| Simple Markdown to PDF | pandoc (see markdown-publishing) |
Default: WeasyPrint for templated business documents (invoices, statements, letters) and Playwright when the source is an existing web page or needs JS rendering.
Workflow
- [ ] 1. Build the HTML template with print CSS from the start (not a screen page squeezed later)
- [ ] 2. Make all assets local or inline: fonts, images, CSS
- [ ] 3. Render with the chosen engine and fixed options
- [ ] 4. Inspect: page count, breaks, headers/footers, fonts embedded
- [ ] 5. Test with long and short data (1 row, 300 rows, long names, RTL if relevant)
- [ ] 6. Automate: deterministic output, error on missing assets, snapshot test
1. Print CSS
@page {
size: A4; /* or Letter, or 210mm 297mm; landscape: A4 landscape */
margin: 20mm 15mm 22mm 15mm;
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; color: #555; }
@top-left { content: string(doc-title); font-size: 9pt; }
}
@page :first { @top-left { content: none; } }
@page wide { size: A4 landscape; }
h1 { string-set: doc-title content(); } /* WeasyPrint, Paged.js */
.appendix-table { page: wide; } /* named page */
h1, h2, h3 { break-after: avoid; }
tr, figure, .signature, .totals { break-inside: avoid; }
.chapter { break-before: page; }
p { orphans: 3; widows: 3; }
thead { display: table-header-group; } /* repeat header row on each page */
tfoot { display: table-footer-group; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } /* keep backgrounds */
body { font: 10.5pt/1.45 "Inter", sans-serif; }
a[href^="http"]::after { content: none; }
Support differs:
| Feature | Chromium (131+) | WeasyPrint | Paged.js |
|---|---|---|---|
@page size, margins | yes | yes | yes |
@page margin boxes (@bottom-right) with counter(page) / counter(pages) | yes (Chrome 131+) | yes | yes |
string-set / string() running text | no | yes | yes |
Running elements (position: running() / element()) | no | yes | yes |
Named pages (page:) | recent versions | yes | yes |
target-counter() for “see page N” | no | yes | yes |
| JavaScript | yes | no | yes |
| Flexbox / grid | yes | partial (flex yes, grid in recent versions) | yes |
Test the specific feature in the installed version before relying on it.
2. Assets
- Fonts: ship font files with the template and declare
@font-face { src: url("fonts/Inter-Regular.woff2") }with a resolvable base URL. System fonts differ between a laptop and a container; the same PDF will reflow. - In Docker images install the fonts you need (and
fonts-noto-cjk/ emoji fonts if user data can contain them) or missing glyphs render as boxes. - Images: absolute file paths or data URIs; for remote URLs set timeouts and fail the job if an image is missing rather than silently producing a blank.
- Use SVG for logos and charts so they stay sharp; raster images at 2x the printed size.
3. Render
WeasyPrint (Python):
from weasyprint import HTML, CSS
HTML(string=html, base_url=str(template_dir)).write_pdf(
"out/invoice-2026-0142.pdf",
stylesheets=[CSS(filename=template_dir / "print.css")],
pdf_variant="pdf/a-3b", # archival variant when required; check supported variants for your version
)
weasyprint --base-url ./templates templates/report.html out/report.pdf
Playwright (Python):
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}", wait_until="networkidle")
page.evaluate("document.fonts.ready")
page.emulate_media(media="print")
page.pdf(
path="out/report.pdf",
prefer_css_page_size=True, # use @page size instead of format=
print_background=True,
display_header_footer=True,
header_template="<span></span>",
footer_template='<div style="font-size:8px;width:100%;text-align:center;">'
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>',
margin={"top": "18mm", "bottom": "18mm", "left": "15mm", "right": "15mm"},
tagged=True, outline=True, # accessible tags and bookmarks (recent Playwright/Chromium)
)
browser.close()
Playwright header/footer templates:
- Do not inherit page CSS; set font size explicitly (default is tiny) and inline all styles.
- Cannot load external images; use data URIs.
- Need page margins large enough to show; with CSS margin boxes (Chrome 131+) you can
skip the templates entirely and use
@pagerules instead. - Special classes:
date,title,url,pageNumber,totalPages.
For JS-rendered content, wait for a readiness signal (page.wait_for_function("window.__READY__ === true"))
rather than a fixed sleep. Reuse one browser for batches; open a new page per document.
Paged.js: include paged.polyfill.js in the page (or use pagedjs-cli), wait for its
render completion, then call page.pdf() with prefer_css_page_size=True.
4. Inspect
pdfinfo out/report.pdf # pages, page size, PDF version, tagged
pdffonts out/report.pdf # every font should show "emb yes"
pdftoppm -png -r 60 out/report.pdf out/preview # look at every page image
pdftotext -layout out/report.pdf - | head -50 # text selectable and in reading order
Look at page images for: orphaned headings at page bottoms, split table rows, header overlapping content, blank trailing page, clipped right edge (content wider than the page).
Gotchas
- Screen breakpoints: A4 at 96 dpi is about 794 px wide; responsive CSS may switch to a
mobile layout. Set print-specific widths or
@media printrules. position: fixedrepeats on every page in Chromium but behaves differently in WeasyPrint; prefer margin boxes for repeating content.- Trailing blank page: usually a last element with
break-after: pageor content overflowing by a pixel; check margins and heights summing above the page. 100vhmeans nothing useful in print; size with mm/pt.- Very large tables (thousands of rows) are slow in WeasyPrint; paginate data or split documents.
- Untrusted HTML (user content) rendered in Chromium can fetch internal URLs (SSRF) or run
scripts; sanitize input, disable JS if not needed, and block network requests with
page.route. WeasyPrint: pass a restrictiveurl_fetcher. - Timestamps and random IDs in templates make byte-identical output impossible; inject them as parameters so tests can fix them.
Verification
pdffontsshows all fonts embedded;pdfinfopage size matches the spec.- Page images reviewed for the short, typical, and longest fixture.
- Snapshot test: render fixtures to PNG and compare against approved images with a small tolerance (see visual-regression), or compare extracted text.
- If PDF/A or accessibility is required, validate with veraPDF and check the tag tree.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.