WeasyPrint to Playwright PDF: five footguns
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.
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:
page.set_content(html)notpage.goto("file://…")— no disk writes, nofile://path resolution, dodges the sandbox-writes problem below.- 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@importinside the inlined CSS still resolves over the network during thenetworkidlewait. threading.Threadwrappingsync_playwright()— without it,sync_apihangs 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:
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.