Skip to content

Commit fa99d2d

Browse files
webmaxruCopilot
andcommitted
Add downloadable PDF edition of the book
Generate a single-page book edition (site/book.html) from the same content source with print-optimized CSS and a light code theme, and render it to a downloadable PDF (site/gh-aw-book.pdf) via Playwright headless Chromium (scripts/build_pdf.py). The PDF keeps syntax highlighting, page numbers, and a chapter outline. Add Download PDF links across the site (home cover action, reader nav, colophon; chapter breadcrumb and footer) and to llms.txt. Wire the PDF build into the deploy workflow so it regenerates on every book change; publish it to gh-pages alongside the site. The PDF is a binary build artifact and is gitignored (never committed to main). Document local PDF builds in the README. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 2bede16 commit fa99d2d

25 files changed

Lines changed: 2729 additions & 48 deletions

.github/workflows/deploy-pages.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ jobs:
4343
python -m pip install pyyaml
4444
python site/generate.py
4545
46+
- name: Build downloadable PDF (single-page edition)
47+
# Renders site/book.html to site/gh-aw-book.pdf with headless Chromium so
48+
# the published PDF is regenerated on every book change and shipped in site/.
49+
run: |
50+
python -m pip install -r scripts/requirements-pdf.txt
51+
python -m playwright install --with-deps chromium
52+
python scripts/build_pdf.py
53+
4654
- name: Publish site/ to gh-pages
4755
run: |
4856
cd site

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ __pycache__/
1414
dist/
1515
build/
1616

17+
# Downloadable book PDF — a binary build artifact rendered from site/book.html by
18+
# scripts/build_pdf.py (in CI on every deploy, and locally on demand). It is
19+
# published to gh-pages but not committed to main.
20+
site/gh-aw-book.pdf
21+
22+
# Playwright browser-download marker (browsers install to a shared cache, not here)
23+
.playwright/
24+
1725
# gh-aw compiled lock files — examples are verified at compile time (gh aw compile),
1826
# but the generated *.lock.yml (≈100 KB each) are build outputs, not committed sources.
1927
*.lock.yml

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,17 @@ site/
164164
generate.py # scaffolds the site from content/toc.yml
165165
index.html # generated
166166
chapters/*.html # generated: one page per chapter in toc.yml
167+
book.html # generated: single-page edition (source for the PDF)
168+
gh-aw-book.pdf # build artifact (gitignored): the downloadable PDF
167169
assets/ # style.css + app.js + analytics.js (built beacon)
168170
analytics/
169171
analytics.entry.js # cookieless App Insights beacon source (bundled → site/assets/analytics.js)
170172
azure/
171173
dashboard.json # Portal engagement dashboard (ARM template)
172174
package.json # analytics build tooling (esbuild bundle + report command)
173175
scripts/
176+
build_pdf.py # renders site/book.html → site/gh-aw-book.pdf (Playwright)
177+
requirements-pdf.txt # Python deps for the PDF build
174178
run-fleet.ps1 # convenience launcher
175179
report.ps1 # engagement report (PowerShell)
176180
```
@@ -211,6 +215,32 @@ gh aw compile examples/<chapter>/<workflow>.md
211215
212216
---
213217

218+
## Download the book as a PDF
219+
220+
The whole book is also available as a **single downloadable PDF**, linked from the site (home page
221+
"Download PDF", the reader nav, and every chapter). It is rendered from a **single-page edition**
222+
(`site/book.html`, which `site/generate.py` produces alongside the chapter pages) using headless
223+
Chromium via [Playwright](https://playwright.dev/python/), so code blocks keep their syntax
224+
highlighting and the PDF carries page numbers and a chapter outline.
225+
226+
```powershell
227+
# 1. (re)generate the site, including site/book.html (the PDF's source)
228+
python site/generate.py
229+
230+
# 2. render the PDF → site/gh-aw-book.pdf
231+
pip install -r scripts/requirements-pdf.txt
232+
python -m playwright install chromium # one-time browser download (add --with-deps on Linux)
233+
python scripts/build_pdf.py
234+
```
235+
236+
The PDF (`site/gh-aw-book.pdf`) is a **binary build artifact**: it is gitignored, not committed to
237+
`main`. On every push that changes the book, the deploy workflow
238+
([`.github/workflows/deploy-pages.yml`](.github/workflows/deploy-pages.yml)) regenerates it right
239+
after `generate.py` and publishes it to the live site — so the downloadable PDF always matches the
240+
current book.
241+
242+
---
243+
214244
## Privacy-friendly analytics (cookieless RUM)
215245

216246
The published site is instrumented with **cookieless Real User Monitoring** via Azure Application

scripts/build_pdf.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
"""Render the single-page book edition (site/book.html) into a downloadable PDF.
3+
4+
This is the second half of the book build: `site/generate.py` emits
5+
`site/book.html` (all chapters on one printable page); this script loads that page
6+
in headless Chromium (Playwright) and prints it to `site/gh-aw-book.pdf` with page
7+
numbers, a running footer, and a PDF outline. Chromium runs highlight.js, so code
8+
blocks keep their syntax colors in the PDF.
9+
10+
The PDF is a binary BUILD ARTIFACT: it is gitignored and produced fresh here (in CI
11+
on every book change, and locally on demand) rather than committed to `main`.
12+
13+
Usage:
14+
python site/generate.py # 1. (re)build site/book.html
15+
python scripts/build_pdf.py # 2. render the PDF
16+
17+
Requirements (see scripts/requirements-pdf.txt):
18+
python -m pip install -r scripts/requirements-pdf.txt
19+
python -m playwright install chromium # add --with-deps on Linux/CI
20+
"""
21+
from __future__ import annotations
22+
23+
import functools
24+
import http.server
25+
import socketserver
26+
import sys
27+
import threading
28+
from pathlib import Path
29+
30+
ROOT = Path(__file__).resolve().parents[1]
31+
SITE = ROOT / "site"
32+
BOOK_PAGE = "book.html"
33+
PDF_PATH = SITE / "gh-aw-book.pdf"
34+
35+
# Running footer: book title on the left, "Page N / M" on the right. The
36+
# pageNumber / totalPages spans are filled in by Chromium.
37+
FOOTER_TEMPLATE = (
38+
'<div style="width:100%;font-family:\'Hanken Grotesk\',Arial,sans-serif;'
39+
'font-size:8px;color:#8a93a6;padding:0 14mm;display:flex;'
40+
'justify-content:space-between;align-items:center;">'
41+
'<span>GitHub Agentic Workflows \u2014 An Interactive Book</span>'
42+
'<span>Page <span class="pageNumber"></span> / <span class="totalPages"></span></span>'
43+
"</div>"
44+
)
45+
HEADER_TEMPLATE = '<div style="height:0"></div>'
46+
47+
PDF_MARGIN = {"top": "16mm", "bottom": "18mm", "left": "15mm", "right": "15mm"}
48+
49+
50+
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
51+
def log_message(self, *args): # noqa: D401 - silence per-request logging
52+
pass
53+
54+
55+
def _serve(directory: Path) -> tuple[socketserver.TCPServer, int]:
56+
"""Serve `directory` on an ephemeral localhost port in a background thread."""
57+
handler = functools.partial(_QuietHandler, directory=str(directory))
58+
httpd = socketserver.TCPServer(("127.0.0.1", 0), handler)
59+
port = httpd.server_address[1]
60+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
61+
return httpd, port
62+
63+
64+
def build_pdf() -> Path:
65+
try:
66+
from playwright.sync_api import sync_playwright
67+
except ImportError as exc: # pragma: no cover - dependency guard
68+
raise SystemExit(
69+
"Playwright is not installed. Run:\n"
70+
" python -m pip install -r scripts/requirements-pdf.txt\n"
71+
" python -m playwright install chromium"
72+
) from exc
73+
74+
book = SITE / BOOK_PAGE
75+
if not book.exists():
76+
raise SystemExit(
77+
f"{book} not found. Run `python site/generate.py` first to build it."
78+
)
79+
80+
httpd, port = _serve(SITE)
81+
url = f"http://127.0.0.1:{port}/{BOOK_PAGE}"
82+
try:
83+
with sync_playwright() as playwright:
84+
browser = playwright.chromium.launch()
85+
page = browser.new_page()
86+
page.goto(url, wait_until="networkidle", timeout=90_000)
87+
# Wait for the page's load handler to run highlight.js and flag readiness.
88+
try:
89+
page.wait_for_function(
90+
"document.documentElement.getAttribute('data-book-ready') === '1'",
91+
timeout=20_000,
92+
)
93+
except Exception: # highlight.js CDN slow/unavailable — print anyway
94+
pass
95+
# Ensure web fonts are laid out before measuring page breaks.
96+
page.evaluate("() => (document.fonts ? document.fonts.ready : true)")
97+
page.wait_for_timeout(400)
98+
99+
pdf_kwargs = dict(
100+
path=str(PDF_PATH),
101+
format="A4",
102+
print_background=True,
103+
display_header_footer=True,
104+
header_template=HEADER_TEMPLATE,
105+
footer_template=FOOTER_TEMPLATE,
106+
margin=PDF_MARGIN,
107+
)
108+
# `outline`/`tagged` add PDF bookmarks + accessibility on newer
109+
# Playwright; fall back cleanly if the installed version lacks them.
110+
try:
111+
page.pdf(outline=True, tagged=True, **pdf_kwargs)
112+
except TypeError:
113+
page.pdf(**pdf_kwargs)
114+
browser.close()
115+
finally:
116+
httpd.shutdown()
117+
118+
return PDF_PATH
119+
120+
121+
def main() -> None:
122+
pdf = build_pdf()
123+
size_kb = pdf.stat().st_size / 1024
124+
print(f"Wrote {pdf} ({size_kb:,.0f} KB)")
125+
if size_kb < 20:
126+
print("Warning: PDF is unexpectedly small — check the render.", file=sys.stderr)
127+
sys.exit(1)
128+
129+
130+
if __name__ == "__main__":
131+
main()

scripts/requirements-pdf.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Dependencies for scripts/build_pdf.py — renders site/book.html into a PDF with
2+
# headless Chromium (Playwright). Kept separate from the site generator, which
3+
# only needs PyYAML, so `python site/generate.py` stays dependency-light.
4+
#
5+
# After installing, download the browser once:
6+
# python -m playwright install --with-deps chromium # CI (Linux)
7+
# python -m playwright install chromium # local (Windows/macOS)
8+
playwright>=1.44

0 commit comments

Comments
 (0)