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 <no@anonymousplanet.org>
This commit is contained in:
nopeitsnothing
2026-04-14 03:22:10 -04:00
parent 0e8de6ccc0
commit 206a6ff6b7
6 changed files with 397 additions and 167 deletions
+13 -4
View File
@@ -31,7 +31,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.13"
- name: Install MkDocs Material - name: Install MkDocs Material
run: pip install mkdocs-material run: pip install mkdocs-material
@@ -44,13 +44,22 @@ jobs:
- name: Build PDF - name: Build PDF
env: env:
CI: true CI: true
run: python scripts/build_guide_pdf.py run: python scripts/build_guide_pdf.py --both
- name: Upload PDF artifact - name: Upload PDF artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: guide-pdf name: light-pdf
path: export/guide.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 archive: false
if-no-files-found: error if-no-files-found: error
retention-days: 90 retention-days: 90
+1
View File
@@ -20,3 +20,4 @@ site/
_site/ _site/
_site_test/ _site_test/
export/ export/
build/
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.2.1] - 2026-04-11
### Added ### Added
+2 -2
View File
@@ -27,9 +27,9 @@ schema:
!!! Note "PDF export (single file)" !!! 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" !!! Note "Our official git mirrors"
+130
View File
@@ -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;
}
}
+247 -161
View File
@@ -1,161 +1,247 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build the MkDocs site, then render docs/guide/ to a single PDF via a Chromium-based browser. """Extended version of build_guide_pdf.py with dark mode support.
Uses headless Chrome/Edge print-to-PDF (embeds images). WeasyPrint-based mkdocs-with-pdf is This script builds both light and dark mode MkDocs site, then renders docs/guide/ to single PDFs via Chromium.
omitted here because it needs GTK/Pango (awkward on Windows).
Usage:
Usage (from repo root): python scripts/build_guide_pdf_extended.py # Generate light mode PDF only
python scripts/build_guide_pdf.py python scripts/build_guide_pdf_extended.py --dark-mode # Generate dark mode PDF only
python scripts/build_guide_pdf.py --site-dir build/html --pdf export/guide.pdf python scripts/build_guide_pdf_extended.py --both # Generate both light and dark mode PDFs
"""
Examples:
from __future__ import annotations 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
import argparse """
import os
import shutil from __future__ import annotations
import subprocess
import sys import argparse
import time import os
from pathlib import Path import shutil
import subprocess
import sys
def repo_root() -> Path: import time
return Path(__file__).resolve().parent.parent from pathlib import Path
def find_chromium_executable() -> Path | None: def repo_root() -> Path:
if sys.platform == "win32": return Path(__file__).resolve().parent.parent
paths = [
Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Microsoft/Edge/Application/msedge.exe",
Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft/Edge/Application/msedge.exe", def find_chromium_executable() -> Path | None:
Path(os.environ.get("PROGRAMFILES", "")) / "Google/Chrome/Application/chrome.exe", if sys.platform == "win32":
Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Google/Chrome/Application/chrome.exe", paths = [
Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/Application/chrome.exe", Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Microsoft/Edge/Application/msedge.exe",
] Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft/Edge/Application/msedge.exe",
for p in paths: Path(os.environ.get("PROGRAMFILES", "")) / "Google/Chrome/Application/chrome.exe",
if p.is_file(): Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Google/Chrome/Application/chrome.exe",
return p Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/Application/chrome.exe",
for name in ("chrome", "msedge"): ]
w = shutil.which(name) for p in paths:
if w: if p.is_file():
return Path(w) return p
elif sys.platform == "darwin": for name in ("chrome", "msedge"):
for p in ( w = shutil.which(name)
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", if w:
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", return Path(w)
"/Applications/Chromium.app/Contents/MacOS/Chromium", elif sys.platform == "darwin":
): for p in (
if os.path.isfile(p): "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
return Path(p) "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
for name in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"): "/Applications/Chromium.app/Contents/MacOS/Chromium",
w = shutil.which(name) ):
if w: if os.path.isfile(p):
return Path(w) return Path(p)
return None for name in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"):
w = shutil.which(name)
if w:
def run_mkdocs(site_dir: Path) -> None: return Path(w)
site_dir.mkdir(parents=True, exist_ok=True) return None
subprocess.run(
[sys.executable, "-m", "mkdocs", "build", "-d", str(site_dir)],
cwd=repo_root(), def run_mkdocs(site_dir: Path) -> None:
check=True, site_dir.mkdir(parents=True, exist_ok=True)
) subprocess.run(
[sys.executable, "-m", "mkdocs", "build", "-d", str(site_dir)],
cwd=repo_root(),
def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path: check=True,
"""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) def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path, dark_mode: bool = False) -> Path:
partial = pdf_out.parent / f".{pdf_out.name}.writing" """Write PDF to ``pdf_out``. Uses a temp file first so an open ``guide.pdf`` on Windows
partial.unlink(missing_ok=True) does not block the build: if the final path is locked, writes ``guide-new.pdf`` instead.
uri = html_file.resolve().as_uri() Args:
# Chromium headless print; allow time for fonts/images on very large pages. browser: Path to Chromium executable
cmd = [str(browser)] html_file: Path to HTML file to convert
if os.environ.get("CI"): pdf_out: Output PDF path
# GitHub Actions / other CI runners often need these for Chromium to start. dark_mode: If True, use dark mode color scheme via --prefers-color-scheme flag
cmd += [ """
"--no-sandbox", pdf_out.parent.mkdir(parents=True, exist_ok=True)
"--disable-setuid-sandbox", partial = pdf_out.parent / f".{pdf_out.name}.writing"
"--disable-dev-shm-usage", partial.unlink(missing_ok=True)
]
cmd += [ uri = html_file.resolve().as_uri()
"--headless=new",
"--disable-gpu", # Chromium headless print; allow time for fonts/images on very large pages.
"--no-pdf-header-footer", cmd = [str(browser)]
f"--print-to-pdf={partial.resolve()}", if os.environ.get("CI"):
uri, # GitHub Actions / other CI runners often need these for Chromium to start.
] cmd += [
subprocess.run(cmd, check=True, timeout=600) "--no-sandbox",
deadline = time.time() + 120 "--disable-setuid-sandbox",
while time.time() < deadline: "--disable-dev-shm-usage",
if partial.exists() and partial.stat().st_size > 0: ]
break
time.sleep(0.25) cmd += [
else: "--headless=new",
partial.unlink(missing_ok=True) "--disable-gpu",
raise RuntimeError(f"PDF was not written to {partial}") "--no-pdf-header-footer",
]
try:
if pdf_out.exists(): # Add dark mode preference if requested
pdf_out.unlink() if dark_mode:
except PermissionError: cmd.append("--prefers-color-scheme=dark")
fallback = pdf_out.with_name(f"{pdf_out.stem}-new{pdf_out.suffix}")
fallback.unlink(missing_ok=True) cmd += [
partial.replace(fallback) f"--print-to-pdf={partial.resolve()}",
return fallback uri,
]
partial.replace(pdf_out)
return pdf_out subprocess.run(cmd, check=True, timeout=600)
deadline = time.time() + 120
while time.time() < deadline:
def main() -> int: if partial.exists() and partial.stat().st_size > 0:
root = repo_root() break
ap = argparse.ArgumentParser(description="Build MkDocs + single-page guide PDF.") time.sleep(0.25)
ap.add_argument( else:
"--site-dir", partial.unlink(missing_ok=True)
type=Path, raise RuntimeError(f"PDF was not written to {partial}")
default=root / "site",
help="MkDocs output directory (default: ./site)", try:
) if pdf_out.exists():
ap.add_argument( pdf_out.unlink()
"--pdf", except PermissionError:
type=Path, fallback = pdf_out.with_name(f"{pdf_out.stem}-new{pdf_out.suffix}")
default=root / "export" / "guide.pdf", fallback.unlink(missing_ok=True)
help="Output PDF path (default: ./export/guide.pdf)", partial.replace(fallback)
) return fallback
ap.add_argument("--skip-mkdocs", action="store_true", help="Reuse existing site dir; only run print-to-pdf.")
args = ap.parse_args() partial.replace(pdf_out)
return pdf_out
guide_html = args.site_dir / "guide" / "index.html"
if not args.skip_mkdocs:
run_mkdocs(args.site_dir) def generate_dark_mode_html(html_file: Path, output_file: Path, dark_css_path: Path) -> None:
if not guide_html.is_file(): """Create a temporary HTML file with dark mode stylesheet applied.
print(f"Missing {guide_html}; run without --skip-mkdocs first.", file=sys.stderr)
return 1 This is used when we need to force dark mode rendering via CSS rather than browser flags.
"""
browser = find_chromium_executable() try:
if not browser: from bs4 import BeautifulSoup
print(
"No Chromium-based browser found (Chrome, Edge, or Chromium). " # Read the original HTML
"Install Google Chrome or Microsoft Edge, or add Chromium to PATH.", html_content = html_file.read_text(encoding='utf-8')
file=sys.stderr, soup = BeautifulSoup(html_content, 'html.parser')
)
return 1 # Add dark mode stylesheet link if not present
existing_links = [link.get('href', '') for link in soup.find_all('link', rel='stylesheet')]
out = print_to_pdf(browser, guide_html, args.pdf) if not any(dark_css_path.name in link for link in existing_links):
size_kb = out.stat().st_size // 1024 head = soup.head or soup.new_tag('head')
print(f"Wrote {out.resolve()} ({size_kb} KiB)") link_tag = soup.new_tag('link', rel='stylesheet', href=str(dark_css_path))
if out.resolve() != args.pdf.resolve(): if soup.head:
print( soup.head.append(link_tag)
f"Note: {args.pdf.name} was in use; close it and rename or replace with the file above.", else:
file=sys.stderr, # Create a new head section
) new_head = soup.new_tag('head')
return 0 new_head.append(link_tag)
soup.insert(0, new_head)
if __name__ == "__main__": # Write the modified HTML
raise SystemExit(main()) 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())