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(
|
||||
|
||||
+266
-31
@@ -15,6 +15,8 @@ Usage:
|
||||
Examples:
|
||||
python scripts/convert.py export/thgtoa.pdf export/thgtoa-dark.pdf
|
||||
python scripts/convert.py export/thgtoa.pdf --dpi 150 --bg 0d1117
|
||||
|
||||
Note: Adds a cover page at the start with title/subtitle/version info.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -54,42 +56,73 @@ def apply_dark_theme(
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Remap a white-background page image to a dark theme.
|
||||
- Near-white pixels → bg color
|
||||
- Dark pixels (ink/text) → text color
|
||||
- Blue-ish pixels → link color
|
||||
|
||||
Strategy:
|
||||
- Near-white pixels (page background) → bg color
|
||||
- Dark, low-saturation pixels (text) → text color
|
||||
- Blue-dominant dark pixels (links) → link color
|
||||
- High-saturation pixels (photos/images) → preserved exactly as-is
|
||||
(no dark remapping; images stay at natural colors)
|
||||
|
||||
The key fix vs. the old version: output is initialized from the *original*
|
||||
pixels, so any pixel that doesn't match a remap mask keeps its source color.
|
||||
This prevents the "black blobs" bug where image regions fell through to the
|
||||
zero-initialized buffer.
|
||||
"""
|
||||
arr = np.array(img.convert('RGB'), dtype=np.float32)
|
||||
orig = arr.copy()
|
||||
norm = arr / 255.0
|
||||
|
||||
lightness = (
|
||||
0.299 * norm[:, :, 0]
|
||||
+ 0.587 * norm[:, :, 1]
|
||||
+ 0.114 * norm[:, :, 2]
|
||||
# Luminance (Rec. 601)
|
||||
lum = (
|
||||
0.299 * norm[:, :, 0] +
|
||||
0.587 * norm[:, :, 1] +
|
||||
0.114 * norm[:, :, 2]
|
||||
)
|
||||
|
||||
r, g, b = orig[:, :, 0], orig[:, :, 1], orig[:, :, 2]
|
||||
link_mask = (
|
||||
(b > 100)
|
||||
& (b > r * 1.3)
|
||||
& (b > g * 0.9)
|
||||
& (lightness < 0.85)
|
||||
# Saturation (HSV model, vectorised)
|
||||
ch_min = np.min(norm, axis=2)
|
||||
ch_max = np.max(norm, axis=2)
|
||||
sat = np.where(ch_max > 0.001, (ch_max - ch_min) / ch_max, 0.0).astype(np.float32)
|
||||
|
||||
# --- Masks ---
|
||||
# High-saturation = image/photo content — leave untouched
|
||||
is_image = sat > 0.20
|
||||
|
||||
# Near-white page background
|
||||
is_bg = (lum > 0.88) & ~is_image
|
||||
|
||||
# Blue-ish hyperlinks: blue channel dominant, dark, not an image
|
||||
is_link = (
|
||||
~is_image &
|
||||
~is_bg &
|
||||
(norm[:, :, 2] > 0.30) &
|
||||
(norm[:, :, 2] > norm[:, :, 0] * 1.20) &
|
||||
(lum < 0.75)
|
||||
)
|
||||
content_mask = (lightness < 0.85) & ~link_mask
|
||||
blend = ((1.0 - lightness) / 0.85).clip(0, 1)
|
||||
|
||||
bg_f = [c / 255.0 for c in bg]
|
||||
text_f = [c / 255.0 for c in text]
|
||||
link_f = [c / 255.0 for c in link]
|
||||
# Dark ink (text, borders, rules): not image, not bg, not link
|
||||
is_text = ~is_image & ~is_bg & ~is_link & (lum < 0.85)
|
||||
|
||||
out = np.zeros_like(norm)
|
||||
for i, (b_c, t, lc) in enumerate(zip(bg_f, text_f, link_f)):
|
||||
channel = np.full(lightness.shape, b_c)
|
||||
channel = np.where(content_mask, b_c + blend * (t - b_c), channel)
|
||||
channel = np.where(link_mask, b_c + blend * (lc - b_c), channel)
|
||||
out[:, :, i] = channel
|
||||
# --- Build output from original pixels ---
|
||||
# Start from a copy so anything not matched keeps source color.
|
||||
out = arr.copy()
|
||||
|
||||
return Image.fromarray((out * 255).clip(0, 255).astype('uint8'))
|
||||
bg_f = np.array(bg, dtype=np.float32)
|
||||
text_f = np.array(text, dtype=np.float32)
|
||||
link_f = np.array(link, dtype=np.float32)
|
||||
|
||||
# Full-strength remap for text: anything that was dark ink becomes text_color
|
||||
# at full brightness. No partial blend — partial blends leave mid-gray elements
|
||||
# (captions, borders, muted labels) at unreadable intermediate values.
|
||||
bg_mask3 = is_bg[..., np.newaxis]
|
||||
text_mask3 = is_text[..., np.newaxis]
|
||||
link_mask3 = is_link[..., np.newaxis]
|
||||
|
||||
out = np.where(bg_mask3, bg_f, out)
|
||||
out = np.where(text_mask3, text_f, out)
|
||||
out = np.where(link_mask3, link_f, out)
|
||||
|
||||
return Image.fromarray(out.clip(0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def _save_images_as_pdf(images: list, output_path: str) -> None:
|
||||
@@ -144,6 +177,106 @@ def _check_dependencies() -> None:
|
||||
)
|
||||
|
||||
|
||||
def create_cover_page(tmp_dir: str, is_dark: bool) -> str:
|
||||
"""Create a research-style text-only cover page for Chromium rendering."""
|
||||
if is_dark:
|
||||
bg_color = '#1f1f31'
|
||||
text_color = '#e0e0e0'
|
||||
rule_color = '#4a4a6a'
|
||||
meta_color = '#a0a0c0'
|
||||
else:
|
||||
bg_color = '#ffffff'
|
||||
text_color = '#1a1a1a'
|
||||
rule_color = '#cccccc'
|
||||
meta_color = '#555555'
|
||||
|
||||
html_path = os.path.join(tmp_dir, 'cover.html')
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}}
|
||||
html, body {{
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
background: {bg_color};
|
||||
color: {text_color};
|
||||
font-family: 'EB Garamond', Georgia, 'Times New Roman', serif;
|
||||
}}
|
||||
.page {{
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30mm 25mm;
|
||||
text-align: center;
|
||||
}}
|
||||
.title {{
|
||||
font-size: 28pt;
|
||||
font-weight: normal;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
margin-bottom: 10mm;
|
||||
}}
|
||||
.rule {{
|
||||
width: 80mm;
|
||||
height: 1px;
|
||||
background: {rule_color};
|
||||
margin: 0 auto 10mm auto;
|
||||
}}
|
||||
.subtitle {{
|
||||
font-size: 13pt;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
color: {meta_color};
|
||||
margin-bottom: 14mm;
|
||||
}}
|
||||
.meta {{
|
||||
font-size: 11pt;
|
||||
color: {meta_color};
|
||||
line-height: 1.9;
|
||||
}}
|
||||
.meta strong {{
|
||||
color: {text_color};
|
||||
font-weight: normal;
|
||||
}}
|
||||
.version {{
|
||||
font-size: 11pt;
|
||||
color: {meta_color};
|
||||
margin-top: 12mm;
|
||||
letter-spacing: 0.05em;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<p class="title">The Hitchhiker’s Guide<br>to Online Anonymity</p>
|
||||
<div class="rule"></div>
|
||||
<p class="subtitle">The comprehensive guide for online anonymity and OpSec.</p>
|
||||
<div class="meta">
|
||||
<p><strong>Author</strong> Anonymous Planet</p>
|
||||
<p><strong>License</strong> Creative Commons BY-SA 4.0</p>
|
||||
<p><strong>Source</strong> https://anonymousplanet.net</p>
|
||||
</div>
|
||||
<p class="version">v1.2.5 — June 2026</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>""")
|
||||
return html_path
|
||||
|
||||
|
||||
def convert_pdf_to_dark(
|
||||
input_path: str | Path,
|
||||
output_path: str | Path,
|
||||
@@ -159,6 +292,8 @@ def convert_pdf_to_dark(
|
||||
For large documents, pages are processed in batches of `batch_size` to
|
||||
avoid OOM, then merged with qpdf. Falls back to single-pass Pillow save
|
||||
if qpdf is not available (fine for small documents).
|
||||
|
||||
Adds a cover page at the start with title/subtitle/version info.
|
||||
"""
|
||||
input_path = str(input_path)
|
||||
output_path = str(output_path)
|
||||
@@ -191,7 +326,51 @@ def convert_pdf_to_dark(
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# 2. Process in batches
|
||||
# Determine theme from filename for cover page
|
||||
pdf_output = Path(output_path)
|
||||
pdf_name = pdf_output.stem.lower()
|
||||
is_dark = 'dark' in pdf_name
|
||||
|
||||
print(f" Theme: {'Going dark' if is_dark else 'Light mode'}")
|
||||
|
||||
# 2. Build front matter: cover + ToC
|
||||
# Import ToC helpers from build_guide_pdf.py (single source of truth).
|
||||
scripts_dir = str(Path(__file__).resolve().parent)
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
from build_guide_pdf import parse_toc_headings, create_toc_html # noqa: PLC0415
|
||||
|
||||
cover_html_path = create_cover_page(tmp, is_dark)
|
||||
browser = find_chromium_executable()
|
||||
|
||||
if not browser:
|
||||
raise RuntimeError(
|
||||
"No Chromium-based browser found; needed for cover page rendering.\n"
|
||||
"Install Chrome, Edge, or add Chromium to PATH."
|
||||
)
|
||||
|
||||
cover_pdf = os.path.join(tmp, 'cover.pdf')
|
||||
cmd = [str(browser), "--headless=new", "--disable-gpu",
|
||||
"--no-pdf-header-footer", "--print-background", "--no-margins",
|
||||
f"--print-to-pdf={cover_pdf}", cover_html_path]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
|
||||
guide_md = Path(__file__).resolve().parent.parent / 'docs' / 'guide' / 'index.md'
|
||||
if guide_md.is_file():
|
||||
print(" Building ToC...", flush=True)
|
||||
headings = parse_toc_headings(guide_md)
|
||||
toc_html_path = create_toc_html(tmp, headings, is_dark)
|
||||
toc_pdf = os.path.join(tmp, 'toc.pdf')
|
||||
cmd_toc = [str(browser), "--headless=new", "--disable-gpu",
|
||||
"--no-pdf-header-footer", "--print-background", "--no-margins",
|
||||
f"--print-to-pdf={toc_pdf}", toc_html_path]
|
||||
subprocess.run(cmd_toc, check=True, capture_output=True)
|
||||
front_pages = [cover_pdf, toc_pdf]
|
||||
else:
|
||||
print(" Warning: guide/index.md not found, skipping ToC.", file=sys.stderr)
|
||||
front_pages = [cover_pdf]
|
||||
|
||||
# 3. Process pages with theme remapping
|
||||
use_batches = total > batch_size and _check_qpdf()
|
||||
|
||||
if use_batches:
|
||||
@@ -213,12 +392,11 @@ def convert_pdf_to_dark(
|
||||
dark = [apply_dark_theme(Image.open(p), bg, text, link) for p in batch]
|
||||
_save_images_as_pdf(dark, batch_path)
|
||||
batch_files.append(batch_path)
|
||||
del dark
|
||||
|
||||
# 3. Merge batches with qpdf
|
||||
print(" Merging batches…", flush=True)
|
||||
# Merge batches with front matter using qpdf
|
||||
print(" Merging batches and front matter...", flush=True)
|
||||
subprocess.run(
|
||||
['qpdf', '--empty', '--pages'] + batch_files + ['--', output_path],
|
||||
['qpdf', '--empty', '--pages'] + front_pages + batch_files + ['--', output_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
@@ -232,10 +410,67 @@ def convert_pdf_to_dark(
|
||||
|
||||
_save_images_as_pdf(dark_pages, output_path)
|
||||
|
||||
# Prepend front matter to the single-pass output
|
||||
if not use_batches:
|
||||
tmp_body = os.path.join(tmp, 'body_only.pdf')
|
||||
os.rename(output_path, tmp_body)
|
||||
subprocess.run(
|
||||
['qpdf', '--empty', '--pages'] + front_pages + [tmp_body] + ['--', output_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
size_mb = os.path.getsize(output_path) / 1024 / 1024
|
||||
print(f" Saved → {output_path} ({size_mb:.1f} MB)")
|
||||
|
||||
|
||||
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
|
||||
import sys
|
||||
|
||||
# First, check WSL/Linux locations (common for WSL Windows)
|
||||
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)
|
||||
|
||||
# Then check shutil.which (standard PATH, includes WSL paths)
|
||||
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)
|
||||
|
||||
# Finally, Windows-specific paths (if running natively on Windows)
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download self-hosted fonts for the thgtoa MkDocs site.
|
||||
Run once from the repo root. Requires network access (uses npm registry
|
||||
via a temporary npm pack, or direct URLs from fontsource CDN as fallback).
|
||||
|
||||
Usage:
|
||||
python scripts/install_fonts.py
|
||||
|
||||
Fonts installed:
|
||||
docs/fonts/eb-garamond/ - EB Garamond 400/700 normal+italic (latin, latin-ext)
|
||||
docs/fonts/fira-code/ - Fira Code variable (latin, latin-ext)
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FONTS_DIR = os.path.join(REPO_ROOT, "docs", "fonts")
|
||||
|
||||
EB_GARAMOND_DIR = os.path.join(FONTS_DIR, "eb-garamond")
|
||||
FIRA_CODE_DIR = os.path.join(FONTS_DIR, "fira-code")
|
||||
|
||||
EB_GARAMOND_FILES = [
|
||||
"eb-garamond-latin-400-normal.woff2",
|
||||
"eb-garamond-latin-400-italic.woff2",
|
||||
"eb-garamond-latin-700-normal.woff2",
|
||||
"eb-garamond-latin-700-italic.woff2",
|
||||
"eb-garamond-latin-ext-400-normal.woff2",
|
||||
"eb-garamond-latin-ext-400-italic.woff2",
|
||||
"eb-garamond-latin-ext-700-normal.woff2",
|
||||
"eb-garamond-latin-ext-700-italic.woff2",
|
||||
]
|
||||
|
||||
FIRA_CODE_FILES = [
|
||||
"fira-code-latin-wght-normal.woff2",
|
||||
"fira-code-latin-ext-wght-normal.woff2",
|
||||
]
|
||||
|
||||
|
||||
def npm_pack(package: str, tmpdir: str) -> str:
|
||||
"""Run npm pack and return the path to the extracted package dir."""
|
||||
result = subprocess.run(
|
||||
["npm", "pack", package, "--silent"],
|
||||
cwd=tmpdir, capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"npm pack failed for {package}:\n{result.stderr}")
|
||||
tgz = os.path.join(tmpdir, result.stdout.strip())
|
||||
with tarfile.open(tgz, "r:gz") as tf:
|
||||
tf.extractall(tmpdir)
|
||||
return os.path.join(tmpdir, "package")
|
||||
|
||||
|
||||
def install_package(package: str, files: list[str], dest: str) -> None:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
print(f" Fetching {package} ...")
|
||||
pkg_dir = npm_pack(package, tmpdir)
|
||||
files_dir = os.path.join(pkg_dir, "files")
|
||||
for fname in files:
|
||||
src = os.path.join(files_dir, fname)
|
||||
if not os.path.exists(src):
|
||||
print(f" WARNING: {fname} not found in package", file=sys.stderr)
|
||||
continue
|
||||
dst = os.path.join(dest, fname)
|
||||
shutil.copy2(src, dst)
|
||||
size = os.path.getsize(dst)
|
||||
print(f" + {fname} ({size // 1024}K)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Installing self-hosted fonts for thgtoa...\n")
|
||||
|
||||
if shutil.which("npm") is None:
|
||||
print("ERROR: npm not found. Install Node.js and re-run.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("EB Garamond (fontsource):")
|
||||
install_package("@fontsource/eb-garamond", EB_GARAMOND_FILES, EB_GARAMOND_DIR)
|
||||
|
||||
print("\nFira Code Variable (fontsource):")
|
||||
install_package("@fontsource-variable/fira-code", FIRA_CODE_FILES, FIRA_CODE_DIR)
|
||||
|
||||
print("\nDone. Font files written to docs/fonts/")
|
||||
print("Commit docs/fonts/ to the repository so it is served with the site.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user