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.

  • namehtml-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

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.

~/.claude/skills/html-to-pdf/SKILL.md

--- name: html-to-pdf description: Generates PDFs from HTML and CSS with Playwright/Chromium, WeasyPrint, or Paged.js - engine choice, print CSS (@page size and margins,… license: MIT ---

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

NeedEngine
Page has JavaScript (charts, React) or must match the browser exactlyPlaywright/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 requirementsPrince or DocRaptor (paid) - mention, do not assume
Simple Markdown to PDFpandoc (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:

FeatureChromium (131+)WeasyPrintPaged.js
@page size, marginsyesyesyes
@page margin boxes (@bottom-right) with counter(page) / counter(pages)yes (Chrome 131+)yesyes
string-set / string() running textnoyesyes
Running elements (position: running() / element())noyesyes
Named pages (page:)recent versionsyesyes
target-counter() for “see page N”noyesyes
JavaScriptyesnoyes
Flexbox / gridyespartial (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 @page rules 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 print rules.
  • position: fixed repeats 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: page or content overflowing by a pixel; check margins and heights summing above the page.
  • 100vh means 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 restrictive url_fetcher.
  • Timestamps and random IDs in templates make byte-identical output impossible; inject them as parameters so tests can fix them.

Verification

  • pdffonts shows all fonts embedded; pdfinfo page 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.

esc