Files
tepbc-ross-retyped/tools/gen_seal.py
T
bdeshiandClaude Sonnet 5.5 2ec880d1c4 Add a generated cover and a content-hashed version mark
The cover is a full-bleed mosaic of historical Bengali types filling the word
ব্যঞ্জণ, produced by tools/gen_cover_mosaic.py from plates/ and fonts/ alone
(build.sh remakes it if missing). It is static, so ebook readers can cache it.

The mark on the About this edition page is drawn by tools/gen_seal.py, seeded
from a SHA-256 of src/ (never the build's own outputs), so it changes exactly
when the transcription does. The colophon is shortened to name the cover
typefaces and the mark's mechanism, and drops the trailing ProQuest note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:36:18 +06:00

458 lines
20 KiB
Python

r"""Generate a fleuron as TikZ paths, deterministically from a version string.
Vector, not raster: the mark sits at about half an inch, where a PNG would
either be huge or visibly soft. Emits \sealbody and \sealseed for
src/colophon.tex (and work/cover/mosaic/cover_byanjan.tex).
Seed: a hash of this edition's sources, so the mark tracks the text. The same
sources always yield the same mark.
APPROACH -- growth, not arrangement.
Following Wong, Zongker & Salesin, "Computer-Generated Floral Ornament"
(SIGGRAPH '98), traditional ornament is generated rather than composed: a main
stem grows, subordinate elements branch from it tangentially, and each element
is placed against the space left by its parent. Earlier attempts here failed in
two opposite directions -- a free random walk gave lopsided scribbles, and a
fixed vocabulary on shared radial bands gave machined rosettes. Growth with
tangential continuity is the middle path, and it is what makes fleurons look
drawn.
Strokes are rendered as filled outlines with a width profile rather than as
constant-width lines. That taper -- full at the base of a scroll, vanishing at
its tip -- is the single thing that most makes the result read as a written
glyph instead of a diagram.
Nothing is mirrored or rotated into place: no symmetry is enforced anywhere.
Any balance is a consequence of the growth rules and the final fit to the disc.
"""
import hashlib
import math
import os
import subprocess
import sys
SCALE = 0.52 # the scale the cover and colophon apply; see W()
WMIN = 0.012 # cm, about 0.34pt -- below this a hairline drops out in print
def W(u):
"""Line width in cm for a stroke of `u` seal-units.
TikZ `scale=` transforms coordinates but NOT line widths, so a width
written as an absolute cm value looks like a hairline in a large preview
and like a heavy bar at the final scale. Widths are therefore expressed as
a fraction of the seal's radius and converted here, with a floor so nothing
lands below what print can hold. (Only the frame is stroked; the ornament
itself is filled outlines, which scale cleanly.)
"""
return max(WMIN, u * SCALE)
def seed_of(text):
# whole digest, not a truncation: the seed was never the limiting factor on
# variety -- the generator's design space is -- but truncating invites the
# question, and consuming all of it costs nothing.
return int(hashlib.sha256(text.encode()).hexdigest(), 16)
class R:
"""Small deterministic PRNG, so the mark does not depend on the Python
version's random module internals."""
def __init__(self, s):
self.s = s & ((1 << 64) - 1)
def _next(self):
self.s = (self.s * 6364136223846793005 + 1442695040888963407) & ((1 << 64) - 1)
return (self.s >> 11) / float(1 << 53)
def uni(self, a, b):
return a + (b - a) * self._next()
def pick(self, xs):
return xs[int(self._next() * len(xs)) % len(xs)]
def chance(self, p):
return self._next() < p
# ---------------------------------------------------------------- geometry
def _spiral(b, sweep, curl, n):
"""A logarithmic-spiral arc in local coordinates, normalised so it starts
at the origin with its tangent along +x and spans roughly unit size.
Log spirals are the backbone of scrollwork: the growth rate `b` fixes how
quickly the scroll opens, which is the proportion the eye reads as either
tight and vegetal or loose and calligraphic.
"""
pts = []
for i in range(n):
th = sweep * i / (n - 1)
r = math.exp(b * th)
pts.append((r * math.cos(th), curl * r * math.sin(th)))
ox, oy = pts[0]
pts = [(x - ox, y - oy) for x, y in pts]
ang = math.atan2(curl, b) # initial tangent
ca, sa = math.cos(-ang), math.sin(-ang)
pts = [(x * ca - y * sa, x * sa + y * ca) for x, y in pts]
m = max(math.hypot(x, y) for x, y in pts) or 1.0
return [(x / m, y / m) for x, y in pts]
def _place(pts, ang, scale, ox, oy):
ca, sa = math.cos(ang), math.sin(ang)
return [(ox + (x * ca - y * sa) * scale, oy + (x * sa + y * ca) * scale)
for x, y in pts]
def _curvature_radius(p0, p1, p2):
"""Circumradius of three consecutive samples."""
ax, ay = p0; bx, by = p1; cx, cy = p2
a = math.hypot(bx-cx, by-cy)
b = math.hypot(ax-cx, ay-cy)
c = math.hypot(ax-bx, ay-by)
area2 = abs((bx-ax)*(cy-ay) - (cx-ax)*(by-ay))
if area2 < 1e-12:
return 1e9
return (a*b*c) / (2.0*area2)
def _tapered(pts, w0, w1):
"""Filled outline of a stroke whose half-width runs from w0 to w1.
The width eases rather than falling linearly, so the stroke keeps its
weight through the body of the curve and gives it up late -- a pen's
behaviour, and the reason this reads as a written mark.
The half-width is additionally capped at a fraction of the local radius of
curvature. Where a scroll curls tighter than its own stroke is wide, the
offset outline turns itself inside out and fills as a solid blob -- which
is exactly what the tight inner coils were doing. Capping keeps the inner
edge from crossing itself.
Returns the path and its outline points, so the caller can fit the mark on
the ink it actually puts down rather than on the centreline.
"""
PA, PB = 0.45, 0.80 # swell exponents; peak at PA/(PA+PB)
TP = PA / (PA + PB)
n = len(pts)
left, right = [], []
for i, (x, y) in enumerate(pts):
t = i / (n - 1)
j0 = max(0, i - 1)
j1 = min(n - 1, i + 1)
dx = pts[j1][0] - pts[j0][0]
dy = pts[j1][1] - pts[j0][1]
L = math.hypot(dx, dy) or 1.0
nx, ny = -dy / L, dx / L
# Swell, not a monotonic taper: the stroke rises from nothing near the
# root, peaks about a third along, and tapers to a point. A profile
# that merely decreased left a blunt slab at the start -- the hard
# straight edge that read as a wedge stuck onto the scroll.
tt = min(max(t, 1e-6), 1.0 - 1e-6)
w = w0 * ((tt / TP) ** PA) * (((1.0 - tt) / (1.0 - TP)) ** PB)
w = max(w, w1 * 0.25)
if 0 < i < n - 1:
w = min(w, 0.55 * _curvature_radius(pts[i-1], pts[i], pts[i+1]))
left.append((x + nx * w, y + ny * w))
right.append((x - nx * w, y - ny * w))
ring = left + right[::-1]
return (" -- ".join(f"({x:.4f},{y:.4f})" for x, y in ring) + " -- cycle", ring)
def _bud(x, y, ang, r, elong):
"""A teardrop terminal: the swelling a scroll resolves into."""
ux, uy = math.cos(ang), math.sin(ang)
px, py = -uy, ux
tipx, tipy = x + ux * r * elong, y + uy * r * elong
c1 = (x + px * r + ux * r * 0.4, y + py * r + uy * r * 0.4)
c2 = (tipx + px * r * 0.25, tipy + py * r * 0.25)
c3 = (tipx - px * r * 0.25, tipy - py * r * 0.25)
c4 = (x - px * r + ux * r * 0.4, y - py * r + uy * r * 0.4)
return (f"({x:.4f},{y:.4f}) .. controls ({c1[0]:.4f},{c1[1]:.4f}) and "
f"({c2[0]:.4f},{c2[1]:.4f}) .. ({tipx:.4f},{tipy:.4f}) .. controls "
f"({c3[0]:.4f},{c3[1]:.4f}) and ({c4[0]:.4f},{c4[1]:.4f}) .. cycle")
def _diamond(x, y, ang, w, h):
"""A lozenge: the gem of the ornament vocabulary."""
ux, uy = math.cos(ang), math.sin(ang)
px, py = -uy, ux
pts = [(x + ux*h, y + uy*h), (x + px*w, y + py*w),
(x - ux*h, y - uy*h), (x - px*w, y - py*w)]
return " -- ".join(f"({a:.4f},{b:.4f})" for a, b in pts) + " -- cycle", pts
def _thorn(x, y, ang, length, w, bend):
"""A spike with a slight bend -- the barb that sharpens a scroll's rhythm
against all the rounded lobes."""
ux, uy = math.cos(ang), math.sin(ang)
px, py = -uy, ux
tip = (x + ux*length + px*bend, y + uy*length + py*bend)
b1 = (x + px*w, y + py*w)
b2 = (x - px*w, y - py*w)
c1 = (x + px*w*0.7 + ux*length*0.45, y + py*w*0.7 + uy*length*0.45)
c2 = (x - px*w*0.7 + ux*length*0.45, y - py*w*0.7 + uy*length*0.45)
return (f"({b1[0]:.4f},{b1[1]:.4f}) .. controls ({c1[0]:.4f},{c1[1]:.4f}) and "
f"({tip[0]:.4f},{tip[1]:.4f}) .. ({tip[0]:.4f},{tip[1]:.4f}) .. controls "
f"({c2[0]:.4f},{c2[1]:.4f}) and ({b2[0]:.4f},{b2[1]:.4f}) .. "
f"({b2[0]:.4f},{b2[1]:.4f}) -- cycle"), [tip, b1, b2]
def build(seed_text):
"""A printer's ornament: a sweeping scroll carrying lobed leaves.
Modelled on typographic ornament specimens (acanthus scrolls, corner
pieces, flourishes) rather than on plants. The vocabulary is: one broad
scroll spine, lobes hanging off it on alternating sides, a vein cut out of
each large lobe, and a tight curl at the terminals. No frame -- these sit
as free silhouettes on the page.
Two earlier approaches missed in opposite directions. Free growth with no
axis produced biomorphic creatures; a stem with mirrored leaves produced
botany. What separates ornament from both is mass: broad filled sweeps
with counters cut into them, not thin strokes of uniform weight.
Mirrored composition is one option among several, not the rule -- the
specimens include both symmetric and asymmetric pieces.
"""
rng = R(seed_of(seed_text))
ink_shapes, cut_shapes, allpts = [], [], []
NS = 26
def add(path, ring, cut=False):
(cut_shapes if cut else ink_shapes).append(path)
allpts.extend(ring)
def bud_extent(x, y, ang, r, elong):
"""The bud's actual corners. A crude +/-3r box here overstated the
piece's radius, so the fit shrank the whole ornament away from the
circle it was supposed to fill."""
ux, uy = math.cos(ang), math.sin(ang)
px, py = -uy, ux
tip = (x + ux*r*elong, y + uy*r*elong)
return [tip, (x + px*r, y + py*r), (x - px*r, y - py*r),
(x - ux*r*0.3, y - uy*r*0.3)]
def sweep_stroke(ox, oy, ang, length, w0, w1, curl, sweep, b):
local = _spiral(b, sweep, curl, NS)
pts = _place(local, ang, length, ox, oy)
path, ring = _tapered(pts, w0, w1)
add(path, ring)
return pts
# ---- the spine: one broad scroll, the piece's backbone
# Envelope: the undrawn shape the piece is fitted into. Ornament
# specimens are not all round -- there are wide band pieces, upright ones
# and squarish ones -- so the format is seeded, and the spine's direction
# is biased to suit it rather than fitted to it after the fact.
fmt = rng.pick(['round', 'round', 'round', 'band', 'band', 'tall', 'square'])
ENV = {'round': (1.00, 1.00), 'band': (1.40, 0.66),
'tall': (0.66, 1.40), 'square': (1.06, 1.06)}[fmt]
curl = 1.0 if rng.chance(0.5) else -1.0
if fmt == 'band':
ang0 = math.radians(rng.uni(-18, 18))
elif fmt == 'tall':
ang0 = math.radians(rng.uni(72, 108))
else:
ang0 = math.radians(rng.uni(-40, 40))
spine_b = rng.uni(0.13, 0.30)
# Sweep kept under a half-turn: a spine that curls further closes on
# itself, and once mirrored it becomes a ring with a big hole punched
# through the middle. Ornament wants open space AROUND the mass, not
# trapped inside it.
spine_sweep = rng.uni(1.9, 3.1)
spine_w = rng.uni(0.14, 0.23)
spine = sweep_stroke(0.0, 0.0, ang0, 1.0, spine_w, spine_w * 0.10,
curl, spine_sweep, spine_b)
# ---- lobes along the spine, alternating sides, largest near the root
nlobe = rng.pick([4, 5, 5, 6, 7]) # floor: too few and the spine reads as a bare comma
side = 1.0 if rng.chance(0.5) else -1.0
lobe_base = rng.uni(0.34, 0.52)
for i in range(nlobe):
f = i / max(1, nlobe - 1)
idx = max(1, min(NS - 2, int((0.10 + 0.74 * f) * (NS - 1))))
px, py = spine[idx]
tang = math.atan2(spine[idx + 1][1] - spine[idx - 1][1],
spine[idx + 1][0] - spine[idx - 1][0])
a = tang + side * math.radians(rng.uni(52, 96))
ln = lobe_base * (1.0 - 0.46 * f)
lw = spine_w * rng.uni(0.75, 1.05) * (1.0 - 0.34 * f)
lc = curl if rng.chance(0.68) else -curl
lpts = sweep_stroke(px, py, a, ln, lw, lw * 0.10, lc,
rng.uni(1.6, 3.0), rng.uni(0.12, 0.30))
# vein: the same curve cut back out in paper, stopping short of the
# tip. Cutting a counter into the mass is what reads as carved
# acanthus rather than as a painted blob.
if lw > spine_w * 0.62 and rng.chance(0.78):
vp = lpts[:int(NS * rng.uni(0.62, 0.80))]
if len(vp) > 3:
vpath, vring = _tapered(vp, lw * rng.uni(0.26, 0.40), lw * 0.05)
add(vpath, vring, cut=True)
# secondary tendril off the larger lobes: the density the richer
# specimen pieces get from subordinate curls
if f < 0.55 and rng.chance(0.55):
tx, ty = lpts[int(len(lpts) * rng.uni(0.45, 0.70))]
ta = a + side * math.radians(rng.uni(40, 95))
sweep_stroke(tx, ty, ta, ln * rng.uni(0.34, 0.55),
lw * rng.uni(0.34, 0.52), lw * 0.05,
-lc, rng.uni(2.2, 3.8), rng.uni(0.08, 0.22))
side = -side if rng.chance(0.72) else side
# ---- terminal curl: a tight eye where the spine runs out
ex, ey = spine[-1]
tg = math.atan2(ey - spine[-2][1], ex - spine[-2][0])
if rng.chance(0.8):
sweep_stroke(ex, ey, tg, rng.uni(0.16, 0.30), spine_w * 0.34,
spine_w * 0.06, curl, rng.uni(2.4, 3.6), rng.uni(0.05, 0.14))
if rng.chance(0.5):
br = spine_w * rng.uni(1.0, 1.8)
rx, ry = spine[0]
ba, be = ang0 + math.pi, rng.uni(1.4, 2.2)
add(_bud(rx, ry, ba, br, be), bud_extent(rx, ry, ba, br, be))
# ---- accents from the wider ornament vocabulary, hung off the spine
naccent = rng.pick([1, 2, 2, 3])
for _ in range(naccent):
kind = rng.pick(['gem', 'berries', 'thorn', 'swash', 'petal'])
idx = max(1, min(NS - 2, int(rng.uni(0.15, 0.92) * (NS - 1))))
ax, ay = spine[idx]
tg = math.atan2(spine[idx + 1][1] - spine[idx - 1][1],
spine[idx + 1][0] - spine[idx - 1][0])
nrm = tg + (math.pi / 2 if rng.chance(0.5) else -math.pi / 2)
off = spine_w * rng.uni(0.9, 2.2)
gx, gy = ax + math.cos(nrm) * off, ay + math.sin(nrm) * off
if kind == 'gem':
gw = spine_w * rng.uni(0.55, 1.0)
gh = gw * rng.uni(1.4, 2.4)
pth, ext = _diamond(gx, gy, nrm, gw, gh)
add(pth, ext)
if rng.chance(0.6): # facet cut out of the gem
pth2, _ = _diamond(gx, gy, nrm, gw * 0.42, gh * 0.42)
add(pth2, [], cut=True)
elif kind == 'berries':
for j in range(rng.pick([2, 3, 3])):
bx = gx + math.cos(nrm + j * 2.1) * spine_w * 0.9
by = gy + math.sin(nrm + j * 2.1) * spine_w * 0.9
br = spine_w * rng.uni(0.34, 0.55)
add(f"({bx:.4f},{by:.4f}) circle[radius={br:.4f}]",
[(bx + br, by + br), (bx - br, by - br)])
elif kind == 'thorn':
tl = spine_w * rng.uni(2.0, 4.0)
pth, ext = _thorn(gx, gy, nrm, tl, spine_w * rng.uni(0.30, 0.50),
tl * rng.uni(-0.35, 0.35))
add(pth, ext)
elif kind == 'petal':
pr = spine_w * rng.uni(0.8, 1.5)
pe = rng.uni(1.5, 2.6)
add(_bud(gx, gy, nrm, pr, pe), bud_extent(gx, gy, nrm, pr, pe))
else: # swash: a long thin sweep, the counterweight to the heavy lobes
sweep_stroke(gx, gy, tg + rng.uni(-0.8, 0.8), rng.uni(0.40, 0.72),
spine_w * rng.uni(0.16, 0.30), spine_w * 0.03,
curl if rng.chance(0.5) else -curl,
rng.uni(2.4, 4.2), rng.uni(0.06, 0.18))
# Mirroring follows the format: a band ornament is a motif mirrored along
# its long axis, which is how it comes to fill a wide envelope at all --
# uniform scaling alone just centred a small piece in a lot of space.
if fmt == 'band':
mirrored = 'x'
elif fmt == 'tall':
mirrored = 'y'
else:
mirrored = 'x' if rng.chance(0.34) else None
# ---- fit inside an undrawn circle: the piece is inscribed in a disc that
# is never stroked. Fitting a bounding box instead lets the diagonal run
# past that disc, so pieces of different proportion sat at visibly
# different sizes; fitting the furthest point makes every mark occupy the
# same optical circle whatever its shape.
if mirrored == 'x':
pts_for_fit = allpts + [(-x, y) for x, y in allpts]
elif mirrored == 'y':
pts_for_fit = allpts + [(x, -y) for x, y in allpts]
else:
pts_for_fit = list(allpts)
xs = [p[0] for p in pts_for_fit]; ys = [p[1] for p in pts_for_fit]
cx, cy = (min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0
ea, eb = ENV
mx = max(abs(x - cx) for x, y in pts_for_fit) or 1e-6
my = max(abs(y - cy) for x, y in pts_for_fit) or 1e-6
if fmt == 'square':
k = 0.95 * min(ea / mx, eb / my)
else:
# uniform scale that just fits the envelope ellipse: never distort the
# letterforms to fill a shape, only choose how much room they get
k = 0.95 * min(ea / mx, eb / my)
out = [f"% seal seeded from: {seed_text}",
f"% printer's ornament, {mirrored or 'single'}, {fmt}",
f"% envelope {ENV[0]:.2f} {ENV[1]:.2f}"]
out.append(f"\\begin{{scope}}[shift={{({-cx*k:.4f},{-cy*k:.4f})}},scale={k:.4f}]")
def emit(xs_):
for pth in ink_shapes:
out.append(f"\\fill[ink,{xs_}] {pth};")
for pth in cut_shapes:
out.append(f"\\fill[paper,{xs_}] {pth};")
emit("xscale=1")
if mirrored == 'x':
emit("xscale=-1")
elif mirrored == 'y':
emit("yscale=-1")
out.append("\\end{scope}")
return "\n".join(out)
def content_hash():
"""SHA-256 over the sources that define this edition's text, so the seal
tracks the CONTENT rather than the commit: re-committing without changing a
word yields the same seal, and changing a word changes it even before
anything is committed."""
# NOT hashed: anything generated -- src/main.tex (build.sh writes it), the
# built PDF, work/seal.tex itself. Hashing a derived file would make the
# seed depend on the seal it produces.
h = hashlib.sha256()
import glob as g
for path in sorted(g.glob('src/pages/*.tex') + g.glob('src/*.tex')):
if path.endswith('main.tex'):
continue
with open(path, 'rb') as f:
h.update(path.encode())
h.update(f.read())
return h.hexdigest()[:16]
if __name__ == '__main__':
args = sys.argv[1:]
if '--scale' in args:
i = args.index('--scale')
globals()['SCALE'] = float(args[i + 1])
del args[i:i + 2]
out_path = 'work/seal.tex'
if '--out' in args:
i = args.index('--out')
out_path = args[i + 1]
del args[i:i + 2]
if args and args[0] == '--content':
tag, kind = content_hash(), 'content'
elif args:
tag, kind = args[0], 'given'
else:
try:
tag = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'],
text=True, stderr=subprocess.DEVNULL).strip()
kind = 'commit'
except Exception:
tag, kind = content_hash(), 'content'
body = build(tag)
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
with open(out_path, 'w') as f:
f.write("% generated by tools/gen_seal.py -- do not edit, not tracked\n")
f.write(f"% seed kind: {kind}\n")
f.write("\\newcommand{\\sealseed}{" + tag + "}\n")
f.write("\\newcommand{\\sealbody}{%\n" + body + "\n}\n")
print(f"{kind}: {tag} -> {out_path}")