This commit is contained in:
root
2026-09-20 14:16:41 +02:00
parent 0798933b05
commit b6d1909d7f
56 changed files with 3752 additions and 1989 deletions
+9 -3
View File
@@ -1,7 +1,13 @@
// Package docs embeds the HC-33 programmer's manual.
// Package docs embeds the source of the HC-33 programmer's manual.
//
// The PDF is not embedded: it is rendered separately (see the manual stage of
// the Dockerfile and scripts/render-manual.sh), so that deploying the app never
// waits for it, and is served from a file by package web.
package docs
import _ "embed"
//go:embed manual.txt
var Manual []byte
// Markdown is the manual's source, docs/manual.md.
//
//go:embed manual.md
var Markdown []byte
+107
View File
@@ -0,0 +1,107 @@
package docs_test
import (
"os"
"regexp"
"strconv"
"strings"
"testing"
"halcyon/docs"
"halcyon/sim"
)
// portValues maps the names used in the manual and in examples/programs/ports.s
// to the simulator's real port numbers, so the documentation cannot drift.
var portValues = map[string]int{
"TURN": sim.PortTurn, "DAY": sim.PortDay, "TURNS": sim.PortTurns,
"POSX": sim.PortPosX, "POSY": sim.PortPosY, "POSZ": sim.PortPosZ,
"VELX": sim.PortVelX, "VELY": sim.PortVelY, "VELZ": sim.PortVelZ,
"THROTTLE": sim.PortThrottle, "AZIMUTH": sim.PortAzimuth, "PITCH": sim.PortPitch,
"FUEL": sim.PortFuel, "MASS": sim.PortMass,
"SELECT": sim.PortScanSelect, "NEAREST": sim.PortScanNearest,
"RELX": sim.PortScanRelX, "RELY": sim.PortScanRelY, "RELZ": sim.PortScanRelZ,
"RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ,
"DIST": sim.PortScanDist,
"ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3,
"MINE": sim.PortMine, "CARGO": sim.PortCargo, "CARGOCAP": sim.PortCargoCap,
"CARGO0": sim.PortCargoOre, "CARGO1": sim.PortCargoOre + 1, "CARGO2": sim.PortCargoOre + 2, "CARGO3": sim.PortCargoOre + 3,
"STNX": sim.PortStnRelX, "STNY": sim.PortStnRelY, "STNZ": sim.PortStnRelZ,
"STNVX": sim.PortStnRelVX, "STNVY": sim.PortStnRelVY, "STNVZ": sim.PortStnRelVZ,
"SELL": sim.PortSell, "EARNED": sim.PortCredits,
"UPNEW": sim.PortUplinkNew, "UPLEN": sim.PortUplinkLen,
"MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ,
"ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3,
}
func section(t *testing.T, md, from, to string) string {
t.Helper()
a := strings.Index(md, from)
if a < 0 {
t.Fatalf("manual has no %q", from)
}
b := len(md)
if to != "" {
b = a + strings.Index(md[a:], to)
}
return md[a:b]
}
func TestAppendixBMatchesSimulator(t *testing.T) {
md := string(docs.Markdown)
rows := regexp.MustCompile(`(?m)^0x([0-9A-F]{2})\s+(\w+)\s`).FindAllStringSubmatch(
section(t, md, "# Appendix B", "# Appendix C"), -1)
seen := map[string]bool{}
for _, r := range rows {
n, _ := strconv.ParseInt(r[1], 16, 32)
want, ok := portValues[r[2]]
if !ok || int(n) != want {
t.Errorf("Appendix B: %s is 0x%s, simulator says %#x (known=%v)", r[2], r[1], want, ok)
}
seen[r[2]] = true
}
for name := range portValues {
if !seen[name] {
t.Errorf("Appendix B does not document port %s", name)
}
}
}
func readExample(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile("../examples/programs/" + name)
if err != nil {
t.Fatal(err)
}
return strings.TrimRight(string(b), "\n")
}
func TestEquateFileMatchesSimulatorAndManual(t *testing.T) {
ports := readExample(t, "ports.s")
seen := map[string]bool{}
for _, m := range regexp.MustCompile(`(?m)^\s*\.equ\s+P_(\w+)\s+0x([0-9A-F]{2})\b`).FindAllStringSubmatch(ports, -1) {
n, _ := strconv.ParseInt(m[2], 16, 32)
if want, ok := portValues[m[1]]; !ok || int(n) != want {
t.Errorf("ports.s: P_%s = 0x%s, simulator says %#x (known=%v)", m[1], m[2], want, ok)
}
seen[m[1]] = true
}
for name := range portValues {
if !seen[name] {
t.Errorf("ports.s lacks P_%s", name)
}
}
// Appendix C prints that file.
if !strings.Contains(string(docs.Markdown), "```asm\n"+ports+"\n```") {
t.Error("Appendix C is out of date with examples/programs/ports.s")
}
}
func TestSampleProgramsMatchExamples(t *testing.T) {
md := string(docs.Markdown)
for _, name := range []string{"echo", "telemetry", "pursue"} {
if !strings.Contains(md, "```asm\n"+readExample(t, name+".s")+"\n```") {
t.Errorf("Chapter 9 listing of %s is out of date with examples/programs/%s.s", name, name)
}
}
}
+1480
View File
File diff suppressed because it is too large Load Diff
-1556
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Measure how legible each page of the photocopied manual is.
check_legibility.py copied.pdf [crisp.pdf]
OCRs every page (tesseract) and scores it against the PDF's own invisible text
layer, which is the known-correct text: the score is the share of true words
that OCR recovers. If a crisp render is given, its score is the baseline (OCR
is imperfect on monospace tables), and a page is judged by how much the copying
cost it. Needs tesseract and poppler's pdftotext/pdftoppm. Development tool;
the build does not run it.
"""
import difflib
import os
import re
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
def words(text):
return re.findall(r"[A-Za-z0-9][A-Za-z0-9'.\-]*", text)
def ocr_scores(pdf, dpi=130):
"""Return {page: (recall, truth_word_count)} for each page of pdf."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
subprocess.run(["pdftoppm", "-r", str(dpi), "-gray", "-png", pdf, str(tmp / "p")], check=True)
images = sorted(tmp.glob("p-*.png"))
def score(img):
n = int(img.stem.split("-")[1])
truth = words(subprocess.run(["pdftotext", "-f", str(n), "-l", str(n), pdf, "-"],
capture_output=True, text=True, check=True).stdout)
got = words(subprocess.run(["tesseract", str(img), "-", "--psm", "6"],
capture_output=True, text=True, env={**os.environ, "OMP_THREAD_LIMIT": "1"}).stdout)
m = difflib.SequenceMatcher(None, truth, got, autojunk=False)
hit = sum(b.size for b in m.get_matching_blocks())
return n, (hit / max(1, len(truth)), len(truth))
with ThreadPoolExecutor(4) as pool:
return dict(pool.map(score, images))
if __name__ == "__main__":
copied = ocr_scores(sys.argv[1])
crisp = ocr_scores(sys.argv[2]) if len(sys.argv) > 2 else {}
print("page words crisp copied loss")
losses = []
for n in sorted(copied):
c, w = copied[n]
b = crisp.get(n, (1.0, w))[0]
losses.append(b - c)
flag = " <-- check" if b - c > 0.10 else ""
print(f"{n:4d} {w:6d} {b:5.0%} {c:5.0%} {b - c:+5.0%}{flag}")
tot = sum(w for _, w in copied.values())
print(f"\nmean copied recall {sum(c * w for c, w in copied.values()) / tot:.1%}"
+ (f", mean crisp {sum(c * w for c, w in crisp.values()) / tot:.1%}" if crisp else ""))
+128
View File
@@ -0,0 +1,128 @@
/* Print stylesheet for the HC-33 manual: a typewritten reference, Letter size,
with room on the left for the binder holes drawn by render.py. */
@page {
size: Letter;
margin: 0.95in 0.7in 0.9in 1.3in;
@top-left {
content: "HC-33 FLIGHT COMPUTER";
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26;
border-bottom: 0.6pt solid #8a7650; padding-bottom: 3pt; vertical-align: bottom;
}
@top-right {
content: string(chapter);
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.12em; text-transform: uppercase; color: #4a3a26;
border-bottom: 0.6pt solid #8a7650; padding-bottom: 3pt; vertical-align: bottom;
}
@bottom-left {
content: "HIC-0033-A";
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; vertical-align: top; padding-top: 6pt;
}
@bottom-right {
content: "PAGE " counter(page);
font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; vertical-align: top; padding-top: 6pt;
}
}
@page :first {
margin: 0.8in 0.75in 0.7in 1.3in;
@top-left { content: none; border: 0; }
@top-right { content: none; border: 0; }
@bottom-left { content: none; }
@bottom-right { content: none; }
}
html { font: 9.6pt/1.42 "Liberation Mono", "Courier New", monospace; color: #211b14; }
body { margin: 0; }
p { margin: 0.55em 0; orphans: 2; widows: 2; }
strong { font-weight: bold; }
a { color: inherit; text-decoration: none; }
/* ---- headings ---------------------------------------------------------- */
h1 {
font: bold 21pt/1.15 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.05em;
margin: 0 0 0.9em; padding: 0.3em 0;
border-top: 3pt double #211b14; border-bottom: 3pt double #211b14;
page-break-before: always; page-break-after: avoid; string-set: chapter content();
}
h1.front { page-break-before: auto; string-set: chapter "Contents"; }
h2 {
font: bold 11.5pt/1.25 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.1em;
margin: 1.5em 0 0.55em; padding-bottom: 0.15em; border-bottom: 0.8pt dashed #6b5a3a;
page-break-after: avoid;
}
h3 {
font: bold 9.8pt/1.3 "Liberation Mono", monospace; margin: 1.05em 0 0.15em; padding-left: 0.6em;
border-left: 3pt solid #8a2a17; page-break-after: avoid;
}
/* ---- lists, code, notes ------------------------------------------------ */
ul, ol { margin: 0.5em 0; padding-left: 1.7em; }
li { margin: 0.3em 0; }
li p { margin: 0.2em 0; }
code { font-family: "Liberation Mono", monospace; }
p > code, li > code { background: rgba(120, 90, 40, 0.10); padding: 0 0.2em; }
pre {
font: 8.6pt/1.3 "Liberation Mono", monospace; white-space: pre;
margin: 0.8em 0; padding: 0.55em 0.8em;
background: rgba(255, 251, 236, 0.5); border-left: 2.5pt solid #8b5a2b;
page-break-inside: avoid;
}
pre code { background: none; padding: 0; }
blockquote {
margin: 1em 0; padding: 0.5em 0.9em;
border: 1.4pt solid #211b14; background: rgba(255, 255, 255, 0.32);
page-break-inside: avoid;
}
blockquote p { margin: 0.2em 0; }
blockquote strong:first-child { text-transform: uppercase; letter-spacing: 0.14em; color: #8a1f11; }
/* ---- cover ------------------------------------------------------------- */
.cover { text-align: center; page-break-after: always; }
.cover p { margin: 0; }
.company {
font: bold 9.5pt "Liberation Sans", sans-serif; color: #3a2a1a;
border-top: 3pt double #3a2a1a; border-bottom: 3pt double #3a2a1a; padding: 0.55em 0;
}
.cover .cover-title {
font: bold italic 35pt/1.12 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.04em;
border: 0; margin: 1.35in 0 0.2in; padding: 0; page-break-before: auto; string-set: none; bookmark-level: none;
}
.cover-sub { font: bold 15pt "Liberation Sans", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 0.3in !important; }
.cover-sub2 { font-size: 10pt; margin-top: 0.1in !important; }
.cover-box {
width: 3.1in; margin: 0.6in auto 0.5in; padding: 0.15in 0.1in;
border: 2.5pt solid #211b14; outline: 0.7pt solid #211b14; outline-offset: 3pt;
font-weight: bold; font-size: 9pt;
}
.cover-box p { margin: 0.25em 0; }
.pubno { font-size: 9.5pt; letter-spacing: 0.06em; }
.cover-for { font-size: 8.6pt; margin: 0.35in 1.1in 0 !important; }
.cover-warn {
margin: 0.4in 0.5in 0 !important; padding: 0.14in 0; font-weight: bold; font-size: 8.4pt; letter-spacing: 0.05em;
border-top: 0.8pt dashed #211b14; border-bottom: 0.8pt dashed #211b14; line-height: 1.6;
}
.cover-notice { margin: 0.32in 0 0 !important; font-size: 6.6pt; line-height: 1.35; text-align: justify; color: #3a2f22; }
/* ---- contents ---------------------------------------------------------- */
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc > ul > li { margin-top: 0.7em; font-weight: bold; font-size: 9.4pt; }
.toc ul ul { margin-top: 0.2em; padding-left: 1.6em; }
.toc ul ul li { font-weight: normal; font-size: 8.8pt; margin: 0.1em 0; }
.toc a::after { content: leader(".") target-counter(attr(href), page); font-weight: normal; }
/* ---- end matter -------------------------------------------------------- */
.end { text-align: center; margin-top: 2.2em; letter-spacing: 0.2em; font-weight: bold; page-break-inside: avoid; }
.colophon { text-align: center; font-size: 7.8pt; color: #4a3a26; letter-spacing: 0.06em; }
+126
View File
@@ -0,0 +1,126 @@
"""Make a page image look like a photocopy of a photocopy.
`intensity` scales the whole effect: 1.0 is a badly worn copy of a copy, 0.5 a
tired one, 0 leaves the image alone.
Each "generation" re-scans the previous copy: a little skew and shift, blur from
the optics, a steeper toner curve, uneven toner, speckle, dust streaks down the
page and dark shadows where the lid did not sit flat. Colour survives only as
faint warmth, so coffee stains stay recognisable but lose their punch.
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
def _lowfreq(rng, w, h, cells_x, cells_y, amp):
"""Smooth random field in [-amp, amp], roughly cells_x by cells_y blobs."""
small = rng.normal(0, 1, (cells_y + 2, cells_x + 2)).astype(np.float32)
small = (small - small.min()) / (np.ptp(small) + 1e-6) * 2 - 1
img = Image.fromarray(small, mode="F").resize((w, h), Image.BICUBIC)
return np.asarray(img, dtype=np.float32) * amp
def _speckles(rng, w, h, count, rmin, rmax):
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
count = max(0, count)
for _ in range(count):
x, y = rng.uniform(0, w), rng.uniform(0, h)
r = rng.uniform(rmin, rmax)
d.ellipse((x - r, y - r, x + r, y + r), fill=int(rng.uniform(120, 255)))
return np.asarray(mask.filter(ImageFilter.GaussianBlur(0.5)), dtype=np.float32) / 255.0
def _streaks(rng, arr, gen, t):
h, w = arr.shape
rows = np.arange(h)
for _ in range(int(rng.integers(2, 5 + gen))):
x0 = rng.uniform(0, w)
xs = x0 + np.cumsum(rng.normal(0, 0.12, h)) # the streak wanders a little
width = int(rng.integers(1, 4))
strength = rng.uniform(0.03, 0.13) * t
# dust comes and goes along the page
fade = 0.55 + 0.45 * np.sin(rows / rng.uniform(60, 400) + rng.uniform(0, 6.28))
fade *= np.clip(_1d(rng, h) + 0.6, 0, 1)
for dx in range(width):
cols = np.clip(xs.astype(int) + dx, 0, w - 1)
arr[rows, cols] -= strength * fade * (1.0 if dx == 0 else 0.55)
# one broad, faint band where the roller was tired
x0 = int(rng.uniform(0.1, 0.9) * w)
arr[:, x0:x0 + int(rng.uniform(20, 60))] *= 1 - (1 - rng.uniform(0.985, 0.997)) * t
def _1d(rng, n):
pts = rng.normal(0, 0.5, n // 40 + 3).astype(np.float32)
return np.interp(np.arange(n), np.linspace(0, n - 1, len(pts)), pts)
def _edge_shadows(rng, arr, t):
h, w = arr.shape
for edge in rng.permutation(4)[: int(rng.integers(1, 3))]:
depth = int(rng.uniform(8, 26))
strength = rng.uniform(0.18, 0.42) * t
ramp = np.linspace(strength, 0, depth, dtype=np.float32) ** 1.6
jag = 1 + 0.35 * _1d(rng, max(h, w))
if edge == 0:
arr[:, :depth] -= ramp[None, :] * jag[:h, None]
elif edge == 1:
arr[:, w - depth:] -= ramp[::-1][None, :] * jag[:h, None]
elif edge == 2:
arr[:depth, :] -= ramp[:, None] * jag[None, :w]
else:
arr[h - depth:, :] -= ramp[::-1][:, None] * jag[None, :w]
def _generation(rng, luma, gen, t):
"""One trip through the copier. luma is a float array in [0, 1]."""
h, w = luma.shape
im = Image.fromarray((np.clip(luma, 0, 1) * 255).astype(np.uint8), "L")
# placed slightly crooked on the glass; the lid backing shows in the wedges
im = im.rotate(rng.uniform(-0.28, 0.28) * t, resample=Image.BICUBIC, fillcolor=70,
translate=(rng.uniform(-4, 4) * t, rng.uniform(-4, 4) * t))
# toner spreads: thin strokes fatten a little on every pass
fat = np.asarray(im.filter(ImageFilter.MinFilter(3)), dtype=np.float32) / 255.0
thin = np.asarray(im, dtype=np.float32) / 255.0
im = Image.fromarray((np.clip((1 - 0.14 * t) * thin + 0.14 * t * fat, 0, 1) * 255).astype(np.uint8), "L")
if t > 0:
im = im.filter(ImageFilter.GaussianBlur(t * (0.55 + 0.05 * gen))) # optics
a = np.asarray(im, dtype=np.float32) / 255.0
# a faint ghost from the previous pass being a hair out of register
dy, dx = int(rng.integers(-2, 3)), int(rng.integers(-2, 3))
if dx or dy:
a = (1 - 0.12 * t) * a + 0.12 * t * np.roll(a, (dy, dx), axis=(0, 1))
# toner curve: blacker blacks, paler paper, but keep mid-tones for stains
k, c = 4.6 + 0.4 * gen, 0.50
s = lambda x: 1 / (1 + np.exp(-k * (x - c)))
a = (1 - t) * a + t * (s(a) - s(0.0)) / (s(1.0) - s(0.0))
# uneven toner and grain
a *= 1 + _lowfreq(rng, w, h, 5, 7, 0.022 * t) + _lowfreq(rng, w, h, w // 45, h // 45, 0.014 * t)
a += rng.normal(0, 0.018 * t, a.shape).astype(np.float32)
# dust and toner specks; a few white dropouts inside the ink
a = np.minimum(a, 1 - 0.65 * _speckles(rng, w, h, int((150 + 90 * gen) * t), 0.5, 1.5))
a = np.maximum(a, 0.85 * _speckles(rng, w, h, int(110 * t), 0.5, 1.3) * (a < 0.35))
_streaks(rng, a, gen, t)
_edge_shadows(rng, a, t)
return np.clip(a, 0, 1)
def photocopy(img, seed, copies=3, intensity=1.0):
"""Return a degraded RGB copy of `img` (an RGB PIL image)."""
rng = np.random.default_rng(seed)
ycc = img.convert("YCbCr")
luma = np.asarray(ycc.split()[0], dtype=np.float32) / 255.0
for g in range(copies):
luma = _generation(rng, luma, g, intensity)
# keep only a whisper of the original colour, smeared
_, cb, cr = ycc.split()
soften = lambda ch: ch.filter(ImageFilter.GaussianBlur(5)).point(lambda v: 128 + (v - 128) * (1 - 0.15 * intensity))
y = Image.fromarray((luma * 255).astype(np.uint8), "L")
return Image.merge("YCbCr", (y, soften(cb), soften(cr))).convert("RGB")
+248
View File
@@ -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 &amp; 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}")