PDF Generator
A practical guide for turning four common input types into PDF files: HTTP URLs, HTML (string or file), Markdown, and Images. Covers the rendering trade-offs and gives working Python recipes.
When to use this skill
Trigger whenever the user wants a PDF produced from content that is not already a PDF. Typical phrasings:
- "Export the overview page / dashboard as PDF"
- "Download this report as PDF"
- "Generate a PDF from this Markdown / README"
- "Turn these screenshots into a single PDF"
- "给我一个 PDF 下载 / 导出当前页面为 PDF"
Decision flow — pick the right backend
| Input | Needs JS / charts? | Recommended backend | Why |
|---|---|---|---|
| HTTP URL (dashboards, ECharts, auth required) | Yes | Playwright + full-page screenshot → PDF | Real browser, honors session cookies, no measurement pitfalls |
| HTTP URL (static HTML only) | No | WeasyPrint via HTML(url=...) |
Pure Python, faster, text selectable |
| HTML string / file (print styles, CSS grid) | No | WeasyPrint | Honors @page, CJK fonts, page breaks, text selectable |
| HTML with JS rendering / charts | Yes | Playwright + page.pdf() or screenshot |
JS executes before capture |
| Markdown | No | markdown → HTML → WeasyPrint | Lightweight; control styling via CSS |
| Images (PNG/JPG) | N/A | Pillow .save(pdf) or img2pdf |
img2pdf is lossless for JPEGs; Pillow is simpler |
The "whitespace / compression" trap for URL → PDF
Two failure modes keep showing up:
- A4 compression — default
page.pdf()uses A4 (~793 px wide). Desktop dashboards (1440 px) get crammed. Fix: render with explicit desktop viewport and set PDF width to match. - Bottom whitespace — setting explicit PDF
heightand measuringscrollHeightis fragile (async Tailwind JIT, flexmin-height, ECharts async redraw all distort it). Fix: usepage.screenshot(full_page=True)and wrap the PNG into a same-size single-page PDF via Pillow. Chromium handles the height internally — zero whitespace by construction.
This is the canonical recipe for dashboards with ECharts/canvas charts.
Installation
# Playwright (for URL and JS-HTML paths)
pip install playwright pillow
playwright install chromium
# On RHEL/CentOS/Alibaba Linux, Chromium system libs:
sudo yum install -y atk at-spi2-atk cups-libs libXcomposite libXrandr libXi libXtst
# WeasyPrint (for static HTML and Markdown)
pip install weasyprint markdown
# system fonts for CJK:
sudo yum install -y google-noto-sans-cjk-fonts # or equivalent
# Image-only path
pip install pillow img2pdf
Recipe 1 — HTTP URL → PDF (authenticated, JS-rendered)
Use when the page has login, filters, ECharts, or any client-side rendering. See scripts/url_to_pdf.py for the full runnable version.
from io import BytesIO
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
from PIL import Image
def url_to_pdf(url, session_cookie=None, cookie_domain=None,
viewport_width=1440, wait_ms=1500):
"""Render a URL to a single-page PDF via full-page screenshot.
session_cookie: {'name': 'session', 'value': '...'} to reuse login
"""
PRINT_CSS = """
nav, footer, .no-print { display: none !important; }
body { background: #fff !important; }
"""
parsed = urlparse(url)
domain = cookie_domain or parsed.hostname or "localhost"
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = browser.new_context(
viewport={"width": viewport_width, "height": 900},
device_scale_factor=2,
)
if session_cookie:
ctx.add_cookies([{
"name": session_cookie["name"], "value": session_cookie["value"],
"domain": domain, "path": "/",
"httpOnly": True, "sameSite": "Lax",
}])
page = ctx.new_page()
page.emulate_media(media="screen")
page.goto(url, wait_until="networkidle", timeout=45000)
page.wait_for_timeout(wait_ms)
page.add_style_tag(content=PRINT_CSS)
page.evaluate("() => window.dispatchEvent(new Event('resize'))")
page.wait_for_timeout(500)
png = page.screenshot(full_page=True, type="png")
browser.close()
img = Image.open(BytesIO(png))
if img.mode in ("RGBA", "LA", "P"):
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
img = bg
out = BytesIO()
img.save(out, format="PDF", resolution=144.0)
out.seek(0)
return out
Key knobs:
viewport_width=1440— match the design width; don't use A4 for dashboardsdevice_scale_factor=2— high-DPI outputemulate_media('screen')— prevent@media printfrom collapsing layoutnetworkidle+wait_ms+ aresizeevent — gives ECharts time to settle
If you must have multi-page paginated PDF (text selectable, printable), swap screenshot for page.pdf(format='A4', print_background=True) and accept the width constraints.
Recipe 2 — HTML string/file → PDF (no JS needed)
WeasyPrint reads HTML+CSS and paginates per @page rules. Great for reports. Full script: scripts/html_to_pdf.py.
from io import BytesIO
from weasyprint import HTML, CSS
def html_to_pdf(html_str, base_url=None, extra_css=None):
css_list = [CSS(string=PDF_BASE_CSS)]
if extra_css:
css_list.append(CSS(string=extra_css))
buf = BytesIO()
HTML(string=html_str, base_url=base_url).write_pdf(buf, stylesheets=css_list)
buf.seek(0)
return buf
PDF_BASE_CSS = """
@page { size: A4; margin: 18mm 16mm;
@bottom-center { content: counter(page) " / " counter(pages);
font-size: 9pt; color: #94a3b8; } }
body { font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
font-size: 11pt; color: #1e293b; line-height: 1.6; }
h1 { color: #6366F1; border-bottom: 2px solid #6366F1; padding-bottom: 6px; }
table { border-collapse: collapse; width: 100%; page-break-inside: auto; }
tr { page-break-inside: avoid; }
th, td { border: 1px solid #e2e8f0; padding: 6px 8px; }
th { background: #f1f5f9; }
img { max-width: 100%; }
"""
For local files / inline images, pass base_url=os.path.dirname(os.path.abspath(path)) so relative paths resolve. For remote images, embed as base64 data URIs to avoid network during rendering.
Recipe 3 — Markdown → PDF
Convert MD → HTML, then reuse Recipe 2. Full script: scripts/markdown_to_pdf.py.
import markdown
from weasyprint import HTML, CSS
def markdown_to_pdf(md_text, title="Document"):
html_body = markdown.markdown(
md_text,
extensions=["tables", "fenced_code", "toc", "sane_lists"],
)
html_doc = f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<title>{title}</title></head><body>
<h1>{title}</h1>{html_body}</body></html>"""
from io import BytesIO
buf = BytesIO()
HTML(string=html_doc).write_pdf(buf, stylesheets=[CSS(string=PDF_BASE_CSS)])
buf.seek(0)
return buf
Tables, code blocks, nested lists all work. Syntax highlighting requires the codehilite extension + Pygments CSS.
Recipe 4 — Images → PDF
Single image or multi-image collage. scripts/images_to_pdf.py.
from io import BytesIO
from PIL import Image
def images_to_pdf(paths):
"""Multiple images → multi-page PDF. Each image is one page."""
imgs = [Image.open(p).convert("RGB") for p in paths]
buf = BytesIO()
imgs[0].save(buf, format="PDF", save_all=True, append_images=imgs[1:],
resolution=144.0)
buf.seek(0)
return buf
For pixel-perfect JPEG retention (no recompression), prefer img2pdf:
import img2pdf
with open("out.pdf", "wb") as f:
f.write(img2pdf.convert(["a.jpg", "b.jpg"]))
Serving PDFs from Flask
End-to-end pattern for a "download this page as PDF" button in a Flask app:
from flask import Blueprint, request, send_file, current_app, abort
from urllib.parse import urljoin, urlparse
bp = Blueprint("export", __name__)
ALLOWED_PREFIXES = ("/", "/report/")
@bp.route("/export/pdf")
def export_pdf():
path = request.args.get("path", "").strip()
if not path.startswith("/") or "://" in path:
abort(400)
if not any(path == p or path.startswith(p) for p in ALLOWED_PREFIXES):
abort(403)
target_url = urljoin(request.host_url, path.lstrip("/"))
cookie_name = current_app.config.get("SESSION_COOKIE_NAME", "session")
cookie_value = request.cookies.get(cookie_name)
if not cookie_value:
abort(401)
pdf_buf = url_to_pdf(
target_url,
session_cookie={"name": cookie_name, "value": cookie_value},
cookie_domain=urlparse(request.host_url).hostname,
)
return send_file(pdf_buf, as_attachment=True,
download_name="report.pdf", mimetype="application/pdf")
Front-end: fetch with credentials: 'same-origin', wrap response as a Blob, trigger <a download> — gives you a progress/error UI instead of a silent iframe redirect.
Troubleshooting
- Content compressed in PDF — you're using A4 portrait with a wide desktop layout. Fix: use
viewport_width=1440+ PDF width matching viewport, or switch to the screenshot-based recipe. - Blank page / white bottom — measuring
scrollHeightand passing it as PDFheight. Switch toscreenshot(full_page=True)+ Pillow PDF wrap. - Chinese text rendered as boxes — no CJK font installed on the rendering machine. Install
Noto Sans CJK SCand reference it infont-family. libatk-1.0.so.0: No such file or directory— Chromium system deps missing. On RHEL family:sudo yum install -y atk at-spi2-atk cups-libs libXcomposite libXrandr libXi libXtst.- Auth page returned instead of content — the session cookie wasn't injected or its
domaindoesn't match the target host. Log the cookies Playwright sees:ctx.cookies(). - ECharts tooltip or animation appears mid-frame —
wait_for_timeouttoo short, or animations enabled. Bump wait, or disable animations withechartsInstance.setOption({animation:false})before capture. - PDF huge (~10MB+) — screenshot resolution too high. Drop
device_scale_factorto 1, or compress the PNG before embedding.
File map
scripts/url_to_pdf.py— Recipe 1, runnable with--urlargumentscripts/html_to_pdf.py— Recipe 2scripts/markdown_to_pdf.py— Recipe 3scripts/images_to_pdf.py— Recipe 4scripts/flask_export_blueprint.py— drop-in Flask blueprint