|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | +"""Convert the print-layout poster HTML to a press-ready PDF. |
| 3 | +
|
| 4 | +Output : poster/html/zyra-poster-print.pdf |
| 5 | +Paper : 48 × 36 inches, landscape (standard conference poster) |
| 6 | +Quality: Text and SVGs are resolution-independent (vector). Raster images are |
| 7 | + embedded at their native resolution; 300 DPI assets render at 300 DPI. |
| 8 | +
|
| 9 | +Requirements (auto-installed on first run): |
| 10 | + pip install playwright |
| 11 | + playwright install chromium |
| 12 | +
|
| 13 | +Run from the repository root: |
| 14 | + python poster/scripts/build_pdf.py |
| 15 | +
|
| 16 | +Options (env vars): |
| 17 | + POSTER_HTML Path to input HTML (default: poster/html/zyra-poster-print.html) |
| 18 | + POSTER_PDF Path to output PDF (default: poster/html/zyra-poster-print.pdf) |
| 19 | + POSTER_OPEN Set to 1 to open the PDF when done |
| 20 | +""" |
| 21 | + |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import os |
| 25 | +import subprocess |
| 26 | +import sys |
| 27 | +from pathlib import Path |
| 28 | + |
| 29 | +# ── Paths ────────────────────────────────────────────────────────────────── |
| 30 | +REPO_ROOT = Path(__file__).resolve().parent.parent.parent |
| 31 | + |
| 32 | +HTML_PATH = Path(os.environ.get( |
| 33 | + "POSTER_HTML", |
| 34 | + REPO_ROOT / "poster" / "html" / "zyra-poster-print.html", |
| 35 | +)) |
| 36 | +PDF_PATH = Path(os.environ.get( |
| 37 | + "POSTER_PDF", |
| 38 | + REPO_ROOT / "poster" / "html" / "zyra-poster-print.pdf", |
| 39 | +)) |
| 40 | + |
| 41 | +# ── Poster geometry ───────────────────────────────────────────────────────── |
| 42 | +# The print CSS defines the poster at exactly 14400 × 10800 CSS pixels, |
| 43 | +# which is 48 × 36 inches at 300 DPI. |
| 44 | +# |
| 45 | +# Playwright's PDF renderer works at 96 CSS px per inch. To map the 14400 px |
| 46 | +# layout onto a 48-inch PDF page we need: |
| 47 | +# |
| 48 | +# scale = (48 in × 96 px/in) / 14400 px = 4608 / 14400 ≈ 0.32 |
| 49 | +# |
| 50 | +# Text and SVG paths are vector in the PDF, so they print crisply at any size. |
| 51 | +# PNG/JPEG assets are embedded at their original pixel resolution. |
| 52 | + |
| 53 | +PAPER_W_IN = 48 # inches |
| 54 | +PAPER_H_IN = 36 # inches |
| 55 | +CSS_PX_W = 14400 |
| 56 | +CSS_PX_H = 10800 |
| 57 | +CSS_DPI = 96 # Playwright's assumed CSS px density |
| 58 | +SCALE = round((PAPER_W_IN * CSS_DPI) / CSS_PX_W, 6) # ≈ 0.32 |
| 59 | + |
| 60 | + |
| 61 | +# ── Dependency bootstrap ──────────────────────────────────────────────────── |
| 62 | + |
| 63 | +def _ensure_playwright() -> None: |
| 64 | + """Install the playwright Python package and Chromium if missing.""" |
| 65 | + try: |
| 66 | + import playwright # noqa: F401 |
| 67 | + except ImportError: |
| 68 | + print("playwright not found — installing …", flush=True) |
| 69 | + subprocess.check_call( |
| 70 | + [sys.executable, "-m", "pip", "install", "-q", "playwright"], |
| 71 | + ) |
| 72 | + |
| 73 | + # Verify the Chromium browser binary exists; install if not. |
| 74 | + try: |
| 75 | + from playwright.sync_api import sync_playwright |
| 76 | + with sync_playwright() as pw: |
| 77 | + exe = pw.chromium.executable_path |
| 78 | + if not Path(exe).exists(): |
| 79 | + raise FileNotFoundError(exe) |
| 80 | + except Exception: |
| 81 | + print("Chromium browser not found — running 'playwright install chromium' …", |
| 82 | + flush=True) |
| 83 | + subprocess.check_call( |
| 84 | + [sys.executable, "-m", "playwright", "install", "chromium"], |
| 85 | + ) |
| 86 | + |
| 87 | + |
| 88 | +# ── Main build ────────────────────────────────────────────────────────────── |
| 89 | + |
| 90 | +def build_pdf() -> None: |
| 91 | + _ensure_playwright() |
| 92 | + |
| 93 | + from playwright.sync_api import sync_playwright # noqa: PLC0415 |
| 94 | + |
| 95 | + if not HTML_PATH.exists(): |
| 96 | + print( |
| 97 | + f"ERROR: {HTML_PATH} not found.\n" |
| 98 | + "Run python poster/scripts/build_poster.py first.", |
| 99 | + file=sys.stderr, |
| 100 | + ) |
| 101 | + sys.exit(1) |
| 102 | + |
| 103 | + html_url = HTML_PATH.as_uri() |
| 104 | + |
| 105 | + print(f"Source {HTML_PATH.relative_to(REPO_ROOT)}") |
| 106 | + print(f"Output {PDF_PATH.relative_to(REPO_ROOT)}") |
| 107 | + print(f"Paper {PAPER_W_IN}″ × {PAPER_H_IN}″ (landscape)") |
| 108 | + print(f"Scale {SCALE} ({CSS_PX_W} px → {PAPER_W_IN} in)") |
| 109 | + print("Rendering …", flush=True) |
| 110 | + |
| 111 | + with sync_playwright() as pw: |
| 112 | + browser = pw.chromium.launch( |
| 113 | + args=[ |
| 114 | + "--no-sandbox", # required in WSL / container |
| 115 | + "--disable-setuid-sandbox", |
| 116 | + "--disable-dev-shm-usage", # avoids /dev/shm crashes in containers |
| 117 | + "--font-render-hinting=none", # crisper font rendering in PDF |
| 118 | + ], |
| 119 | + ) |
| 120 | + |
| 121 | + page = browser.new_page( |
| 122 | + viewport={"width": CSS_PX_W, "height": CSS_PX_H}, |
| 123 | + ) |
| 124 | + |
| 125 | + # Navigate and wait for the full page render (fonts, images, iframes). |
| 126 | + page.goto(html_url, wait_until="networkidle", timeout=90_000) |
| 127 | + |
| 128 | + # Extra dwell time for web-font FOUT and deferred image loads. |
| 129 | + page.wait_for_timeout(3_000) |
| 130 | + |
| 131 | + pdf_bytes = page.pdf( |
| 132 | + width=f"{PAPER_W_IN}in", |
| 133 | + height=f"{PAPER_H_IN}in", |
| 134 | + print_background=True, |
| 135 | + scale=SCALE, |
| 136 | + ) |
| 137 | + |
| 138 | + browser.close() |
| 139 | + |
| 140 | + PDF_PATH.write_bytes(pdf_bytes) |
| 141 | + |
| 142 | + size_mb = PDF_PATH.stat().st_size / (1024 * 1024) |
| 143 | + print(f"Done {size_mb:.1f} MB → {PDF_PATH.name}") |
| 144 | + |
| 145 | + if os.environ.get("POSTER_OPEN") == "1": |
| 146 | + _open_file(PDF_PATH) |
| 147 | + |
| 148 | + |
| 149 | +def _open_file(path: Path) -> None: |
| 150 | + """Best-effort: open the PDF in the system viewer.""" |
| 151 | + import shutil |
| 152 | + for cmd in ("xdg-open", "open", "start"): |
| 153 | + if shutil.which(cmd): |
| 154 | + subprocess.Popen([cmd, str(path)]) |
| 155 | + return |
| 156 | + |
| 157 | + |
| 158 | +if __name__ == "__main__": |
| 159 | + build_pdf() |
0 commit comments