fix
This commit is contained in:
@@ -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 ""))
|
||||
@@ -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; }
|
||||
@@ -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")
|
||||
@@ -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