# xhtml2pdf: one rendering backend, a Xenial test image, and a lint target that does not exist

> The pure Python HTML to PDF library has collapsed its graphics backends to one name and shipped three tags in September 2026, yet its root Dockerfile builds on Ubuntu 16.04 and its Makefile advertises a lint target that no phony entry declares. What the file listing actually contains, and where it stops mid sentence.

**xhtml2pdf/xhtml2pdf** — A library for converting HTML into PDFs using ReportLab

- Repository: https://github.com/xhtml2pdf/xhtml2pdf
- Website: https://xhtml2pdf.readthedocs.io/
- Stars: 2,395 · Forks: 659
- Language: Python
- License: Apache-2.0
- Published: 2026-09-28 · Updated: 2026-09-28 · Language: en
- Canonical page: https://hysenlabs.com/projects/xhtml2pdf-xhtml2pdf

## ReportLab 5 removed one backend, so the extras list offers only one

The install line is short, and it is not the whole story. The setup section gives one command and states that every mandatory requirement is listed in `pyproject.toml` and installed automatically:

```
pip install xhtml2pdf
```

What that sentence leaves out is the C library ReportLab needs to generate bitmaps and vector graphic formats. ReportLab reaches that work through a rendering backend, and the project names the cairo graphics library as the recommended choice, installed system-wide through the operating system package manager, then pulled in through a PyCairo extra:

```
pip install xhtml2pdf[pycairo]
```

The extras list used to carry two names. The `renderpm` extra is now a deprecated alias for `pycairo`, since ReportLab 5 removed the C `RenderPM` backend and pycairo is the only backend left. A setup guide written against an older release still tells you to ask for `renderpm`, and that name no longer selects a second engine, it selects the same one. The distance between the two commands above is a system package rather than a Python one, so an image that runs the first command and stops has no graphics backend at all, and the failure arrives at render time instead of at install time.

## The test image builds on Ubuntu Xenial and opens ImageMagick's PDF policy

The Dockerfile at the root of the repository begins with `FROM ubuntu:xenial`, and its default command is the reference renderer rather than an application:

```
CMD [ "python3", "testrender/testrender.py" ]
```

Two apt-get passes install imagemagick, ghostscript and python3, then python3-pip. A commented out block for vim sits between them, left in the file. The line that carries a security decision is the sed expression. ImageMagick ships a default policy that denies its PDF coder, and the build rewrites that deny into a read and write grant:

```
RUN sed -i 's#<policy domain="coder" rights="none" pattern="PDF" />#<policy domain="coder" rights="read|write" pattern="PDF" />#' /etc/ImageMagick-6/policy.xml
```

The path names ImageMagick 6, which matches the xenial base. Nothing else in the file explains why the policy has to change, so the reason has to be read out of the default command: the renderer compares output, and comparing rasterized PDF output needs a tool allowed to read and write PDF. The build closes with `python3 -m pip install --no-cache-dir .`, so it installs from the copied tree without consulting an index. Read as a deployment recipe this file works against its own purpose, and moving the policy line into an unrelated image would carry the loosening forward without the reason that justified it.

## make help advertises a lint target the phony list never declares

The Makefile opens by declaring its phony targets, then prints one help line per target. Read the two lists against each other and one line has no target behind it:

```
lint - check style with flake8
```

No lint entry appears in the phony declaration, which covers help, setup, devsetup, clean, clean-pyc, clean-build, list, test, test-all, test-ref, test-render, test-render-all, test-browser, test-browser-update, perf, perf-scaling, perf-golden, perf-golden-update, docs, release and sdist. The rest of the help text does line up. It describes setup as creating a venv and installing xhtml2pdf in editable mode, and devsetup as setup plus the test, docs and release extras, where the release extra brings in build and twine, together with the pre-commit hooks. The interpreter path is not hardcoded:

```
VENV ?= .venv
```

The file then explains an arrangement whose behaviour depends on where make runs. When neither CI nor an already active virtualenv is detected, targets that run code first prepare `.venv`, then put its bin directory at the front of PATH, so the plain python of a recipe is the venv interpreter even after the recipe changes directory. Two stamps, `.installed` and `.installed-browser`, record which preparations are done. The comment names both exceptions: not in CI, which installs into the runner's Python with a pinned reportlab, and not inside a virtualenv already active. Those two cases run with the Python they have.

## The development setup still begins with easy_install

The contributor instructions start with a command from a different era than the rest of the page. Step one, for anyone without pip, is:

```
sudo easy_install pip
```

The line is followed by a link to the pip installer site. After that the project recommends venv and gives two routes to one. The automated route is a single target, and the page spells out what it does: it creates `.venv`, installs xhtml2pdf in editable mode with the `test`, `docs` and `release` extras, where the release extra brings in `build` and `twine`, and installs the pre-commit hooks:

```
make devsetup
source .venv/bin/activate
```

The by hand route writes the same steps as separate commands. Create the environment with `python -m venv .venv`, activate it with `source .venv/bin/activate`, leave it later with `deactivate`, then install the same three extras:

```
pip install -e .[test,docs,release]
```

Running the suite is `tox`, and the page fixes the expected result as a log with the following success status, `congratulations :) (75.67 seconds)`. That duration is a literal written into the documentation rather than a measurement taken from a run, so it describes the shape of the output and nothing about how long your machine will take. A configuration check that expects a fixed number is a poor gate, and the same file admits the larger gap by pointing contributors at the Coveralls report and asking them to identify parts that are yet untouched.

## The metadata says Beta while three tags landed in one month

The build config is a single `pyproject.toml` with setuptools behind it. Dirk Holtwick is the listed author, and Luis Zarate and Timo Brembeck the maintainers, which matches the history section placing Holtwick on this project from 2000 and Zarate in the current role from 2018. The description field reads PDF generator using HTML and CSS, the readme points at `README.rst`, and the license is declared as a file reference to `LICENSE.txt` rather than as a licence string. Two details stand out. The classifier list carries Development Status 4 Beta, on a repository whose three most recent tags, v0.2.19, v0.2.20 and v0.2.21, were all published in September 2026, and whose last push is dated 2026-09-28. Separately, the keyword array lists PDF, HTML, HTML, XML and CSS, with HTML written twice, the kind of detail that surfaces when a field is edited by hand and nobody reads it back. The dependency array is where the file stops being readable. Six names are complete: arabic-reshaper, html5lib, Pillow, pyHanko, pyhanko-certvalidator and pypdf. A seventh begins with pyth and the text ends there. ReportLab, described elsewhere in the documentation as the PDF library this package depends on, is not among the six names that are visible.

## CI skips the virtualenv and pins its own reportlab

The same Makefile comment describes three environments, and the third is the one contributors never see. Recipes normally prepare `.venv` and prepend its bin directory to PATH so that the plain python inside a recipe is the venv interpreter, an arrangement that keeps working even after a recipe changes directory. Under CI that injection is skipped. The reason is given in one clause: CI installs into the runner's Python with a pinned reportlab. So the versions exercised by a test run are not the versions a contributor gets from an editable install, and the reportlab build used there is deliberately held at one version rather than resolved from the dependency list. The same skip applies inside a virtualenv that is already active, because that environment already has an interpreter to run. Two stamps, `.installed` and `.installed-browser`, keep track of the preparation work, and `VENV ?= .venv` keeps the path overridable, with the comment suggesting a variant such as `.venv312`. For anyone reading a green build, the practical consequence is narrow and worth stating plainly: a passing CI run tells you the pinned combination works, and tells you nothing about the combination your own machine resolved.

## The demo directory names preserve the project's earlier names

The root listing carries four example directories: `demo/cherrypy/`, `demo/djangoproject/`, `demo/tgpisa/` and `demo/wsgi/`. None is named after the current project, and two are named after earlier ones. The history section explains why. From 2000 to 2007 Dirk Holtwick ran it as a commercial project of spirito.de. From 2007 to 2010 it was called pisa and released under GPL. From 2010 to 2012 it took the name xhtml2pdf and changed licence to Apache. Chris Glass held it from 2012 to 2015, Benjamin Bach from 2015 to 2016, Sam Spencer from 2016 to 2018, and Luis Zarate from 2018 to the present. The tgpisa directory is a leftover of the pisa era, and wsgi names the deployment shape rather than a brand. The licence paragraph at the end of the file stops partway through its own first line, ending at Licensed under the Apache License, Version 2.0 (th, so the file never finishes the sentence that states the terms. The copyright line above it reads Copyright 2010 Dirk Holtwick, holtwick.it, the same year the name changed. LICENSE.txt sits in the root listing and pyproject.toml points its licence field at that file, so the complete text is in the repository even where the readme leaves off.

## The comparison with WeasyPrint is a single unfinished sentence

The alternatives section offers one comparison and it is not finished. The sentence begins by pointing at WeasyPrint, then continues that the codebase is pretty, it has different features and it does a lot of what xhtml2pdf does. No versions, no axis of comparison, no guidance about which problem each one solves. The design claim made for xhtml2pdf is narrower than that. It is completely written in pure Python and therefore platform independent, it supports HTML5 and CSS 2.1 and some of CSS 3, and its stated benefit is that a user with web skills like HTML and CSS is able to generate PDF templates very quickly without learning new technologies. How much of CSS 3 is covered is never stated, and that is the detail that decides the comparison. Two other lines set expectations before anything is installed. The readme says the use of open source software in production depends on many factors, so be aware that you may find issues in some cases. And the documentation host is invited as a known gap in itself, with `doc/source/usage.rst` named as a good place to start improving it. Integration examples sit in `test/simple.py`.

## Conclusion

Choose xhtml2pdf when your templates are already HTML and CSS and keeping one pure Python stack matters more than CSS 3 coverage, and treat WeasyPrint as the alternative the project itself names. Before you commit, install the system cairo library rather than trusting the bare pip command, treat CSS 2.1 as the real ceiling, read the Beta classifier against the September 2026 tags, and plan for a functional suite the project asks contributors to help grow.

## FAQ

### how to install xhtml2pdf

Two commands cover it. `pip install xhtml2pdf` handles the mandatory requirements listed in pyproject.toml, and `pip install xhtml2pdf[pycairo]` adds the PyCairo extra. The cairo graphics library itself has to be installed system-wide through the operating system package manager, because ReportLab needs a rendering backend to generate bitmaps and vector graphic formats.

### xhtml2pdf vs reportlab

reportlab sits underneath rather than beside. The documentation names it as the PDF library this package depends on, and xhtml2pdf supplies the HTML5 and CSS 2.1 front end on top of it. The visible dependency list adds html5lib for parsing and pypdf for the PDF side, alongside Pillow and arabic-reshaper.

### Which Python versions does xhtml2pdf support?

Only Python 3.10 and newer is tested and guaranteed to work, and pyproject.toml sets requires-python to >=3.10. The classifier list names 3.10, 3.11, 3.12, 3.13 and 3.14.

### Does xhtml2pdf support CSS 3?

Partly. The stated support is HTML5 and CSS 2.1, and some of CSS 3. No part of the documentation says which CSS 3 properties are implemented.

### How recent are the xhtml2pdf releases?

v0.2.21, v0.2.20 and v0.2.19 were published on 2026-09-26, 2026-09-16 and 2026-09-12, and the last push is dated 2026-09-28. The repository is not archived, while the Development Status classifier in pyproject.toml still reads 4 Beta.

## Sources

- [License: Apache-2.0](https://github.com/xhtml2pdf/xhtml2pdf/blob/master/LICENSE)
- [Project website](https://xhtml2pdf.readthedocs.io/)
- [README](https://github.com/xhtml2pdf/xhtml2pdf/blob/master/README.md)
- [Releases](https://github.com/xhtml2pdf/xhtml2pdf/releases)
- [xhtml2pdf/xhtml2pdf on GitHub](https://github.com/xhtml2pdf/xhtml2pdf)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/xhtml2pdf-xhtml2pdf
