mirror of
https://github.com/Anon-Planet/thgtoa.git
synced 2026-08-05 02:25:09 +02:00
site!(design): do it better
This redesign isn't final, but it is much more clean and responsive.
This commit is contained in:
+306
-46
@@ -2,7 +2,7 @@
|
||||
"""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
|
||||
python scripts/build_guide_pdf.py # Light PDF only (clean light theme)
|
||||
python scripts/build_guide_pdf.py --dark # Dark PDF only (requires light PDF to exist)
|
||||
python scripts/build_guide_pdf.py --both # Light PDF, then dark PDF
|
||||
|
||||
@@ -14,10 +14,12 @@ Examples:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html as _html_mod
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,37 +29,51 @@ def repo_root() -> Path:
|
||||
|
||||
|
||||
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",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Google/Chrome/Application/chrome.exe",
|
||||
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",
|
||||
Path(_os.environ.get("PROGRAMFILES", "")) / "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:
|
||||
"""Build MkDocs site."""
|
||||
site_dir.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mkdocs", "build", "-d", str(site_dir)],
|
||||
@@ -66,29 +82,56 @@ def run_mkdocs(site_dir: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
||||
"""Render html_file to pdf_out via headless Chromium.
|
||||
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"
|
||||
|
||||
Uses a temp file first so an open guide.pdf on Windows does not block the
|
||||
build; if the final path is still locked, writes guide-new.pdf instead.
|
||||
"""
|
||||
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"<head(?:[^>]*)>", html_content, re.IGNORECASE)
|
||||
if not head_match:
|
||||
print("Warning: Could not find <head> tag in HTML, skipping CSS injection.", file=sys.stderr)
|
||||
return html_path
|
||||
|
||||
insert_pos = head_match.end()
|
||||
style_block = (
|
||||
"\n<!-- Light PDF theme: injected inline so file:// URI resolves correctly -->\n"
|
||||
"<style>\n"
|
||||
+ css_content
|
||||
+ "\n</style>\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 += ["--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,
|
||||
]
|
||||
@@ -116,11 +159,228 @@ def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
||||
partial.replace(pdf_out)
|
||||
return pdf_out
|
||||
|
||||
# Use scripts/convert.py in place of broken dark-mode hack
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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]], is_dark: bool) -> 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 in headings:
|
||||
safe = _html_mod.escape(title)
|
||||
if level == 2:
|
||||
rows.append(
|
||||
f'<div class="toc-h2">'
|
||||
f'<span class="toc-title">{safe}</span>'
|
||||
f'<span class="toc-dots"></span>'
|
||||
f"</div>"
|
||||
)
|
||||
else:
|
||||
rows.append(f'<div class="toc-h3">{safe}</div>')
|
||||
|
||||
body = "\n".join(rows)
|
||||
|
||||
css = (
|
||||
"* { box-sizing: border-box; margin: 0; padding: 0; }\n"
|
||||
"@page { size: A4; margin: 0; }\n"
|
||||
"html, body {\n"
|
||||
f" width: 210mm;\n"
|
||||
f" background: {bg};\n"
|
||||
f" color: {fg};\n"
|
||||
" font-family: 'EB Garamond', Georgia, 'Times New Roman', serif;\n"
|
||||
"}\n"
|
||||
".page {\n"
|
||||
" width: 210mm;\n"
|
||||
" min-height: 297mm;\n"
|
||||
" padding: 25mm 28mm 28mm 28mm;\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"
|
||||
" white-space: nowrap;\n"
|
||||
" overflow: hidden;\n"
|
||||
" flex-shrink: 0;\n"
|
||||
" max-width: 85%;\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"
|
||||
" 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 = (
|
||||
'<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n'
|
||||
f"<style>\n{css}</style>\n</head>\n<body>\n"
|
||||
'<div class="page">\n'
|
||||
' <p class="toc-heading">Table of Contents</p>\n'
|
||||
f"{body}\n"
|
||||
"</div>\n</body>\n</html>\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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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) -> 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)
|
||||
|
||||
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_html = create_toc_html(tmp, headings, is_dark=is_dark)
|
||||
toc_pdf = os.path.join(tmp, "toc.pdf")
|
||||
_render_html_to_pdf(browser, toc_html, toc_pdf, ci=ci)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark PDF build (delegates to convert.py which adds its own front matter)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_dark_pdf(light_pdf: Path, dark_pdf: Path) -> Path:
|
||||
"""Convert the light PDF to dark mode using scripts/convert.py."""
|
||||
convert_script = repo_root() / "scripts" / "convert.py"
|
||||
print(f"Converting {light_pdf.name} → {dark_pdf.name} (dark mode)…")
|
||||
print(f"Converting {light_pdf.name} -> {dark_pdf.name} (dark mode)...")
|
||||
subprocess.run(
|
||||
[sys.executable, str(convert_script), str(light_pdf), str(dark_pdf)],
|
||||
check=True,
|
||||
@@ -128,6 +388,10 @@ def build_dark_pdf(light_pdf: Path, dark_pdf: Path) -> Path:
|
||||
return dark_pdf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
ap = argparse.ArgumentParser(
|
||||
@@ -158,15 +422,16 @@ def main() -> int:
|
||||
)
|
||||
|
||||
mode = ap.add_mutually_exclusive_group()
|
||||
mode.add_argument("--dark", action="store_true", help="Dark PDF only (light PDF must already exist)")
|
||||
mode.add_argument("--both", action="store_true", help="Build light PDF, then dark PDF")
|
||||
# default (no flag) = light only
|
||||
mode.add_argument("--dark", action="store_true",
|
||||
help="Dark PDF only (light PDF must already exist)")
|
||||
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
|
||||
|
||||
# --- Light PDF (Chromium) ---
|
||||
# --- Light PDF ---
|
||||
if build_light:
|
||||
guide_html = args.site_dir / "guide" / "index.html"
|
||||
|
||||
@@ -187,16 +452,11 @@ def main() -> int:
|
||||
return 1
|
||||
|
||||
out_light = print_to_pdf(browser, guide_html, args.pdf_light)
|
||||
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]")
|
||||
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,
|
||||
)
|
||||
|
||||
# --- Dark PDF (pixel converter) ---
|
||||
# --- Dark PDF ---
|
||||
if build_dark:
|
||||
if not args.pdf_light.exists():
|
||||
print(
|
||||
|
||||
Reference in New Issue
Block a user