mrkeyoor.com_
Fri 07 Aug 20:55 UTC
PyPIDataupdated 07 Aug 2026

weasyprint

WeasyPrint turns HTML and CSS into PDF with a layout engine written in Python instead of a browser. There is no Chromium, no V8 and no JavaScript anywhere in the pipeline: it parses markup with tinyhtml5, resolves CSS with tinycss2 and cssselect2, shapes text through Pango, and serialises the PDF with pydyf. Because the engine was built for paged media rather than screens, the parts of CSS that browsers treat as an afterthought are the parts it takes seriously: @page size and margins, margin boxes for running headers and footers, page and pages counters, named strings, break-before and break-inside, orphans and widows. The trade is the exact inverse of headless Chrome. You give up scripting and pixel-for-pixel browser fidelity, and you get a small single-process renderer whose output is a pure function of your HTML, your CSS and the fonts on the machine.

Verdict

For server-side HTML to PDF where the markup is yours and static, WeasyPrint is the least painful option in Python and the only one that treats paged CSS as a first-class target. Budget the first day for system libraries and fonts, and the second for turning screen CSS into print CSS.

API stability3/5HTML, CSS and write_pdf have kept the same shape for years, but a major version ships every few months (65.1 in April 2025 through 69.0 in June 2026) and each one can change rendering output, so byte-comparison tests need rebaselining. Deprecations are real too: the module-level default_url_fetcher now warns and points at the URLFetcher class.
Docs4/5doc.courtbouillon.org has per-distribution install commands with the exact system packages, an API reference generated from the docstrings, and chapters on URL fetchers, PDF variants and going further. The gap is a per-property CSS support matrix, so whether a given layout works is still something you find out by rendering it.
Maintenance5/569.0 shipped on 2 June 2026 and the repository was pushed on 7 August 2026, with 146 open issues plus 11 open PRs against a codebase started in 2011. CourtBouillon sells paid support and maintenance on top of it, which is why the release cadence has not slipped.
Ecosystem4/5Around 9.25M weekly downloads and packaged by Debian, Ubuntu, Fedora, Arch and Alpine, with django-weasyprint wrapping it for Django views. It is a leaf tool rather than a platform, so there are few plugins; what you extend instead is the URL fetcher and the finisher callback.

Use it if

  • You generate invoices, statements, tickets or reports from server-side templates and care about page breaks, repeated table headers and page numbering more than about matching a Chrome screenshot
  • You cannot ship a headless browser: no Chromium in the image, a container memory budget in the hundreds of megabytes, or a platform that will not let you spawn a sandboxed subprocess
  • You need PDF/A or PDF/UA output, tagged PDFs for screen readers, embedded file attachments or XMP metadata, all of which are keyword options on write_pdf rather than a separate post-processing step
  • You want the render to be reproducible in CI: same HTML, same CSS, same fonts, same bytes, with no browser version drifting underneath you
Skip it if

Setup reality

pip install weasyprint pulls pydyf, cffi, tinyhtml5, tinycss2, cssselect2, Pyphen, Pillow and fontTools with the woff extra, and none of that is the hard part. The hard part is Pango 1.44 or newer plus HarfBuzz, GObject and fontconfig, which are system shared libraries that pip does not touch. On Debian and Ubuntu that means apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0; on Alpine you need the so:libpango-1.0.so.0 style package names; on Windows you install the GTK runtime. Run weasyprint --info first in any new image, because it prints the Pango version it found and fails loudly instead of producing a broken PDF. The second surprise is fonts: a slim container ships with none, so text falls back to whatever fontconfig can find, which may be nothing at all. Install a font package or embed WOFF files through @font-face with a shared FontConfiguration object. Python 3.10 or newer.

Patterns

Render an HTML string to PDF bytesrender-string-to-bytes

from weasyprint import HTML

pdf_bytes = HTML(
    string="<h1>Invoice 42</h1>",
    base_url="https://example.com/",
).write_pdf()

Leaving target unset returns the PDF as bytes instead of writing a file. Always pass base_url when the input is a string: without it every relative img src and stylesheet link resolves against nothing, and the resource is skipped with only a log line to show for it.

Add stylesheets from outside the documentattach-stylesheets

from weasyprint import CSS, HTML

HTML(filename="invoice.html").write_pdf(
    "invoice.pdf",
    stylesheets=[
        CSS(filename="print.css"),
        CSS(string="@page { size: A4; margin: 18mm; }"),
    ],
)

These are applied at user origin, not author origin, so any rule the document itself links wins a specificity tie against them. If a stylesheet you pass here appears to do nothing, that is why; mark the declaration !important or move it into the document.

Page size, margins and running headerspaged-media-css

@page {
  size: A4;
  margin: 20mm 15mm 22mm 15mm;
  @top-center { content: string(doc-title); font-size: 9pt; }
  @bottom-right { content: "Page " counter(page) " of " counter(pages); }
}

h1 { string-set: doc-title content(); }

counter(pages) is only knowable after layout, which is why you cannot compute a total in your template. string-set plus content() is the mechanism for a header that tracks the current section, and there is no scripting escape hatch if you need something the margin boxes cannot express.

Keep rows together and repeat table headerscontrol-page-breaks

.invoice-line { break-inside: avoid; }
h2            { break-after: avoid; }
.appendix     { break-before: page; }

thead { display: table-header-group; }
tfoot { display: table-footer-group; }

p { orphans: 3; widows: 3; }

display: table-header-group is the only switch that repeats a table header on every page. break-before: page on an element that already sits at the top of a page still emits a blank page, so guard it with a :not(:first-child) selector.

Embed a font with @font-faceembed-webfonts

from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration

font_config = FontConfiguration()
css = CSS(string="""
  @font-face {
    font-family: Inter;
    src: url(file:///app/fonts/Inter.woff2) format("woff2");
  }
  body { font-family: Inter, sans-serif; }
""", font_config=font_config)

HTML(filename="report.html").write_pdf(
    "report.pdf", stylesheets=[css], font_config=font_config)

The same FontConfiguration instance has to reach every CSS object and write_pdf itself. Pass it to one and not the other and the @font-face rule is parsed and then discarded, with no error and a silent fallback font in the PDF.

Lock down what the renderer is allowed to fetchrestrict-url-fetching

from weasyprint import HTML
from weasyprint.urls import URLFetcher

class LocalOnly(URLFetcher):
    def fetch(self, url, headers=None):
        if not url.startswith(("file:///app/assets/", "data:")):
            raise ValueError(f"blocked: {url}")
        return super().fetch(url, headers)

HTML(string=user_html, base_url="file:///app/assets/",
     url_fetcher=LocalOnly(timeout=5)).write_pdf("out.pdf")

Rendering user-supplied HTML with the default fetcher is a server-side request forgery hole: an img tag pointed at a cloud metadata endpoint gets fetched by your process. URLFetcher also accepts allowed_protocols and fail_on_errors directly if a protocol allowlist is enough. The old module-level default_url_fetcher is deprecated in favour of this class.

Produce PDF/A or a tagged accessible PDFarchive-pdf-variant

from weasyprint import HTML

HTML(filename="report.html").write_pdf(
    "report.pdf",
    pdf_variant="pdf/a-3b",   # or pdf/ua-1, pdf/a-1a, pdf/x-4 ...
    custom_metadata=True,
)

Choosing a variant overrides related options for you: the pdf/a-*a conformance levels force pdf_tags on, and every PDF/A profile sets pdf_version, pdf_identifier and an sRGB output intent. Setting pdf_variant does not by itself stop font subsetting, so pass full_fonts=True if your validator demands complete embedding.

Concatenate several rendered documentsmerge-rendered-documents

from weasyprint import HTML

docs = [HTML(filename=p).render() for p in ("cover.html", "body.html")]
pages = [page for doc in docs for page in doc.pages]
docs[0].copy(pages).write_pdf("merged.pdf")

render() stops before serialisation and hands you a Document whose .pages list can be sliced, reordered or concatenated. Page counters were resolved per source document, so numbering restarts at each boundary; if you need one continuous sequence, render a single document instead.

Cut the file size of an image-heavy documentshrink-output

from weasyprint import HTML

HTML(filename="catalogue.html").write_pdf(
    "catalogue.pdf",
    optimize_images=True,
    jpeg_quality=80,
    dpi=150,
    full_fonts=False,
)

optimize_images re-encodes rasters and dpi downsamples them to that resolution, and both add render time on a document with many photos. full_fonts=False subsets embedded fonts and is already the default, so if your PDFs are large and text-heavy the fonts are probably not the cause.

Serve a PDF from a Django viewdjango-pdf-response

from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML

def invoice_pdf(request, pk):
    html = render_to_string("invoice.html", {"invoice": load(pk)})
    pdf = HTML(
        string=html,
        base_url=request.build_absolute_uri("/"),
    ).write_pdf()
    return HttpResponse(pdf, content_type="application/pdf")

build_absolute_uri is what makes {% static %} URLs resolve; drop it and the logo and stylesheet vanish without an exception. This also runs layout inside the request, so move anything longer than a couple of pages into Celery or RQ before it starts eating gunicorn workers.

Find out why an image or font did not renderdiagnose-missing-resources

import logging
from weasyprint import HTML

logging.basicConfig(level=logging.INFO)
logging.getLogger("weasyprint").setLevel(logging.DEBUG)
logging.getLogger("weasyprint.progress").setLevel(logging.INFO)

HTML(filename="report.html").write_pdf("report.pdf")

WeasyPrint installs a NullHandler and says nothing by default, so a stylesheet that 404s or a local font it cannot open produces blank output and a clean exit code. The weasyprint logger carries resource and CSS syntax problems; weasyprint.progress carries the step-by-step rendering trace.

Render from the command line and check the installcli-usage

weasyprint --info

weasyprint --stylesheet print.css --media-type print \
           --presentational-hints invoice.html invoice.pdf

weasyprint --pdf-variant pdf/a-3b --optimize-images \
           --jpeg-quality 80 --dpi 150 report.html report.pdf

weasyprint --info is the first command to run in a new image because it reports the Pango version it actually found. --presentational-hints turns on legacy HTML attributes such as table border and bgcolor, which is off by default and is usually what people are missing when an old email template renders unstyled.

Alternatives

PackageRegistryPick it when
playwrightPyPIThe document only exists after JavaScript runs, or you need the output to match what Chrome shows on screen
reportlabPyPIYou would rather draw the page programmatically in Python than express it as HTML and CSS
pypdfPyPIThe task is merging, splitting, stamping or reading existing PDFs rather than generating new ones