Files
animation/tools/humans_to_pdf.py
T
jeremy 4bbd1a1732 docs(humans): ship the MD explainer as a PDF with the images baked in
The HTML references screenshots relatively, so it only renders inside a
checkout -- mailing the file on its own gives broken images. The PDF is one
self-contained file, which is what actually gets sent to Ozlem.

- tools/humans_to_pdf.py: renders .humans/*.html via headless Chrome (or
  Edge), so the output is what the browser shows. HTML stays the source.
- @media print block on the page: the wide tables and the pipeline diagram
  scroll on screen but would be cut off at the paper edge; this reflows
  them and keeps figures, callouts and table rows off page breaks.
  Verified at A4 content width -- 6 pages, both screenshots embedded.
- *.pdf -> LFS. Each regeneration is a whole new ~2 MB blob, and unlike
  png/jpg there are no PDFs tracked raw yet, so there is no history to
  rewrite by adding it now.

Also corrects working-files.md: it claimed .gitattributes does not LFS-track
.blend, which was true when written and is no longer -- .blend/.zprj/.obj/
.npy/.npz have since been added. The note now covers what is actually still
excluded (png/jpg, deliberately) and why that makes QA renders scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:31:37 -07:00

95 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Render a .humans/ HTML page to a self-contained PDF via headless Chrome.
The pages in `.humans/` reference screenshots relatively
(`../tools/tailor/screenshots/...`), so the HTML only works from inside a repo
checkout. The PDF embeds those images, which makes it the thing you actually
send to someone.
The HTML stays the source of truth: edit the page, re-run this, commit both.
python tools/humans_to_pdf.py .humans/marvelous-designer.html
python tools/humans_to_pdf.py --all
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
CHROME_CANDIDATES = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
]
def find_chrome() -> Path:
for c in CHROME_CANDIDATES:
p = Path(c)
if p.exists():
return p
raise SystemExit("error: no Chrome or Edge found; tried:\n "
+ "\n ".join(CHROME_CANDIDATES))
def to_pdf(html: Path, chrome: Path) -> Path:
out = html.with_suffix(".pdf")
# Chrome refuses to reuse a running profile, so give it a throwaway one.
with tempfile.TemporaryDirectory() as profile:
cmd = [
str(chrome),
"--headless=new",
"--disable-gpu",
f"--user-data-dir={profile}",
"--no-pdf-header-footer", # drop the URL/date furniture
"--virtual-time-budget=15000", # let local images decode first
f"--print-to-pdf={out}",
html.resolve().as_uri(),
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if not out.exists():
sys.stderr.write(r.stderr or "")
raise SystemExit(f"error: Chrome produced no PDF for {html}")
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("page", type=Path, nargs="?", help="HTML page under .humans/")
ap.add_argument("--all", action="store_true", help="render every .humans/*.html")
args = ap.parse_args()
repo = Path(__file__).resolve().parent.parent
if args.all:
pages = sorted((repo / ".humans").glob("*.html"))
elif args.page:
pages = [args.page]
else:
ap.error("give a page, or --all")
if not pages:
print("nothing to render")
return 0
chrome = find_chrome()
print(f"using {chrome.name}")
for html in pages:
if not html.is_file():
print(f" skip (missing): {html}")
continue
out = to_pdf(html, chrome)
print(f" {out.stat().st_size / 1048576:.2f} MB {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())