#!/usr/bin/env python3 """Build light-mode PDF with MkDocs + Chromium, then produce dark-mode PDF via convert.py. Usage: python scripts/build_guide_pdf.py # Light PDF only (clean light theme) python scripts/build_guide_pdf.py --dark # Dark PDF only (requires a body-only # cache from a prior light build in # this checkout, e.g. via --both) python scripts/build_guide_pdf.py --both # Light PDF, then dark PDF Examples: python scripts/build_guide_pdf.py --site-dir build/html --pdf-light export/thgtoa.pdf python scripts/build_guide_pdf.py --both --pdf-light export/thgtoa.pdf --pdf-dark export/thgtoa-dark.pdf """ from __future__ import annotations import argparse import html as _html_mod import os import re import shutil import subprocess import sys import tempfile import time import unicodedata from pathlib import Path def repo_root() -> Path: return Path(__file__).resolve().parent.parent def find_chromium_executable() -> Path | None: """Find a Chromium-based browser on the system (prioritizes WSL/Linux paths). On WSL Windows, checks WSL tools first, then falls back to Windows paths. """ import os as _os import shutil wsl_paths = [ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/usr/bin/microsoft-edge-dev", "/snap/bin/chromium", "/usr/local/bin/chrome", "/opt/google-chrome/", ] for p in wsl_paths: if os.path.isfile(p): return Path(p) for name in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "microsoft-edge-stable", "microsoft-edge", "msedge", "chrome"): w = shutil.which(name) if w: return Path(w) if sys.platform == "win32": paths = [ Path(_os.environ.get("PROGRAMFILES(X86)", "")) / "Microsoft/Edge/Application/msedge.exe", Path(_os.environ.get("LOCALAPPDATA", "")) / "Microsoft/Edge/Application/msedge.exe", Path(_os.environ.get("PROGRAMFILES", "")) / "Google/Chrome/Application/chrome.exe", ] for p in paths: if p.is_file(): return p return None def run_mkdocs(site_dir: Path) -> None: """Build MkDocs site.""" site_dir.mkdir(parents=True, exist_ok=True) subprocess.run( [sys.executable, "-m", "mkdocs", "build", "-d", str(site_dir)], cwd=repo_root(), check=True, ) def inject_print_light_css(html_file: Path) -> Path: """Inject print-light.css inline into the built HTML for light PDF builds.""" html_path = html_file.resolve() print_light_css = repo_root() / "docs" / "stylesheets" / "print-light.css" if not print_light_css.is_file(): print(f"Warning: {print_light_css} not found, skipping CSS injection.", file=sys.stderr) return html_path css_content = print_light_css.read_text(encoding="utf-8") html_content = html_path.read_text(encoding="utf-8") head_match = re.search(r"]*)>", html_content, re.IGNORECASE) if not head_match: print("Warning: Could not find tag in HTML, skipping CSS injection.", file=sys.stderr) return html_path insert_pos = head_match.end() style_block = ( "\n\n" "\n" ) new_html = html_content[:insert_pos] + style_block + html_content[insert_pos:] output_file = html_path.with_stem(html_path.stem + "-light-pdf") output_file.write_text(new_html, encoding="utf-8") return output_file def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path: """Render html_file to pdf_out via headless Chromium.""" pdf_out.parent.mkdir(parents=True, exist_ok=True) partial = pdf_out.parent / f".{pdf_out.name}.writing" partial.unlink(missing_ok=True) html_file = inject_print_light_css(html_file) uri = html_file.resolve().as_uri() cmd = [str(browser)] if os.environ.get("CI"): cmd += ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] cmd += [ "--headless=new", "--disable-gpu", "--no-pdf-header-footer", "--print-background", "--no-margins", f"--print-to-pdf={partial.resolve()}", uri, ] subprocess.run(cmd, check=True, timeout=600) deadline = time.time() + 120 while time.time() < deadline: if partial.exists() and partial.stat().st_size > 0: break time.sleep(0.25) else: partial.unlink(missing_ok=True) raise RuntimeError(f"PDF was not written to {partial}") try: if pdf_out.exists(): pdf_out.unlink() except PermissionError: fallback = pdf_out.with_name(f"{pdf_out.stem}-new{pdf_out.suffix}") fallback.unlink(missing_ok=True) partial.replace(fallback) return fallback partial.replace(pdf_out) return pdf_out # Page numbers (stamped with qpdf; no extra Python dependencies) def stamp_page_numbers(pdf_path: Path) -> None: """Stamp a right-aligned page number at the bottom of every page, in place. Chromium's --print-to-pdf can't draw page numbers when the default header/footer is disabled, and @page margin boxes need Chrome >= 131 (CI pins 120). So build a tiny overlay PDF by hand (standard Times font, no dependencies) and merge it with `qpdf --overlay`. Called on the body-only PDF, before the cached copy is taken and before the cover + ToC are prepended, so the body starts at page 1 in both the light and dark builds. """ n_pages = int( subprocess.run( ["qpdf", "--show-npages", str(pdf_path)], check=True, capture_output=True, text=True, ).stdout.strip() ) width, height = 595.28, 841.89 # A4 in points (matches @page size) font_size = 10 baseline_y = 28 # ~10 mm from the bottom edge (inside the 16 mm margin) digit_w = 0.5 * font_size # Times-Roman digits are 500/1000 em right_x = width - 12 * 72 / 25.4 # right text edge (12 mm page margin) objs: list[bytes] = [b""] * (3 + 2 * n_pages) kids = " ".join(f"{4 + 2 * i} 0 R" for i in range(n_pages)) objs[0] = b"<< /Type /Catalog /Pages 2 0 R >>" objs[1] = f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode() objs[2] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>" for i in range(n_pages): label = str(i + 1) x = right_x - digit_w * len(label) stream = f"BT /F1 {font_size} Tf 0.33 g {x:.2f} {baseline_y} Td ({label}) Tj ET".encode() objs[3 + 2 * i] = ( f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] " f"/Resources << /Font << /F1 3 0 R >> >> /Contents {5 + 2 * i} 0 R >>" ).encode() objs[4 + 2 * i] = ( f"<< /Length {len(stream)} >>\nstream\n".encode() + stream + b"\nendstream" ) out = bytearray(b"%PDF-1.4\n") offsets: list[int] = [] for num, body in enumerate(objs, start=1): offsets.append(len(out)) out += f"{num} 0 obj\n".encode() + body + b"\nendobj\n" xref_pos = len(out) out += f"xref\n0 {len(objs) + 1}\n".encode() out += b"0000000000 65535 f \n" for off in offsets: out += f"{off:010d} 00000 n \n".encode() out += ( f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n" f"startxref\n{xref_pos}\n%%EOF\n" ).encode() with tempfile.TemporaryDirectory() as tmp: overlay = Path(tmp) / "page-numbers.pdf" overlay.write_bytes(bytes(out)) stamped = Path(tmp) / "stamped.pdf" subprocess.run( ["qpdf", str(pdf_path), "--overlay", str(overlay), "--", str(stamped)], check=True, ) shutil.move(str(stamped), str(pdf_path)) # --------------------------------------------------------------------------- # Front matter: ToC parsing + HTML generation # --------------------------------------------------------------------------- def parse_toc_headings(guide_md: Path) -> list[tuple[int, str]]: """Extract H2 and H3 headings from the guide markdown source. Returns a list of (level, title) tuples in document order. Strips MkDocs anchor suffixes like { #some-id } and common inline markup. Skips headings inside fenced code blocks. """ headings: list[tuple[int, str]] = [] in_fence = False fence_re = re.compile(r"^\s*```") heading_re = re.compile(r"^(#{2,3})\s+(.+?)(?:\s*\{[^}]*\})?\s*$") inline_re = re.compile( r"\*{1,2}([^*]+)\*{1,2}" # **bold** / *italic* r"|`[^`]+`" # `code` r"|\[([^\]]+)\]\([^)]*\)" # [text](url) ) for line in guide_md.read_text(encoding="utf-8").splitlines(): if fence_re.match(line): in_fence = not in_fence continue if in_fence: continue m = heading_re.match(line) if m: level = len(m.group(1)) raw = m.group(2).strip() title = inline_re.sub( lambda x: x.group(1) or x.group(2) or "", raw ).strip() headings.append((level, title)) return headings def create_toc_html( tmp_dir: str, headings: list[tuple[int, str]], pages: list[int | None], is_dark: bool, scale: float = 1.0, ) -> str: """Write a research-style Table of Contents HTML file and return its path. H2 entries: full-width row with dot leader. H3 entries: indented, italic, muted colour. Layout matches the cover page (A4, same font stack, no Chromium margins). """ if is_dark: bg = "#1f1f31" fg = "#e0e0e0" muted = "#a0a0c0" rule = "#4a4a6a" h3color = "#b8b8d8" else: bg = "#ffffff" fg = "#1a1a1a" muted = "#555555" rule = "#cccccc" h3color = "#444466" rows: list[str] = [] for (level, title), page in zip(headings, pages): safe = _html_mod.escape(title) page_html = f'{page}' if page else "" cls = "toc-h2" if level == 2 else "toc-h3" rows.append( f'
' f'{safe}' f'' f"{page_html}" f"
" ) body = "\n".join(rows) css = ( "* { box-sizing: border-box; margin: 0; padding: 0; }\n" "@page { size: A4; margin: 12mm 12mm 16mm 12mm; }\n" "html, body {\n" f" background: {bg};\n" f" color: {fg};\n" " font-family: 'EB Garamond', Georgia, 'Times New Roman', serif;\n" "}\n" ".page {\n" " padding: 0;\n" f" zoom: {scale};\n" "}\n" ".toc-heading {\n" " font-size: 18pt;\n" " font-weight: normal;\n" " letter-spacing: 0.04em;\n" " margin-bottom: 8mm;\n" " padding-bottom: 3mm;\n" f" border-bottom: 1px solid {rule};\n" f" color: {fg};\n" "}\n" ".toc-h2 {\n" " display: flex;\n" " align-items: baseline;\n" " font-size: 10.5pt;\n" " font-weight: normal;\n" f" color: {fg};\n" " margin-top: 3.5pt;\n" " margin-bottom: 1.5pt;\n" "}\n" ".toc-title {\n" " flex: 0 1 auto;\n" " max-width: 88%;\n" "}\n" ".toc-page {\n" " flex-shrink: 0;\n" " padding-left: 4pt;\n" " font-style: normal;\n" " font-variant-numeric: tabular-nums;\n" "}\n" ".toc-dots {\n" " flex: 1;\n" f" border-bottom: 1px dotted {muted};\n" " margin: 0 4pt;\n" " position: relative;\n" " top: -2pt;\n" " min-width: 8pt;\n" "}\n" ".toc-h3 {\n" " display: flex;\n" " align-items: baseline;\n" " font-size: 9pt;\n" f" color: {h3color};\n" " padding-left: 10mm;\n" " margin-top: 1pt;\n" " margin-bottom: 1pt;\n" " font-style: italic;\n" "}\n" ) html = ( '\n\n\n\n' f"\n\n\n" '
\n' '

Table of Contents

\n' f"{body}\n" "
\n\n\n" ) html_path = os.path.join(tmp_dir, "toc.html") with open(html_path, "w", encoding="utf-8") as f: f.write(html) return html_path # --------------------------------------------------------------------------- # Locating headings in the built body PDF (for ToC page numbers) # --------------------------------------------------------------------------- def locate_heading_pages( pdf_path: Path, headings: list[tuple[int, str]] ) -> list[int | None]: """Return the 1-based page each (level, title) heading starts on in pdf_path. Uses pdftotext (poppler, already required by convert.py) and matches each heading, in document order, against a whole line (or up to 3 consecutive wrapped lines), so a passing mention in body text can't be mistaken for the heading. Headings that can't be found get None: the ToC row then shows dots only, and a warning is printed. """ def norm(s: str) -> str: s = unicodedata.normalize("NFKC", s).replace("­", "") s = (s.replace("‘", "'").replace("’", "'") .replace("“", '"').replace("”", '"') .replace("–", "-").replace("—", "-")) return " ".join(s.split()).casefold() raw = subprocess.run( ["pdftotext", "-enc", "UTF-8", str(pdf_path), "-"], check=True, capture_output=True, ).stdout.decode("utf-8", "replace") lines: list[tuple[int, str]] = [] for page_no, page_text in enumerate(raw.split("\f"), start=1): for line in page_text.splitlines(): n = norm(line) if n: lines.append((page_no, n)) result: list[int | None] = [] missing: list[str] = [] cursor = 0 for _level, title in headings: target = norm(title) hit: tuple[int, int] | None = None for j in range(cursor, len(lines)): joined = lines[j][1] if not target.startswith(joined): continue for k in range(1, 4): if k > 1: if j + k - 1 >= len(lines): break joined = joined + " " + lines[j + k - 1][1] if not target.startswith(joined): break if joined == target: hit = (j, k) break if hit: break if hit: result.append(lines[hit[0]][0]) cursor = hit[0] + hit[1] else: result.append(None) missing.append(title) found = len(headings) - len(missing) print(f" Located {found}/{len(headings)} headings in the body PDF.", flush=True) for t in missing: print(f" Warning: no page found for heading: {t!r}", file=sys.stderr) return result # --------------------------------------------------------------------------- # Shared Chromium render helper # --------------------------------------------------------------------------- def _render_html_to_pdf( browser: Path, html_path: str, pdf_path: str, ci: bool = False ) -> None: """Render a local HTML file to PDF via headless Chromium.""" cmd = [ str(browser), "--headless=new", "--disable-gpu", "--no-pdf-header-footer", "--print-background", "--no-margins", f"--print-to-pdf={pdf_path}", Path(html_path).resolve().as_uri(), ] if ci: cmd[1:1] = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] subprocess.run(cmd, check=True, capture_output=True, timeout=120) # --------------------------------------------------------------------------- # Front matter assembly: cover + ToC prepended to body PDF # --------------------------------------------------------------------------- def prepend_front_matter( browser: Path, pdf_path: Path, is_dark: bool, page_source: Path | None = None, ) -> Path: """Render cover + ToC and prepend them to pdf_path in-place. - Cover HTML is sourced from convert.py (single definition). - ToC headings are parsed from docs/guide/index.md. - Merged order: cover -> toc -> body, via a single qpdf call. """ sys.path.insert(0, str(repo_root() / "scripts")) from convert import create_cover_page # noqa: PLC0415 guide_md = repo_root() / "docs" / "guide" / "index.md" ci = bool(os.environ.get("CI")) print(" Parsing ToC headings...", flush=True) headings = parse_toc_headings(guide_md) n_h2 = sum(1 for l, _ in headings if l == 2) n_h3 = sum(1 for l, _ in headings if l == 3) print(f" Found {len(headings)} headings ({n_h2} H2, {n_h3} H3).", flush=True) # Page numbers come from a text-bearing body PDF: pdf_path itself for the # light build; the cached light body for dark (its pages are rasterised). pages = locate_heading_pages(page_source or pdf_path, headings) with tempfile.TemporaryDirectory() as tmp: print(" Rendering cover page...", flush=True) cover_html = create_cover_page(tmp, is_dark=is_dark) cover_pdf = os.path.join(tmp, "cover.pdf") _render_html_to_pdf(browser, cover_html, cover_pdf, ci=ci) print(" Rendering ToC page...", flush=True) toc_pdf = os.path.join(tmp, "toc.pdf") def _render_toc(scale: float) -> int: html_path = create_toc_html(tmp, headings, pages, is_dark=is_dark, scale=scale) _render_html_to_pdf(browser, html_path, toc_pdf, ci=ci) return int(subprocess.run( ["qpdf", "--show-npages", toc_pdf], check=True, capture_output=True, text=True, ).stdout.strip()) # If the ToC spills onto an extra page, shrink it a little at a time # (down to 85%) until it fits in one page fewer; otherwise keep 100%. toc_pages = _render_toc(1.0) if toc_pages > 1: for scale in (0.97, 0.94, 0.91, 0.88, 0.85): if _render_toc(scale) < toc_pages: print(f" ToC scaled to {scale:.0%} to save a page.", flush=True) break else: _render_toc(1.0) merged = str(pdf_path.parent / f".{pdf_path.name}.with-front") subprocess.run( ["qpdf", "--empty", "--pages", cover_pdf, toc_pdf, str(pdf_path), "--", merged], check=True, ) Path(merged).replace(pdf_path) return pdf_path def build_dark_pdf(browser: Path, body_pdf: Path, dark_pdf: Path) -> Path: """Convert a body-only PDF to dark mode, then prepend fresh front matter.""" convert_script = repo_root() / "scripts" / "convert.py" print(f"Converting {body_pdf.name} -> {dark_pdf.name} (dark mode)...") subprocess.run( [sys.executable, str(convert_script), str(body_pdf), str(dark_pdf)], check=True, ) return prepend_front_matter(browser, dark_pdf, is_dark=True, page_source=body_pdf) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> int: root = repo_root() ap = argparse.ArgumentParser( description="Build MkDocs + single-page guide PDF (light and/or dark mode)." ) ap.add_argument( "--site-dir", type=Path, default=root / "build" / "html", help="MkDocs output directory (default: ./build/html)", ) ap.add_argument( "--pdf-light", type=Path, default=root / "export" / "thgtoa.pdf", help="Output path for light PDF (default: ./export/thgtoa.pdf)", ) ap.add_argument( "--pdf-dark", type=Path, default=root / "export" / "thgtoa-dark.pdf", help="Output path for dark PDF (default: ./export/thgtoa-dark.pdf)", ) ap.add_argument( "--skip-mkdocs", action="store_true", help="Reuse existing site dir; skip MkDocs build.", ) mode = ap.add_mutually_exclusive_group() mode.add_argument("--dark", action="store_true", help="Dark PDF only (requires a body-only cache from a prior light build)") mode.add_argument("--both", action="store_true", help="Build light PDF, then dark PDF") args = ap.parse_args() build_light = not args.dark build_dark = args.dark or args.both body_cache = args.pdf_light.parent / f".{args.pdf_light.stem}.body.pdf" browser = find_chromium_executable() if not browser: print( "No Chromium-based browser found (Chrome, Edge, or Chromium). " "Install Google Chrome or Microsoft Edge, or add Chromium to PATH.", file=sys.stderr, ) return 1 # --- Light PDF --- if build_light: guide_html = args.site_dir / "guide" / "index.html" if not args.skip_mkdocs: run_mkdocs(args.site_dir) if not guide_html.is_file(): print(f"Missing {guide_html}; run without --skip-mkdocs first.", file=sys.stderr) return 1 out_light = print_to_pdf(browser, guide_html, args.pdf_light) print(" Stamping page numbers...", flush=True) stamp_page_numbers(out_light) import shutil as _shutil # noqa: PLC0415 body_cache.parent.mkdir(parents=True, exist_ok=True) _shutil.copy2(out_light, body_cache) out_light = prepend_front_matter(browser, out_light, is_dark=False) size_kb = out_light.stat().st_size // 1024 print(f"Wrote {out_light.resolve()} ({size_kb} KiB) [Light Mode]") # --- Dark PDF --- if build_dark: if not body_cache.exists(): print( f"No body-only PDF cached at {body_cache}. " "Run with --both (or a plain light build first) so a fresh " "body-only copy exists before converting to dark mode.", file=sys.stderr, ) return 1 out_dark = build_dark_pdf(browser, body_cache, args.pdf_dark) size_kb = out_dark.stat().st_size // 1024 print(f"Wrote {out_dark.resolve()} ({size_kb} KiB) [Dark Mode]") return 0 if __name__ == "__main__": raise SystemExit(main())