From 206a6ff6b7d088bb26a88d6690b190f5185b94a6 Mon Sep 17 00:00:00 2001 From: nopeitsnothing Date: Tue, 14 Apr 2026 03:22:10 -0400 Subject: [PATCH] Refactor PDF build in CI, add dark mode PDF (pt 4) Refactored GitHub Actions workflow **Build guide PDF** (`scripts\build_guide_pdf.py`): now builds both light and dark mode PDFs (`export/thgtoa.pdf` and `export/thgtoa-dark.pdf` respectively). Signed-off-by: nopeitsnothing --- .github/workflows/build-pdf.yml | 17 +- .gitignore | 1 + CHANGELOG.md | 4 + docs/mirrors/index.md | 4 +- docs/stylesheets/dark-extra.css | 130 ++++++++++ scripts/build_guide_pdf.py | 408 +++++++++++++++++++------------- 6 files changed, 397 insertions(+), 167 deletions(-) create mode 100644 docs/stylesheets/dark-extra.css diff --git a/.github/workflows/build-pdf.yml b/.github/workflows/build-pdf.yml index 67c2895..16f37c1 100644 --- a/.github/workflows/build-pdf.yml +++ b/.github/workflows/build-pdf.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Install MkDocs Material run: pip install mkdocs-material @@ -44,13 +44,22 @@ jobs: - name: Build PDF env: CI: true - run: python scripts/build_guide_pdf.py + run: python scripts/build_guide_pdf.py --both - name: Upload PDF artifact uses: actions/upload-artifact@v7 with: - name: guide-pdf - path: export/guide.pdf + name: light-pdf + path: export/thgtoa.pdf + archive: false + if-no-files-found: error + retention-days: 90 + + - name: Upload PDF artifact (Dark Mode) + uses: actions/upload-artifact@v7 + with: + name: dark-pdf + path: export/thgtoa-dark.pdf archive: false if-no-files-found: error retention-days: 90 diff --git a/.gitignore b/.gitignore index 4481382..6125409 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ site/ _site/ _site_test/ export/ +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 123fc52..4ecde53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Refactored GitHub Actions workflow **Build guide PDF** (`scripts\build_guide_pdf.py`): now builds both light and dark mode PDFs (`export/thgtoa.pdf` and `export/thgtoa-dark.pdf` respectively). + ## [1.2.1] - 2026-04-11 ### Added diff --git a/docs/mirrors/index.md b/docs/mirrors/index.md index 469cc71..af07d58 100644 --- a/docs/mirrors/index.md +++ b/docs/mirrors/index.md @@ -27,9 +27,9 @@ schema: !!! Note "PDF export (single file)" - The guide is also available as a **PDF** (images and layout preserved). It is built automatically in GitHub Actions: open [**Build guide PDF**](https://github.com/Anon-Planet/thgtoa/actions/workflows/build-pdf.yml) on the [**thgtoa** source repository](https://github.com/Anon-Planet/thgtoa), pick a successful run, and download the **`guide-pdf`** artifact. You can start a fresh build anytime (**Actions** → **Build guide PDF** → **Run workflow**). + The guide is also available as a **PDF** (images and layout preserved). It is built automatically in GitHub Actions: open [**Build guide PDF**](https://github.com/Anon-Planet/thgtoa/actions/workflows/build-pdf.yml) on the [**source repository**](https://github.com/Anon-Planet/thgtoa), pick a successful run, and download the **`thgtoa`** and **`thgtoa-dark`** artifacts. You can start a fresh build anytime (**Actions** → **Build guide PDF** → **Run workflow**). - To produce the same file locally, clone the repository and run `python scripts/build_guide_pdf.py` (Python, [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/), and **Google Chrome** or **Microsoft Edge** required). More detail is in the [repository README](https://github.com/Anon-Planet/thgtoa#ways-to-read-or-export-the-guide). + To produce the same file locally, clone the repository and run `python3 scripts/build_guide_pdf_extended.py --both` (Python, [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/), and **Google Chrome** or **Microsoft Edge** required). More detail is in the [repository README](https://github.com/Anon-Planet/thgtoa#ways-to-read-or-export-the-guide). !!! Note "Our official git mirrors" diff --git a/docs/stylesheets/dark-extra.css b/docs/stylesheets/dark-extra.css new file mode 100644 index 0000000..0f55f5f --- /dev/null +++ b/docs/stylesheets/dark-extra.css @@ -0,0 +1,130 @@ +/* Generate dark mode PDF of the HTML at guide/index.html */ + +/* + DARK_MODE_PDF.CSS + Use this stylesheet when generating a PDF from HTML. +*/ + +:root { + /* Color Palette */ + --bg-color: #121212; /* Deep dark grey (easier on eyes than pure black) */ + --text-primary: #e0e0e0; /* Off-white for readability */ + --text-secondary: #a0a0a0; /* Grey for captions/metadata */ + --accent-color: #bb86fc; /* Light purple accent (optional) */ + --border-color: #333333; /* Subtle borders */ + + /* Fonts - System fonts ensure best rendering across PDF engines */ + --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +/* --- RESET & BASE STYLES --- */ +* { + box-sizing: border-box; +} + +body { + background-color: var(--bg-color); + color: var(--text-primary); + font-family: var(--font-main); + line-height: 1.6; + margin: 0; + padding: 0; +} + +/* --- TYPOGRAPHY & HEADINGS --- */ +h1, h2, h3, h4, h5, h6 { + color: var(--text-primary); + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +p { + margin-bottom: 1rem; + color: var(--text-secondary); /* Slightly dimmer text for body copy */ +} + +a { + color: var(--accent-color); + text-decoration: underline; +} + +/* --- CONTAINER & LAYOUT --- */ +.container { + max-width: 800px; + margin: 40px auto; + padding: 20px; +} + +/* Cards / Sections with dark backgrounds */ +.card { + background-color: #1e1e1e; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} + +/* --- TABLES (Common in PDFs) --- */ +table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + color: var(--text-primary); +} + +th, td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +th { + background-color: #2c2c2c; + font-weight: bold; +} + +tr:last-child td { + border-bottom: none; +} + +/* --- IMAGES --- */ +img { + max-width: 100%; + height: auto; + display: block; + margin: 20px 0; + /* This ensures high contrast images don't get washed out */ + filter: brightness(1.1); +} + +/* --- CRITICAL FOR PDF GENERATORS --- */ +/* Forces the browser/PDF engine to print background colors and graphics */ +@media print { + @page { + size: A4; /* Change to 'Letter' if preferred */ + margin: 20mm; + } + + body { + background-color: var(--bg-color) !important; + color: var(--text-primary) !important; + } + + .card, table th { + -webkit-print-color-adjust: exact !important; /* Chrome/Safari */ + print-color-adjust: exact !important; /* Firefox/Standard */ + background-color: #1e1e1e !important; + color: var(--text-primary) !important; + } + + /* Prevent page breaks in the middle of a sentence or card if possible */ + .card { + break-inside: avoid; + } + + /* Hide elements you don't want in PDF (like navigation bars) */ + nav, footer, button { + display: none !important; + } +} diff --git a/scripts/build_guide_pdf.py b/scripts/build_guide_pdf.py index 7ad142c..b183735 100644 --- a/scripts/build_guide_pdf.py +++ b/scripts/build_guide_pdf.py @@ -1,161 +1,247 @@ -#!/usr/bin/env python3 -"""Build the MkDocs site, then render docs/guide/ to a single PDF via a Chromium-based browser. - -Uses headless Chrome/Edge print-to-PDF (embeds images). WeasyPrint-based mkdocs-with-pdf is -omitted here because it needs GTK/Pango (awkward on Windows). - -Usage (from repo root): - python scripts/build_guide_pdf.py - python scripts/build_guide_pdf.py --site-dir build/html --pdf export/guide.pdf -""" - -from __future__ import annotations - -import argparse -import os -import shutil -import subprocess -import sys -import time -from pathlib import Path - - -def repo_root() -> Path: - return Path(__file__).resolve().parent.parent - - -def find_chromium_executable() -> Path | None: - 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", - Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Google/Chrome/Application/chrome.exe", - Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/Application/chrome.exe", - ] - for p in paths: - if p.is_file(): - return p - for name in ("chrome", "msedge"): - w = shutil.which(name) - if w: - return Path(w) - elif sys.platform == "darwin": - for p in ( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - ): - if os.path.isfile(p): - return Path(p) - for name in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"): - w = shutil.which(name) - if w: - return Path(w) - return None - - -def run_mkdocs(site_dir: Path) -> None: - 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 print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path: - """Write PDF to ``pdf_out``. Uses a temp file first so an open ``guide.pdf`` on Windows - does not block the build: if the final path is locked, writes ``guide-new.pdf`` instead. - """ - pdf_out.parent.mkdir(parents=True, exist_ok=True) - partial = pdf_out.parent / f".{pdf_out.name}.writing" - partial.unlink(missing_ok=True) - - uri = html_file.resolve().as_uri() - # Chromium headless print; allow time for fonts/images on very large pages. - cmd = [str(browser)] - if os.environ.get("CI"): - # GitHub Actions / other CI runners often need these for Chromium to start. - cmd += [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - ] - cmd += [ - "--headless=new", - "--disable-gpu", - "--no-pdf-header-footer", - 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 - - -def main() -> int: - root = repo_root() - ap = argparse.ArgumentParser(description="Build MkDocs + single-page guide PDF.") - ap.add_argument( - "--site-dir", - type=Path, - default=root / "site", - help="MkDocs output directory (default: ./site)", - ) - ap.add_argument( - "--pdf", - type=Path, - default=root / "export" / "guide.pdf", - help="Output PDF path (default: ./export/guide.pdf)", - ) - ap.add_argument("--skip-mkdocs", action="store_true", help="Reuse existing site dir; only run print-to-pdf.") - args = ap.parse_args() - - 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 - - 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 - - out = print_to_pdf(browser, guide_html, args.pdf) - size_kb = out.stat().st_size // 1024 - print(f"Wrote {out.resolve()} ({size_kb} KiB)") - if out.resolve() != args.pdf.resolve(): - print( - f"Note: {args.pdf.name} was in use; close it and rename or replace with the file above.", - file=sys.stderr, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 +"""Extended version of build_guide_pdf.py with dark mode support. + +This script builds both light and dark mode MkDocs site, then renders docs/guide/ to single PDFs via Chromium. + +Usage: + python scripts/build_guide_pdf_extended.py # Generate light mode PDF only + python scripts/build_guide_pdf_extended.py --dark-mode # Generate dark mode PDF only + python scripts/build_guide_pdf_extended.py --both # Generate both light and dark mode PDFs + +Examples: + python scripts/build_guide_pdf_extended.py --site-dir build/html --pdf-light export/thgtoa.pdf + python scripts/build_guide_pdf_extended.py --dark-mode --pdf-dark export/thgtoa-dark.pdf +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def find_chromium_executable() -> Path | None: + 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", + Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Google/Chrome/Application/chrome.exe", + Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/Application/chrome.exe", + ] + for p in paths: + if p.is_file(): + return p + for name in ("chrome", "msedge"): + w = shutil.which(name) + if w: + return Path(w) + elif sys.platform == "darwin": + for p in ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ): + if os.path.isfile(p): + return Path(p) + for name in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"): + w = shutil.which(name) + if w: + return Path(w) + return None + + +def run_mkdocs(site_dir: Path) -> None: + 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 print_to_pdf(browser: Path, html_file: Path, pdf_out: Path, dark_mode: bool = False) -> Path: + """Write PDF to ``pdf_out``. Uses a temp file first so an open ``guide.pdf`` on Windows + does not block the build: if the final path is locked, writes ``guide-new.pdf`` instead. + + Args: + browser: Path to Chromium executable + html_file: Path to HTML file to convert + pdf_out: Output PDF path + dark_mode: If True, use dark mode color scheme via --prefers-color-scheme flag + """ + pdf_out.parent.mkdir(parents=True, exist_ok=True) + partial = pdf_out.parent / f".{pdf_out.name}.writing" + partial.unlink(missing_ok=True) + + uri = html_file.resolve().as_uri() + + # Chromium headless print; allow time for fonts/images on very large pages. + cmd = [str(browser)] + if os.environ.get("CI"): + # GitHub Actions / other CI runners often need these for Chromium to start. + cmd += [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + ] + + cmd += [ + "--headless=new", + "--disable-gpu", + "--no-pdf-header-footer", + ] + + # Add dark mode preference if requested + if dark_mode: + cmd.append("--prefers-color-scheme=dark") + + cmd += [ + 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 + + +def generate_dark_mode_html(html_file: Path, output_file: Path, dark_css_path: Path) -> None: + """Create a temporary HTML file with dark mode stylesheet applied. + + This is used when we need to force dark mode rendering via CSS rather than browser flags. + """ + try: + from bs4 import BeautifulSoup + + # Read the original HTML + html_content = html_file.read_text(encoding='utf-8') + soup = BeautifulSoup(html_content, 'html.parser') + + # Add dark mode stylesheet link if not present + existing_links = [link.get('href', '') for link in soup.find_all('link', rel='stylesheet')] + if not any(dark_css_path.name in link for link in existing_links): + head = soup.head or soup.new_tag('head') + link_tag = soup.new_tag('link', rel='stylesheet', href=str(dark_css_path)) + if soup.head: + soup.head.append(link_tag) + else: + # Create a new head section + new_head = soup.new_tag('head') + new_head.append(link_tag) + soup.insert(0, new_head) + + # Write the modified HTML + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_text(str(soup), encoding='utf-8') + except ImportError: + print("BeautifulSoup not available. Skipping CSS injection.") + + +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 PDF path for light mode (default: ./export/guide.pdf)", + ) + ap.add_argument( + "--pdf-dark", + type=Path, + default=root / "export" / "thgtoa-dark.pdf", + help="Output PDF path for dark mode (default: ./export/guide-dark.pdf)", + ) + ap.add_argument("--skip-mkdocs", action="store_true", help="Reuse existing site dir; only run print-to-pdf.") + ap.add_argument("--dark-mode", action="store_true", help="Generate dark mode PDF only") + ap.add_argument("--both", action="store_true", help="Generate both light and dark mode PDFs") + args = ap.parse_args() + + # Determine which modes to generate + if args.dark_mode: + modes = ["dark"] + elif args.both: + modes = ["light", "dark"] + else: + modes = ["light"] + + guide_html = args.site_dir / "guide" / "index.html" + + if not args.skip_mkdocs or any(mode == "light" for mode in modes): + 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 + + 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 + + dark_css_path = root / "docs" / "stylesheets" / "dark-extra.css" + + # Generate light mode PDF (default) + if "light" in modes: + out_light = print_to_pdf(browser, guide_html, args.pdf_light, dark_mode=False) + size_kb = out_light.stat().st_size // 1024 + print(f"Wrote {out_light.resolve()} ({size_kb} KiB) [Light Mode]") + if out_light.resolve() != args.pdf_light.resolve(): + print( + f"Note: {args.pdf_light.name} was in use; close it and rename or replace with the file above.", + file=sys.stderr, + ) + + # Generate dark mode PDF + if "dark" in modes: + out_dark = print_to_pdf(browser, guide_html, args.pdf_dark, dark_mode=True) + size_kb = out_dark.stat().st_size // 1024 + print(f"Wrote {out_dark.resolve()} ({size_kb} KiB) [Dark Mode]") + if out_dark.resolve() != args.pdf_dark.resolve(): + print( + f"Note: {args.pdf_dark.name} was in use; close it and rename or replace with the file above.", + file=sys.stderr, + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())