#!/usr/bin/env python3 """ Dark-mode PDF converter (pixel-based, batch-safe). Rasterizes each page with pdftoppm, applies the hacker theme palette pixel-by-pixel, then reassembles into a PDF. Processes in batches of 50 pages to stay within memory limits on large documents, then merges with qpdf. Usage: python scripts/convert.py INPUT.pdf [OUTPUT.pdf] python scripts/convert.py INPUT.pdf [OUTPUT.pdf] [--dpi 200] [--bg 1f1f31] [--text e0e0e0] [--link 5e8bde] [--batch-size 50] 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 import argparse import glob import os import subprocess import sys import tempfile from pathlib import Path import numpy as np from PIL import Image # --------------------------------------------------------------------------- # # Defaults (Hacker theme) # --------------------------------------------------------------------------- # DEFAULT_BG = (0x1f, 0x1f, 0x31) DEFAULT_TEXT = (0xe0, 0xe0, 0xe0) DEFAULT_LINK = (0x5e, 0x8b, 0xde) DEFAULT_DPI = 200 DEFAULT_BATCH = 50 def hex_to_rgb(h: str) -> tuple: h = h.lstrip('#') return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) def apply_dark_theme( img: Image.Image, bg=DEFAULT_BG, text=DEFAULT_TEXT, link=DEFAULT_LINK, ) -> Image.Image: """ Remap a white-background page image to a dark theme. 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) norm = arr / 255.0 # Luminance (Rec. 601) lum = ( 0.299 * norm[:, :, 0] + 0.587 * norm[:, :, 1] + 0.114 * norm[:, :, 2] ) # Saturation (HSV model, vectorised) ch_min = np.min(norm, axis=2) ch_max = np.max(norm, axis=2) # Suppress the numpy divide error, idc # division happens on all elements including the zero ones with np.errstate(divide='ignore', invalid='ignore'): 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) ) # Dark ink (text, borders, rules): not image, not bg, not link is_text = ~is_image & ~is_bg & ~is_link & (lum < 0.85) # --- Build output from original pixels --- # Start from a copy so anything not matched keeps source color. out = arr.copy() 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: """Save a list of RGB PIL images as a PDF without requiring libjpeg. Pillow's PDF writer defaults to JPEG encoding for RGB images, which fails when libjpeg is absent in the environment. Fix: quantize each image to palette mode (256 colours, FASTOCTREE) so Pillow uses zlib/deflate instead of JPEG, save each as an individual single-page PDF, then merge the page PDFs with qpdf. Colour fidelity is preserved — the hacker theme uses only a handful of distinct colours so 256-colour quantization is visually lossless. """ import tempfile as _tempfile with _tempfile.TemporaryDirectory() as staging: page_pdfs = [] for i, img in enumerate(images): page_path = os.path.join(staging, f'p{i:05d}.pdf') img.quantize(colors=256, method=Image.Quantize.FASTOCTREE).save( page_path, format='PDF' ) page_pdfs.append(page_path) subprocess.run( ['qpdf', '--empty', '--pages'] + page_pdfs + ['--', output_path], check=True, ) def _check_qpdf() -> bool: return subprocess.run( ['qpdf', '--version'], capture_output=True ).returncode == 0 def _check_dependencies() -> None: """Verify required system tools are available before doing any work.""" missing = [] for tool in ('pdftoppm', 'qpdf'): if subprocess.run(['which', tool], capture_output=True).returncode != 0: missing.append(tool) if missing: tools = ', '.join(missing) instructions = ( f"Install with:\n" f" Linux/WSL: sudo apt install poppler-utils qpdf\n" f" macOS: brew install poppler qpdf\n" f" Windows: see docs/code/develop.md" ) raise RuntimeError( f"Missing required system tool(s): {tools}\n{instructions}" ) def create_cover_page(tmp_dir: str, is_dark: bool) -> str: """Create the hacker-theme text cover page for Chromium rendering.""" # Hacker theme: monospace type with green/amber accents, drawn directly on # the page so the cover matches the body pages in both builds. if is_dark: bg_color = '#1f1f31' green = '#33ff66' amber = '#ffb000' muted = '#a8b0c0' glow = '0 0 6px rgba(51, 255, 102, 0.45)' else: bg_color = '#ffffff' green = '#005c2e' amber = '#8a5a00' muted = '#555555' glow = 'none' # Embed Fira Code so the cover matches the site regardless of what the # machine running Chromium has installed. import base64 font_path = (Path(__file__).resolve().parent.parent / 'docs' / 'fonts' / 'fira-code' / 'fira-code-latin-wght-normal.woff2') font_face = '' if font_path.is_file(): b64 = base64.b64encode(font_path.read_bytes()).decode('ascii') font_face = ( "@font-face { font-family: 'Fira Code Cover'; " f"src: url(data:font/woff2;base64,{b64}) format('woff2'); " "font-weight: 300 700; font-style: normal; }" ) mono = "'Fira Code Cover', 'Fira Code', 'DejaVu Sans Mono', Consolas, 'Courier New', monospace" html_path = os.path.join(tmp_dir, 'cover.html') with open(html_path, 'w', encoding='utf-8') as f: f.write(f"""
The comprehensive guide for online anonymity and OpSec.
v1.2.6 — October 24 2026