# WeasyPrint to Playwright PDF: five footguns

> Migrating five PDF formats off WeasyPrint to Playwright + Chromium worked — after the asyncio deadlock, the systemd sandbox, and the per-user browser cache.

**Canonical URL**: https://agentcookbooks.com/blog/playwright-pdf-replace-weasyprint-five-footguns/

**Published**: 2026-05-25

**Tags**: claude-code, deployment

---

WeasyPrint 68 crashed in `layout/preferred.py` with `AttributeError: 'FunctionBlock' object has no attribute 'unit'` when a `calc(100% - 44mm)` value reached the table-width resolver. The traceback didn't point at the offending CSS rule — the failure surfaced during layout, not CSS parse, so the error message offered no breadcrumb back to the template. Short-term fix: precompute the `calc()` to a constant. Long-term: rip out WeasyPrint and render through headless Chromium via Playwright. Five PDF formats migrated to a 60-LOC shared wrapper. Five sharp edges later, here's what the docs don't tell you when you put Chromium behind a hardened systemd service.

## What I ran

No skill activation — production migration on a service that emits five document types (deck, one-pager, carousel, sales deck, monthly report). Goal: ship modern CSS in PDFs — `clamp()`, `color-mix()`, `font-variation-settings`, `calc()` inside `display: table` — without forking WeasyPrint or constraining the template language to its 2019-era CSS subset.

The migration plan was a one-day spike. It landed in two days because of the friction below.

## The recipe

A single shared helper. ~60 LOC. Same wrapper handles PNG.

```python
def render_html_to_pdf(html: str, path: str, page_w_mm: int, page_h_mm: int) -> None:
    def _work():
        with sync_playwright() as p:
            browser = p.chromium.launch()
            page = browser.new_page(viewport={
                "width": int(page_w_mm * 3.78),
                "height": int(page_h_mm * 3.78),
            })
            page.set_content(html, wait_until="networkidle", timeout=30_000)
            page.pdf(path=path, prefer_css_page_size=True)
            browser.close()

    t = threading.Thread(target=_work, daemon=True)
    t.start()
    t.join(timeout=120)
```

Three load-bearing choices in those lines:

1. **`page.set_content(html)` not `page.goto("file://…")`** — no disk writes, no `file://` path resolution, dodges the sandbox-writes problem below.
2. **Inline brand CSS via Jinja into the HTML string** — `<style>{{ brand_css|safe }}</style>` followed by `@page { size: <W>mm <H>mm; margin: 0 }`. Google Fonts `@import` inside the inlined CSS still resolves over the network during the `networkidle` wait.
3. **`threading.Thread` wrapping `sync_playwright()`** — without it, `sync_api` hangs silently inside any asyncio-hosted process. Detail in footgun #1.

The PNG variant uses the same wrapper shape with `page.screenshot(path=..., clip={...})` and `device_scale_factor=1`. The `clip` parameter guarantees pixel-exact crops with no scrollbars or whitespace.

## Where it drifted — five sharp edges

**1. `sync_playwright()` deadlocks inside any thread that already hosts an asyncio loop.** Bot frameworks (PTB v20+, aiogram, FastAPI handlers) run command handlers on the event loop. Calling `sync_api` from one raises `RuntimeError: Cannot run sync API inside asyncio loop` — or worse, hangs silently with no exception. Fix: `threading.Thread(target=_work, daemon=True).join(timeout=120)`. The new thread has no event loop; `sync_api` starts fresh. The async API is the alternative but requires propagating `async` through the whole render path, which wasn't tractable in a one-day spike.

**2. `systemd ProtectSystem=strict` / `ReadOnlyPaths=` makes the templates dir read-only.** Any attempt to write a temp HTML file into a repo-relative path to feed `file://` fails: `OSError: [Errno 30] Read-only file system`. The fix is structural, not a config tweak: never write temp files. Inline CSS into the HTML string and use `page.set_content(html_str)`. Disk writes you never make can't be blocked by a sandbox.

**3. `systemd PrivateTmp=true` isolates `/tmp` between services.** A bot's `/tmp` and any sidecar process's `/tmp` are different directories — bind-mounted onto the same name. Don't use `/tmp` as a handoff path between services that both have `PrivateTmp=true`. Use a shared non-tmp directory with explicit ownership (`/opt/<app>/renders/` chowned to the service user). This one wasted an hour because `ls /tmp` from a debug shell showed the file existed and the sidecar still couldn't see it.

**4. Playwright browser cache is per-user.** `playwright install chromium` downloads to `~/.cache/ms-playwright/` of the calling user. Running `sudo playwright install` as root puts Chromium in `/root/.cache/ms-playwright/` — invisible to the service user. The service raises `Executable doesn't exist at /home/service_user/.cache/ms-playwright/...` and nothing in the error hints at the per-user cache. Fix:

```bash
sudo -u service_user /opt/<app>/.venv/bin/playwright install chromium
```

System deps (`--with-deps`) still need root, but the *browser binary* must land in the service user's cache. Do both: `sudo playwright install-deps chromium` once, then `sudo -u service_user … playwright install chromium`.

**5. `systemd ProtectSystem=strict` also breaks Chromium itself.** Even after you stop trying to write to read-only template dirs, Chromium's sandbox needs more filesystem access than `strict` permits and exits with code 226/NAMESPACE on launch. There is no Chromium flag that papers over this. Drop the strict filesystem protections for the service that runs the renderer; use light hardening only — `NoNewPrivileges=true`, `ProtectKernelTunables=true`, `RestrictSUIDSGID=true`. Document the trade in the unit file so the next operator doesn't restore `strict` and break the renderer six months from now.

## What I'd change

**Default to `set_content()` for any PDF render behind a sandbox.** The disk-write footguns (#2, #3) both disappear if you commit to in-memory HTML from day one. There's no performance cost — `set_content` doesn't reparse anything `goto("file://…")` wouldn't have — and you avoid two classes of failure that don't surface until production hardening lands.

**Bake the `sudo -u service_user` install into the deploy playbook, not the runbook.** The browser-cache footgun (#4) is a one-line fix once you know the rule, but every operator who runs `sudo playwright install` out of habit will hit it again. Put the explicit `sudo -u <service_user>` form in the README, the systemd unit file as a comment, and the deploy script. The cost of pre-empting it is zero; the cost of debugging it cold is an hour per occurrence.

WeasyPrint is gone from `requirements.txt`. Chromium runs at ~150 MB resident per render, 2–3s for an A4-ish PDF with brand fonts. No more `calc()` resolver crashes — and no `calc()` workarounds left in the templates either. The modern CSS that triggered the original migration ships now without per-template special cases.