fix
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
#!/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'<radialGradient id="wash{uid}" cx="50%" cy="50%" r="50%">'
|
||||
f'<stop offset="0" stop-color="#a8712f" stop-opacity="{0.02 * a:.3f}"/>'
|
||||
f'<stop offset="0.75" stop-color="#a8712f" stop-opacity="{0.07 * a:.3f}"/>'
|
||||
f'<stop offset="1" stop-color="#8c5a22" stop-opacity="{0.16 * a:.3f}"/></radialGradient>'
|
||||
)
|
||||
body = []
|
||||
outline = ring_path(cx, cy, r, rng)
|
||||
body.append(f'<path d="{outline}" fill="url(#wash{uid})"/>')
|
||||
# soft halo just outside the rim
|
||||
body.append(f'<path d="{ring_path(cx, cy, r * 1.03, rng)}" fill="none" stroke="#a8742f" stroke-width="9" opacity="{0.05 * a:.3f}"/>')
|
||||
body.append(f'<path d="{ring_path(cx, cy, r * 1.01, rng)}" fill="none" stroke="#a8742f" stroke-width="5" opacity="{0.09 * a:.3f}"/>')
|
||||
# the rim itself: thin all round, thicker and darker in places
|
||||
body.append(f'<path d="{outline}" fill="none" stroke="#7b4a1c" stroke-width="2.2" opacity="{0.34 * a:.3f}"/>')
|
||||
dash = " ".join(f"{rng.uniform(60, 240):.0f} {rng.uniform(30, 120):.0f}" for _ in range(3))
|
||||
body.append(f'<path d="{outline}" fill="none" stroke="#5f3510" stroke-width="3.6" stroke-dasharray="{dash}" opacity="{0.36 * a:.3f}"/>')
|
||||
# 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'<circle cx="{cx + dist * math.cos(ang):.1f}" cy="{cy + dist * math.sin(ang):.1f}" '
|
||||
f'r="{rng.uniform(0.8, 4.2):.1f}" fill="#6b3f18" opacity="{rng.uniform(0.12, 0.34) * a:.3f}"/>'
|
||||
)
|
||||
return "".join(out), "".join(body)
|
||||
|
||||
|
||||
def hole(cx, cy, reinforced, cracked, rng):
|
||||
parts = []
|
||||
if reinforced: # a stick-on paper reinforcement ring
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="20.5" fill="none" stroke="#000" stroke-width="10" opacity="0.06"/>')
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="19" fill="none" stroke="#f8f2df" stroke-width="10"/>')
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="24" fill="none" stroke="#cdbf90" stroke-width="0.8" opacity="0.8"/>')
|
||||
parts.append(f'<circle cx="{cx + 1}" cy="{cy + 1.4}" r="15.5" fill="#000" opacity="0.10"/>') # soft shadow
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="13.6" fill="none" stroke="#fffaf0" stroke-width="1.6" opacity="0.85"/>') # cut edge
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="12.4" fill="#2a1f14"/>')
|
||||
parts.append(f'<circle cx="{cx + 1.6}" cy="{cy + 1.8}" r="9.6" fill="#4a3a29" opacity="0.75"/>') # lit far wall
|
||||
parts.append(f'<circle cx="{cx}" cy="{cy}" r="12.4" fill="none" stroke="#000" stroke-width="1.4" opacity="0.45"/>')
|
||||
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'<path d="M{x0:.1f},{y0:.1f} l{ln * math.cos(ang):.1f},{ln * math.sin(ang) + rng.uniform(-2, 2):.1f}" stroke="#6b5a3a" stroke-width="0.9" opacity="0.55" fill="none"/>')
|
||||
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'<rect width="{W}" height="{H}" fill="#f2e9cf"/>']
|
||||
|
||||
# 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'<circle cx="{cx:.0f}" cy="{cy:.0f}" r="{r * (1 - k / 18):.0f}" fill="#d9c17a" opacity="0.016"/>')
|
||||
|
||||
# aged edges: nested frames, darker towards the sheet edge
|
||||
for k in range(48):
|
||||
inset = k * 3.6 + 1.8
|
||||
layers.append(f'<rect x="{inset}" y="{inset}" width="{W - 2 * inset}" height="{H - 2 * inset}" fill="none" '
|
||||
f'stroke="#b08840" stroke-width="3.6" opacity="{0.20 * (1 - k / 48) ** 2.6:.3f}"/>')
|
||||
|
||||
# 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'<path d="{"".join(fib)}" stroke="#8a7448" stroke-width="0.6" opacity="0.10" fill="none"/>')
|
||||
for _ in range(170):
|
||||
layers.append(f'<circle cx="{rng.uniform(0, W):.0f}" cy="{rng.uniform(0, H):.0f}" r="{rng.uniform(0.4, 1.9):.1f}" fill="#7a5426" opacity="{rng.uniform(0.04, 0.2):.2f}"/>')
|
||||
|
||||
for k in range(40): # binder-side shading fading over 80 px
|
||||
layers.append(f'<rect x="{k * 2}" width="2" height="{H}" fill="#5b4526" opacity="{0.17 * (1 - k / 40) ** 1.6:.3f}"/>')
|
||||
|
||||
# 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'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">'
|
||||
f'<defs>{"".join(defs)}</defs>{"".join(layers)}</svg>')
|
||||
|
||||
|
||||
# ---- 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 (
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
||||
"<title>HC-33 Flight Computer: Programmer's Reference Manual</title>"
|
||||
'<meta name="author" content="Halcyon Instrument & Control">'
|
||||
'<meta name="description" content="Publication HIC-0033-A, first edition">'
|
||||
f"</head><body>{body}</body></html>"
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
Reference in New Issue
Block a user