#!/usr/bin/env python3 """Render the HC-33 manual (Markdown) to a PDF that looks like a well-used copy from a ring binder: yellowed paper, three punched holes and coffee rings. render.py manual.md manual.pdf [--copies 3] [--intensity 0.5] [--dpi 130] The look is generated (no image assets) and deterministic: the same input always produces the same stains and dust. Needs the `markdown`, `weasyprint`, `pillow` and `numpy` packages, poppler's pdftoppm, and the Liberation fonts. --copies N photocopies the pages N times over. The pages are rasterised, run through the copier model in photocopy.py, and used as page backgrounds under invisible live text, so search, selection, links and bookmarks still work. --copies 0 leaves the crisp vector pages. --intensity scales how worn each copy looks (1.0 = badly, 0.5 = default, 0 = clean scans). """ import argparse import concurrent.futures import math import os import pathlib import random import subprocess import tempfile import markdown from weasyprint import CSS, HTML import photocopy HERE = pathlib.Path(__file__).resolve().parent W, H = 816, 1056 # US Letter at 96 dpi # ---- page backgrounds -------------------------------------------------------- def ring_path(cx, cy, r, rng, wobble=0.05): """A slightly irregular closed outline around (cx, cy).""" phases = [rng.uniform(0, 2 * math.pi) for _ in range(4)] amps = [rng.uniform(0.01, wobble) for _ in range(4)] pts = [] for i in range(80): t = 2 * math.pi * i / 80 rr = r * (1 + sum(a * math.sin((k + 2) * t + p) for k, (a, p) in enumerate(zip(amps, phases)))) pts.append((cx + rr * math.cos(t), cy + rr * math.sin(t) * 0.97)) return "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts) + " Z" def coffee_ring(uid, cx, cy, r, rng, strength=1.0): """A cup ring: a pale wash, a dark uneven rim, a halo, and a few splashes.""" out = [] a = strength out.append( f'' f'' f'' f'' ) body = [] outline = ring_path(cx, cy, r, rng) body.append(f'') # soft halo just outside the rim body.append(f'') body.append(f'') # the rim itself: thin all round, thicker and darker in places body.append(f'') dash = " ".join(f"{rng.uniform(60, 240):.0f} {rng.uniform(30, 120):.0f}" for _ in range(3)) body.append(f'') # splashes for _ in range(rng.randint(4, 10)): ang = rng.uniform(0, 2 * math.pi) dist = r * rng.uniform(1.06, 1.45) body.append( f'' ) return "".join(out), "".join(body) def hole(cx, cy, reinforced, cracked, rng): parts = [] if reinforced: # a stick-on paper reinforcement ring parts.append(f'') parts.append(f'') parts.append(f'') parts.append(f'') # soft shadow parts.append(f'') # cut edge parts.append(f'') parts.append(f'') # lit far wall parts.append(f'') if cracked: # a small tear running out from the hole ang = rng.uniform(-0.5, 0.5) ln = rng.uniform(9, 20) x0, y0 = cx + 13 * math.cos(ang), cy + 13 * math.sin(ang) parts.append(f'') return "".join(parts) STAIN_ANCHORS = [(705, 925), (690, 130), (170, 985), (430, 1030), (790, 520), (640, 640), (300, 60)] def page_svg(n, cover=False, boost=1.0): rng = random.Random(4242 + n * 7919) defs = [] layers = [f''] # broad yellowing blotches, built from stacked translucent discs for _ in range(5): cx, cy, r = rng.uniform(0, W), rng.uniform(0, H), rng.uniform(150, 340) for k in range(18): layers.append(f'') # aged edges: nested frames, darker towards the sheet edge for k in range(48): inset = k * 3.6 + 1.8 layers.append(f'') # paper fibres and foxing fib = [] for _ in range(260): x, y, a, ln = rng.uniform(0, W), rng.uniform(0, H), rng.uniform(0, math.pi), rng.uniform(5, 22) fib.append(f'M{x:.0f},{y:.0f}l{ln * math.cos(a):.1f},{ln * math.sin(a):.1f}') layers.append(f'') for _ in range(170): layers.append(f'') for k in range(40): # binder-side shading fading over 80 px layers.append(f'') # coffee if cover: # The cover is mostly text, so the cups go in the empty corners: one off the # bottom-right edge, a small one beside the binder holes, a stray at top right. stains = [(700, 1052, 92, 1.15), (100, 655, 50, 0.9), (745, 150, 44, 0.8)] else: stains = [] if rng.random() < 0.38: ax, ay = rng.choice(STAIN_ANCHORS) stains.append((ax + rng.uniform(-40, 40), ay + rng.uniform(-40, 40), rng.uniform(52, 98), rng.uniform(0.7, 1.15))) if rng.random() < 0.3: # the cup was put down twice stains.append((stains[0][0] + rng.uniform(-40, 40), stains[0][1] + rng.uniform(25, 50), stains[0][2] * 0.75, 0.75)) for i, (cx, cy, r, s) in enumerate(stains): d, body = coffee_ring(f"{n}_{i}", cx, cy, r, rng, s * boost) defs.append(d) layers.append(body) # binder holes, US three-hole spacing (4.25 in), centred 0.5 in from the edge for cy in (120, 528, 936): layers.append(hole(48, cy, reinforced=(n % 3 == 0), cracked=(rng.random() < 0.12), rng=rng)) return (f'' f'{"".join(defs)}{"".join(layers)}') # ---- document ---------------------------------------------------------------- def build_html(md_text): body = markdown.markdown( md_text, extensions=["toc", "fenced_code", "md_in_html", "sane_lists"], extension_configs={"toc": {"toc_depth": "1-2"}}, ) return ( '' "HC-33 Flight Computer: Programmer's Reference Manual" '' '' f"{body}" ) def _copy_page(job): from PIL import Image src, dst, seed, copies, dpi, intensity = job photocopy.photocopy(Image.open(src).convert("RGB"), seed=seed, copies=copies, intensity=intensity).save( dst, quality=58, optimize=True, progressive=True, dpi=(dpi, dpi)) INVISIBLE = """ html *, html *::before, html *::after { color: rgba(0, 0, 0, 0.01) !important; background: none !important; border-color: transparent !important; outline: 0 !important; text-shadow: none !important; } @page { @top-left { color: rgba(0, 0, 0, 0.01); border-color: transparent; } @top-right { color: rgba(0, 0, 0, 0.01); border-color: transparent; } @bottom-left { color: rgba(0, 0, 0, 0.01); } @bottom-right { color: rgba(0, 0, 0, 0.01); } } """ def page_rules(image_for, pages): rules = [] for n in range(1, pages + 1): # WeasyPrint positions a page background against the content box, so # shift it back by that page's margins to cover the whole sheet. top = "-0.8in" if n == 1 else "-0.95in" rules.append(f"@page :nth({n}) {{ background: #f2e9cf url({image_for(n)}) no-repeat -1.3in {top} / 8.5in 11in; }}") return "\n".join(rules) def render(md_path, pdf_path, copies=3, dpi=130, intensity=0.5): html = build_html(pathlib.Path(md_path).read_text(encoding="utf-8")) base_css = CSS(filename=str(HERE / "manual.css")) with tempfile.TemporaryDirectory() as tmp: tmp = pathlib.Path(tmp) doc = HTML(string=html, base_url=str(tmp)) pages = len(doc.render(stylesheets=[base_css]).pages) # backgrounds do not affect layout for n in range(1, pages + 1): (tmp / f"bg-{n}.svg").write_text(page_svg(n, cover=(n == 1), boost=1.0 if copies <= 0 else 1.0 + 1.4 * intensity), encoding="utf-8") bg_css = CSS(string=page_rules(lambda n: f"bg-{n}.svg", pages), base_url=str(tmp)) out_tmp = f"{pdf_path}.tmp" # written beside the target, then renamed into place def publish(): os.replace(out_tmp, pdf_path) if copies <= 0: doc.render(stylesheets=[base_css, bg_css]).write_pdf(out_tmp) publish() return pages # 1. the crisp original, 2. rasterise it, 3. photocopy the rasters doc.render(stylesheets=[base_css, bg_css]).write_pdf(tmp / "crisp.pdf") subprocess.run(["pdftoppm", "-r", str(dpi), "-png", str(tmp / "crisp.pdf"), str(tmp / "page")], check=True) width = len(str(pages)) jobs = [(tmp / f"page-{n:0{width}d}.png", tmp / f"scan-{n}.jpg", 9000 + n, copies, dpi, intensity) for n in range(1, pages + 1)] with concurrent.futures.ProcessPoolExecutor() as pool: # pages are independent list(pool.map(_copy_page, jobs)) # 4. the same document again, but the copies are the paper and the live text is invisible scan_css = CSS(string=page_rules(lambda n: f"scan-{n}.jpg", pages) + INVISIBLE, base_url=str(tmp)) doc.render(stylesheets=[base_css, scan_css]).write_pdf(out_tmp) publish() return pages if __name__ == "__main__": ap = argparse.ArgumentParser(description="Render the HC-33 manual to a battered PDF.") ap.add_argument("markdown") ap.add_argument("pdf") ap.add_argument("--copies", type=int, default=3, help="photocopy generations (0 = crisp)") ap.add_argument("--intensity", type=float, default=0.5, help="how worn the copies look (1.0 = heavy)") ap.add_argument("--dpi", type=int, default=130, help="resolution of the copied pages") args = ap.parse_args() print(f"rendered {render(args.markdown, args.pdf, args.copies, args.dpi, args.intensity)} pages -> {args.pdf}")