Skip to content

Commit e41b2f8

Browse files
Hackshavenrelay-bot
authored andcommitted
feat: add script to convert HTML poster to press-ready PDF
Signed-off-by: Eric Hackathorn <erichackathorn@gmail.com>
1 parent 9286a45 commit e41b2f8

File tree

2 files changed

+159
-0
lines changed

2 files changed

+159
-0
lines changed

poster/html/zyra-poster-print.pdf

3.38 MB
Binary file not shown.

poster/scripts/build_pdf.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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

Comments
 (0)